signoff-cli 0.0.0-stage → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.pre-commit-hooks.yaml +27 -0
  2. package/CHANGELOG.md +336 -0
  3. package/LICENSE +21 -0
  4. package/README.md +540 -3
  5. package/action.yml +24 -0
  6. package/bin/signoff.js +218 -0
  7. package/docs/diagrams/README.md +41 -0
  8. package/docs/diagrams/signoff-1-capability-en.png +0 -0
  9. package/docs/diagrams/signoff-1-capability.png +0 -0
  10. package/docs/diagrams/signoff-2-market-en.png +0 -0
  11. package/docs/diagrams/signoff-2-market.png +0 -0
  12. package/docs/diagrams/signoff-3-user-en.png +0 -0
  13. package/docs/diagrams/signoff-3-user.png +0 -0
  14. package/docs/diagrams/signoff-4-flow-en.png +0 -0
  15. package/docs/diagrams/signoff-4-flow.png +0 -0
  16. package/docs/diagrams/signoff-5-competitors-en.png +0 -0
  17. package/docs/diagrams/signoff-5-competitors.png +0 -0
  18. package/docs/diagrams/src/signoff-1-capability-en.mmd +81 -0
  19. package/docs/diagrams/src/signoff-1-capability.mmd +84 -0
  20. package/docs/diagrams/src/signoff-2-market-en.mmd +72 -0
  21. package/docs/diagrams/src/signoff-2-market.mmd +72 -0
  22. package/docs/diagrams/src/signoff-3-user-en.mmd +94 -0
  23. package/docs/diagrams/src/signoff-3-user.mmd +92 -0
  24. package/docs/gates.en.md +105 -0
  25. package/docs/gates.md +88 -0
  26. package/docs/integrations.md +58 -0
  27. package/docs/promotion.md +71 -0
  28. package/docs/signoff.schema.json +108 -0
  29. package/docs/trust.md +72 -0
  30. package/gates/guard.py +474 -0
  31. package/gates/p1_verify.py +111 -0
  32. package/gates/p2_coverage.py +127 -0
  33. package/gates/p3_queue.py +186 -0
  34. package/gates/p4_ledger.py +249 -0
  35. package/gates/p5_slop.py +109 -0
  36. package/lib/audit.js +213 -0
  37. package/lib/bootstrap.js +361 -0
  38. package/lib/checkpoint.js +184 -0
  39. package/lib/ci.js +58 -0
  40. package/lib/core.js +120 -0
  41. package/lib/cov.js +253 -0
  42. package/lib/detect.js +169 -0
  43. package/lib/doctor.js +304 -0
  44. package/lib/editor.js +97 -0
  45. package/lib/explain.js +73 -0
  46. package/lib/guard.js +1229 -0
  47. package/lib/p1.js +469 -0
  48. package/lib/p2.js +323 -0
  49. package/lib/p3.js +633 -0
  50. package/lib/p4.js +733 -0
  51. package/lib/p5.js +438 -0
  52. package/lib/preset.js +66 -0
  53. package/lib/secrets.js +336 -0
  54. package/lib/sign.js +196 -0
  55. package/package.json +55 -6
  56. package/test/selftest.js +1226 -0
  57. package/test/selftest.py +190 -0
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ // Signed checkpoints for the P4 ledger.
3
+ //
4
+ // The ledger is already an append-only hash chain, which catches edits but not
5
+ // deletion: truncate the file and every remaining line still verifies. A
6
+ // checkpoint is a signed statement "at seq N the head was H and the tree root was
7
+ // R"; it is written once, shipped somewhere the repo cannot reach (release notes,
8
+ // CI summary, a colleague's message), and any later truncation or rewrite shows up
9
+ // as a mismatch. The anchor line is the thing you publish:
10
+ //
11
+ // signoff1:<seq>:<head12>:<root16>
12
+
13
+ const crypto = require("crypto");
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+
17
+ const p4 = require("./p4");
18
+ const core = require("./core");
19
+ const signlib = require("./sign");
20
+
21
+ const VERSION = 1;
22
+ const PREFIX = "signoff1";
23
+
24
+ function sha256(s) {
25
+ return crypto.createHash("sha256").update(String(s), "utf8").digest("hex");
26
+ }
27
+
28
+ function checkpointPath() {
29
+ return path.join(path.dirname(p4.ledgerPath()), "checkpoint.json");
30
+ }
31
+
32
+ // Binary Merkle root over the per-record hashes; an odd node is paired with
33
+ // itself, the usual convention, so the root is stable for any length.
34
+ function merkleRoot(hashes) {
35
+ if (!hashes.length) return sha256("signoff:empty");
36
+ let level = hashes.map((h) => sha256("leaf:" + h));
37
+ while (level.length > 1) {
38
+ const next = [];
39
+ for (let i = 0; i < level.length; i += 2) {
40
+ const a = level[i];
41
+ const b = i + 1 < level.length ? level[i + 1] : a;
42
+ next.push(sha256("node:" + a + b));
43
+ }
44
+ level = next;
45
+ }
46
+ return level[0];
47
+ }
48
+
49
+ // The exact bytes a signature covers. Versioned so a future format cannot be
50
+ // replayed against this one.
51
+ function payload(seq, head, root) {
52
+ return ["signoff-checkpoint", "v" + VERSION, String(seq), head, root].join("\n");
53
+ }
54
+
55
+ function anchorOf(seq, head, root) {
56
+ return `${PREFIX}:${seq}:${String(head).slice(0, 12)}:${String(root).slice(0, 16)}`;
57
+ }
58
+
59
+ function summarize() {
60
+ const chain = p4.entries();
61
+ const head = chain.length ? String(chain[chain.length - 1].hash || "") : "genesis";
62
+ const root = merkleRoot(chain.map((r) => String(r.hash || "")));
63
+ return { seq: chain.length, head, root, chain };
64
+ }
65
+
66
+ function create(opts) {
67
+ const o = opts || {};
68
+ const s = summarize();
69
+ const body = payload(s.seq, s.head, s.root);
70
+ let key = null;
71
+ let sig = null;
72
+ if (o.sign !== false) {
73
+ // A checkpoint nobody can attribute is decoration. If the machine has no key,
74
+ // make a repo-local one (same convention as guard --sign) instead of silently
75
+ // producing an unsigned file.
76
+ let keyPath = signlib.findKey();
77
+ if (!keyPath) {
78
+ keyPath = signlib.generateKey(path.join(path.dirname(p4.ledgerPath()), "keys"));
79
+ if (keyPath) process.env.SIGNOFF_SIGN_KEY = keyPath;
80
+ }
81
+ const signed = signlib.sign(body);
82
+ if (signed) {
83
+ key = signed.key;
84
+ sig = signed.sig;
85
+ }
86
+ }
87
+ const cp = {
88
+ version: VERSION,
89
+ seq: s.seq,
90
+ head: s.head,
91
+ root: s.root,
92
+ ts: p4.timestamp(),
93
+ repo: p4.repoKey(),
94
+ key: key,
95
+ sig: sig,
96
+ anchor: anchorOf(s.seq, s.head, s.root),
97
+ };
98
+ const file = o.file || checkpointPath();
99
+ fs.mkdirSync(path.dirname(file), { recursive: true });
100
+ fs.writeFileSync(file, JSON.stringify(cp, null, 2) + "\n", "utf8");
101
+ return { ok: true, file, checkpoint: cp };
102
+ }
103
+
104
+ function readCheckpoint(file) {
105
+ const f = file || checkpointPath();
106
+ if (!fs.existsSync(f)) return null;
107
+ try {
108
+ const d = JSON.parse(fs.readFileSync(f, "utf8"));
109
+ return d && typeof d === "object" ? d : null;
110
+ } catch (e) {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ // Why a checkpoint would not verify, in the order a reader should care about:
116
+ // missing, truncated (rollback), rewritten, bad signature.
117
+ function verdict(cp, opts) {
118
+ const o = opts || {};
119
+ const root = o.root || process.cwd();
120
+ const problems = [];
121
+ if (!cp) {
122
+ return { ok: false, state: "missing", problems: ["no checkpoint written yet"] };
123
+ }
124
+ let chain;
125
+ try {
126
+ chain = p4.entries();
127
+ } catch (e) {
128
+ return { ok: false, state: "unreadable", problems: [String((e && e.message) || e)] };
129
+ }
130
+ const seq = Number(cp.seq);
131
+ const head = String(cp.head || "");
132
+ const rootHash = String(cp.root || "");
133
+ if (!Number.isFinite(seq) || seq < 0) problems.push("checkpoint seq is not a number");
134
+ if (!/^[0-9a-f]{64}$/.test(head) && head !== "genesis") problems.push("checkpoint head is not a hash");
135
+ if (seq > chain.length) {
136
+ problems.push(`ledger has ${chain.length} records but the checkpoint covers ${seq} (records were removed)`);
137
+ } else {
138
+ const actualHead = seq === 0 ? "genesis" : String(chain[seq - 1].hash || "");
139
+ if (actualHead !== head) problems.push(`record ${seq} hashes to ${actualHead.slice(0, 12)}, checkpoint says ${head.slice(0, 12)}`);
140
+ const actualRoot = merkleRoot(chain.slice(0, seq).map((r) => String(r.hash || "")));
141
+ if (actualRoot !== rootHash) problems.push("tree root does not match the checkpoint");
142
+ }
143
+ if (o.anchor) {
144
+ const want = anchorOf(seq, head, rootHash);
145
+ const got = core.pyStrip ? core.pyStrip(String(o.anchor)) : String(o.anchor).trim();
146
+ if (got !== want) problems.push(`published anchor '${got}' does not match local '${want}'`);
147
+ }
148
+ // Signature last: a chain that already fails cannot be rescued by a valid sig.
149
+ let state = "unsigned";
150
+ if (!problems.length) {
151
+ if (!cp.key || !cp.sig) {
152
+ if (!o.allowUnsigned) problems.push("checkpoint is not signed");
153
+ } else {
154
+ const allowed = signlib.allowedKeys(root);
155
+ const r = signlib.verify(payload(seq, head, rootHash), cp.key, cp.sig, allowed);
156
+ state = r.state;
157
+ if (!r.ok || state === "bad-signature" || state === "unverifiable") {
158
+ problems.push("signature: " + (r.reason || state));
159
+ }
160
+ }
161
+ }
162
+ return {
163
+ ok: problems.length === 0,
164
+ state: problems.length && state === "unsigned" ? "unsigned" : state,
165
+ seq,
166
+ head,
167
+ root: rootHash,
168
+ anchor: anchorOf(seq, head, rootHash),
169
+ problems,
170
+ };
171
+ }
172
+
173
+ module.exports = {
174
+ VERSION,
175
+ PREFIX,
176
+ create,
177
+ readCheckpoint,
178
+ verdict,
179
+ summarize,
180
+ merkleRoot,
181
+ payload,
182
+ anchorOf,
183
+ checkpointPath,
184
+ };
package/lib/ci.js ADDED
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ // GitHub Actions output.
3
+ //
4
+ // A gate that prints a paragraph is invisible in a PR: the reviewer sees "job
5
+ // failed" and scrolls a log. These helpers turn findings into annotations that
6
+ // land on the changed line, and a step summary that survives the run.
7
+
8
+ const fs = require("fs");
9
+
10
+ function isCI() {
11
+ return String(process.env.GITHUB_ACTIONS || "").toLowerCase() === "true";
12
+ }
13
+
14
+ // GitHub's workflow-command escaping: % -> %25, CR -> %0D, LF -> %0A. A property
15
+ // value additionally escapes , and : .
16
+ function escData(s) {
17
+ return String(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
18
+ }
19
+
20
+ function escProp(s) {
21
+ return escData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
22
+ }
23
+
24
+ // list: [{file, line, rule, message, level}]
25
+ function annotate(list) {
26
+ if (!isCI()) return 0;
27
+ let n = 0;
28
+ for (const f of list || []) {
29
+ if (!f || !f.file) continue;
30
+ const level = f.level === "warning" ? "warning" : "error";
31
+ const props = [`file=${escProp(f.file)}`];
32
+ if (f.line) props.push(`line=${Number(f.line)}`);
33
+ if (f.rule) props.push(`title=${escProp("signoff/" + f.rule)}`);
34
+ const msg = f.message || `${f.rule || "signoff"} finding`;
35
+ process.stdout.write(`::${level} ${props.join(",")}::${escData(msg)}\n`);
36
+ n++;
37
+ }
38
+ return n;
39
+ }
40
+
41
+ function summary(md) {
42
+ const p = process.env.GITHUB_STEP_SUMMARY;
43
+ if (!p || !md) return false;
44
+ try {
45
+ fs.appendFileSync(p, String(md).replace(/\r\n/g, "\n") + "\n", "utf8");
46
+ return true;
47
+ } catch (e) {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ function table(rows, headers) {
53
+ const head = `| ${headers.join(" | ")} |`;
54
+ const sep = `| ${headers.map(() => "---").join(" | ")} |`;
55
+ return [head, sep, ...rows.map((r) => `| ${r.join(" | ")} |`)].join("\n");
56
+ }
57
+
58
+ module.exports = { isCI, annotate, summary, table, escData, escProp };
package/lib/core.js ADDED
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ // Shared helpers for the JavaScript gate implementations.
3
+ // The gates are pure Node (>=18) on purpose: `npm i -g dsh-signoff` must give the
4
+ // full P1-P5 set on any machine that can run npm, with no Python and no degraded mode.
5
+
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const { spawnSync } = require("child_process");
9
+
10
+ const ROOT = path.join(__dirname, "..");
11
+
12
+ function nowISO() {
13
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
14
+ }
15
+
16
+ function readJSON(p, dflt) {
17
+ try {
18
+ return JSON.parse(fs.readFileSync(p, "utf8"));
19
+ } catch (e) {
20
+ return dflt;
21
+ }
22
+ }
23
+
24
+ function writeJSON(p, obj) {
25
+ fs.mkdirSync(path.dirname(p), { recursive: true });
26
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + "\n", "utf8");
27
+ }
28
+
29
+ function writeText(p, text) {
30
+ fs.mkdirSync(path.dirname(p), { recursive: true });
31
+ fs.writeFileSync(p, text, "utf8");
32
+ }
33
+
34
+ function stateDir(cwd) {
35
+ const d = path.join(cwd || process.cwd(), ".signoff");
36
+ fs.mkdirSync(d, { recursive: true });
37
+ return d;
38
+ }
39
+
40
+ function statePath(cwd, name) {
41
+ return path.join(stateDir(cwd), name);
42
+ }
43
+
44
+ function git(args, cwd) {
45
+ const r = spawnSync("git", args, { cwd: cwd || process.cwd(), encoding: "utf8", windowsHide: true });
46
+ return { code: r.status === null ? 127 : r.status, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
47
+ }
48
+
49
+ function json(obj) {
50
+ process.stdout.write(JSON.stringify(obj) + "\n");
51
+ }
52
+
53
+ function die(msg, code) {
54
+ process.stderr.write(String(msg) + "\n");
55
+ return code === undefined ? 1 : code;
56
+ }
57
+
58
+ // Minimal argv parser: `--flag`, `--key value`, `--key=value`, positionals.
59
+ function parseArgs(argv, spec) {
60
+ const out = { _: [] };
61
+ for (const [k, v] of Object.entries(spec || {})) out[k] = v;
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const a = argv[i];
64
+ if (a.startsWith("--")) {
65
+ const eq = a.indexOf("=");
66
+ const key = (eq === -1 ? a.slice(2) : a.slice(2, eq)).replace(/-([a-z])/g, (m, c) => c.toUpperCase());
67
+ let val;
68
+ if (eq !== -1) val = a.slice(eq + 1);
69
+ else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) val = argv[++i];
70
+ else val = true;
71
+ if (out[key] === undefined || Array.isArray(out[key])) {
72
+ if (Array.isArray(out[key])) out[key].push(val);
73
+ else out[key] = val;
74
+ } else if (val === true) out[key] = true;
75
+ else out[key] = val;
76
+ } else {
77
+ out._.push(a);
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ function appendJsonl(p, obj) {
84
+ fs.mkdirSync(path.dirname(p), { recursive: true });
85
+ fs.appendFileSync(p, JSON.stringify(obj) + "\n", "utf8");
86
+ }
87
+
88
+ function sha256(text) {
89
+ return require("crypto").createHash("sha256").update(text, "utf8").digest("hex");
90
+ }
91
+
92
+ // ~/.signoff, overridable for tests and for machines with a different home layout.
93
+ function homeDir() {
94
+ const override = process.env.SIGNOFF_HOME;
95
+ if (override) {
96
+ fs.mkdirSync(override, { recursive: true });
97
+ return override;
98
+ }
99
+ const h = process.env.HOME || process.env.USERPROFILE || process.cwd();
100
+ const d = path.join(h, ".signoff");
101
+ fs.mkdirSync(d, { recursive: true });
102
+ return d;
103
+ }
104
+
105
+ module.exports = {
106
+ ROOT,
107
+ nowISO,
108
+ readJSON,
109
+ writeJSON,
110
+ writeText,
111
+ stateDir,
112
+ statePath,
113
+ git,
114
+ json,
115
+ die,
116
+ parseArgs,
117
+ appendJsonl,
118
+ sha256,
119
+ homeDir,
120
+ };
package/lib/cov.js ADDED
@@ -0,0 +1,253 @@
1
+ "use strict";
2
+ // Line coverage for the change, not for the repo.
3
+ //
4
+ // P2 grades scenarios - it answers "was this behaviour exercised". This answers a
5
+ // narrower, harder question: of the lines this commit touches, how many did the
6
+ // suite actually execute? It reads whatever the project already produces
7
+ // (istanbul JSON, python `coverage json`, lcov.info) so no extra runner is needed.
8
+
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const core = require("./core");
12
+
13
+ const USAGE =
14
+ "usage: signoff cov [-h] [--min PCT] [--diff] [--base REF] [--file PATH] [--json] [--allow-missing]";
15
+ const HELP =
16
+ USAGE +
17
+ "\n\n" +
18
+ [
19
+ "options:",
20
+ " -h, --help show this help message and exit",
21
+ " --min PCT fail when changed-line coverage is below PCT (default 80)",
22
+ " --diff only count lines added by the change (default: whole files)",
23
+ " --base REF diff against REF (default: HEAD, or origin/main when present)",
24
+ " --file PATH read this coverage file instead of auto-detecting",
25
+ " --json print one JSON line",
26
+ " --allow-missing pass when no coverage data exists instead of failing",
27
+ ].join("\n") +
28
+ "\n";
29
+
30
+ const CANDIDATES = [
31
+ "coverage/coverage-final.json",
32
+ "coverage.json",
33
+ "coverage/lcov.info",
34
+ "lcov.info",
35
+ "coverage/lcov/lcov.info",
36
+ ];
37
+
38
+ function norm(p) {
39
+ return String(p).replace(/\\/g, "/").replace(/^\.\//, "");
40
+ }
41
+
42
+ // ---- readers -------------------------------------------------------------
43
+
44
+ function fromPython(obj, root) {
45
+ const out = new Map();
46
+ for (const [file, d] of Object.entries(obj.files || {})) {
47
+ const hit = new Set((d.executed_lines || []).map(Number));
48
+ const miss = new Set((d.missing_lines || []).map(Number));
49
+ const all = new Set([...hit, ...miss]);
50
+ if (!all.size) continue;
51
+ out.set(norm(path.isAbsolute(file) ? path.relative(root, file) : file), { hit, all });
52
+ }
53
+ return out;
54
+ }
55
+
56
+ function fromIstanbul(obj, root) {
57
+ const out = new Map();
58
+ for (const [file, d] of Object.entries(obj)) {
59
+ if (!d || typeof d !== "object" || !d.statementMap || !d.s) continue;
60
+ const hit = new Set();
61
+ const all = new Set();
62
+ for (const [id, st] of Object.entries(d.statementMap)) {
63
+ const ln = st && st.start ? st.start.line : 0;
64
+ if (!ln) continue;
65
+ all.add(ln);
66
+ if (Number((d.s || {})[id] || 0) > 0) hit.add(ln);
67
+ }
68
+ if (all.size) out.set(norm(path.isAbsolute(file) ? path.relative(root, file) : file), { hit, all });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ function fromLcov(text) {
74
+ const out = new Map();
75
+ let cur = null;
76
+ for (const line of String(text).split(/\r?\n/)) {
77
+ if (line.startsWith("SF:")) {
78
+ cur = { file: norm(line.slice(3).trim()), hit: new Set(), all: new Set() };
79
+ continue;
80
+ }
81
+ if (!cur) continue;
82
+ if (line.startsWith("DA:")) {
83
+ const [ln, count] = line.slice(3).split(",");
84
+ const n = Number(ln);
85
+ if (!n) continue;
86
+ cur.all.add(n);
87
+ if (Number(count) > 0) cur.hit.add(n);
88
+ }
89
+ if (line === "end_of_record") {
90
+ if (cur.all.size) out.set(cur.file, { hit: cur.hit, all: cur.all });
91
+ cur = null;
92
+ }
93
+ }
94
+ if (cur && cur.all.size) out.set(cur.file, { hit: cur.hit, all: cur.all });
95
+ return out;
96
+ }
97
+
98
+ function load(root, explicit) {
99
+ const tried = explicit ? [explicit] : CANDIDATES;
100
+ for (const rel of tried) {
101
+ const p = path.isAbsolute(rel) ? rel : path.join(root, rel);
102
+ if (!fs.existsSync(p)) continue;
103
+ const text = fs.readFileSync(p, "utf8");
104
+ if (/\/(coverage-final|coverage)\.json$|^coverage\.json$|\.json$/.test(rel)) {
105
+ let obj;
106
+ try {
107
+ obj = JSON.parse(text);
108
+ } catch (e) {
109
+ continue;
110
+ }
111
+ const map = obj && obj.files ? fromPython(obj, root) : fromIstanbul(obj, root);
112
+ if (map.size) return { source: rel, map };
113
+ } else {
114
+ const map = fromLcov(text);
115
+ if (map.size) return { source: rel, map };
116
+ }
117
+ }
118
+ return { source: null, map: new Map() };
119
+ }
120
+
121
+ // ---- diff ----------------------------------------------------------------
122
+
123
+ function baseRef(root, given) {
124
+ if (given) return given;
125
+ const originMain = core.git(["rev-parse", "--verify", "--quiet", "origin/main"], root);
126
+ if (originMain.code === 0 && originMain.out.trim()) return "origin/main";
127
+ const head = core.git(["rev-parse", "--verify", "--quiet", "HEAD"], root);
128
+ return head.code === 0 ? "HEAD" : null;
129
+ }
130
+
131
+ // Added line numbers per file, from a zero-context diff. Deleted lines are not
132
+ // coverage targets: nothing can execute a line that no longer exists.
133
+ function changedLines(root, base) {
134
+ const r = core.git(["diff", "--unified=0", "--no-color", base, "--"], root);
135
+ const out = new Map();
136
+ if (r.code !== 0) return out;
137
+ let file = null;
138
+ for (const line of r.out.split(/\r?\n/)) {
139
+ if (line.startsWith("+++ b/")) {
140
+ file = norm(line.slice(6).trim());
141
+ continue;
142
+ }
143
+ if (line.startsWith("+++ ") && line.includes("/dev/null")) {
144
+ file = null;
145
+ continue;
146
+ }
147
+ const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
148
+ if (!m || !file) continue;
149
+ const start = Number(m[1]);
150
+ const count = m[2] === undefined ? 1 : Number(m[2]);
151
+ if (!out.has(file)) out.set(file, new Set());
152
+ for (let i = 0; i < count; i++) out.get(file).add(start + i);
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function parse(argv) {
158
+ const a = { min: 80, diff: false, base: null, file: null, json: false, allowMissing: false, help: false };
159
+ for (let i = 0; i < argv.length; i++) {
160
+ const tok = String(argv[i]);
161
+ const val = () => {
162
+ if (tok.includes("=")) return tok.slice(tok.indexOf("=") + 1);
163
+ if (i + 1 >= argv.length) return undefined;
164
+ i++;
165
+ return String(argv[i]);
166
+ };
167
+ if (tok === "-h" || tok === "--help") a.help = true;
168
+ else if (tok === "--diff") a.diff = true;
169
+ else if (tok === "--json") a.json = true;
170
+ else if (tok === "--allow-missing") a.allowMissing = true;
171
+ else if (tok === "--min" || tok.startsWith("--min=")) {
172
+ const v = val();
173
+ if (v === undefined || Number.isNaN(Number(v))) return { error: "argument --min: expected a number" };
174
+ a.min = Number(v);
175
+ } else if (tok === "--base" || tok.startsWith("--base=")) {
176
+ const v = val();
177
+ if (v === undefined) return { error: "argument --base: expected one argument" };
178
+ a.base = v;
179
+ } else if (tok === "--file" || tok.startsWith("--file=")) {
180
+ const v = val();
181
+ if (v === undefined) return { error: "argument --file: expected one argument" };
182
+ a.file = v;
183
+ } else if (tok.startsWith("-") && tok.length > 1) return { error: `unrecognized arguments: ${tok}` };
184
+ }
185
+ return a;
186
+ }
187
+
188
+ function run(argv) {
189
+ const a = parse(argv || []);
190
+ if (a.error) {
191
+ process.stderr.write(USAGE + "\n" + `signoff cov: error: ${a.error}\n`);
192
+ return 2;
193
+ }
194
+ if (a.help) {
195
+ process.stdout.write(HELP);
196
+ return 0;
197
+ }
198
+ const root = core.git(["rev-parse", "--show-toplevel"], process.cwd()).out.trim() || process.cwd();
199
+ const { source, map } = load(root, a.file);
200
+ if (!map.size) {
201
+ const msg = a.allowMissing ? "no coverage data (allowed)" : "no coverage data found (looked for " + CANDIDATES.join(", ") + ")";
202
+ if (a.json) core.json({ gate: "cov", ok: !!a.allowMissing, source: null, reason: msg });
203
+ else process.stdout.write(`[cov] ${a.allowMissing ? "PASS" : "FAIL"} ${msg}\n`);
204
+ return a.allowMissing ? 0 : 2;
205
+ }
206
+
207
+ const base = baseRef(root, a.base);
208
+ const changed = a.diff && base ? changedLines(root, base) : null;
209
+ const files = [];
210
+ let covered = 0;
211
+ let total = 0;
212
+ for (const [file, data] of map) {
213
+ const wanted = changed ? changed.get(file) : data.all;
214
+ if (!wanted || !wanted.size) continue;
215
+ let fTotal = 0;
216
+ let fHit = 0;
217
+ for (const ln of wanted) {
218
+ if (!data.all.has(ln)) continue; // line exists but the runner did not instrument it
219
+ fTotal++;
220
+ if (data.hit.has(ln)) fHit++;
221
+ }
222
+ if (!fTotal) continue;
223
+ covered += fHit;
224
+ total += fTotal;
225
+ files.push({ file, covered: fHit, total: fTotal, pct: Math.round((fHit / fTotal) * 1000) / 10 });
226
+ }
227
+ files.sort((x, y) => x.pct - y.pct || x.file.localeCompare(y.file));
228
+ const pct = total ? Math.round((covered / total) * 1000) / 10 : 100;
229
+ const ok = total === 0 || pct >= a.min;
230
+
231
+ core.writeJSON(path.join(root, ".signoff", "cov.json"), {
232
+ gate: "cov",
233
+ ok,
234
+ source,
235
+ base,
236
+ mode: changed ? "diff" : "files",
237
+ covered,
238
+ total,
239
+ pct,
240
+ min: a.min,
241
+ files,
242
+ });
243
+
244
+ if (a.json) {
245
+ core.json({ gate: "cov", ok, source, mode: changed ? "diff" : "files", covered, total, pct, min: a.min, files: files.length });
246
+ } else {
247
+ for (const f of files.slice(0, 10)) console.log(`[cov] ${String(f.pct).padStart(5)}% ${f.covered}/${f.total} ${f.file}`);
248
+ console.log(`[cov] ${ok ? "PASS" : "FAIL"} ${pct}% of ${changed ? "changed" : "instrumented"} lines (min ${a.min}%), source=${source}`);
249
+ }
250
+ return ok ? 0 : 1;
251
+ }
252
+
253
+ module.exports = { run, USAGE, HELP, load, changedLines, fromLcov };