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/lib/detect.js ADDED
@@ -0,0 +1,169 @@
1
+ "use strict";
2
+ // Detect the command that actually runs this repo's tests.
3
+ //
4
+ // The point is not to guess cleverly; it is to never stay silent. A repo with a
5
+ // `tests/` folder but no installed runner used to print "not detected (p1 will be
6
+ // skipped)", which reads like a pass. `detect()` therefore returns a hint when it
7
+ // found tests but no way to run them, and callers show that hint.
8
+
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const { spawnSync } = require("child_process");
12
+
13
+ function exists(p) {
14
+ try {
15
+ fs.accessSync(p);
16
+ return true;
17
+ } catch (e) {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ function isDir(p) {
23
+ try {
24
+ return fs.statSync(p).isDirectory();
25
+ } catch (e) {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ function readJson(p) {
31
+ try {
32
+ return JSON.parse(fs.readFileSync(p, "utf8"));
33
+ } catch (e) {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ // A python that can import pytest, in the same order the gates use: SIGNOFF_PYTHON,
39
+ // then `python3`/`python`/`py -3`, then the usual Windows install locations.
40
+ function pythonCandidates() {
41
+ const out = [];
42
+ if (process.env.SIGNOFF_PYTHON) out.push(process.env.SIGNOFF_PYTHON);
43
+ out.push("python3", "python", "py");
44
+ if (process.platform === "win32") {
45
+ const home = process.env.USERPROFILE || process.env.HOME || "";
46
+ if (home) {
47
+ out.push(path.join(home, ".openviking", "venv", "Scripts", "python.exe"));
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function hasPytest() {
54
+ for (const exe of pythonCandidates()) {
55
+ const args = exe === "py" ? ["-3", "-c", "import pytest"] : ["-c", "import pytest"];
56
+ const r = spawnSync(exe, args, { encoding: "utf8", windowsHide: true });
57
+ if (r.status === 0) return exe === "py" ? "py -3" : exe;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ const PY_TEST = /(^test_.*\.py$|.*_test\.py$)/;
63
+
64
+ function hasPythonTests(root) {
65
+ // An empty `tests/` folder is not a suite: require at least one python file under it,
66
+ // otherwise every scaffold prints "pytest" and p1 fails with "no tests ran".
67
+ let underTests = false;
68
+ const scanTests = (dir, depth) => {
69
+ if (depth > 2 || underTests) return;
70
+ let list = [];
71
+ try {
72
+ list = fs.readdirSync(dir, { withFileTypes: true });
73
+ } catch (e) {
74
+ return;
75
+ }
76
+ for (const e of list) {
77
+ if (e.isFile() && e.name.endsWith(".py")) underTests = true;
78
+ else if (e.isDirectory()) scanTests(path.join(dir, e.name), depth + 1);
79
+ }
80
+ };
81
+ if (isDir(path.join(root, "tests"))) scanTests(path.join(root, "tests"), 0);
82
+ if (underTests) return true;
83
+ let hits = 0;
84
+ const walk = (dir, depth) => {
85
+ if (depth > 3 || hits > 0) return;
86
+ let list = [];
87
+ try {
88
+ list = fs.readdirSync(dir, { withFileTypes: true });
89
+ } catch (e) {
90
+ return;
91
+ }
92
+ for (const e of list) {
93
+ if (hits > 0) return;
94
+ if (e.name === "node_modules" || e.name === ".git" || e.name === "venv" || e.name === ".venv") continue;
95
+ const p = path.join(dir, e.name);
96
+ if (e.isDirectory()) walk(p, depth + 1);
97
+ else if (PY_TEST.test(e.name)) hits += 1;
98
+ }
99
+ };
100
+ walk(root, 0);
101
+ return hits > 0;
102
+ }
103
+
104
+ function hasTarget(file, target) {
105
+ try {
106
+ return new RegExp("^" + target + "\\s*:", "m").test(fs.readFileSync(file, "utf8"));
107
+ } catch (e) {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ // Returns {cmd, source, hint}. `cmd` runs the suite; `hint` explains why there is no
113
+ // command even though something looked testable. Both null means "nothing here".
114
+ function detect(root, cfg) {
115
+ cfg = cfg || {};
116
+ if (cfg.test_cmd) return { cmd: String(cfg.test_cmd), source: "signoff config", hint: "" };
117
+
118
+ const pkg = readJson(path.join(root, "package.json"));
119
+ if (pkg) {
120
+ const scripts = pkg.scripts || {};
121
+ if (scripts.test && !String(scripts.test).includes("no test specified")) {
122
+ return { cmd: "npm test --silent", source: "package.json scripts.test", hint: "" };
123
+ }
124
+ // A known runner in the dependency list beats a missing npm script: `npx --no-install`
125
+ // fails loudly instead of downloading something unrelated.
126
+ const deps = Object.assign({}, pkg.dependencies, pkg.devDependencies);
127
+ for (const [name, cmd] of [
128
+ ["vitest", "npx --no-install vitest run"],
129
+ ["jest", "npx --no-install jest"],
130
+ ["mocha", "npx --no-install mocha"],
131
+ ]) {
132
+ if (deps[name]) return { cmd, source: name, hint: "" };
133
+ }
134
+ }
135
+
136
+ if (exists(path.join(root, "Cargo.toml"))) return { cmd: "cargo test --quiet", source: "Cargo.toml", hint: "" };
137
+ if (exists(path.join(root, "go.mod"))) return { cmd: "go test ./...", source: "go.mod", hint: "" };
138
+ for (const name of ["Makefile", "makefile"]) {
139
+ if (hasTarget(path.join(root, name), "test")) return { cmd: "make test", source: name, hint: "" };
140
+ }
141
+
142
+ if (hasPythonTests(root)) {
143
+ const py = hasPytest();
144
+ if (py) return { cmd: `"${py}" -m pytest -q`, source: "pytest", hint: "" };
145
+ // unittest discovery needs no dependency and covers `tests/test_*.py`.
146
+ for (const exe of pythonCandidates()) {
147
+ const args = exe === "py" ? ["-3", "-c", "import sys"] : ["-c", "import sys"];
148
+ if (spawnSync(exe, args, { encoding: "utf8", windowsHide: true }).status === 0) {
149
+ return { cmd: `"${exe}" -m unittest discover -q`, source: "unittest (pytest not installed)", hint: "" };
150
+ }
151
+ }
152
+ return {
153
+ cmd: null,
154
+ source: "",
155
+ hint: "python tests found but no python interpreter; install one or set --test-cmd",
156
+ };
157
+ }
158
+ return { cmd: null, source: "", hint: "" };
159
+ }
160
+
161
+ // One line for `init` / `status`: what will run, or why nothing will.
162
+ function describe(root, cfg) {
163
+ const d = detect(root, cfg);
164
+ if (d.cmd) return `${d.cmd} (${d.source})`;
165
+ if (d.hint) return d.hint;
166
+ return "none (nothing testable found; p1 is skipped)";
167
+ }
168
+
169
+ module.exports = { detect, describe, pythonCandidates, hasPytest };
package/lib/doctor.js ADDED
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // signoff doctor — one command that answers "why didn't the guard do what I expected?".
4
+ //
5
+ // Every check is read-only: the doctor never installs, rewrites or deletes anything. It
6
+ // composes the commands that already exist (`status`, `audit`, `p4 verify`) instead of
7
+ // re-deriving their logic, so a doctor that says `ok` and a gate that runs later cannot
8
+ // disagree about what "installed" or "missing" means.
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const { spawnSync } = require("child_process");
13
+ const core = require("./core");
14
+ const p4 = require("./p4");
15
+ const detect = require("./detect");
16
+
17
+ const DISPATCHER = path.join(__dirname, "..", "bin", "signoff.js");
18
+ const STATE = ".signoff";
19
+ const HOOK_MARK = "# generated by signoff-cli";
20
+ const LEGACY_HOOK_MARK = "# generated by dsh-signoff";
21
+ function markedByUs(text) {
22
+ const t = String(text || "");
23
+ return t.includes(HOOK_MARK) || t.includes(LEGACY_HOOK_MARK);
24
+ }
25
+ const STAGES = ["pre-commit", "commit-msg", "pre-push"];
26
+
27
+ const USAGE = `usage: signoff doctor [-h] [--json] [--quiet] [--fix]
28
+
29
+ Why is (or isn't) this repo guarded? Read-only: nothing is installed, changed or deleted.
30
+
31
+ options:
32
+ -h, --help show this help message and exit
33
+ --json machine-readable result
34
+ --fix repair what can be proved: reinstall the hooks when they are
35
+ missing or foreign, and chmod +x on our hooks under POSIX
36
+ --quiet only print checks that are not ok
37
+ `;
38
+
39
+ function cli(args) {
40
+ const r = spawnSync(process.execPath, [DISPATCHER, ...args], { encoding: "utf8" });
41
+ return { code: r.status === null ? 127 : r.status, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
42
+ }
43
+
44
+ function readJson(p) {
45
+ try {
46
+ return JSON.parse(fs.readFileSync(p, "utf8"));
47
+ } catch (e) {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function onPath(exe) {
53
+ const dirs = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
54
+ const names = process.platform === "win32" ? [`${exe}.cmd`, `${exe}.ps1`, `${exe}.exe`, exe] : [exe];
55
+ for (const d of dirs) {
56
+ for (const n of names) {
57
+ if (fs.existsSync(path.join(d, n))) return path.join(d, n);
58
+ }
59
+ }
60
+ return "";
61
+ }
62
+
63
+ function repoTop() {
64
+ const r = core.git(["rev-parse", "--show-toplevel"]);
65
+ return r.code === 0 ? r.out.trim() : "";
66
+ }
67
+
68
+ function run(argv) {
69
+ const args = core.parseArgs(argv || [], {});
70
+ if (args.help || args._.indexOf("-h") !== -1) {
71
+ console.log(USAGE);
72
+ return 0;
73
+ }
74
+ const asJson = !!args.json;
75
+ const quiet = !!args.quiet;
76
+ const fix = !!args.fix;
77
+ const fixed = [];
78
+
79
+ const checks = [];
80
+ const add = (id, status, detail, fix) => checks.push({ id, status, detail, fix: fix || "" });
81
+
82
+ // --- environment (the old `doctor` rows, now with verdicts) -----------------
83
+ const major = Number(process.version.replace(/^v/, "").split(".")[0]);
84
+ add("node", major >= 18 ? "ok" : "fail", process.version, major >= 18 ? "" : "install Node 18 or newer");
85
+ const gitBin = onPath("git");
86
+ add("git", gitBin ? "ok" : "fail", gitBin || "not on PATH", gitBin ? "" : "install git and put it on PATH");
87
+ const engine = process.env.SIGNOFF_ENGINE === "python" ? "python" : "node";
88
+ add("engine", "ok", engine);
89
+
90
+ const top = repoTop();
91
+ if (!top || core.git(["rev-parse", "--git-dir"]).code !== 0) {
92
+ add("repo", "fail", process.cwd(), "run inside a git repository");
93
+ return report(checks, asJson, quiet, 2);
94
+ }
95
+ add("repo", "ok", top);
96
+
97
+ // --- installation -----------------------------------------------------------
98
+ const st = readJsonOr(() => JSON.parse(cli(["status", "--json"]).out), null);
99
+ const version = st && st.version ? st.version : "";
100
+ add("version", version ? "ok" : "warn", version || "unknown", version ? "" : "reinstall: npm i -g signoff-cli");
101
+
102
+ const hooksDir = path.join(top, ".git", "hooks");
103
+ for (const stage of STAGES) {
104
+ const p = path.join(hooksDir, stage);
105
+ const exists = fs.existsSync(p);
106
+ const ours = exists && markedByUs(fs.readFileSync(p, "utf8"));
107
+ if (!ours) {
108
+ add(`hook:${stage}`, "fail", exists ? "a foreign hook is there" : "missing", "run: signoff init");
109
+ continue;
110
+ }
111
+ let mode = "ok";
112
+ if (process.platform !== "win32") {
113
+ if ((fs.statSync(p).mode & 0o111) === 0) mode = "fail";
114
+ }
115
+ add(`hook:${stage}`, mode, mode === "ok" ? "installed" : "not executable", mode === "ok" ? "" : `chmod +x ${p}`);
116
+ const backup = path.join(hooksDir, `${stage}.signoff-backup`);
117
+ if (fs.existsSync(backup)) {
118
+ add(`hook-backup:${stage}`, "warn", "old hook saved, not chained", "wire it manually or rerun init after moving it");
119
+ }
120
+ }
121
+
122
+ const hookText = fs.existsSync(path.join(hooksDir, "pre-commit"))
123
+ ? fs.readFileSync(path.join(hooksDir, "pre-commit"), "utf8")
124
+ : "";
125
+ const resolvable = onPath("signoff");
126
+ if (hookText && hookText.indexOf("signoff") !== -1) {
127
+ add(
128
+ "hook:path",
129
+ resolvable ? "ok" : "warn",
130
+ resolvable || "signoff not on PATH for non-interactive shells",
131
+ resolvable ? "" : "hooks that call `signoff` fail silently when PATH differs; reinstall globally"
132
+ );
133
+ }
134
+ // --- configuration ----------------------------------------------------------
135
+ const cfgPath = path.join(top, STATE, "config.json");
136
+ const cfg = fs.existsSync(cfgPath) ? readJson(cfgPath) : null;
137
+ if (!cfg) {
138
+ add("config", fs.existsSync(cfgPath) ? "fail" : "warn", fs.existsSync(cfgPath) ? "unreadable" : "missing", "run: signoff init");
139
+ } else {
140
+ const bad = [];
141
+ if (typeof cfg.version !== "number") bad.push("version");
142
+ if (cfg.docs !== undefined && !Array.isArray(cfg.docs)) bad.push("docs");
143
+ if (cfg.test_cmd !== undefined && cfg.test_cmd !== null && typeof cfg.test_cmd !== "string") bad.push("test_cmd");
144
+ if (cfg.budget !== undefined && !(typeof cfg.budget === "number" && cfg.budget > 0)) bad.push("budget");
145
+ add("config", bad.length ? "fail" : "ok", bad.length ? `bad field(s): ${bad.join(", ")}` : JSON.stringify(cfg).slice(0, 80), bad.length ? "fix or delete .signoff/config.json, then rerun signoff init" : "");
146
+ if (typeof cfg.test_cmd === "string" && cfg.test_cmd) {
147
+ const exe = cfg.test_cmd.split(/\s+/)[0];
148
+ const found = exe.includes(path.sep) || exe.includes("/") ? fs.existsSync(path.join(top, exe)) : !!onPath(exe);
149
+ add("test-cmd", found ? "ok" : "warn", cfg.test_cmd, found ? "" : "p1 will fail to start it; check the command or PATH");
150
+ }
151
+ const det = detect.detect(top, cfg);
152
+ if (det.cmd) {
153
+ add("test-runner", "ok", det.cmd + (det.source ? ` (${det.source})` : ""), "");
154
+ } else if (det.hint) {
155
+ add("test-runner", "warn", det.hint, "set --test-cmd, or install the runner");
156
+ } else {
157
+ add("test-runner", "ok", "nothing testable; p1 is skipped", "");
158
+ }
159
+ }
160
+
161
+ // --- install point ----------------------------------------------------------
162
+ const ipPath = path.join(top, STATE, "installed.json");
163
+ const ip = fs.existsSync(ipPath) ? readJson(ipPath) : null;
164
+ if (!ip || !ip.sha) {
165
+ add("install-point", "warn", "not recorded", "run: signoff init (records it; re-init keeps the first)");
166
+ } else if (core.git(["cat-file", "-e", `${ip.sha}^{commit}`]).code !== 0) {
167
+ add("install-point", "fail", `${ip.sha.slice(0, 12)} not in history (rewritten?)`, "reinstall after the rewrite: signoff init");
168
+ } else {
169
+ add("install-point", "ok", `${ip.sha.slice(0, 12)} (${ip.ts || "no ts"})`);
170
+ }
171
+ if (ip && ip.version && version && ip.version !== version) {
172
+ add("install-version", "warn", `installed ${ip.version}, CLI ${version}`, "upgrade in place: npm i -g signoff-cli && signoff init");
173
+ }
174
+
175
+ // --- leftover state ---------------------------------------------------------
176
+ const marker = path.join(top, STATE, "passed");
177
+ if (fs.existsSync(marker)) {
178
+ const m = readJson(marker);
179
+ add("marker", "warn", `leftover ${m && m.mode ? m.mode : "?"} record`, "a commit was interrupted; the next pre-commit overwrites it");
180
+ } else {
181
+ add("marker", "ok", "none");
182
+ }
183
+ const skipsPath = path.join(top, STATE, "skips.log");
184
+ if (fs.existsSync(skipsPath)) {
185
+ const n = fs.readFileSync(skipsPath, "utf8").split(/\r?\n/).filter(Boolean).length;
186
+ add("skips", "warn", `${n} recorded skip(s)`, "inspect: .signoff/skips.log; audit --strict fails on them");
187
+ } else {
188
+ add("skips", "ok", "none");
189
+ }
190
+
191
+ // --- ledger -----------------------------------------------------------------
192
+ const ledger = p4.ledgerPath();
193
+ if (!fs.existsSync(ledger)) {
194
+ add("ledger", "ok", "empty (nothing recorded yet)");
195
+ } else {
196
+ let entries = [];
197
+ try {
198
+ entries = p4.entries();
199
+ } catch (e) {
200
+ add("ledger", "fail", "unreadable", "inspect or move it away, then re-record");
201
+ }
202
+ const key = p4.repoKey();
203
+ const foreign = entries.filter((e) => e && e.repo && key && e.repo !== key).length;
204
+ const v = readJsonOr(() => JSON.parse(cli(["p4", "verify", "--json"]).out), null);
205
+ if (!v) {
206
+ add("ledger", "fail", "verify did not return JSON", "run: signoff p4 verify");
207
+ } else {
208
+ add(
209
+ "ledger",
210
+ v.ok ? (foreign ? "warn" : "ok") : "fail",
211
+ `${entries.length} entries, ${foreign} from other repos${v.problems && v.problems.length ? `, ${v.problems.length} problem(s)` : ""}`,
212
+ v.ok ? "" : "the chain is broken or a rollback point is gone; inspect .signoff/audit.log"
213
+ );
214
+ for (const p of (v.problems || []).slice(0, 3)) add("ledger:problem", "fail", String(p));
215
+ }
216
+ }
217
+
218
+ // --- what actually landed ----------------------------------------------------
219
+ const audit = readJsonOr(() => {
220
+ const r = cli(["audit", "--since-install", "--json"]);
221
+ return r.out ? JSON.parse(r.out) : null;
222
+ }, null);
223
+ if (!audit) {
224
+ add("audit", "warn", "--since-install unavailable (no install point)", "signoff audit --range A..B");
225
+ } else {
226
+ const c = audit.counts || {};
227
+ add(
228
+ "audit",
229
+ c.missing ? "fail" : c.skip ? "warn" : "ok",
230
+ `pass=${c.pass || 0} skip=${c.skip || 0} missing=${c.missing || 0}`,
231
+ c.missing ? "commits without a trailer: they bypassed the hook (--no-verify) or predate the install point" : ""
232
+ );
233
+ }
234
+
235
+ // --- --fix: only repairs that are provably safe ----------------------------
236
+ if (fix) {
237
+ const hooksDir = path.join(top, ".git", "hooks");
238
+ const installBroken = checks.some(
239
+ (c) => /^hook:/.test(c.id) && c.status === "fail" && /missing|foreign/.test(c.detail)
240
+ );
241
+ if (installBroken) {
242
+ // The documented fix for a missing or foreign hook *is* `signoff init`; run it
243
+ // instead of telling the user to, then re-check rather than trusting the exit code.
244
+ const guard = require("./guard");
245
+ const rc = guard.main(["init", "--quiet"]);
246
+ fixed.push(`reinstalled hooks (signoff init, rc=${rc})`);
247
+ }
248
+ for (const stage of STAGES) {
249
+ const p2 = path.join(hooksDir, stage);
250
+ if (!fs.existsSync(p2) || !markedByUs(fs.readFileSync(p2, "utf8"))) continue;
251
+ if (process.platform !== "win32" && (fs.statSync(p2).mode & 0o111) === 0) {
252
+ try {
253
+ fs.chmodSync(p2, 0o755);
254
+ fixed.push(`chmod +x .git/hooks/${stage}`);
255
+ } catch (e) {
256
+ add(`fix:hook:${stage}`, "warn", String(e.message || e), "chmod it yourself");
257
+ }
258
+ }
259
+ }
260
+ const marker = path.join(top, STATE, "passed");
261
+ const staged = core.git(["diff", "--cached", "--name-only"]).out.trim();
262
+ if (fs.existsSync(marker) && !staged) {
263
+ try {
264
+ fs.rmSync(marker, { force: true });
265
+ fixed.push(`removed stale ${STATE}/passed (nothing staged)`);
266
+ } catch (e) {
267
+ add("fix:marker", "warn", String(e.message || e), "delete it yourself");
268
+ }
269
+ }
270
+ if (fixed.length) {
271
+ if (!asJson && !quiet) console.log(`fixed: ${fixed.join("; ")}`);
272
+ return run((argv || []).filter((a) => a !== "--fix"));
273
+ }
274
+ if (!asJson && !quiet) console.log("fixed: nothing to repair");
275
+ }
276
+
277
+ return report(checks, asJson, quiet, checks.some((c) => c.status === "fail") ? 1 : 0);
278
+ }
279
+
280
+ function readJsonOr(fn, fallback) {
281
+ try {
282
+ return fn();
283
+ } catch (e) {
284
+ return fallback;
285
+ }
286
+ }
287
+
288
+ function report(checks, asJson, quiet, code) {
289
+ const fails = checks.filter((c) => c.status === "fail").length;
290
+ const warns = checks.filter((c) => c.status === "warn").length;
291
+ if (asJson) {
292
+ core.json({ ok: fails === 0, fails, warns, checks });
293
+ return code;
294
+ }
295
+ for (const c of checks) {
296
+ if (quiet && c.status === "ok") continue;
297
+ console.log(`${c.status.padEnd(5)}${c.id.padEnd(20)}${c.detail}`);
298
+ if (c.fix) console.log(`${"".padEnd(5)}${"".padEnd(20)}→ ${c.fix}`);
299
+ }
300
+ console.log(`doctor: ${fails ? `${fails} fail` : "no failures"}, ${warns} warning(s)`);
301
+ return code;
302
+ }
303
+
304
+ module.exports = { run, USAGE };
package/lib/editor.js ADDED
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ // Editor integration: the gate should be runnable from the window the developer
3
+ // already has open, and a failure should land in the Problems panel, not the log.
4
+
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const core = require("./core");
8
+
9
+ // The guard prints `[secrets] path:line rule match` and `[cov] 42.8% 3/7 file`.
10
+ // One matcher per shape; `$signoff-gate` is the aggregate the tasks use.
11
+ const MATCHERS = {
12
+ "signoff-secret": {
13
+ owner: "signoff",
14
+ fileLocation: ["relative", "${workspaceFolder}"],
15
+ pattern: [
16
+ {
17
+ regexp: "^\\[secrets\\] (.+?):(\\d+)\\s+(\\S+)\\s+(.*)$",
18
+ file: 1,
19
+ line: 2,
20
+ code: 3,
21
+ message: 4,
22
+ },
23
+ ],
24
+ },
25
+ "signoff-cov": {
26
+ owner: "signoff",
27
+ fileLocation: ["relative", "${workspaceFolder}"],
28
+ severity: "warning",
29
+ pattern: [
30
+ {
31
+ regexp: "^\\[cov\\]\\s+([\\d.]+)%\\s+(\\d+)/(\\d+)\\s+(.+)$",
32
+ file: 4,
33
+ message: 1,
34
+ },
35
+ ],
36
+ },
37
+ };
38
+
39
+ function tasks(signoff) {
40
+ const run = (label, args, matcher, extra) =>
41
+ Object.assign(
42
+ {
43
+ label,
44
+ type: "shell",
45
+ command: signoff,
46
+ args,
47
+ group: "test",
48
+ presentation: { reveal: "always", panel: "dedicated", clear: true },
49
+ problemMatcher: matcher ? [matcher] : [],
50
+ },
51
+ extra || {}
52
+ );
53
+ return {
54
+ version: "2.0.0",
55
+ tasks: [
56
+ run("signoff: guard", ["guard"], "signoff-secret"),
57
+ run("signoff: all gates", ["all"], "signoff-secret"),
58
+ run("signoff: secrets", ["secrets", "--all"], "signoff-secret"),
59
+ run("signoff: coverage", ["cov", "--diff"], "signoff-cov"),
60
+ run("signoff: audit", ["audit", "--verify"], null, { group: "none", problemMatcher: [] }),
61
+ run("signoff: status", ["status"], null, { group: "none", problemMatcher: [] }),
62
+ ],
63
+ };
64
+ }
65
+
66
+ // Merge instead of overwrite: a repo may already have tasks, and losing them is
67
+ // not a thing a "make it convenient" flag is allowed to do.
68
+ function vscode(root, opts) {
69
+ const o = opts || {};
70
+ const dir = path.join(root, ".vscode");
71
+ const file = path.join(dir, "tasks.json");
72
+ fs.mkdirSync(dir, { recursive: true });
73
+ const wanted = tasks(o.command || "signoff");
74
+ let existing = null;
75
+ if (fs.existsSync(file)) {
76
+ try {
77
+ existing = JSON.parse(fs.readFileSync(file, "utf8"));
78
+ } catch (e) {
79
+ // A tasks.json with comments (jsonc) is not parseable; appending a second
80
+ // tasks.json would be worse than leaving the user's file alone.
81
+ return { ok: false, file, reason: "existing tasks.json is not plain JSON (jsonc?); left untouched" };
82
+ }
83
+ }
84
+ const base = existing && Array.isArray(existing.tasks) ? existing : { version: "2.0.0", tasks: [] };
85
+ const labels = new Set((base.tasks || []).map((t) => t && t.label));
86
+ // Problem matchers live in a user-level file; the task references them by owner
87
+ // name, so the JSON we write stays valid for VS Code as-is.
88
+ for (const t of wanted.tasks) {
89
+ if (!labels.has(t.label)) base.tasks.push(t);
90
+ }
91
+ fs.writeFileSync(file, JSON.stringify(base, null, 2) + "\n", "utf8");
92
+ const matcherFile = path.join(dir, "signoff-problem-matchers.json");
93
+ fs.writeFileSync(matcherFile, JSON.stringify(MATCHERS, null, 2) + "\n", "utf8");
94
+ return { ok: true, file, matcherFile, added: base.tasks.length - (existing ? (existing.tasks || []).length : 0) };
95
+ }
96
+
97
+ module.exports = { vscode, MATCHERS, tasks };
package/lib/explain.js ADDED
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+
3
+ // `signoff explain <gate>` — what a gate checks and how it decides. Kept next to the
4
+ // gates themselves so the answer comes from the same repo, not from memory.
5
+
6
+ const USAGE = `usage: signoff explain [-h] [--json] {p1,p2,p3,p4,p5}`;
7
+
8
+ const GATES = {
9
+ p1: {
10
+ name: "tests",
11
+ checks: "Runs the repository's own test command and fails if it exits non-zero.",
12
+ decides:
13
+ "Command precedence: .signoff/config.json test_cmd, then package.json scripts.test (npm test --silent), " +
14
+ "then vitest/jest/mocha via npx --no-install, then cargo test, go test ./..., make test, then pytest " +
15
+ "(if a .py file exists under tests/), then python -m unittest discover.",
16
+ fix: "Fix the failing test, or pin the real command: signoff init --lock-test (or --test-cmd \"...\").",
17
+ },
18
+ p2: {
19
+ name: "coverage",
20
+ checks: "Reads the scenario table and counts scenarios and assertions against the thresholds.",
21
+ decides:
22
+ "Fails when scenarios < --min-scenario or assertions < --min-assertions; --strict also fails on " +
23
+ "a scenario that claims success without assertions.",
24
+ fix: "Add the missing scenarios or assertions, or lower the thresholds in .signoff/config.json on purpose.",
25
+ },
26
+ p3: {
27
+ name: "queue",
28
+ checks: "Reads tasks/<id>/card.json statuses and refuses to call work done while cards are not finished.",
29
+ decides: "Fails when a card is missing a status, has an unknown status, or is not done; records the run in .signoff/p3.json.",
30
+ fix: "Finish the card, set its status honestly, or remove the card if the task is gone.",
31
+ },
32
+ p4: {
33
+ name: "ledger",
34
+ checks: "Verifies the append-only ledger: every record hashes its predecessor, and every rollback point still exists.",
35
+ decides: "Fails on a broken hash chain, a missing field, or a rollback commit that is not in the repository.",
36
+ fix: "Do not edit .signoff/audit.log by hand; re-record the action with signoff p4 confirm.",
37
+ },
38
+ p5: {
39
+ name: "anti-slop",
40
+ checks: "Scans added documents for empty claims: TODO/TBD/FIXME placeholders and AI-filler words.",
41
+ decides:
42
+ "Fails on a placeholder outside a word boundary (so \"TODO\" in code comments counts but 待补 inside a " +
43
+ "bigger word does not) and on filler phrases that assert progress without evidence.",
44
+ fix: "Replace the claim with what was actually verified, or delete the placeholder.",
45
+ },
46
+ };
47
+
48
+ function run(argv) {
49
+ const list = argv || [];
50
+ if (list.indexOf("-h") !== -1 || list.indexOf("--help") !== -1) {
51
+ console.log(USAGE);
52
+ return 0;
53
+ }
54
+ // Only `--json` exists here, so the gate is simply the one token that is not a flag.
55
+ const asJson = list.indexOf("--json") !== -1;
56
+ const which = String(list.filter((a) => !String(a).startsWith("-"))[0] || "").toLowerCase();
57
+ if (!GATES[which]) {
58
+ console.error(USAGE);
59
+ return 2;
60
+ }
61
+ const g = GATES[which];
62
+ if (asJson) {
63
+ console.log(JSON.stringify({ gate: which, name: g.name, checks: g.checks, decides: g.decides, fix: g.fix }));
64
+ return 0;
65
+ }
66
+ console.log(`${which} — ${g.name}`);
67
+ console.log(` checks ${g.checks}`);
68
+ console.log(` decides ${g.decides}`);
69
+ console.log(` fix ${g.fix}`);
70
+ return 0;
71
+ }
72
+
73
+ module.exports = { run, USAGE, GATES };