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
package/lib/guard.js ADDED
@@ -0,0 +1,1229 @@
1
+ "use strict";
2
+ // SignOff auto-guard: install once, then the gates run themselves.
3
+ //
4
+ // `guard 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 hook <stage>` is what the hooks call.
7
+ //
8
+ // Port of gates/guard.py with one intended change: a gate is spawned as
9
+ // `process.execPath bin/signoff.js <gate> ...` (the pure-Node dispatcher)
10
+ // instead of `python <gate>.py`. Nothing here requires Python: the pytest
11
+ // fallback is only considered when a python interpreter happens to exist.
12
+ //
13
+ // Design rules (kept from the Python reference):
14
+ // * stdlib only, no network;
15
+ // * quiet on success (one line), full gate output only when something fails;
16
+ // * a pass is cached per staged tree, so re-running the same commit is ~0 ms;
17
+ // * fail closed: a missing CLI or a red gate blocks, and the only way past is
18
+ // an explicit, recorded skip (SIGNOFF_SKIP=1) or `git commit --no-verify`.
19
+
20
+ const fs = require("fs");
21
+ const os = require("os");
22
+ const path = require("path");
23
+ const { spawnSync } = require("child_process");
24
+ const core = require("./core");
25
+ const detector = require("./detect");
26
+
27
+ const ROOT = path.join(__dirname, "..");
28
+ const DISPATCHER = path.join(ROOT, "bin", "signoff.js");
29
+ const HOOK_MARK = "# generated by signoff-cli";
30
+ const LEGACY_HOOK_MARK = "# generated by dsh-signoff";
31
+ function markedByUs(text) {
32
+ const t = String(text || "");
33
+ return t.includes(HOOK_MARK) || t.includes(LEGACY_HOOK_MARK);
34
+ }
35
+ const STATE = ".signoff";
36
+ const DEFAULT_CONFIG = {
37
+ checkpoint: false,
38
+ version: 2,
39
+ docs: ["README.md", "docs"],
40
+ test_cmd: null,
41
+ budget: 180,
42
+ tasks: "tasks",
43
+ pre_push: true,
44
+ ci: true,
45
+ // Explicit defaults: a reader should not have to infer these from a `!== false`.
46
+ secrets: true,
47
+ cache: true,
48
+ docsFast: true,
49
+ coverage: null,
50
+ sign: false,
51
+ };
52
+ // pre-commit: run the gates and record what they saw
53
+ // commit-msg: turn that record into a trailer on the message (never fails a commit)
54
+ // pre-push: the full set, since a push is the last cheap moment to stop
55
+ const STAGES = ["pre-commit", "commit-msg", "pre-push"];
56
+ const PREPARE_SCRIPT = "signoff init --quiet";
57
+
58
+ class GuardExit extends Error {
59
+ constructor(message, code) {
60
+ super(message);
61
+ this.code = code === undefined ? 1 : code;
62
+ }
63
+ }
64
+
65
+ // A bad flag value, raised before anything is written (argparse does the same).
66
+ class ArgparseError extends Error {}
67
+
68
+ // ---------------------------------------------------------------- helpers
69
+
70
+ function now() {
71
+ return core.nowISO();
72
+ }
73
+
74
+ function sh(args, cwd, timeout) {
75
+ const r = spawnSync(args[0], args.slice(1), {
76
+ cwd: cwd === undefined || cwd === null ? process.cwd() : String(cwd),
77
+ encoding: "utf8",
78
+ timeout: timeout === undefined || timeout === null ? undefined : timeout,
79
+ windowsHide: true,
80
+ maxBuffer: 64 * 1024 * 1024,
81
+ });
82
+ return {
83
+ code: r.status === null ? 127 : r.status,
84
+ out: r.stdout || "",
85
+ err: r.stderr || "",
86
+ error: r.error,
87
+ };
88
+ }
89
+
90
+ function readText(p) {
91
+ return fs.readFileSync(p, "utf8");
92
+ }
93
+
94
+ function isFile(p) {
95
+ try {
96
+ return fs.statSync(p).isFile();
97
+ } catch (e) {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ function isDir(p) {
103
+ try {
104
+ return fs.statSync(p).isDirectory();
105
+ } catch (e) {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ function exists(p) {
111
+ try {
112
+ fs.statSync(p);
113
+ return true;
114
+ } catch (e) {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ function repoRoot(cwd) {
120
+ const r = sh(["git", "rev-parse", "--show-toplevel"], cwd);
121
+ if (r.code !== 0) throw new GuardExit("signoff: not a git repository (run inside a repo)", 1);
122
+ // git prints forward slashes; the Python reference prints the platform form.
123
+ return path.resolve(r.out.trim());
124
+ }
125
+
126
+ function stateDir(root) {
127
+ const d = path.join(root, STATE);
128
+ fs.mkdirSync(d, { recursive: true });
129
+ return d;
130
+ }
131
+
132
+ function loadConfig(root) {
133
+ const p = path.join(root, STATE, "config.json");
134
+ const cfg = Object.assign({}, DEFAULT_CONFIG);
135
+ if (exists(p)) {
136
+ try {
137
+ Object.assign(cfg, JSON.parse(fs.readFileSync(p, "utf8")));
138
+ } catch (e) {
139
+ /* keep the defaults */
140
+ }
141
+ }
142
+ return cfg;
143
+ }
144
+
145
+ function saveConfig(root, cfg) {
146
+ fs.mkdirSync(path.join(root, STATE), { recursive: true });
147
+ fs.writeFileSync(path.join(root, STATE, "config.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
148
+ }
149
+
150
+ function packageVersion() {
151
+ try {
152
+ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
153
+ return String(pkg.version === undefined ? "0" : pkg.version);
154
+ } catch (e) {
155
+ return "0";
156
+ }
157
+ }
158
+
159
+ // ---------------------------------------------------------------- install
160
+
161
+ function hookBody(stage, chain) {
162
+ // commit-msg receives the message file as $1; it must never block a commit, so
163
+ // a missing CLI warns and gets out of the way instead of exiting 1.
164
+ const extra = stage === "commit-msg" ? ' "$1"' : "";
165
+ // A chained install keeps the hook that was already there as `<stage>.local` and runs
166
+ // it first: its verdict is the repo owner's, so a failure stops the commit before any
167
+ // signoff gate spends time on it.
168
+ // A chained hook can be a shell script, a python file, a node script or a PowerShell
169
+ // script. The suffix picks the launcher: on Windows a git hook has no exec bit, so
170
+ // `sh file.py` would fail even though `python file.py` works. `.local` stays first so
171
+ // repos installed before 3.9.0 keep working.
172
+ const chained = chain
173
+ ? `CHAIN_DIR="$(git rev-parse --git-dir)/hooks"
174
+ for LOCAL in "$CHAIN_DIR/${stage}.local" "$CHAIN_DIR/${stage}.local.sh" "$CHAIN_DIR/${stage}.local.py" "$CHAIN_DIR/${stage}.local.js" "$CHAIN_DIR/${stage}.local.ps1" "$CHAIN_DIR/${stage}.local.cmd"; do
175
+ [ -f "$LOCAL" ] || continue
176
+ if [ -x "$LOCAL" ]; then
177
+ "$LOCAL" "$@" || exit $?
178
+ else
179
+ case "$LOCAL" in
180
+ *.py) PY=$(command -v python3 || command -v python || command -v py) || { echo "[signoff] chained hook needs python: $LOCAL" >&2; exit 1; }; "$PY" "$LOCAL" "$@" || exit $? ;;
181
+ *.js) node "$LOCAL" "$@" || exit $? ;;
182
+ *.ps1) powershell -NoProfile -ExecutionPolicy Bypass -File "$LOCAL" "$@" || exit $? ;;
183
+ *.cmd) cmd //c "$LOCAL" "$@" || exit $? ;;
184
+ *) sh "$LOCAL" "$@" || exit $? ;;
185
+ esac
186
+ fi
187
+ break
188
+ done
189
+ `
190
+ : "";
191
+ const tail =
192
+ stage === "commit-msg"
193
+ ? `echo "[signoff] CLI not found: the commit continues, but it gets no Signoff trailer" >&2
194
+ exit 0
195
+ `
196
+ : `echo "[signoff] CLI not found: npm i -g signoff-cli (or add it as a devDependency)" >&2
197
+ echo "[signoff] this repo is guarded; install the CLI, or bypass this once with --no-verify" >&2
198
+ exit 1
199
+ `;
200
+ return `#!/bin/sh
201
+ ${HOOK_MARK} ${packageVersion()} -- remove with: signoff uninstall
202
+ ROOT=$(git rev-parse --show-toplevel) || exit 1
203
+ ${chained}if command -v signoff >/dev/null 2>&1; then
204
+ exec signoff hook ${stage}${extra}
205
+ fi
206
+ if [ -f "$ROOT/node_modules/signoff-cli/bin/signoff.js" ]; then
207
+ exec node "$ROOT/node_modules/signoff-cli/bin/signoff.js" hook ${stage}${extra}
208
+ fi
209
+ if [ -f "$ROOT/node_modules/dsh-signoff/bin/signoff.js" ]; then
210
+ exec node "$ROOT/node_modules/dsh-signoff/bin/signoff.js" hook ${stage}${extra}
211
+ fi
212
+ ${tail}`;
213
+ }
214
+
215
+ const CI_BODY = `# generated by signoff-cli -- remove with: signoff uninstall
216
+ name: signoff
217
+ on: [push, pull_request]
218
+ jobs:
219
+ gates:
220
+ runs-on: ubuntu-latest
221
+ steps:
222
+ - uses: actions/checkout@v4
223
+ with: { fetch-depth: 0 }
224
+ - uses: actions/setup-node@v4
225
+ with: { node-version: "20" }
226
+ - uses: actions/setup-python@v5
227
+ with: { python-version: "3.11" }
228
+ - run: npx --yes signoff-cli@latest guard --ci
229
+ - name: audit trailers
230
+ env:
231
+ GITHUB_EVENT_BEFORE: \${{ github.event.before }}
232
+ run: |
233
+ # A shallow clone has no history, and \`git rev-parse HEAD~1\` echoes the missing
234
+ # name instead of failing, so ask for a verified commit hash or nothing at all.
235
+ BASE="\${GITHUB_EVENT_BEFORE}"
236
+ if [ -z "$BASE" ] || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
237
+ git fetch --unshallow --quiet 2>/dev/null || git fetch --deepen=50 --quiet 2>/dev/null || true
238
+ BASE=$(git rev-parse --verify --quiet "HEAD~1^{commit}" || true)
239
+ fi
240
+ if [ -n "$BASE" ]; then
241
+ npx --yes signoff-cli@latest audit --range "$BASE..HEAD"
242
+ else
243
+ echo "no base commit in this clone; skipping the trailer audit"
244
+ fi
245
+ `;
246
+
247
+ function writeExec(p, text) {
248
+ fs.writeFileSync(p, text, "utf8");
249
+ try {
250
+ fs.chmodSync(p, 0o755);
251
+ } catch (e) {
252
+ /* windows has no execute bit */
253
+ }
254
+ }
255
+
256
+ // ---------------------------------------------------------------- marker
257
+ //
258
+ // `.signoff/passed` is the hand-off from pre-commit to commit-msg: the tree the
259
+ // gate run saw, and whether it passed or was explicitly skipped. It is deleted
260
+ // by commit-msg as soon as it is used, so a marker never outlives its commit.
261
+
262
+ // Consume the hand-off marker. It is single-use: a stale marker (tree no longer
263
+ // matches, or a commit that never happened) must never survive to a later commit,
264
+ // or the same trailer could be appended twice.
265
+ function dropMarker(root) {
266
+ try {
267
+ fs.rmSync(markerPath(root), { force: true });
268
+ return true;
269
+ } catch (e) {
270
+ return false;
271
+ }
272
+ }
273
+
274
+ function markerPath(root) {
275
+ return path.join(root, STATE, "passed");
276
+ }
277
+
278
+ function currentTree(root) {
279
+ const r = sh(["git", "write-tree"], root);
280
+ const tree = r.code === 0 ? r.out.trim() : "";
281
+ return /^[0-9a-f]{40,64}$/.test(tree) ? tree : null;
282
+ }
283
+
284
+ function writeMarker(root, mode) {
285
+ const tree = currentTree(root);
286
+ if (!tree) return null;
287
+ fs.mkdirSync(path.join(root, STATE), { recursive: true });
288
+ dropMarker(root);
289
+ fs.writeFileSync(markerPath(root), JSON.stringify({ tree, mode, ts: now() }) + "\n", "utf8");
290
+ if (mode === "pass") maybeCheckpoint(root);
291
+ return tree;
292
+ }
293
+
294
+ // A repo that has ever written a checkpoint keeps writing them: the ledger is then
295
+ // tamper-evident from the outside, not only from inside the file.
296
+ function maybeCheckpoint(root) {
297
+ try {
298
+ const cfg = loadConfig(root);
299
+ const cp = require("./checkpoint");
300
+ if (cfg.checkpoint !== true && !exists(cp.checkpointPath())) return { ok: true, skipped: "no checkpoint in this repo" };
301
+ const now = cp.summarize();
302
+ const prev = cp.readCheckpoint();
303
+ // Same ledger, same statement: re-signing would only churn the file.
304
+ if (prev && Number(prev.seq) === now.seq && prev.head === now.head) {
305
+ return { ok: true, seq: now.seq, unchanged: true };
306
+ }
307
+ const made = cp.create({ sign: true });
308
+ return { ok: true, seq: made.checkpoint.seq, anchor: made.checkpoint.anchor };
309
+ } catch (e) {
310
+ // A hook must never fail a commit over bookkeeping; callers that care can see why.
311
+ return { ok: false, error: String((e && e.message) || e) };
312
+ }
313
+ }
314
+
315
+ // commit-msg <msgfile>: append the trailer only when the message belongs to the
316
+ // tree the gates saw. Anything else (no marker, stale marker, unreadable file)
317
+ // leaves the message alone and exits 0 — this hook never fails a commit.
318
+ function cmdCommitMsg(msgfile) {
319
+ try {
320
+ if (typeof msgfile !== "string" || !msgfile) return 0;
321
+ const root = repoRoot();
322
+ const p = path.isAbsolute(msgfile) ? msgfile : path.join(root, msgfile);
323
+ if (!exists(p) || !exists(markerPath(root))) return 0;
324
+ let marker;
325
+ try {
326
+ marker = JSON.parse(readText(markerPath(root)));
327
+ } catch (e) {
328
+ return 0;
329
+ }
330
+ const tree = currentTree(root);
331
+ if (!tree || !marker) return 0;
332
+ if (marker.tree !== tree) {
333
+ dropMarker(root); // stale: the staged tree changed after the gate run
334
+ return 0;
335
+ }
336
+ const skip = marker.mode === "skip";
337
+ const line = `Signoff-${skip ? "Skip" : "Pass"}: ${tree.slice(0, 12)}`;
338
+ // Sign the full tree (not the 12-char prefix) so an auditor can verify against
339
+ // the commit's own tree without trusting anything local. Signing never blocks a
340
+ // commit: a missing key just leaves the trailer unsigned.
341
+ let extra = "";
342
+ if (!skip && process.env.SIGNOFF_NO_SIGN !== "1") {
343
+ try {
344
+ const signed = require("./sign").sign(tree);
345
+ if (signed) extra = `\nSignoff-Key: ${signed.key}\nSignoff-Sig: ${signed.sig}`;
346
+ } catch (e) {
347
+ extra = "";
348
+ }
349
+ }
350
+ let text = readText(p);
351
+ if (text.length && !text.endsWith("\n")) text += "\n";
352
+ fs.writeFileSync(p, text + line + extra + "\n", "utf8");
353
+ dropMarker(root);
354
+ console.log(`signoff: ${line}`);
355
+ return 0;
356
+ } catch (e) {
357
+ // fail open on purpose: a broken trailer is better than a lost commit
358
+ return 0;
359
+ }
360
+ }
361
+
362
+ // `init --prepare`: make the guard self-installing for anyone who clones the repo.
363
+ function addPrepareScript(root, say, dryRun) {
364
+ const pkgPath = path.join(root, "package.json");
365
+ if (!isFile(pkgPath)) {
366
+ say(" prepare skipped (no package.json)");
367
+ return;
368
+ }
369
+ let pkg;
370
+ try {
371
+ pkg = JSON.parse(readText(pkgPath));
372
+ } catch (e) {
373
+ say(" prepare skipped (package.json is not valid JSON)");
374
+ return;
375
+ }
376
+ if (!pkg || typeof pkg !== "object") {
377
+ say(" prepare skipped (package.json is not an object)");
378
+ return;
379
+ }
380
+ // A prepare script that cannot find the CLI breaks every npm install of the repo.
381
+ // Only write it when the package is a devDependency (then node_modules/.bin/signoff
382
+ // exists before lifecycle scripts run) or a local install is already present.
383
+ const deps = Object.assign({}, pkg.devDependencies || {}, pkg.dependencies || {});
384
+ const selfDep =
385
+ Object.prototype.hasOwnProperty.call(deps, "signoff-cli") ||
386
+ Object.prototype.hasOwnProperty.call(deps, "dsh-signoff");
387
+ const binName = process.platform === "win32" ? "signoff.cmd" : "signoff";
388
+ const localBin = isFile(path.join(root, "node_modules", ".bin", binName));
389
+ if (!selfDep && !localBin) {
390
+ say(" prepare skipped: signoff-cli is not a devDependency, so the CLI is absent during install");
391
+ say(" run: npm i -D signoff-cli then signoff init --prepare");
392
+ return;
393
+ }
394
+ const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
395
+ const old = typeof scripts.prepare === "string" ? scripts.prepare.trim() : "";
396
+ if (old.includes(PREPARE_SCRIPT)) {
397
+ say(` prepare already set (${old})`);
398
+ return;
399
+ }
400
+ const next = old ? `${old} && ${PREPARE_SCRIPT}` : PREPARE_SCRIPT;
401
+ if (dryRun) {
402
+ say(`would set scripts.prepare = "${next}" in package.json`);
403
+ return;
404
+ }
405
+ pkg.scripts = Object.assign({}, scripts, { prepare: next });
406
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
407
+ say(` prepare package.json scripts.prepare = "${next}"`);
408
+ }
409
+
410
+ // The extension of a chained hook decides how it is launched, so keep the one that
411
+ // says what it is. A shell script (or anything unrecognised) keeps the plain `.local`.
412
+ function chainSuffix(file) {
413
+ const text = readText(file);
414
+ if (/^#!.*\bpython/i.test(text)) return ".py";
415
+ if (/^#!.*\bnode\b/i.test(text)) return ".js";
416
+ if (/^#!.*\bpwsh\b|^#!.*\bpowershell\b/i.test(text)) return ".ps1";
417
+ return "";
418
+ }
419
+
420
+ const CHAIN_SUFFIXES = ["", ".sh", ".py", ".js", ".ps1", ".cmd"];
421
+
422
+ function findChainFile(hooks, name) {
423
+ for (const suffix of CHAIN_SUFFIXES) {
424
+ const p = path.join(hooks, `${name}.local${suffix}`);
425
+ if (exists(p)) return p;
426
+ }
427
+ return null;
428
+ }
429
+
430
+ function cmdInit(args) {
431
+ const root = repoRoot();
432
+ const cfg = loadConfig(root);
433
+ let lockedNote = "";
434
+ if (args.testCmd) cfg.test_cmd = args.testCmd;
435
+ // Pinning what detection found keeps the runner from drifting on another machine
436
+ // (a laptop with pytest installed used to silently switch p1 to pytest).
437
+ if (args.lockTest) {
438
+ // `say` is defined further down (it honours --quiet), so report through it lazily.
439
+ const d = detector.detect(root, cfg);
440
+ if (d.cmd) {
441
+ cfg.test_cmd = d.cmd;
442
+ lockedNote = ` locked ${d.cmd} (${d.source})`;
443
+ } else {
444
+ lockedNote = " locked nothing to lock: no test runner was detected";
445
+ }
446
+ }
447
+ if (args.budget) cfg.budget = args.budget;
448
+ if (args.docs) cfg.docs = args.docs;
449
+ cfg.pre_push = !args.noPush;
450
+ cfg.ci = !args.noCi;
451
+ cfg.version = 2;
452
+
453
+ const say = args.quiet ? () => {} : (line) => console.log(line);
454
+
455
+ const hooks = path.join(root, ".git", "hooks");
456
+ const targets = [
457
+ ["pre-commit", "pre-commit"],
458
+ ["commit-msg", "commit-msg"],
459
+ ];
460
+ if (cfg.pre_push) targets.push(["pre-push", "pre-push"]);
461
+
462
+ if (args.dryRun) {
463
+ for (const [name] of targets) {
464
+ const target = path.join(hooks, name);
465
+ const local = path.join(hooks, `${name}.local${chainSuffix(target)}`);
466
+ if (exists(target) && !markedByUs(readText(target))) {
467
+ say(
468
+ args.chain
469
+ ? `would chain ${path.join(hooks, name)} -> ${path.basename(local)}`
470
+ : `would back up ${path.join(hooks, name)}`
471
+ );
472
+ }
473
+ say(`would write ${target}`);
474
+ }
475
+ if (cfg.ci) say(`would write ${path.join(root, ".github", "workflows", "signoff.yml")}`);
476
+ say(`would write ${path.join(root, STATE, "config.json")}`);
477
+ if (args.prepare) addPrepareScript(root, say, true);
478
+ return 0;
479
+ }
480
+
481
+ fs.mkdirSync(hooks, { recursive: true });
482
+ const notes = [];
483
+ for (const [name, stage] of targets) {
484
+ const target = path.join(hooks, name);
485
+ const existingChain = findChainFile(hooks, name);
486
+ let chain = !!existingChain;
487
+ if (exists(target) && !markedByUs(readText(target))) {
488
+ if (args.chain) {
489
+ if (!existingChain) {
490
+ const local = path.join(hooks, `${name}.local${chainSuffix(target)}`);
491
+ fs.renameSync(target, local);
492
+ notes.push(` chained .git/hooks/${path.basename(local)} runs first`);
493
+ } else {
494
+ notes.push(` chained .git/hooks/${path.basename(existingChain)} runs first`);
495
+ }
496
+ chain = true;
497
+ } else {
498
+ const backup = path.join(hooks, `${name}.signoff-backup`);
499
+ if (!exists(backup)) {
500
+ fs.writeFileSync(backup, readText(target), "utf8");
501
+ notes.push(` saved old hook -> .git/hooks/${path.basename(backup)}`);
502
+ }
503
+ }
504
+ }
505
+ writeExec(target, hookBody(stage, chain));
506
+ }
507
+
508
+ if (cfg.ci) {
509
+ const ci = path.join(root, ".github", "workflows");
510
+ fs.mkdirSync(ci, { recursive: true });
511
+ writeExec(path.join(ci, "signoff.yml"), CI_BODY);
512
+ }
513
+
514
+ const gi = path.join(root, ".gitignore");
515
+ const line = `${STATE}/`;
516
+ if (exists(gi)) {
517
+ const txt = readText(gi);
518
+ if (!txt.split(/\r?\n/).includes(line)) {
519
+ fs.writeFileSync(gi, txt.replace(/\n+$/, "") + "\n" + line + "\n", "utf8");
520
+ }
521
+ } else {
522
+ fs.writeFileSync(gi, line + "\n", "utf8");
523
+ }
524
+
525
+ if (args.preset) {
526
+ const pre = require("./preset").apply(cfg, args.preset, cfg);
527
+ if (!pre.ok) {
528
+ console.error(`signoff init: ${pre.error}`);
529
+ return 2;
530
+ }
531
+ for (const n of pre.notes) say(n);
532
+ }
533
+
534
+ if (args.editor) {
535
+ if (String(args.editor) !== "vscode") {
536
+ console.error(`signoff init: unknown editor '${args.editor}' (supported: vscode)`);
537
+ return 2;
538
+ }
539
+ const ed = require("./editor").vscode(root, { command: "signoff" });
540
+ say(ed.ok ? ` editor .vscode/tasks.json (${ed.added} task(s) added)` : ` editor ${ed.reason}`);
541
+ }
542
+
543
+ saveConfig(root, cfg);
544
+
545
+ // The install point is what `audit --since-install` measures from, and what lets the
546
+ // plain audit explain a `missing` commit that simply predates the guard. Re-running
547
+ // init keeps the first one: history before the guard is history, not a failure.
548
+ const installPath = path.join(root, STATE, "installed.json");
549
+ if (args.resetInstall) {
550
+ const head = core.git(["rev-parse", "HEAD"], root);
551
+ const record = { sha: head.code === 0 ? head.out.trim() : "", ts: now(), version: packageVersion() };
552
+ fs.writeFileSync(installPath, JSON.stringify(record) + "\n", "utf8");
553
+ notes.push(` install reset to ${record.sha ? record.sha.slice(0, 12) : "(no commits yet)"}`);
554
+ } else if (!exists(installPath)) {
555
+ const head = core.git(["rev-parse", "HEAD"], root);
556
+ const record = { sha: head.code === 0 ? head.out.trim() : "", ts: now(), version: packageVersion() };
557
+ fs.writeFileSync(installPath, JSON.stringify(record) + "\n", "utf8");
558
+ notes.push(` install ${record.sha ? record.sha.slice(0, 12) : "(no commits yet)"}`);
559
+ } else {
560
+ // Re-init rewrites the hooks, so the recorded version has to follow them; the install
561
+ // point itself (sha + ts) stays put - that is the boundary of "before the guard".
562
+ let prev = null;
563
+ try {
564
+ prev = JSON.parse(fs.readFileSync(installPath, "utf8"));
565
+ } catch (e) {
566
+ prev = null;
567
+ }
568
+ if (prev) {
569
+ const next = Object.assign({}, prev);
570
+ let reason = null;
571
+ // A bootstrap can run during the very first commit, where HEAD does not exist yet, so
572
+ // the install point arrives without a sha. Re-init after that commit is the moment to
573
+ // fill it in - otherwise `audit --since-install` stays unusable for the repo's life.
574
+ if (!prev.sha) {
575
+ const head = core.git(["rev-parse", "HEAD"], root);
576
+ const headSha = head.code === 0 ? head.out.trim() : "";
577
+ if (headSha) {
578
+ next.sha = headSha;
579
+ next.ts = now();
580
+ reason = `${headSha.slice(0, 12)} (filled in: the first bootstrap ran before this commit)`;
581
+ }
582
+ }
583
+ // Re-init rewrites the hooks, so the recorded version has to follow them; the install
584
+ // point itself (sha + ts) stays put - that is the boundary of "before the guard".
585
+ if (prev.version !== packageVersion()) {
586
+ next.version = packageVersion();
587
+ if (!reason) reason = `${String(prev.version || "?")} -> ${packageVersion()} (same sha)`;
588
+ }
589
+ if (reason) {
590
+ fs.writeFileSync(installPath, JSON.stringify(next) + "\n", "utf8");
591
+ notes.push(` install ${reason}`);
592
+ }
593
+ }
594
+ }
595
+
596
+ if (args.sign) {
597
+ // A repo-local key is optional; without one the trailer is simply unsigned.
598
+ const keyPath = require("./sign").generateKey(path.join(root, STATE));
599
+ if (keyPath) {
600
+ cfg.sign_key = path.relative(root, keyPath).split(path.sep).join("/");
601
+ saveConfig(root, cfg);
602
+ notes.push(" sign commits carry Signoff-Sig (local key: .signoff/signing_key)");
603
+ } else {
604
+ notes.push(" sign skipped (ssh-keygen not available)");
605
+ }
606
+ }
607
+
608
+ if (args.share) {
609
+ // Only the portable fields: a pinned test command would break on a clone without that
610
+ // runner, which is the opposite of "clone and it works".
611
+ const decl = { $schema: "https://raw.githubusercontent.com/ayuanxx-max/dsh-signoff/master/docs/signoff.schema.json" };
612
+ decl.gates = ["p1", "p2", "p3", "p4", "p5"];
613
+ if (args.lockTest && cfg.test_cmd) decl.test_cmd = cfg.test_cmd;
614
+ fs.writeFileSync(path.join(root, "signoff.json"), JSON.stringify(decl, null, 2) + "\n", "utf8");
615
+ notes.push(" share signoff.json (commit it: clones build the five gates themselves)");
616
+ }
617
+
618
+ if (args.prepare) addPrepareScript(root, say, false);
619
+
620
+ say(`signoff ${packageVersion()} installed in ${path.basename(root)}`);
621
+ for (const n of notes) say(n);
622
+ for (const [name] of targets) say(` hook .git/hooks/${name}`);
623
+ if (cfg.ci) say(" ci .github/workflows/signoff.yml");
624
+ say(` config ${STATE}/config.json`);
625
+ if (lockedNote) say(lockedNote);
626
+ say(` install ${STATE}/installed.json`);
627
+ say(` test ${cfg.test_cmd || detector.describe(root, cfg)}`);
628
+ say(" next git commit as usual; a red gate stops the commit");
629
+ return 0;
630
+ }
631
+
632
+ // ---------------------------------------------------------------- run
633
+
634
+ function pythonExe() {
635
+ const override = process.env.SIGNOFF_PYTHON;
636
+ if (override) return override;
637
+ return process.platform === "win32" ? "python" : "python3";
638
+ }
639
+
640
+ // Python is optional: it is probed only to keep the pytest shortcut working in
641
+ // repos that really use it. A machine without python just detects nothing.
642
+ function pythonInterpreter() {
643
+ const candidates = [pythonExe()];
644
+ if (!process.env.SIGNOFF_PYTHON && process.platform !== "win32") candidates.push("python");
645
+ for (const exe of candidates) {
646
+ const r = sh([exe, "-c", "import sys;print(sys.executable)"]);
647
+ if (r.code === 0 && r.out.trim()) return r.out.trim();
648
+ }
649
+ return null;
650
+ }
651
+
652
+ function findTestFiles(root) {
653
+ const found = [];
654
+ const skip = new Set([".git", "node_modules", ".signoff", "__pycache__", ".venv", "venv"]);
655
+ const walk = (dir, depth) => {
656
+ if (depth > 8 || found.length) return;
657
+ let entries;
658
+ try {
659
+ entries = fs.readdirSync(dir, { withFileTypes: true });
660
+ } catch (e) {
661
+ return;
662
+ }
663
+ for (const e of entries) {
664
+ if (found.length) return;
665
+ const p = path.join(dir, e.name);
666
+ if (e.isDirectory()) {
667
+ if (skip.has(e.name)) continue;
668
+ walk(p, depth + 1);
669
+ } else if (/^test_.*\.py$/.test(e.name)) {
670
+ found.push(p);
671
+ return;
672
+ }
673
+ }
674
+ };
675
+ walk(root, 0);
676
+ return found;
677
+ }
678
+
679
+ function detectTest(root, cfg) {
680
+ const d = detector.detect(root, cfg);
681
+ return d.cmd || null;
682
+ }
683
+
684
+ function stagedFiles(root) {
685
+ const r = sh(["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"], root);
686
+ return r.out
687
+ .split(/\r?\n/)
688
+ .map((ln) => ln.trim())
689
+ .filter((ln) => ln.length > 0);
690
+ }
691
+
692
+ function treeKey(root) {
693
+ const r = sh(["git", "write-tree"], root);
694
+ const base = r.code === 0 ? r.out.trim() : "";
695
+ const r2 = sh(["git", "diff", "--cached", "--name-only"], root);
696
+ return base + "|" + r2.out.split(/\s+/).filter(Boolean).sort().join(",");
697
+ }
698
+
699
+ function stagedDocs(files, cfg) {
700
+ const docs = cfg.docs || [];
701
+ const out = [];
702
+ for (const f of files) {
703
+ if (!/\.(md|mdx|txt)$/.test(f)) continue;
704
+ for (const raw of docs) {
705
+ const d = String(raw).replace(/\/+$/, "");
706
+ if (f === d || f.startsWith(d + "/")) {
707
+ out.push(f);
708
+ break;
709
+ }
710
+ }
711
+ }
712
+ return out;
713
+ }
714
+
715
+ function ledgerPath() {
716
+ const home = process.env.SIGNOFF_HOME;
717
+ return home ? path.join(home, "audit.log") : path.join(os.homedir(), ".signoff", "audit.log");
718
+ }
719
+
720
+ function docsFiles(root, cfg) {
721
+ const out = [];
722
+ for (const raw of cfg.docs || []) {
723
+ const base = String(raw).replace(/\/+$/, "");
724
+ const target = path.join(root, base);
725
+ if (isFile(target)) {
726
+ out.push(base);
727
+ } else if (isDir(target)) {
728
+ const md = [];
729
+ const walk = (dir) => {
730
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
731
+ const p = path.join(dir, e.name);
732
+ if (e.isDirectory()) walk(p);
733
+ else if (e.name.endsWith(".md")) md.push(p);
734
+ }
735
+ };
736
+ walk(target);
737
+ md.sort();
738
+ for (const p of md) out.push(path.relative(root, p).split(path.sep).join("/"));
739
+ }
740
+ }
741
+ return out;
742
+ }
743
+
744
+ function plan(root, cfg, files, full) {
745
+ const steps = [];
746
+ // Secrets first: a leaked key is not a style issue and must not wait for the suite.
747
+ if (cfg.secrets !== false) steps.push(["secrets", ["--json"]]);
748
+ const docs = stagedDocs(files, cfg);
749
+ if (docs.length) steps.push(["p5", docs]);
750
+ const cmd = detectTest(root, cfg);
751
+ if (cmd) steps.push(["p1", ["--run", cmd, "--budget", String(cfg.budget === undefined ? 180 : cfg.budget)]]);
752
+ // P2 only joins when the repo actually has scenario fixtures; a repo without
753
+ // them is not "low coverage", it is simply not using this gate.
754
+ if (isDir(path.join(root, "verify", "scenarios")) || cfg.apps) steps.push(["p2", ["--strict"]]);
755
+ if (exists(ledgerPath())) steps.push(["p4", ["verify"]]);
756
+ // Line coverage of the change is only meaningful when the project already produces
757
+ // a coverage file; a missing file is reported by the gate itself, not by the guard.
758
+ if (cfg.coverage) {
759
+ const min = typeof cfg.coverage === "object" && cfg.coverage.min !== undefined ? cfg.coverage.min : 80;
760
+ steps.push(["cov", ["--diff", "--min", String(min), "--allow-missing"]]);
761
+ }
762
+ return steps;
763
+ }
764
+
765
+ // The one intended change from the Python guard: the gate is this CLI itself,
766
+ // run on the current node, so a global `npm i -g signoff-cli` needs no python.
767
+ function runGate(name, gargs, root, timeoutSeconds) {
768
+ const t0 = Date.now();
769
+ const r = sh([process.execPath, DISPATCHER, name, ...gargs], root, timeoutSeconds * 1000);
770
+ const secs = (Date.now() - t0) / 1000;
771
+ if (r.error && (r.error.code === "ETIMEDOUT" || r.error.signal)) {
772
+ return { rc: 1, secs, out: `${name}: timed out after ${Math.round(timeoutSeconds)}s` };
773
+ }
774
+ if (r.error) {
775
+ return { rc: 127, secs, out: `${name}: cannot start ${process.execPath} (${r.error.message})` };
776
+ }
777
+ return { rc: r.code, secs, out: r.out + r.err };
778
+ }
779
+
780
+ // A PR reviewer should not have to open the log to learn which gate failed.
781
+ function writeSummary(steps, seconds, ok, failed) {
782
+ const ci = require("./ci");
783
+ if (!ci.isCI()) return;
784
+ const bad = new Set((failed || []).map(([n]) => n));
785
+ const rows = steps.map(([name]) => [name, bad.has(name) ? "FAIL" : "ok"]);
786
+ ci.summary(
787
+ `### SignOff ${ok ? "PASS" : "FAIL"} (${Number(seconds).toFixed(2)}s)\n\n` +
788
+ ci.table(rows, ["gate", "result"]) +
789
+ (bad.size ? `\n\nFull output: \`.signoff/hook.log\`` : "")
790
+ );
791
+ }
792
+
793
+ function cmdGuard(args) {
794
+ const root = repoRoot();
795
+ const cfg = loadConfig(root);
796
+ const files = stagedFiles(root);
797
+ const full = !!(args.full || args.ci);
798
+ // Only pre-commit writes the hand-off marker: it is what commit-msg turns into a
799
+ // trailer. A pre-push run must not leave one behind for a commit that never came.
800
+ const mark = args.mark !== false;
801
+
802
+ if (!files.length && !full) {
803
+ console.log("signoff guard: nothing staged");
804
+ return 0;
805
+ }
806
+
807
+ const key = treeKey(root);
808
+ const cache = path.join(root, STATE, "cache.json");
809
+ if (!full && exists(cache)) {
810
+ try {
811
+ const c = JSON.parse(fs.readFileSync(cache, "utf8"));
812
+ if (c.key === key && c.ok) {
813
+ if (mark) writeMarker(root, "pass");
814
+ console.log(`signoff guard: cached pass (${Number(c.seconds || 0).toFixed(2)}s)`);
815
+ return 0;
816
+ }
817
+ } catch (e) {
818
+ /* a corrupt cache is a cache miss */
819
+ }
820
+ }
821
+
822
+ if (process.env.SIGNOFF_SKIP === "1") {
823
+ const sd = stateDir(root);
824
+ if (mark) writeMarker(root, "skip");
825
+ fs.appendFileSync(path.join(sd, "skips.log"), `${now()} skipped ${files.length} file(s) via SIGNOFF_SKIP=1\n`, "utf8");
826
+ console.log("signoff guard: SKIPPED (SIGNOFF_SKIP=1, recorded in .signoff/skips.log)");
827
+ return 0;
828
+ }
829
+
830
+ let steps = plan(root, cfg, files, full);
831
+ if (args.ci) {
832
+ // CI has no index: check every configured doc, not just the staged ones.
833
+ const allDocs = docsFiles(root, cfg);
834
+ if (allDocs.length) steps = [["p5", allDocs]].concat(steps.filter((s) => s[0] !== "p5"));
835
+ }
836
+ if (!steps.length) {
837
+ if (mark) writeMarker(root, "pass");
838
+ console.log("signoff guard: no gate applies to this change");
839
+ return 0;
840
+ }
841
+
842
+ const budget = Number(cfg.budget === undefined ? 180 : cfg.budget);
843
+ console.log(`signoff guard: ${files.length} file(s) staged, ${steps.length} gate(s)`);
844
+ const t0 = Date.now();
845
+ const failed = [];
846
+ for (const [name, gargs] of steps) {
847
+ const res = runGate(name, gargs, root, Math.max(budget, 60));
848
+ console.log(` ${name.padEnd(4)} ${res.rc === 0 ? "ok " : "FAIL"} ${res.secs.toFixed(2).padStart(6, " ")}s`);
849
+ if (res.rc !== 0) failed.push([name, res.out]);
850
+ }
851
+ const total = (Date.now() - t0) / 1000;
852
+
853
+ if (failed.length) {
854
+ const sd = stateDir(root);
855
+ fs.writeFileSync(path.join(sd, "hook.log"), failed.map(([n, o]) => `== ${n} ==\n${o}`).join("\n\n"), "utf8");
856
+ console.log(`signoff guard: FAIL (${total.toFixed(2)}s)`);
857
+ for (const [n, out] of failed) {
858
+ const tail = out
859
+ .trim()
860
+ .split(/\r?\n/)
861
+ .filter((ln) => ln.trim())
862
+ .slice(-8);
863
+ console.log(`-- ${n} --`);
864
+ for (const ln of tail) console.log(` ${ln}`);
865
+ }
866
+ console.log(` full output: ${STATE}/hook.log`);
867
+ writeSummary(steps, total, false, failed);
868
+ console.log(" bypass once: SIGNOFF_SKIP=1 git commit ... (recorded) or git commit --no-verify");
869
+ return 1;
870
+ }
871
+
872
+ fs.writeFileSync(
873
+ path.join(root, STATE, "cache.json"),
874
+ JSON.stringify({ key, ok: true, ts: now(), seconds: Number(total.toFixed(3)) }) + "\n",
875
+ "utf8"
876
+ );
877
+ if (mark) writeMarker(root, "pass");
878
+ console.log(`signoff guard: PASS (${total.toFixed(2)}s)`);
879
+ writeSummary(steps, total, true, []);
880
+ return 0;
881
+ }
882
+
883
+ // ---------------------------------------------------------------- status
884
+
885
+ function installed(root) {
886
+ const hooks = path.join(root, ".git", "hooks");
887
+ const out = {};
888
+ for (const name of STAGES) {
889
+ const p = path.join(hooks, name);
890
+ out[name] = !!(exists(p) && markedByUs(readText(p)));
891
+ }
892
+ const ci = path.join(root, ".github", "workflows", "signoff.yml");
893
+ out.ci = !!(exists(ci) && markedByUs(readText(ci)));
894
+ out.config = exists(path.join(root, STATE, "config.json"));
895
+ // A commit can carry a trailer only when both halves of the hand-off exist:
896
+ // pre-commit records the tree, commit-msg writes the line from that record.
897
+ out.trailer = !!(out["pre-commit"] && out["commit-msg"]);
898
+ const c = path.join(root, STATE, "cache.json");
899
+ if (exists(c)) {
900
+ try {
901
+ const d = JSON.parse(fs.readFileSync(c, "utf8"));
902
+ out.last_run = { ok: d.ok, ts: d.ts, seconds: d.seconds };
903
+ } catch (e) {
904
+ /* unreadable cache: no last run */
905
+ }
906
+ }
907
+ return out;
908
+ }
909
+
910
+ function cmdStatus(args) {
911
+ const root = repoRoot();
912
+ const cfg = loadConfig(root);
913
+ const st = installed(root);
914
+ if (args.json) {
915
+ console.log(JSON.stringify({ root, version: packageVersion(), trailer: st.trailer, installed: st, config: cfg }));
916
+ return 0;
917
+ }
918
+ console.log(`repo ${root}`);
919
+ console.log(`version ${packageVersion()}`);
920
+ for (const k of ["pre-commit", "commit-msg", "pre-push", "ci"]) {
921
+ console.log(`${k.padEnd(12)}${st[k] ? "installed" : "missing"}`);
922
+ }
923
+ console.log(`${"trailer".padEnd(12)}${st.trailer ? "enforced" : "off"}`);
924
+ console.log(`${"config".padEnd(12)}${st.config ? "present" : "missing"}`);
925
+ console.log(`${"test".padEnd(12)}${detector.describe(root, cfg)}`);
926
+ const lr = st.last_run;
927
+ console.log(`${"last run".padEnd(12)}${lr ? lr.ts + (lr.ok ? " PASS" : " FAIL") : "never"}`);
928
+ return 0;
929
+ }
930
+
931
+ function cmdUninstall(args) {
932
+ const root = repoRoot();
933
+ const removedState = [];
934
+ const runtime = [`${STATE}/passed`, `${STATE}/cache.json`];
935
+ const removed = [];
936
+ for (const name of STAGES) {
937
+ const p = path.join(root, ".git", "hooks", name);
938
+ if (!exists(p) || !markedByUs(readText(p))) continue;
939
+ const local = findChainFile(path.join(root, ".git", "hooks"), name);
940
+ if (local) {
941
+ fs.rmSync(p, { force: true });
942
+ fs.renameSync(local, p);
943
+ removed.push(`.git/hooks/${name} (restored from ${path.basename(local)})`);
944
+ } else {
945
+ fs.unlinkSync(p);
946
+ removed.push(`.git/hooks/${name}`);
947
+ }
948
+ }
949
+ const ci = path.join(root, ".github", "workflows", "signoff.yml");
950
+ if (exists(ci) && markedByUs(readText(ci))) {
951
+ fs.unlinkSync(ci);
952
+ removed.push(".github/workflows/signoff.yml");
953
+ }
954
+ // The marker and the last-run cache describe a hook that no longer exists; leaving
955
+ // them behind makes the next `status` look installed. config/installed/ledger stay
956
+ // unless asked for: they are the repo's record, not runtime state.
957
+ for (const rel of runtime) {
958
+ const p = path.join(root, rel);
959
+ if (exists(p)) {
960
+ fs.rmSync(p, { force: true });
961
+ removedState.push(rel);
962
+ }
963
+ }
964
+ if (args.all || args.keepConfig) {
965
+ const ip = path.join(root, STATE, "installed.json");
966
+ if (exists(ip)) {
967
+ fs.rmSync(ip, { force: true });
968
+ removedState.push(`${STATE}/installed.json`);
969
+ }
970
+ }
971
+ if (args.all) {
972
+ const p = path.join(root, STATE, "config.json");
973
+ if (exists(p)) {
974
+ fs.unlinkSync(p);
975
+ removed.push(`${STATE}/config.json`);
976
+ }
977
+ }
978
+ console.log(`removed: ${removed.length ? removed.join(", ") : "nothing"}`);
979
+ if (removedState.length) console.log(`state: ${removedState.join(", ")}`);
980
+ if (!args.all) {
981
+ const kept = [`${STATE}/config.json`, `${STATE}/ledger.jsonl`, `${STATE}/skips.log`].filter((rel) =>
982
+ exists(path.join(root, rel))
983
+ );
984
+ if (kept.length) console.log(`kept: ${kept.join(", ")} (use --all to drop the config)`);
985
+ }
986
+ return 0;
987
+ }
988
+
989
+ // ---------------------------------------------------------------- lock
990
+ // Two commits in one repo at the same moment (IDE plus terminal) would race on
991
+ // .signoff/passed: the first commit-msg consumes the marker the second run just wrote.
992
+ // One hook run at a time; a lock left by a dead pid or older than 10 minutes is broken.
993
+ function lockPath(root) {
994
+ return path.join(stateDir(root), "lock");
995
+ }
996
+
997
+ function pidAlive(pid) {
998
+ if (!pid) return false;
999
+ try {
1000
+ process.kill(pid, 0);
1001
+ return true;
1002
+ } catch (e) {
1003
+ return e.code === "EPERM";
1004
+ }
1005
+ }
1006
+
1007
+ function sleepMs(ms) {
1008
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1009
+ }
1010
+
1011
+ // The owner record is a file inside the lock directory; 3.9.0 wrote a plain file at the
1012
+ // same path, so both shapes are read (and both can be broken).
1013
+ function readLockOwner(p) {
1014
+ try {
1015
+ const file = fs.statSync(p).isDirectory() ? path.join(p, "owner.json") : p;
1016
+ return JSON.parse(fs.readFileSync(file, "utf8"));
1017
+ } catch (e) {
1018
+ return null;
1019
+ }
1020
+ }
1021
+
1022
+ function withLock(root, stage, fn) {
1023
+ const p = lockPath(root);
1024
+ fs.mkdirSync(path.dirname(p), { recursive: true });
1025
+ const raw = process.env.SIGNOFF_LOCK_WAIT_MS;
1026
+ const wait = raw === undefined ? 8000 : Number(raw) || 0;
1027
+ const deadline = Date.now() + wait;
1028
+ for (;;) {
1029
+ try {
1030
+ // mkdir is atomic and, unlike O_EXCL create, it is also honoured on SMB/NFS shares,
1031
+ // where two `open(..., "wx")` calls can both succeed.
1032
+ fs.mkdirSync(p);
1033
+ fs.writeFileSync(
1034
+ path.join(p, "owner.json"),
1035
+ JSON.stringify({ pid: process.pid, stage: stage, ts: new Date().toISOString() }),
1036
+ "utf8"
1037
+ );
1038
+ break;
1039
+ } catch (e) {
1040
+ const held = e.code === "EEXIST" ? readLockOwner(p) : null;
1041
+ if (e.code !== "EEXIST") {
1042
+ // EACCES or a plain file where the directory should be: treat as held only if
1043
+ // something is visibly there, otherwise let the run continue.
1044
+ if (!fs.existsSync(p)) return fn();
1045
+ }
1046
+ const old = !held || !pidAlive(held.pid) || Date.now() - Date.parse(held.ts || 0) > 10 * 60 * 1000;
1047
+ if (old) {
1048
+ fs.rmSync(p, { force: true, recursive: true });
1049
+ continue;
1050
+ }
1051
+ if (Date.now() >= deadline) {
1052
+ console.error(`signoff: another signoff run holds the lock (pid ${held.pid}, ${held.stage}, ${held.ts})`);
1053
+ console.error("signoff: retry when it finishes; if it is gone, delete .signoff/lock");
1054
+ return 1;
1055
+ }
1056
+ sleepMs(200);
1057
+ }
1058
+ }
1059
+ try {
1060
+ return fn();
1061
+ } finally {
1062
+ fs.rmSync(p, { force: true, recursive: true });
1063
+ }
1064
+ }
1065
+
1066
+ function optedIn(root) {
1067
+ return exists(path.join(root, STATE, "config.json"));
1068
+ }
1069
+
1070
+ // The machine-wide dispatcher (core.hooksPath) reaches repos that never ran `signoff init`.
1071
+ // Five gates there would fail for reasons the repo never agreed to (no cards, no scenario
1072
+ // files, prose rules), so an unopted repo gets the one gate that is always meaningful -
1073
+ // do the tests pass - and leaves no state behind.
1074
+ function lightGuard(root, cfg, files) {
1075
+ if (!files.length) return 0;
1076
+ // A leaked key is the one accident that cannot be undone by a revert, so it is
1077
+ // checked even where the repo never opted in. --no-state keeps that promise.
1078
+ if (cfg.secrets !== false) {
1079
+ const sec = runGate("secrets", ["--json", "--no-state"], root, 60);
1080
+ if (sec.rc !== 0 && sec.rc !== 2) {
1081
+ console.log("signoff guard: FAIL (staged secret, machine-wide guard)");
1082
+ for (const ln of String(sec.out || "").trim().split(/\r?\n/).filter(Boolean).slice(-8)) {
1083
+ console.log(` ${ln}`);
1084
+ }
1085
+ console.log(" remove the value, or allow this rule in .signoff/config.json");
1086
+ return 1;
1087
+ }
1088
+ }
1089
+ const res = runGate("p1", [], root, 300);
1090
+ if (res.rc === 0) return 0;
1091
+ if (res.rc === 2) {
1092
+ console.log("signoff: no test runner detected; run `signoff init` for the full five gates");
1093
+ return 0;
1094
+ }
1095
+ console.log("signoff guard: FAIL (P1 tests, machine-wide guard)");
1096
+ for (const ln of String(res.out || "").trim().split(/\r?\n/).filter(Boolean).slice(-8)) {
1097
+ console.log(` ${ln}`);
1098
+ }
1099
+ console.log(" opt in with `signoff init` (or `npm i -D signoff-cli`) to get all five gates");
1100
+ return 1;
1101
+ }
1102
+
1103
+ function cmdHook(args) {
1104
+ const stage = args._[0];
1105
+ if (stage === undefined)
1106
+ return argparseError(HOOK_USAGE, "signoff guard hook", "the following arguments are required: stage");
1107
+ if (STAGES.indexOf(stage) === -1)
1108
+ return argparseError(
1109
+ HOOK_USAGE,
1110
+ "signoff guard hook",
1111
+ `argument stage: invalid choice: '${stage}' (choose from 'pre-commit', 'commit-msg', 'pre-push')`
1112
+ );
1113
+ const root = repoRoot();
1114
+ if (!optedIn(root)) {
1115
+ // Nothing to hand off and nothing to audit: an unopted repo has no marker protocol.
1116
+ if (stage === "commit-msg") return 0;
1117
+ // Test suites and scripted runs own their fixture repos; they ask for the machine
1118
+ // layer to stay out of the way explicitly rather than by unsetting core.hooksPath.
1119
+ if (process.env.SIGNOFF_NO_MACHINE === "1") return 0;
1120
+ // A committed signoff.json is the repository opting in for everyone who clones it:
1121
+ // finish the local install, then run the same five gates as a local `signoff init`.
1122
+ const boot = require("./bootstrap");
1123
+ if (!boot.declaration(root)) return lightGuard(root, loadConfig(root), stagedFiles(root));
1124
+ boot.ensureRepo({ cwd: root, quiet: true });
1125
+ if (!optedIn(root)) return lightGuard(root, loadConfig(root), stagedFiles(root));
1126
+ }
1127
+ if (stage === "commit-msg") return cmdCommitMsg(args._[1]);
1128
+ // commit-msg is deliberately not locked: it must never block a commit that git already
1129
+ // accepted, and it only reads the marker pre-commit wrote.
1130
+ return withLock(root, stage, () =>
1131
+ cmdGuard({ full: stage === "pre-push", ci: false, mark: stage === "pre-commit" })
1132
+ );
1133
+ }
1134
+
1135
+ // ---------------------------------------------------------------- main
1136
+
1137
+ // argparse-shaped errors, so a failed command reads the same as the Python
1138
+ // reference (message on stderr, exit 2, nothing on stdout).
1139
+ const USAGE = "usage: signoff guard [-h] {init,guard,hook,status,uninstall} ...";
1140
+ const HOOK_USAGE = "usage: signoff guard hook [-h] {pre-commit,commit-msg,pre-push}";
1141
+ const INIT_USAGE =
1142
+ "usage: signoff guard init [-h] [--test-cmd TEST_CMD] [--budget BUDGET]\n" +
1143
+ " [--docs [DOCS ...]] [--no-ci] [--no-push]\n" +
1144
+ " [--prepare] [--lock-test] [--reset-install] [--share]\n" +
1145
+ " [--quiet] [--dry-run]";
1146
+
1147
+ function argparseError(usage, prog, body) {
1148
+ process.stderr.write(`${usage}\n`);
1149
+ process.stderr.write(`${prog}: error: ${body}\n`);
1150
+ return 2;
1151
+ }
1152
+
1153
+ function usageError(body) {
1154
+ return argparseError(USAGE, "signoff guard", body);
1155
+ }
1156
+
1157
+ function isInitPositionalError(args) {
1158
+ return args.docs === undefined && args._.length > 0;
1159
+ }
1160
+
1161
+ function main(argv) {
1162
+ const args = core.parseArgs(argv || [], {});
1163
+ const cmd = args._.shift();
1164
+ try {
1165
+ if (cmd === "init") {
1166
+ // argparse raises "unrecognized arguments" from the top-level parser,
1167
+ // so the usage line is the parent's, not the subparser's.
1168
+ if (isInitPositionalError(args)) return usageError(`unrecognized arguments: ${args._.join(" ")}`);
1169
+ return cmdInit(normalizeInit(args));
1170
+ }
1171
+ if (cmd === "guard") return cmdGuard(args);
1172
+ if (cmd === "hook") return cmdHook(args);
1173
+ if (cmd === "status") return cmdStatus(args);
1174
+ if (cmd === "uninstall") {
1175
+ if (args._.length) return usageError(`unrecognized arguments: ${args._.join(" ")}`);
1176
+ return cmdUninstall(args);
1177
+ }
1178
+ if (!cmd) return usageError("the following arguments are required: cmd");
1179
+ return usageError(
1180
+ `argument cmd: invalid choice: '${cmd}' (choose from 'init', 'guard', 'hook', 'status', 'uninstall')`
1181
+ );
1182
+ } catch (e) {
1183
+ if (e instanceof GuardExit) {
1184
+ // repoRoot() fails closed: same one-line message and exit 1 as the Python guard.
1185
+ process.stderr.write(`${e.message}\n`);
1186
+ return e.code;
1187
+ }
1188
+ if (e instanceof ArgparseError) return argparseError(INIT_USAGE, "signoff guard init", e.message);
1189
+ throw e;
1190
+ }
1191
+ }
1192
+
1193
+ // `--docs` takes zero or more values in the Python reference (argparse nargs="*").
1194
+ // init has no positionals of its own, so any left-over words belong to --docs.
1195
+ function normalizeInit(args) {
1196
+ const rawBudget = args.budget;
1197
+ let budget = null;
1198
+ if (rawBudget === true) throw new ArgparseError("argument --budget: expected one argument");
1199
+ if (rawBudget !== undefined) {
1200
+ budget = Number(rawBudget);
1201
+ if (!Number.isFinite(budget)) throw new ArgparseError(`argument --budget: invalid float value: '${rawBudget}'`);
1202
+ }
1203
+ if (args.testCmd === true) throw new ArgparseError("argument --test-cmd: expected one argument");
1204
+ const out = {
1205
+ testCmd: typeof args.testCmd === "string" ? args.testCmd : null,
1206
+ lockTest: !!args.lockTest,
1207
+ resetInstall: !!args.resetInstall,
1208
+ budget,
1209
+ docs: null,
1210
+ noCi: !!args.noCi,
1211
+ noPush: !!args.noPush,
1212
+ chain: !!args.chain,
1213
+ prepare: !!args.prepare,
1214
+ quiet: !!args.quiet,
1215
+ dryRun: !!args.dryRun,
1216
+ share: !!args.share,
1217
+ sign: !!args.sign,
1218
+ preset: args.preset ? String(args.preset) : null,
1219
+ editor: args.editor ? String(args.editor) : null,
1220
+ };
1221
+ if (args.docs !== undefined) {
1222
+ const list = Array.isArray(args.docs) ? args.docs.slice() : args.docs === true ? [] : [String(args.docs)];
1223
+ out.docs = list.concat(args._.map(String));
1224
+ if (out.docs.length === 0) out.docs = null; // `--docs` alone: argparse -> []
1225
+ }
1226
+ return out;
1227
+ }
1228
+
1229
+ module.exports = { main, hookBody, CI_BODY, DEFAULT_CONFIG, maybeCheckpoint };