signoff-cli 0.0.0-stage → 4.0.1

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 +344 -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 +56 -6
  56. package/test/selftest.js +1227 -0
  57. package/test/selftest.py +190 -0
package/docs/trust.md ADDED
@@ -0,0 +1,72 @@
1
+ # 信任模型:这个提交当时真的过了?
2
+
3
+ 同类工具通常只给你一个"通过",落印给你**一串可以互相校验的证据**。每一层都能单独被打假,
4
+ 合起来才构成"事后可核对"。
5
+
6
+ ## 四层证据
7
+
8
+ | 层 | 内容 | 打假方式 |
9
+ | --- | --- | --- |
10
+ | 1. marker | `.signoff/passed` = `{tree, mode, ts}`,tree 是 `git write-tree` 的 40 位哈希 | 改动暂存内容后 tree 变,marker 失效 |
11
+ | 2. trailer | 提交信息尾部 `Signoff-Pass: <tree12>` + `Signoff-Key` + `Signoff-Sig` | 签名覆盖完整 40 位 tree,改一个字就验不过 |
12
+ | 3. 账本 | `.signoff/audit.log`,每条记录 `hash = sha256(prev + body)` | 改内容 → 本条 hash 对不上;改顺序 → prev 对不上 |
13
+ | 4. 检查点 | `.signoff/checkpoint.json` = `{seq, head, root, key, sig}`,root 是记录哈希的 Merkle 根 | 删掉末尾记录 → seq 与 root 对不上 |
14
+
15
+ 第 4 层解决的是前三层的盲区:**删掉最后几条记录,链本身依然是自洽的**。
16
+ 检查点写死了"第 N 条之后的样子",并在仓库外留一行锚点:
17
+
18
+ ```
19
+ signoff1:12:9f2c1ab34d0e:71b0c9d2e4a85f61
20
+ ```
21
+
22
+ ## 三种篡改,三种答复
23
+
24
+ | 攻击 | 结果 |
25
+ | --- | --- |
26
+ | 改提交信息里的 trailer | `audit --verify` 报 `bad-signature` |
27
+ | 改账本某条记录的内容 | `p4 verify` 报 `content hash mismatch (edited after the fact)` |
28
+ | 删掉账本末尾记录 | `p4 verify --checkpoint` 报 `records were removed` |
29
+ | 换一把 key 重新签 | 只有 key 在 `.signoff/allowed_signers` 里才算 `verified`,否则是 `self-signed` |
30
+ | 把锚点贴到别处对不上 | `p4 verify --checkpoint --anchor <那行>` 报锚点不匹配 |
31
+
32
+ ## 怎么用
33
+
34
+ ```bash
35
+ # 每个仓库一次:写第一个检查点
36
+ signoff p4 checkpoint
37
+
38
+ # 之后每次 guard 通过都会自动续签(同一份账本不重复签)
39
+ signoff guard
40
+
41
+ # 把锚点发布出去:发版说明、CI 步骤摘要、同事的消息,任选其一
42
+ signoff p4 anchor
43
+
44
+ # 事后核对
45
+ signoff p4 verify --checkpoint --anchor signoff1:12:9f2c1ab34d0e:71b0c9d2e4a85f61
46
+ ```
47
+
48
+ CI 里:
49
+
50
+ ```yaml
51
+ - run: signoff p4 checkpoint
52
+ - run: echo "signoff1:$(...)" >> "$GITHUB_STEP_SUMMARY"
53
+ ```
54
+
55
+ ## 它不证明什么
56
+
57
+ 诚实地写明边界,比多给三个绿色对勾有用:
58
+
59
+ - **不证明"是谁"**:默认是仓库内自签。要绑定身份,把公钥写进 `.signoff/allowed_signers`。
60
+ - **不证明"活是真人干的"**:测试可以是假的,落印只证明"当时这套命令过了"。
61
+ - **不防 key 泄露**:拿到私钥的人可以重新签;锚点只能让"重写历史"变得可见,不能阻止。
62
+ - **同时重写账本与检查点并重新签名**,且没有发布过锚点——这种情况下本地无法察觉。
63
+ 锚点一旦发布到仓库之外,这条路就断了。
64
+
65
+ ## 和同类的位置
66
+
67
+ - gitleaks / Vale / diff-cover:只有单关结论,没有账本。
68
+ - pre-commit / husky / lefthook:只负责"跑",不负责"留下可核对的记录"。
69
+ - in-toto:有规范化的 attestation 与供应链验证,但要求 layout、签名密钥与验证器一整套基础设施。
70
+ - 落印:一条命令给出"提交 + 账本 + 检查点 + 可发布锚点",代价是自签、格式私有。
71
+
72
+ 一句话:**in-toto 的严谨性它没有,in-toto 的部署成本它也没有。**
package/gates/guard.py ADDED
@@ -0,0 +1,474 @@
1
+ #!/usr/bin/env python3
2
+ """SignOff auto-guard: install once, then the gates run themselves.
3
+
4
+ `guard.py init` wires the five gates into git hooks and CI so that a normal
5
+ `git commit` / `git push` / pull request cannot get past a red gate without
6
+ leaving a trace. `guard.py hook <stage>` is what the hooks call.
7
+
8
+ Design rules:
9
+ * stdlib only, no network;
10
+ * quiet on success (one line), full gate output only when something fails;
11
+ * a pass is cached per staged tree, so re-running the same commit is ~0 ms;
12
+ * fail closed: a missing CLI or a red gate blocks, and the only way past is
13
+ an explicit, recorded skip (SIGNOFF_SKIP=1) or `git commit --no-verify`.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+
26
+ GATES = Path(__file__).resolve().parent
27
+ HOOK_MARK = "# generated by dsh-signoff"
28
+ STATE = ".signoff"
29
+ DEFAULT_CONFIG = {
30
+ "version": 2,
31
+ "docs": ["README.md", "docs"],
32
+ "test_cmd": None,
33
+ "budget": 180,
34
+ "tasks": "tasks",
35
+ "pre_push": True,
36
+ "ci": True,
37
+ }
38
+
39
+
40
+ def now() -> str:
41
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
42
+
43
+
44
+ def sh(args, cwd=None, timeout=None):
45
+ return subprocess.run(
46
+ args, cwd=None if cwd is None else str(cwd), capture_output=True, text=True, errors="replace", timeout=timeout
47
+ )
48
+
49
+
50
+ def repo_root(cwd: Path | None = None) -> Path:
51
+ r = sh(["git", "rev-parse", "--show-toplevel"], cwd=cwd)
52
+ if r.returncode != 0:
53
+ raise SystemExit("signoff: not a git repository (run inside a repo)")
54
+ return Path(r.stdout.strip())
55
+
56
+
57
+ def state_dir(root: Path) -> Path:
58
+ d = root / STATE
59
+ d.mkdir(exist_ok=True)
60
+ return d
61
+
62
+
63
+ def load_config(root: Path) -> dict:
64
+ p = root / STATE / "config.json"
65
+ cfg = dict(DEFAULT_CONFIG)
66
+ if p.exists():
67
+ try:
68
+ cfg.update(json.loads(p.read_text(encoding="utf-8")))
69
+ except Exception:
70
+ pass
71
+ return cfg
72
+
73
+
74
+ def save_config(root: Path, cfg: dict) -> None:
75
+ (root / STATE).mkdir(exist_ok=True)
76
+ (root / STATE / "config.json").write_text(
77
+ json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
78
+ )
79
+
80
+
81
+ def package_version() -> str:
82
+ try:
83
+ pkg = json.loads((GATES.parent / "package.json").read_text(encoding="utf-8"))
84
+ return str(pkg.get("version", "0"))
85
+ except Exception:
86
+ return "0"
87
+
88
+
89
+ # ---------------------------------------------------------------- install
90
+
91
+
92
+ def hook_body(stage: str) -> str:
93
+ return f"""#!/bin/sh
94
+ {HOOK_MARK} {package_version()} -- remove with: signoff uninstall
95
+ ROOT=$(git rev-parse --show-toplevel) || exit 1
96
+ if command -v signoff >/dev/null 2>&1; then
97
+ exec signoff hook {stage}
98
+ fi
99
+ if [ -f "$ROOT/node_modules/dsh-signoff/bin/signoff.js" ]; then
100
+ exec node "$ROOT/node_modules/dsh-signoff/bin/signoff.js" hook {stage}
101
+ fi
102
+ echo "[signoff] CLI not found: npm i -g dsh-signoff (or add it as a devDependency)" >&2
103
+ echo "[signoff] this repo is guarded; install the CLI, or bypass this once with --no-verify" >&2
104
+ exit 1
105
+ """
106
+
107
+
108
+ CI_BODY = """# generated by dsh-signoff -- remove with: signoff uninstall
109
+ name: signoff
110
+ on: [push, pull_request]
111
+ jobs:
112
+ gates:
113
+ runs-on: ubuntu-latest
114
+ steps:
115
+ - uses: actions/checkout@v4
116
+ - uses: actions/setup-node@v4
117
+ with: { node-version: "20" }
118
+ - uses: actions/setup-python@v5
119
+ with: { python-version: "3.11" }
120
+ - run: npx --yes dsh-signoff@latest guard --ci
121
+ """
122
+
123
+
124
+ def write_exec(path: Path, text: str) -> None:
125
+ path.write_text(text, encoding="utf-8", newline="\n")
126
+ try:
127
+ os.chmod(path, 0o755)
128
+ except OSError:
129
+ pass
130
+
131
+
132
+ def cmd_init(args) -> int:
133
+ root = repo_root()
134
+ cfg = load_config(root)
135
+ if args.test_cmd:
136
+ cfg["test_cmd"] = args.test_cmd
137
+ if args.budget:
138
+ cfg["budget"] = args.budget
139
+ if args.docs:
140
+ cfg["docs"] = args.docs
141
+ cfg["pre_push"] = not args.no_push
142
+ cfg["ci"] = not args.no_ci
143
+ cfg["version"] = 2
144
+
145
+ hooks = root / ".git" / "hooks"
146
+ targets = [("pre-commit", "pre-commit")]
147
+ if cfg["pre_push"]:
148
+ targets.append(("pre-push", "pre-push"))
149
+
150
+ if args.dry_run:
151
+ for name, _ in targets:
152
+ print(f"would write {hooks / name}")
153
+ if cfg["ci"]:
154
+ print(f"would write {root / '.github/workflows/signoff.yml'}")
155
+ print(f"would write {root / STATE / 'config.json'}")
156
+ return 0
157
+
158
+ hooks.mkdir(parents=True, exist_ok=True)
159
+ notes = []
160
+ for name, stage in targets:
161
+ target = hooks / name
162
+ if target.exists() and HOOK_MARK not in target.read_text(encoding="utf-8", errors="replace"):
163
+ backup = hooks / f"{name}.signoff-backup"
164
+ if not backup.exists():
165
+ backup.write_text(target.read_text(encoding="utf-8", errors="replace"), encoding="utf-8")
166
+ notes.append(f" saved old hook -> .git/hooks/{backup.name}")
167
+ write_exec(target, hook_body(stage))
168
+
169
+ if cfg["ci"]:
170
+ ci = root / ".github" / "workflows"
171
+ ci.mkdir(parents=True, exist_ok=True)
172
+ write_exec(ci / "signoff.yml", CI_BODY)
173
+
174
+ gi = root / ".gitignore"
175
+ line = f"{STATE}/"
176
+ if gi.exists():
177
+ txt = gi.read_text(encoding="utf-8", errors="replace")
178
+ if line not in txt.splitlines():
179
+ gi.write_text(txt.rstrip("\n") + "\n" + line + "\n", encoding="utf-8")
180
+ else:
181
+ gi.write_text(line + "\n", encoding="utf-8")
182
+
183
+ save_config(root, cfg)
184
+
185
+ print(f"signoff {package_version()} installed in {root.name}")
186
+ for n in notes:
187
+ print(n)
188
+ for name, _ in targets:
189
+ print(f" hook .git/hooks/{name}")
190
+ if cfg["ci"]:
191
+ print(" ci .github/workflows/signoff.yml")
192
+ print(f" config {STATE}/config.json")
193
+ print(f" test {cfg['test_cmd'] or detect_test(root, cfg) or 'not detected (p1 will be skipped)'}")
194
+ print(" next git commit as usual; a red gate stops the commit")
195
+ return 0
196
+
197
+
198
+ # ---------------------------------------------------------------- run
199
+
200
+
201
+ def detect_test(root: Path, cfg: dict) -> str | None:
202
+ if cfg.get("test_cmd"):
203
+ return str(cfg["test_cmd"])
204
+ pkg = root / "package.json"
205
+ if pkg.exists():
206
+ try:
207
+ scripts = json.loads(pkg.read_text(encoding="utf-8")).get("scripts", {})
208
+ test = scripts.get("test")
209
+ if test and "no test specified" not in test:
210
+ return "npm test --silent"
211
+ except Exception:
212
+ pass
213
+ has_py = (root / "tests").is_dir() or any(root.glob("test_*.py")) or any(root.glob("**/test_*.py"))
214
+ if has_py:
215
+ probe = sh([sys.executable, "-c", "import pytest"])
216
+ if probe.returncode == 0:
217
+ return f'"{sys.executable}" -m pytest -q'
218
+ return None
219
+
220
+
221
+ def staged_files(root: Path) -> list[str]:
222
+ r = sh(["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"], cwd=root)
223
+ return [ln.strip() for ln in r.stdout.splitlines() if ln.strip()]
224
+
225
+
226
+ def tree_key(root: Path) -> str:
227
+ r = sh(["git", "write-tree"], cwd=root)
228
+ base = r.stdout.strip() if r.returncode == 0 else ""
229
+ r2 = sh(["git", "diff", "--cached", "--name-only"], cwd=root)
230
+ return base + "|" + ",".join(sorted(r2.stdout.split()))
231
+
232
+
233
+ def staged_docs(files: list[str], cfg: dict) -> list[str]:
234
+ docs = cfg.get("docs") or []
235
+ out = []
236
+ for f in files:
237
+ if not f.endswith((".md", ".mdx", ".txt")):
238
+ continue
239
+ for d in docs:
240
+ d = str(d).rstrip("/")
241
+ if f == d or f.startswith(d + "/"):
242
+ out.append(f)
243
+ break
244
+ return out
245
+
246
+
247
+ def ledger_path() -> Path:
248
+ home = os.environ.get("SIGNOFF_HOME")
249
+ return (Path(home) / "audit.log") if home else (Path.home() / ".signoff" / "audit.log")
250
+
251
+
252
+ def docs_files(root: Path, cfg: dict) -> list[str]:
253
+ out: list[str] = []
254
+ for d in cfg.get("docs") or []:
255
+ base = d.rstrip("/")
256
+ target = root / base
257
+ if target.is_file():
258
+ out.append(base)
259
+ elif target.is_dir():
260
+ out.extend(
261
+ str(p.relative_to(root)).replace("\\", "/") for p in sorted(target.rglob("*.md"))
262
+ )
263
+ return out
264
+
265
+
266
+ def plan(root: Path, cfg: dict, files: list[str], full: bool) -> list[tuple[str, list[str]]]:
267
+ steps: list[tuple[str, list[str]]] = []
268
+ docs = staged_docs(files, cfg)
269
+ if docs:
270
+ steps.append(("p5", docs))
271
+ cmd = detect_test(root, cfg)
272
+ if cmd:
273
+ steps.append(("p1", ["--run", cmd, "--budget", str(cfg.get("budget", 180))]))
274
+ # P2 only joins when the repo actually has scenario fixtures; a repo without
275
+ # them is not "low coverage", it is simply not using this gate.
276
+ if (root / "verify" / "scenarios").is_dir() or cfg.get("apps"):
277
+ steps.append(("p2", ["--strict"]))
278
+ if ledger_path().exists():
279
+ steps.append(("p4", ["verify"]))
280
+ return steps
281
+
282
+
283
+ GATE_FILES = {
284
+ "p1": "p1_verify.py",
285
+ "p2": "p2_coverage.py",
286
+ "p3": "p3_queue.py",
287
+ "p4": "p4_ledger.py",
288
+ "p5": "p5_slop.py",
289
+ }
290
+
291
+
292
+ def run_gate(name: str, args: list[str], root: Path, timeout: float) -> tuple[int, float, str]:
293
+ t0 = time.time()
294
+ try:
295
+ r = sh([sys.executable, str(GATES / GATE_FILES[name]), *args], cwd=root, timeout=timeout)
296
+ except subprocess.TimeoutExpired:
297
+ return 1, time.time() - t0, f"{name}: timed out after {timeout:.0f}s"
298
+ return r.returncode, time.time() - t0, (r.stdout or "") + (r.stderr or "")
299
+
300
+
301
+ def cmd_guard(args) -> int:
302
+ root = repo_root()
303
+ cfg = load_config(root)
304
+ files = staged_files(root)
305
+ full = args.full or args.ci
306
+
307
+ if not files and not full:
308
+ print("signoff guard: nothing staged")
309
+ return 0
310
+
311
+ key = tree_key(root)
312
+ cache = root / STATE / "cache.json"
313
+ if not full and cache.exists():
314
+ try:
315
+ c = json.loads(cache.read_text(encoding="utf-8"))
316
+ if c.get("key") == key and c.get("ok"):
317
+ print(f"signoff guard: cached pass ({c.get('seconds', 0):.2f}s)")
318
+ return 0
319
+ except Exception:
320
+ pass
321
+
322
+ if os.environ.get("SIGNOFF_SKIP") == "1":
323
+ sd = state_dir(root)
324
+ (sd / "skips.log").open("a", encoding="utf-8").write(f"{now()} skipped {len(files)} file(s) via SIGNOFF_SKIP=1\n")
325
+ print("signoff guard: SKIPPED (SIGNOFF_SKIP=1, recorded in .signoff/skips.log)")
326
+ return 0
327
+
328
+ steps = plan(root, cfg, files, full)
329
+ if args.ci:
330
+ # CI has no index: check every configured doc, not just the staged ones.
331
+ all_docs = docs_files(root, cfg)
332
+ if all_docs:
333
+ steps = [("p5", all_docs)] + [s for s in steps if s[0] != "p5"]
334
+ if not steps:
335
+ print("signoff guard: no gate applies to this change")
336
+ return 0
337
+
338
+ budget = float(cfg.get("budget", 180))
339
+ print(f"signoff guard: {len(files)} file(s) staged, {len(steps)} gate(s)")
340
+ t0 = time.time()
341
+ failed: list[tuple[str, str]] = []
342
+ for name, gargs in steps:
343
+ rc, secs, out = run_gate(name, gargs, root, max(budget, 60))
344
+ print(f" {name:<4} {'ok ' if rc == 0 else 'FAIL'} {secs:6.2f}s")
345
+ if rc != 0:
346
+ failed.append((name, out))
347
+ total = time.time() - t0
348
+
349
+ if failed:
350
+ sd = state_dir(root)
351
+ (sd / "hook.log").write_text("\n\n".join(f"== {n} ==\n{o}" for n, o in failed), encoding="utf-8")
352
+ print(f"signoff guard: FAIL ({total:.2f}s)")
353
+ for n, out in failed:
354
+ tail = [ln for ln in out.strip().splitlines() if ln.strip()][-8:]
355
+ print(f"-- {n} --")
356
+ for ln in tail:
357
+ print(f" {ln}")
358
+ print(f" full output: {STATE}/hook.log")
359
+ print(" bypass once: SIGNOFF_SKIP=1 git commit ... (recorded) or git commit --no-verify")
360
+ return 1
361
+
362
+ save = root / STATE / "cache.json"
363
+ save.write_text(json.dumps({"key": key, "ok": True, "ts": now(), "seconds": round(total, 3)}) + "\n", encoding="utf-8")
364
+ print(f"signoff guard: PASS ({total:.2f}s)")
365
+ return 0
366
+
367
+
368
+ # ---------------------------------------------------------------- status
369
+
370
+
371
+ def installed(root: Path) -> dict:
372
+ hooks = root / ".git" / "hooks"
373
+ out = {}
374
+ for name in ("pre-commit", "pre-push"):
375
+ p = hooks / name
376
+ out[name] = bool(p.exists() and HOOK_MARK in p.read_text(encoding="utf-8", errors="replace"))
377
+ ci = root / ".github" / "workflows" / "signoff.yml"
378
+ out["ci"] = bool(ci.exists() and HOOK_MARK in ci.read_text(encoding="utf-8", errors="replace"))
379
+ out["config"] = (root / STATE / "config.json").exists()
380
+ c = root / STATE / "cache.json"
381
+ if c.exists():
382
+ try:
383
+ d = json.loads(c.read_text(encoding="utf-8"))
384
+ out["last_run"] = {"ok": d.get("ok"), "ts": d.get("ts"), "seconds": d.get("seconds")}
385
+ except Exception:
386
+ pass
387
+ return out
388
+
389
+
390
+ def cmd_status(args) -> int:
391
+ root = repo_root()
392
+ cfg = load_config(root)
393
+ st = installed(root)
394
+ if args.json:
395
+ print(json.dumps({"root": str(root), "version": package_version(), "installed": st, "config": cfg}, ensure_ascii=False))
396
+ return 0
397
+ print(f"repo {root}")
398
+ print(f"version {package_version()}")
399
+ for k in ("pre-commit", "pre-push", "ci"):
400
+ print(f"{k:<12}{'installed' if st[k] else 'missing'}")
401
+ print(f"{'config':<12}{'present' if st['config'] else 'missing'}")
402
+ print(f"{'test':<12}{detect_test(root, cfg) or 'not detected'}")
403
+ lr = st.get("last_run")
404
+ print(f"{'last run':<12}{lr['ts'] + (' PASS' if lr['ok'] else ' FAIL') if lr else 'never'}")
405
+ return 0
406
+
407
+
408
+ def cmd_uninstall(args) -> int:
409
+ root = repo_root()
410
+ removed = []
411
+ for name in ("pre-commit", "pre-push"):
412
+ p = root / ".git" / "hooks" / name
413
+ if p.exists() and HOOK_MARK in p.read_text(encoding="utf-8", errors="replace"):
414
+ p.unlink()
415
+ removed.append(f".git/hooks/{name}")
416
+ ci = root / ".github" / "workflows" / "signoff.yml"
417
+ if ci.exists() and HOOK_MARK in ci.read_text(encoding="utf-8", errors="replace"):
418
+ ci.unlink()
419
+ removed.append(".github/workflows/signoff.yml")
420
+ if args.all:
421
+ p = root / STATE / "config.json"
422
+ if p.exists():
423
+ p.unlink()
424
+ removed.append(f"{STATE}/config.json")
425
+ print("removed: " + (", ".join(removed) if removed else "nothing"))
426
+ return 0
427
+
428
+
429
+ def cmd_hook(args) -> int:
430
+ return cmd_guard(argparse.Namespace(full=args.stage == "pre-push", ci=False))
431
+
432
+
433
+ def main(argv: list[str] | None = None) -> int:
434
+ ap = argparse.ArgumentParser(prog="signoff guard", description="install and run the automatic gates")
435
+ sub = ap.add_subparsers(dest="cmd", required=True)
436
+
437
+ i = sub.add_parser("init", help="install git hooks + CI workflow")
438
+ i.add_argument("--test-cmd", default=None)
439
+ i.add_argument("--budget", type=float, default=None)
440
+ i.add_argument("--docs", nargs="*", default=None)
441
+ i.add_argument("--no-ci", action="store_true")
442
+ i.add_argument("--no-push", action="store_true")
443
+ i.add_argument("--dry-run", action="store_true")
444
+
445
+ g = sub.add_parser("guard", help="run the gates now (what the hooks call)")
446
+ g.add_argument("--full", action="store_true")
447
+ g.add_argument("--ci", action="store_true")
448
+ g.add_argument("--json", action="store_true")
449
+
450
+ h = sub.add_parser("hook", help="internal: called by git hooks")
451
+ h.add_argument("stage", choices=["pre-commit", "pre-push"])
452
+
453
+ s = sub.add_parser("status", help="show whether this repo is guarded")
454
+ s.add_argument("--json", action="store_true")
455
+
456
+ u = sub.add_parser("uninstall", help="remove hooks and CI workflow")
457
+ u.add_argument("--all", action="store_true", help="also remove .signoff/config.json")
458
+
459
+ args = ap.parse_args(argv)
460
+ if args.cmd == "init":
461
+ return cmd_init(args)
462
+ if args.cmd == "guard":
463
+ return cmd_guard(args)
464
+ if args.cmd == "hook":
465
+ return cmd_hook(args)
466
+ if args.cmd == "status":
467
+ return cmd_status(args)
468
+ if args.cmd == "uninstall":
469
+ return cmd_uninstall(args)
470
+ return 2
471
+
472
+
473
+ if __name__ == "__main__":
474
+ raise SystemExit(main())
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """SignOff gate 1 - verify before you speak.
3
+
4
+ Runs the project's own checks, times them, parses the case count and writes
5
+ .signoff/p1.json. Exit code is the verdict: 0 only when every command passed
6
+ inside the time budget. Output is ASCII on purpose: a GBK console must not be
7
+ able to turn a real PASS into mojibake.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import re
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ ROOT = Path.cwd()
20
+ OUT = ROOT / ".signoff"
21
+
22
+
23
+ def default_commands() -> list[str]:
24
+ if (ROOT / "tests").is_dir():
25
+ return [f'"{sys.executable}" -m pytest tests -q']
26
+ if (ROOT / "package.json").exists():
27
+ return ["npm test --silent"]
28
+ return []
29
+
30
+
31
+ def count_cases(text: str) -> int | None:
32
+ for pat in (r"(\d+)\s+passed", r"(\d+)\s+tests?\b", r"(\d+)\s+checks?\b", r"(\d+)\s+assertions?\b"):
33
+ hits = re.findall(pat, text)
34
+ if hits:
35
+ return max(int(h) for h in hits)
36
+ return None
37
+
38
+
39
+ def install_hook() -> int:
40
+ hooks = ROOT / ".git" / "hooks"
41
+ if not hooks.is_dir():
42
+ print("[p1] not a git repository (no .git/hooks)")
43
+ return 2
44
+ hook = hooks / "pre-commit"
45
+ hook.write_text("#!/bin/sh\n# installed by SignOff / dsh-signoff\nexec signoff p1\n", encoding="utf-8")
46
+ try:
47
+ hook.chmod(0o755)
48
+ except OSError:
49
+ pass
50
+ print(f"[p1] hook installed: {hook}")
51
+ return 0
52
+
53
+
54
+ def main() -> int:
55
+ ap = argparse.ArgumentParser(prog="signoff p1")
56
+ ap.add_argument("--run", action="append", default=[], help="command to run (repeatable)")
57
+ ap.add_argument("--budget", type=float, default=180.0, help="seconds for all commands together")
58
+ ap.add_argument("--install-hook", action="store_true")
59
+ ap.add_argument("--json", action="store_true")
60
+ ap.add_argument("--quiet", action="store_true")
61
+ a = ap.parse_args()
62
+
63
+ if a.install_hook:
64
+ return install_hook()
65
+
66
+ cmds = a.run or default_commands()
67
+ if not cmds:
68
+ print("[p1] nothing to run: no tests/ and no package.json")
69
+ return 2
70
+
71
+ started = time.perf_counter()
72
+ results = []
73
+ for cmd in cmds:
74
+ t0 = time.perf_counter()
75
+ proc = subprocess.run(cmd, shell=True, capture_output=True, text=True, errors="replace")
76
+ ms = round((time.perf_counter() - t0) * 1000)
77
+ out = (proc.stdout or "") + (proc.stderr or "")
78
+ results.append(
79
+ {
80
+ "cmd": cmd,
81
+ "rc": proc.returncode,
82
+ "ms": ms,
83
+ "cases": count_cases(out),
84
+ "tail": out.strip().splitlines()[-3:],
85
+ }
86
+ )
87
+ if not a.quiet:
88
+ print(f"[p1] rc={proc.returncode} {ms} ms {cmd}")
89
+
90
+ elapsed = round((time.perf_counter() - started) * 1000)
91
+ ok = all(r["rc"] == 0 for r in results) and elapsed <= a.budget * 1000
92
+ total_cases = sum(r["cases"] or 0 for r in results)
93
+ payload = {
94
+ "gate": "p1",
95
+ "ok": ok,
96
+ "elapsedMs": elapsed,
97
+ "budgetMs": int(a.budget * 1000),
98
+ "cases": total_cases,
99
+ "results": results,
100
+ }
101
+ OUT.mkdir(exist_ok=True)
102
+ (OUT / "p1.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
103
+ if a.json:
104
+ print(json.dumps({k: payload[k] for k in ("gate", "ok", "elapsedMs", "budgetMs", "cases")}, ensure_ascii=False))
105
+ else:
106
+ print(f"[p1] {'PASS' if ok else 'FAIL'} {elapsed} ms (budget {int(a.budget * 1000)} ms), cases={total_cases}")
107
+ return 0 if ok else 1
108
+
109
+
110
+ if __name__ == "__main__":
111
+ raise SystemExit(main())