← Files get-fableARCHIVED FILE
hooks/fable_lint.py
4.09 KB · Oct 2, 2026 · 00:30 UTC
#!/usr/bin/env python3
"""fable-mode lint — one-shot discipline check for a fable-mode project.
Not a hook: a CLI the model (or CI) runs at wrap-up, and the machine-checkable
acceptance for the discipline itself:
python3 ~/.claude/skills/fable-mode/hooks/fable_lint.py [project_dir]
Checks (each finding names the file and the rule):
1. docs/SPEC.md exists next to the `.fable/` project root.
2. SPEC.md decisions carry source tags — at least one of
[measured]/[inferred]/[not-shown]/[design-gap] (or the Chinese set
[实测]/[读数]/[推断]/[未展示]/[设计补全]). Untagged specs hide guesses.
3. Every open `- [ ]` ledger card hints at its acceptance (mentions a test/
acceptance/验收, or carries a `backtick command`).
4. Every closed `- [x]` ledger card carries an evidence marker
(`-- evidence:` / `证据:`).
5. Every deferred `- [~]` card carries a reason (`-- deferred: why` /
`搁置:`/`推迟:`) — deferring everything is the cheap way out of a round;
reasons keep it auditable.
Exit 0 = clean, exit 1 = findings (printed one per line), exit 0 with a note
if the directory isn't a fable-mode project at all.
"""
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _fable_common import ( # noqa: E402
find_fable_dir, ledger_path, closed_without_evidence,
)
SOURCE_TAG_RE = re.compile(
r"\[(measured|inferred|not-shown|design-gap|实测|读数|推断|未展示|设计补全)\]",
re.IGNORECASE)
ACCEPTANCE_HINT_RE = re.compile(r"(accept|验收|test|测试|`[^`]+`)", re.IGNORECASE)
DEFER_REASON_RE = re.compile(r"(deferred|搁置|推迟)\s*[::]\s*\S{2,}", re.IGNORECASE)
def lint(project_dir):
findings = []
fable_dir = find_fable_dir(project_dir)
if not fable_dir:
print("fable-lint: no .fable/ found from %s — not a fable-mode "
"project, nothing to lint" % project_dir)
return 0
root = os.path.dirname(fable_dir)
spec = os.path.join(root, "docs", "SPEC.md")
if not os.path.isfile(spec):
findings.append("%s: missing (plan gate: requirements + approach + "
"task cards live here)" % spec)
else:
try:
with open(spec, encoding="utf-8", errors="replace") as fh:
if not SOURCE_TAG_RE.search(fh.read()):
findings.append(
"%s: no source tags — tag design decisions "
"[measured]/[inferred]/[not-shown] so reviewers know "
"which foundations are guesses" % spec)
except Exception:
pass
lp = ledger_path(fable_dir)
if os.path.isfile(lp):
try:
with open(lp, encoding="utf-8", errors="replace") as fh:
for i, line in enumerate(fh, 1):
s = line.strip()
if s[:5].lower() == "- [ ]" and not ACCEPTANCE_HINT_RE.search(s):
findings.append(
"%s:%d: open card with no acceptance hint "
"(name the test/command that will prove it): %s"
% (lp, i, s))
elif s[:5].lower() == "- [~]" and not DEFER_REASON_RE.search(s):
findings.append(
"%s:%d: deferred card without a reason "
"(`-- deferred: why` keeps skips auditable): %s"
% (lp, i, s))
except Exception:
pass
for s in closed_without_evidence(lp):
findings.append(
"%s: checked card without `-- evidence:` marker: %s" % (lp, s))
else:
findings.append("%s: missing (no task cards -> no round structure)" % lp)
for f in findings:
print("FINDING " + f)
print("fable-lint: %d finding(s)" % len(findings))
return 1 if findings else 0
if __name__ == "__main__":
target = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
try:
sys.exit(lint(os.path.abspath(target)))
except Exception as e:
print("fable-lint: error (%r)" % e)
sys.exit(0)
SHA-256: f97f27749dc1b5c969ce6ba0eb13b1fa4cfc73f2827289d3d246b4c868e598e6