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.
- package/.pre-commit-hooks.yaml +27 -0
- package/CHANGELOG.md +336 -0
- package/LICENSE +21 -0
- package/README.md +540 -3
- package/action.yml +24 -0
- package/bin/signoff.js +218 -0
- package/docs/diagrams/README.md +41 -0
- package/docs/diagrams/signoff-1-capability-en.png +0 -0
- package/docs/diagrams/signoff-1-capability.png +0 -0
- package/docs/diagrams/signoff-2-market-en.png +0 -0
- package/docs/diagrams/signoff-2-market.png +0 -0
- package/docs/diagrams/signoff-3-user-en.png +0 -0
- package/docs/diagrams/signoff-3-user.png +0 -0
- package/docs/diagrams/signoff-4-flow-en.png +0 -0
- package/docs/diagrams/signoff-4-flow.png +0 -0
- package/docs/diagrams/signoff-5-competitors-en.png +0 -0
- package/docs/diagrams/signoff-5-competitors.png +0 -0
- package/docs/diagrams/src/signoff-1-capability-en.mmd +81 -0
- package/docs/diagrams/src/signoff-1-capability.mmd +84 -0
- package/docs/diagrams/src/signoff-2-market-en.mmd +72 -0
- package/docs/diagrams/src/signoff-2-market.mmd +72 -0
- package/docs/diagrams/src/signoff-3-user-en.mmd +94 -0
- package/docs/diagrams/src/signoff-3-user.mmd +92 -0
- package/docs/gates.en.md +105 -0
- package/docs/gates.md +88 -0
- package/docs/integrations.md +58 -0
- package/docs/promotion.md +71 -0
- package/docs/signoff.schema.json +108 -0
- package/docs/trust.md +72 -0
- package/gates/guard.py +474 -0
- package/gates/p1_verify.py +111 -0
- package/gates/p2_coverage.py +127 -0
- package/gates/p3_queue.py +186 -0
- package/gates/p4_ledger.py +249 -0
- package/gates/p5_slop.py +109 -0
- package/lib/audit.js +213 -0
- package/lib/bootstrap.js +361 -0
- package/lib/checkpoint.js +184 -0
- package/lib/ci.js +58 -0
- package/lib/core.js +120 -0
- package/lib/cov.js +253 -0
- package/lib/detect.js +169 -0
- package/lib/doctor.js +304 -0
- package/lib/editor.js +97 -0
- package/lib/explain.js +73 -0
- package/lib/guard.js +1229 -0
- package/lib/p1.js +469 -0
- package/lib/p2.js +323 -0
- package/lib/p3.js +633 -0
- package/lib/p4.js +733 -0
- package/lib/p5.js +438 -0
- package/lib/preset.js +66 -0
- package/lib/secrets.js +336 -0
- package/lib/sign.js +196 -0
- package/package.json +55 -6
- package/test/selftest.js +1226 -0
- 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())
|
package/gates/p5_slop.py
ADDED
|
@@ -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())
|