signoff-cli 0.0.0-stage → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.pre-commit-hooks.yaml +27 -0
  2. package/CHANGELOG.md +336 -0
  3. package/LICENSE +21 -0
  4. package/README.md +540 -3
  5. package/action.yml +24 -0
  6. package/bin/signoff.js +218 -0
  7. package/docs/diagrams/README.md +41 -0
  8. package/docs/diagrams/signoff-1-capability-en.png +0 -0
  9. package/docs/diagrams/signoff-1-capability.png +0 -0
  10. package/docs/diagrams/signoff-2-market-en.png +0 -0
  11. package/docs/diagrams/signoff-2-market.png +0 -0
  12. package/docs/diagrams/signoff-3-user-en.png +0 -0
  13. package/docs/diagrams/signoff-3-user.png +0 -0
  14. package/docs/diagrams/signoff-4-flow-en.png +0 -0
  15. package/docs/diagrams/signoff-4-flow.png +0 -0
  16. package/docs/diagrams/signoff-5-competitors-en.png +0 -0
  17. package/docs/diagrams/signoff-5-competitors.png +0 -0
  18. package/docs/diagrams/src/signoff-1-capability-en.mmd +81 -0
  19. package/docs/diagrams/src/signoff-1-capability.mmd +84 -0
  20. package/docs/diagrams/src/signoff-2-market-en.mmd +72 -0
  21. package/docs/diagrams/src/signoff-2-market.mmd +72 -0
  22. package/docs/diagrams/src/signoff-3-user-en.mmd +94 -0
  23. package/docs/diagrams/src/signoff-3-user.mmd +92 -0
  24. package/docs/gates.en.md +105 -0
  25. package/docs/gates.md +88 -0
  26. package/docs/integrations.md +58 -0
  27. package/docs/promotion.md +71 -0
  28. package/docs/signoff.schema.json +108 -0
  29. package/docs/trust.md +72 -0
  30. package/gates/guard.py +474 -0
  31. package/gates/p1_verify.py +111 -0
  32. package/gates/p2_coverage.py +127 -0
  33. package/gates/p3_queue.py +186 -0
  34. package/gates/p4_ledger.py +249 -0
  35. package/gates/p5_slop.py +109 -0
  36. package/lib/audit.js +213 -0
  37. package/lib/bootstrap.js +361 -0
  38. package/lib/checkpoint.js +184 -0
  39. package/lib/ci.js +58 -0
  40. package/lib/core.js +120 -0
  41. package/lib/cov.js +253 -0
  42. package/lib/detect.js +169 -0
  43. package/lib/doctor.js +304 -0
  44. package/lib/editor.js +97 -0
  45. package/lib/explain.js +73 -0
  46. package/lib/guard.js +1229 -0
  47. package/lib/p1.js +469 -0
  48. package/lib/p2.js +323 -0
  49. package/lib/p3.js +633 -0
  50. package/lib/p4.js +733 -0
  51. package/lib/p5.js +438 -0
  52. package/lib/preset.js +66 -0
  53. package/lib/secrets.js +336 -0
  54. package/lib/sign.js +196 -0
  55. package/package.json +55 -6
  56. package/test/selftest.js +1226 -0
  57. package/test/selftest.py +190 -0
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """SignOff gate 2 - coverage has a level, not a vibe.
3
+
4
+ Walks app directories, reads verify/scenarios/*.json, counts real assertions and
5
+ assigns one of four levels (scenario / harness / smoke / missing). "There is a
6
+ dev page" is not coverage; a scenario with N checks is.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ ROOT = Path.cwd()
16
+ OUT = ROOT / ".signoff"
17
+ LEVELS = {"missing": 0, "smoke": 1, "harness": 2, "scenario": 3}
18
+
19
+
20
+ def app_dirs() -> list[Path]:
21
+ found = []
22
+ if (ROOT / "package.json").exists() or (ROOT / "verify").is_dir():
23
+ found.append(ROOT)
24
+ for base in (ROOT, ROOT / "packages", ROOT / "apps"):
25
+ if not base.is_dir():
26
+ continue
27
+ for d in sorted(base.iterdir()):
28
+ if not d.is_dir() or d.name.startswith(".") or d.name in {"node_modules", "docs", "gates", "bin", "test"}:
29
+ continue
30
+ if (d / "package.json").exists() or (d / "verify").is_dir():
31
+ found.append(d)
32
+ return found
33
+
34
+
35
+ def scenario_checks(path: Path) -> int:
36
+ try:
37
+ data = json.loads(path.read_text(encoding="utf-8"))
38
+ except (OSError, ValueError):
39
+ return -1
40
+ checks = data.get("checks")
41
+ return len(checks) if isinstance(checks, list) else -1
42
+
43
+
44
+ def inspect(app: Path) -> dict:
45
+ sdir = app / "verify" / "scenarios"
46
+ scenarios = []
47
+ if sdir.is_dir():
48
+ for f in sorted(sdir.glob("*.json")):
49
+ scenarios.append({"file": f.name, "checks": scenario_checks(f)})
50
+ broken = [s for s in scenarios if s["checks"] < 0]
51
+ if scenarios and not broken:
52
+ level = "scenario"
53
+ elif (app / "verify").is_dir():
54
+ level = "harness"
55
+ elif (app / "package.json").exists():
56
+ level = "smoke"
57
+ else:
58
+ level = "missing"
59
+ assertions = sum(s["checks"] for s in scenarios if s["checks"] > 0)
60
+ return {
61
+ "app": app.name,
62
+ "level": level,
63
+ "scenarios": scenarios,
64
+ "assertions": assertions,
65
+ "broken": [s["file"] for s in broken],
66
+ }
67
+
68
+
69
+ def main() -> int:
70
+ ap = argparse.ArgumentParser(prog="signoff p2")
71
+ ap.add_argument("--min-scenario", type=int, default=0, help="require at least N scenario-level apps")
72
+ ap.add_argument("--min-assertions", type=int, default=0)
73
+ ap.add_argument("--strict", action="store_true", help="fail on missing/harness/broken")
74
+ ap.add_argument("--md", metavar="FILE", help="write a markdown table")
75
+ ap.add_argument("--json", action="store_true")
76
+ a = ap.parse_args()
77
+
78
+ apps = app_dirs()
79
+ rows = [inspect(d) for d in apps]
80
+ scenario_apps = [r for r in rows if r["level"] == "scenario"]
81
+ assertions = sum(r["assertions"] for r in rows)
82
+ broken = [r for r in rows if r["broken"]]
83
+ ok = (
84
+ len(scenario_apps) >= a.min_scenario
85
+ and assertions >= a.min_assertions
86
+ and not broken
87
+ and (not a.strict or all(r["level"] == "scenario" for r in rows))
88
+ )
89
+
90
+ header = f"{'app':34} {'level':9} {'scenarios':22} assertions"
91
+ lines = [header, "-" * len(header)]
92
+ for r in rows:
93
+ names = ", ".join(f"{s['file'][:-5]}({s['checks']})" for s in r["scenarios"]) or "-"
94
+ lines.append(f"{r['app'][:33]:34} {r['level']:9} {names[:21]:22} {r['assertions']}")
95
+
96
+ if a.md:
97
+ md = ["| app | level | scenarios | assertions |", "| --- | --- | --- | --- |"]
98
+ for r in rows:
99
+ names = ", ".join(f"{s['file'][:-5]}({s['checks']})" for s in r["scenarios"]) or "-"
100
+ md.append(f"| {r['app']} | {r['level']} | {names} | {r['assertions']} |")
101
+ Path(a.md).write_text("\n".join(md) + "\n", encoding="utf-8")
102
+ lines.append(f"[p2] table written: {a.md}")
103
+
104
+ payload = {
105
+ "gate": "p2",
106
+ "ok": ok,
107
+ "apps": len(rows),
108
+ "scenarioApps": len(scenario_apps),
109
+ "assertions": assertions,
110
+ "broken": [{"app": b["app"], "files": b["broken"]} for b in broken],
111
+ "rows": rows,
112
+ }
113
+ OUT.mkdir(exist_ok=True)
114
+ (OUT / "p2.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
115
+
116
+ if a.json:
117
+ print(json.dumps({k: payload[k] for k in ("gate", "ok", "apps", "scenarioApps", "assertions")}, ensure_ascii=False))
118
+ else:
119
+ print("\n".join(lines))
120
+ print(
121
+ f"[p2] {'PASS' if ok else 'FAIL'} apps={len(rows)} scenario={len(scenario_apps)} assertions={assertions} broken={len(broken)}"
122
+ )
123
+ return 0 if ok else 1
124
+
125
+
126
+ if __name__ == "__main__":
127
+ raise SystemExit(main())
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env python3
2
+ """SignOff gate 3 - parallel work, no collisions.
3
+
4
+ A task card says what a worker may touch, what proves it done and where the
5
+ rollback point is. `plan` turns a pile of cards into waves: cards whose scopes
6
+ overlap never share a wave. `collect` checks the diff back against the scopes and
7
+ names both kinds of failure - a file nobody owns, and a card that shipped nothing.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import fnmatch
13
+ import json
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ ROOT = Path.cwd()
19
+ OUT = ROOT / ".signoff"
20
+ REQUIRED = ("id", "goal", "scope", "acceptance", "rollback")
21
+
22
+
23
+ def load_cards(cards_dir: Path) -> list[dict]:
24
+ cards = []
25
+ for f in sorted(cards_dir.rglob("card.json")) if cards_dir.is_dir() else []:
26
+ try:
27
+ data = json.loads(f.read_text(encoding="utf-8"))
28
+ except (OSError, ValueError) as exc:
29
+ cards.append({"id": str(f), "broken": f"unreadable: {exc}"})
30
+ continue
31
+ data["_file"] = str(f.relative_to(ROOT)) if f.is_relative_to(ROOT) else str(f)
32
+ cards.append(data)
33
+ return cards
34
+
35
+
36
+ def validate(cards: list[dict]) -> list[str]:
37
+ problems: list[str] = []
38
+ seen: dict[str, str] = {}
39
+ for c in cards:
40
+ cid = c.get("id", "?")
41
+ if c.get("broken"):
42
+ problems.append(f"{cid}: {c['broken']}")
43
+ continue
44
+ for key in REQUIRED:
45
+ if not c.get(key):
46
+ problems.append(f"{cid}: missing '{key}'")
47
+ scope = c.get("scope")
48
+ if isinstance(scope, list):
49
+ for pat in scope:
50
+ if not isinstance(pat, str) or not pat.strip():
51
+ problems.append(f"{cid}: bad scope entry {pat!r}")
52
+ elif "scope" in c:
53
+ problems.append(f"{cid}: scope must be a list")
54
+ if cid in seen:
55
+ problems.append(f"duplicate id {cid} ({seen[cid]} vs {c['_file']})")
56
+ seen[cid] = c.get("_file", "")
57
+ return problems
58
+
59
+
60
+ def prefix(glob: str) -> str:
61
+ for i, ch in enumerate(glob):
62
+ if ch in "*?[":
63
+ return glob[:i].rstrip("/\\")
64
+ return glob.rstrip("/\\")
65
+
66
+
67
+ def conflicts(a: dict, b: dict) -> bool:
68
+ for pa in a.get("scope") or []:
69
+ for pb in b.get("scope") or []:
70
+ if pa == pb:
71
+ return True
72
+ fa, fb = prefix(pa).replace("\\", "/"), prefix(pb).replace("\\", "/")
73
+ if not fa or not fb:
74
+ return True
75
+ if fa.startswith(fb) or fb.startswith(fa):
76
+ return True
77
+ return False
78
+
79
+
80
+ def plan(cards: list[dict]) -> list[dict]:
81
+ waves: list[list[dict]] = []
82
+ for card in cards:
83
+ if card.get("broken"):
84
+ continue
85
+ placed = False
86
+ for wave in waves:
87
+ if not any(conflicts(card, other) for other in wave):
88
+ wave.append(card)
89
+ placed = True
90
+ break
91
+ if not placed:
92
+ waves.append([card])
93
+ return waves
94
+
95
+
96
+ def card_owns(card: dict, path: str) -> bool:
97
+ path = path.replace("\\", "/")
98
+ for pat in card.get("scope") or []:
99
+ pat = pat.replace("\\", "/")
100
+ if fnmatch.fnmatch(path, pat) or path == prefix(pat):
101
+ return True
102
+ pre = prefix(pat)
103
+ if pre and path.startswith(pre.rstrip("/") + "/"):
104
+ return True
105
+ return False
106
+
107
+
108
+ def git_changed() -> list[str]:
109
+ files: list[str] = []
110
+ for args in (["git", "diff", "--name-only", "HEAD"], ["git", "ls-files", "--others", "--exclude-standard"]):
111
+ try:
112
+ r = subprocess.run(args, capture_output=True, text=True, errors="replace")
113
+ except OSError:
114
+ continue
115
+ if r.returncode == 0:
116
+ files.extend([ln.strip() for ln in r.stdout.splitlines() if ln.strip()])
117
+ return sorted(set(files))
118
+
119
+
120
+ def main() -> int:
121
+ ap = argparse.ArgumentParser(prog="signoff p3")
122
+ ap.add_argument("cmd", choices=["validate", "plan", "collect"])
123
+ ap.add_argument("--cards", default="tasks")
124
+ ap.add_argument("--changed", nargs="*", default=None)
125
+ ap.add_argument("--json", action="store_true")
126
+ a = ap.parse_args()
127
+
128
+ cards = load_cards(ROOT / a.cards)
129
+ problems = validate(cards)
130
+
131
+ if a.cmd == "validate":
132
+ ok = not problems
133
+ payload = {"gate": "p3", "cmd": "validate", "ok": ok, "cards": len(cards), "problems": problems}
134
+ OUT.mkdir(exist_ok=True)
135
+ (OUT / "p3.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
136
+ if a.json:
137
+ print(json.dumps({k: payload[k] for k in ("gate", "cmd", "ok", "cards")}, ensure_ascii=False))
138
+ else:
139
+ for p in problems:
140
+ print(f"[p3] {p}")
141
+ print(f"[p3] {'PASS' if ok else 'FAIL'} cards={len(cards)} problems={len(problems)}")
142
+ return 0 if ok else 1
143
+
144
+ if a.cmd == "plan":
145
+ waves = plan(cards)
146
+ ok = not problems and bool(waves)
147
+ payload = {"gate": "p3", "cmd": "plan", "ok": ok, "waves": [[c.get("id") for c in w] for w in waves], "problems": problems}
148
+ OUT.mkdir(exist_ok=True)
149
+ (OUT / "p3.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
150
+ if a.json:
151
+ print(json.dumps(payload, ensure_ascii=False))
152
+ else:
153
+ for i, wave in enumerate(waves, 1):
154
+ print(f"[p3] wave {i}: " + ", ".join(c.get("id", "?") for c in wave))
155
+ print(f"[p3] {'PASS' if ok else 'FAIL'} waves={len(waves)} cards={len(cards)}")
156
+ return 0 if ok else 1
157
+
158
+ changed = a.changed if a.changed is not None else git_changed()
159
+ owners = {f: [c.get("id") for c in cards if not c.get("broken") and card_owns(c, f)] for f in changed}
160
+ unowned = [f for f, ids in owners.items() if not ids]
161
+ missing = [c.get("id") for c in cards if not c.get("broken") and not any(card_owns(c, f) for f in changed)]
162
+ ok = not problems and not unowned
163
+ payload = {
164
+ "gate": "p3",
165
+ "cmd": "collect",
166
+ "ok": ok,
167
+ "changed": len(changed),
168
+ "unowned": unowned,
169
+ "cardsWithoutChange": missing,
170
+ "problems": problems,
171
+ }
172
+ OUT.mkdir(exist_ok=True)
173
+ (OUT / "p3.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
174
+ if a.json:
175
+ print(json.dumps({k: payload[k] for k in ("gate", "cmd", "ok", "changed")}, ensure_ascii=False))
176
+ else:
177
+ for f in unowned:
178
+ print(f"[p3] unowned change: {f}")
179
+ for cid in missing:
180
+ print(f"[p3] card shipped nothing: {cid}")
181
+ print(f"[p3] {'PASS' if ok else 'FAIL'} changed={len(changed)} unowned={len(unowned)} empty={len(missing)}")
182
+ return 0 if ok else 1
183
+
184
+
185
+ if __name__ == "__main__":
186
+ raise SystemExit(main())
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env python3
2
+ """SignOff gate 4 - risky actions leave a ledger.
3
+
4
+ Three categories are gated: config, profile, publish. A ticket is minted first
5
+ (`ask`), it is single-use and expires, and only then does `confirm` append a
6
+ hash-chained entry carrying the rollback point. `verify` recomputes the chain and
7
+ checks that every rollback point still exists - a free-text rollback is rejected.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import secrets
16
+ import subprocess
17
+ import time
18
+ from pathlib import Path
19
+
20
+ ACTIONS = ("config", "profile", "publish")
21
+ BODY_KEYS_V1 = ("ts", "action", "target", "detail", "rollback", "decision")
22
+ # 3.3.0 records also hash their repository; pre-3.3.0 records have no `repo` key and keep
23
+ # verifying under the v1 tuple. Mirrors lib/p4.js so the two engines stay byte-compatible.
24
+ BODY_KEYS_V2 = BODY_KEYS_V1 + ("repo",)
25
+
26
+
27
+ def repo_root() -> Path | None:
28
+ try:
29
+ r = subprocess.run(
30
+ ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, errors="replace"
31
+ )
32
+ except OSError:
33
+ return None
34
+ if r.returncode != 0:
35
+ return None
36
+ return Path(r.stdout.strip())
37
+
38
+
39
+ def repo_key() -> str:
40
+ root = repo_root()
41
+ if root is None:
42
+ return ""
43
+ out = str(root).replace("\\", "/").rstrip("/")
44
+ return out.lower() if os.name == "nt" else out
45
+
46
+
47
+ def signoff_dir() -> Path:
48
+ override = os.environ.get("SIGNOFF_HOME")
49
+ if override:
50
+ return Path(override)
51
+ root = repo_root()
52
+ return (root / ".signoff") if root is not None else (Path.home() / ".signoff")
53
+
54
+
55
+ def ledger_path() -> Path:
56
+ return signoff_dir() / "audit.log"
57
+
58
+
59
+ def tickets_dir() -> Path:
60
+ return signoff_dir() / "tickets"
61
+
62
+
63
+ def body_for(rec: dict) -> dict:
64
+ keys = BODY_KEYS_V2 if "repo" in rec else BODY_KEYS_V1
65
+ return {k: rec.get(k) for k in keys}
66
+
67
+
68
+ def seed_ledger() -> None:
69
+ target = ledger_path()
70
+ if target.exists() or os.environ.get("SIGNOFF_HOME"):
71
+ return
72
+ legacy = Path.home() / ".signoff" / "audit.log"
73
+ if legacy.resolve() == target.resolve() or not legacy.exists():
74
+ return
75
+ lines = [line.strip() for line in legacy.read_text(encoding="utf-8").splitlines() if line.strip()]
76
+ if not lines:
77
+ return
78
+ for line in lines:
79
+ try:
80
+ rec = json.loads(line)
81
+ except ValueError:
82
+ return
83
+ rb = rec.get("rollback") or ""
84
+ if not rb or rb == "no-git":
85
+ continue
86
+ if " " in rb:
87
+ return
88
+ try:
89
+ r = subprocess.run(
90
+ ["git", "rev-parse", "--verify", "--quiet", rb], capture_output=True, text=True, errors="replace"
91
+ )
92
+ except OSError:
93
+ continue
94
+ if r.returncode != 0:
95
+ return
96
+ target.parent.mkdir(parents=True, exist_ok=True)
97
+ with target.open("w", encoding="utf-8", newline="") as fh:
98
+ fh.write(os.linesep.join(lines) + os.linesep)
99
+
100
+
101
+ def entries() -> list[dict]:
102
+ seed_ledger()
103
+ log = ledger_path()
104
+ if not log.exists():
105
+ return []
106
+ out = []
107
+ for line in log.read_text(encoding="utf-8").splitlines():
108
+ line = line.strip()
109
+ if line:
110
+ out.append(json.loads(line))
111
+ return out
112
+
113
+
114
+ def digest(prev: str, body: dict) -> str:
115
+ payload = json.dumps(body, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
116
+ return hashlib.sha256((prev + payload).encode("utf-8")).hexdigest()
117
+
118
+
119
+ def git_describe() -> str:
120
+ try:
121
+ r = subprocess.run(["git", "describe", "--tags", "--always"], capture_output=True, text=True, errors="replace")
122
+ return r.stdout.strip() if r.returncode == 0 else "no-git"
123
+ except OSError:
124
+ return "no-git"
125
+
126
+
127
+ def ask(action: str, target: str, ttl: int) -> int:
128
+ tickets_dir().mkdir(parents=True, exist_ok=True)
129
+ token = secrets.token_hex(4)
130
+ (tickets_dir() / f"{token}.json").write_text(
131
+ json.dumps({"token": token, "action": action, "target": target, "expires": time.time() + ttl}, ensure_ascii=False) + "\n",
132
+ encoding="utf-8",
133
+ )
134
+ print(f"ticket {token} action={action} target={target} ttl={ttl}s")
135
+ print(f'confirm with: signoff p4 confirm --ticket {token} --decision allow --detail "..."')
136
+ return 0
137
+
138
+
139
+ def confirm(ticket: str, decision: str, detail: str, rollback: str) -> int:
140
+ path = tickets_dir() / f"{ticket}.json"
141
+ if not path.exists():
142
+ print(f"[p4] unknown or already used ticket {ticket}")
143
+ return 2
144
+ try:
145
+ meta = json.loads(path.read_text(encoding="utf-8"))
146
+ except (OSError, ValueError):
147
+ print(f"[p4] unreadable ticket {ticket}")
148
+ return 2
149
+ if meta.get("expires", 0) < time.time():
150
+ path.unlink(missing_ok=True)
151
+ print(f"[p4] ticket {ticket} expired")
152
+ return 2
153
+ path.unlink(missing_ok=True)
154
+
155
+ chain = entries()
156
+ prev = chain[-1]["hash"] if chain else "genesis"
157
+ body = {
158
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
159
+ "action": meta["action"],
160
+ "target": meta.get("target", ""),
161
+ "detail": detail,
162
+ "rollback": rollback,
163
+ "decision": decision,
164
+ "repo": repo_key(),
165
+ }
166
+ record = dict(body, prev=prev, hash=digest(prev, body))
167
+ ledger_path().parent.mkdir(parents=True, exist_ok=True)
168
+ with ledger_path().open("a", encoding="utf-8") as fh:
169
+ fh.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
170
+ print(json.dumps({"ok": decision == "allow", "hash": record["hash"], "rollback": rollback}, ensure_ascii=False))
171
+ return 0 if decision == "allow" else 1
172
+
173
+
174
+ def verify() -> int:
175
+ chain = entries()
176
+ current = repo_key()
177
+ problems: list[str] = []
178
+ prev = "genesis"
179
+ for i, rec in enumerate(chain, 1):
180
+ body = body_for(rec)
181
+ if rec.get("prev") != prev:
182
+ problems.append(f"line {i}: prev hash mismatch")
183
+ if rec.get("hash") != digest(prev, body):
184
+ problems.append(f"line {i}: content hash mismatch (edited after the fact)")
185
+ prev = rec.get("hash", "")
186
+ owner = rec.get("repo") or ""
187
+ if owner and current and owner != current:
188
+ continue
189
+ rb = rec.get("rollback") or ""
190
+ if not rb:
191
+ problems.append(f"line {i}: no rollback point recorded")
192
+ elif rb != "no-git":
193
+ if " " in rb:
194
+ problems.append(f"line {i}: rollback point '{rb}' is not a git ref")
195
+ else:
196
+ try:
197
+ r = subprocess.run(["git", "rev-parse", "--verify", "--quiet", rb], capture_output=True, text=True, errors="replace")
198
+ except OSError:
199
+ r = None
200
+ if r is not None and r.returncode != 0:
201
+ problems.append(f"line {i}: rollback point '{rb}' no longer exists")
202
+ print(json.dumps({"ok": not problems, "entries": len(chain), "problems": problems}, ensure_ascii=False))
203
+ return 0 if not problems else 1
204
+
205
+
206
+ def export(out: str) -> int:
207
+ chain = entries()
208
+ lines = ["# SignOff ledger", "", "| # | action | target | decision | rollback | hash |", "| --- | --- | --- | --- | --- | --- |"]
209
+ for i, r in enumerate(chain, 1):
210
+ lines.append(
211
+ f"| {i} | {r.get('action','')} | {r.get('target','')} | {r.get('decision','')} | {r.get('rollback','')} | {str(r.get('hash',''))[:12]} |"
212
+ )
213
+ Path(out).write_text("\n".join(lines) + "\n", encoding="utf-8")
214
+ print(f"[p4] {len(chain)} entries written to {out}")
215
+ return 0
216
+
217
+
218
+ def main() -> int:
219
+ ap = argparse.ArgumentParser(prog="signoff p4")
220
+ sub = ap.add_subparsers(dest="cmd", required=True)
221
+
222
+ a = sub.add_parser("ask")
223
+ a.add_argument("--action", choices=ACTIONS, required=True)
224
+ a.add_argument("--target", default="")
225
+ a.add_argument("--ttl", type=int, default=600)
226
+
227
+ c = sub.add_parser("confirm")
228
+ c.add_argument("--ticket", required=True)
229
+ c.add_argument("--decision", choices=["allow", "deny"], default="allow")
230
+ c.add_argument("--detail", default="")
231
+ c.add_argument("--rollback", default=None)
232
+
233
+ sub.add_parser("verify")
234
+
235
+ e = sub.add_parser("export")
236
+ e.add_argument("--out", default="docs/signoff-ledger.md")
237
+
238
+ args = ap.parse_args()
239
+ if args.cmd == "ask":
240
+ return ask(args.action, args.target, args.ttl)
241
+ if args.cmd == "confirm":
242
+ return confirm(args.ticket, args.decision, args.detail, args.rollback or git_describe())
243
+ if args.cmd == "verify":
244
+ return verify()
245
+ return export(args.out)
246
+
247
+
248
+ if __name__ == "__main__":
249
+ raise SystemExit(main())
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env python3
2
+ """SignOff gate 5 - empty words are a failure.
3
+
4
+ Six rules over markdown. Code fences and table rows are skipped so a legitimate
5
+ example cannot be reported as slop; the point is documents that claim progress
6
+ without a number behind it.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import re
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ ROOT = Path.cwd()
17
+ OUT = ROOT / ".signoff"
18
+
19
+ AI_WORDS = (
20
+ "赋能", "闭环", "抓手", "颗粒度", "底层逻辑", "组合拳", "全链路", "护城河",
21
+ "打通", "沉淀", "体系化", "复用", "生态位", "范式", "维度上", "协同增效",
22
+ )
23
+ ABSOLUTE = ("100%", "零缺陷", "永不", "绝对", "完美", "彻底", "极致", "无缝", "彻底解决")
24
+ ADVERBS = ("极其", "非常", "十分", "相当", "大幅")
25
+ CLAIMS = re.compile(r"(已验证|已覆盖|全部通过|全绿|都验证过)")
26
+ PLACEHOLDER = re.compile(r"\b(TODO|TBD|FIXME)\b|待补|待定")
27
+
28
+
29
+ def scan(text: str) -> list[tuple[str, int, str]]:
30
+ hits: list[tuple[str, int, str]] = []
31
+ in_fence = False
32
+ for n, raw in enumerate(text.splitlines(), 1):
33
+ line = raw.strip()
34
+ if line.startswith("```"):
35
+ in_fence = not in_fence
36
+ continue
37
+ if in_fence or line.startswith("|") or not line:
38
+ continue
39
+ # A document may legitimately quote a banned word: `赋能` in backticks is
40
+ # an example, not a claim.
41
+ line = re.sub(r"`[^`]*`", "", line).strip()
42
+ if not line:
43
+ continue
44
+ if PLACEHOLDER.search(line):
45
+ hits.append(("placeholder", n, "unfinished marker"))
46
+ for w in AI_WORDS:
47
+ if w in line:
48
+ hits.append(("ai-word", n, w))
49
+ for w in ABSOLUTE:
50
+ if w in line:
51
+ hits.append(("absolute-claim", n, w))
52
+ if CLAIMS.search(line) and not re.search(r"\d", line):
53
+ hits.append(("claim-without-number", n, "verification claim with no figure"))
54
+ adverbs = sum(line.count(w) for w in ADVERBS)
55
+ if adverbs >= 2:
56
+ hits.append(("empty-adverbs", n, f"{adverbs} intensifiers in one line"))
57
+ counts: dict[str, int] = {}
58
+ for kind, _, word in hits:
59
+ if kind in ("ai-word", "empty-adverbs"):
60
+ counts[word] = counts.get(word, 0) + 1
61
+ return hits
62
+
63
+
64
+ def targets(args: list[str]) -> list[Path]:
65
+ if args:
66
+ out = []
67
+ for a in args:
68
+ p = Path(a)
69
+ if p.is_dir():
70
+ out.extend(sorted(p.rglob("*.md")))
71
+ elif p.exists():
72
+ out.append(p)
73
+ return out
74
+ found = [ROOT / "README.md", ROOT / "CHANGELOG.md"]
75
+ if (ROOT / "docs").is_dir():
76
+ found.extend(sorted((ROOT / "docs").rglob("*.md")))
77
+ return [p for p in found if p.exists()]
78
+
79
+
80
+ def main() -> int:
81
+ ap = argparse.ArgumentParser(prog="signoff p5")
82
+ ap.add_argument("paths", nargs="*")
83
+ ap.add_argument("--json", action="store_true")
84
+ ap.add_argument("--allow", action="append", default=[], help="rule id to ignore")
85
+ a = ap.parse_args()
86
+
87
+ files = targets(a.paths)
88
+ report = []
89
+ for f in files:
90
+ hits = [h for h in scan(f.read_text(encoding="utf-8", errors="replace")) if h[0] not in a.allow]
91
+ rel = str(f.relative_to(ROOT)) if f.is_relative_to(ROOT) else str(f)
92
+ for kind, line, word in hits:
93
+ report.append({"file": rel, "line": line, "rule": kind, "hit": word})
94
+
95
+ ok = not report
96
+ payload = {"gate": "p5", "ok": ok, "files": len(files), "hits": report}
97
+ OUT.mkdir(exist_ok=True)
98
+ (OUT / "p5.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
99
+ if a.json:
100
+ print(json.dumps({"gate": "p5", "ok": ok, "files": len(files), "hits": len(report)}, ensure_ascii=False))
101
+ else:
102
+ for h in report:
103
+ print(f"[p5] {h['file']}:{h['line']} {h['rule']} {h['hit']}")
104
+ print(f"[p5] {'PASS' if ok else 'FAIL'} files={len(files)} hits={len(report)}")
105
+ return 0 if ok else 1
106
+
107
+
108
+ if __name__ == "__main__":
109
+ raise SystemExit(main())