chore: 升级 Trellis 并精简规则(09-05-trellis-upgrade-rule-cleanup)

这个提交包含在:
2026-09-06 11:14:41 +08:00
父节点 ab82fe30c0
当前提交 c3cef5cfc4
共修改 48 个文件,包含 4588 行新增和 1053 行删除
+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