chore: 升级 Trellis 并精简规则(09-05-trellis-upgrade-rule-cleanup)
这个提交包含在:
+105
-6
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
JSON file I/O utilities.
|
||||
File I/O utilities.
|
||||
|
||||
Provides read_json and write_json as the single source of truth
|
||||
for JSON file operations across all Trellis scripts.
|
||||
Provides read_json / write_json as the single source of truth for JSON file
|
||||
operations, plus write_text_atomic for the Markdown state files (journal,
|
||||
index.md) that carry durable session state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,17 +14,96 @@ import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
JSON_READ_MISSING = "missing"
|
||||
JSON_READ_INVALID = "invalid"
|
||||
JSON_READ_UNREADABLE = "unreadable"
|
||||
JSON_READ_NOT_OBJECT = "not-object"
|
||||
JSON_READ_EMPTY = "empty"
|
||||
JSON_READ_UNDECODABLE = "undecodable"
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict | None:
|
||||
"""Read and parse a JSON file.
|
||||
|
||||
Returns None if the file doesn't exist, is invalid JSON, or can't be read.
|
||||
Use this for optional reads only — a caller that is about to overwrite the
|
||||
file, or that must tell a parse error from a permissions error, wants
|
||||
read_json_checked instead.
|
||||
"""
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError, UnicodeDecodeError):
|
||||
# UnicodeDecodeError is not an OSError. Without it here a non-UTF-8
|
||||
# session file raises out of a tolerant read, so the hook path fails
|
||||
# instead of degrading to "no active task".
|
||||
return None
|
||||
|
||||
|
||||
def read_json_checked(path: Path) -> tuple[dict | None, str | None]:
|
||||
"""Read a JSON object, keeping the ways it can fail distinguishable.
|
||||
|
||||
Returns ``(data, None)`` on success, or ``(None, reason)`` where reason is
|
||||
one of the ``JSON_READ_*`` constants. An empty object counts as a failure:
|
||||
a state file that parses to ``{}`` carries none of the fields callers read,
|
||||
and treating it as success would silently rebuild it from defaults.
|
||||
"""
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None, JSON_READ_MISSING
|
||||
except UnicodeDecodeError:
|
||||
# Not an OSError, so it escaped both handlers and surfaced as a
|
||||
# traceback. The point of this reader is that every failure mode stays
|
||||
# nameable, and "not valid UTF-8" is a different repair from
|
||||
# "not valid JSON".
|
||||
return None, JSON_READ_UNDECODABLE
|
||||
except OSError:
|
||||
return None, JSON_READ_UNREADABLE
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return None, JSON_READ_INVALID
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return None, JSON_READ_NOT_OBJECT
|
||||
if not data:
|
||||
return None, JSON_READ_EMPTY
|
||||
return data, None
|
||||
|
||||
|
||||
def describe_json_read_failure(path: Path, reason: str | None) -> tuple[str, str]:
|
||||
"""Return ``(what happened, what to do)`` for a read_json_checked reason."""
|
||||
if reason == JSON_READ_MISSING:
|
||||
return (f"{path}: file not found", "Pass an existing task directory, or create the task first.")
|
||||
if reason == JSON_READ_UNREADABLE:
|
||||
return (
|
||||
f"{path}: could not be read (permission denied or I/O error)",
|
||||
"Check the file and directory permissions, then retry.",
|
||||
)
|
||||
if reason == JSON_READ_INVALID:
|
||||
return (
|
||||
f"{path}: not valid JSON",
|
||||
f"Fix the syntax (e.g. `python3 -m json.tool {path}`), then retry.",
|
||||
)
|
||||
if reason == JSON_READ_NOT_OBJECT:
|
||||
return (
|
||||
f"{path}: top level is not a JSON object",
|
||||
"Restore the file to a JSON object ({ ... }), then retry.",
|
||||
)
|
||||
if reason == JSON_READ_EMPTY:
|
||||
return (
|
||||
f"{path}: contains an empty JSON object",
|
||||
"Restore the task fields (or recreate the task), then retry.",
|
||||
)
|
||||
if reason == JSON_READ_UNDECODABLE:
|
||||
return (
|
||||
f"{path}: not valid UTF-8 text",
|
||||
"Re-save the file as UTF-8 (or restore it from git), then retry.",
|
||||
)
|
||||
return (f"{path}: could not be loaded", "Inspect the file, then retry.")
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict) -> bool:
|
||||
"""Write dict to JSON file with pretty formatting.
|
||||
|
||||
@@ -34,7 +114,19 @@ def write_json(path: Path, data: dict) -> bool:
|
||||
|
||||
Returns True on success, False on error.
|
||||
"""
|
||||
payload = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
return write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def write_text_atomic(path: Path, text: str) -> bool:
|
||||
"""Write text to a file atomically (temp in same dir, then replace).
|
||||
|
||||
The same never-truncate-in-place guarantee as :func:`write_json`, for the
|
||||
Markdown state files that hold durable session state (journal files,
|
||||
index.md). A crash or Ctrl-C mid-write leaves the previous content intact
|
||||
instead of a half-written record that no retry can classify.
|
||||
|
||||
Returns True on success, False on error.
|
||||
"""
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
|
||||
@@ -50,7 +142,7 @@ def write_json(path: Path, data: dict) -> bool:
|
||||
os.close(fd)
|
||||
raise
|
||||
with f:
|
||||
f.write(payload)
|
||||
f.write(text)
|
||||
os.replace(tmp, path)
|
||||
return True
|
||||
except OSError:
|
||||
@@ -59,3 +151,10 @@ def write_json(path: Path, data: dict) -> bool:
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
except BaseException:
|
||||
# Ctrl-C mid-write: drop the temp file, then let the interrupt through.
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
在新工单中引用
屏蔽一个用户