@blamejs/exceptd-skills 0.12.7 → 0.12.9

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 (77) hide show
  1. package/AGENTS.md +15 -1
  2. package/ARCHITECTURE.md +21 -5
  3. package/CHANGELOG.md +150 -0
  4. package/README.md +1 -1
  5. package/bin/exceptd.js +416 -69
  6. package/data/_indexes/_meta.json +44 -44
  7. package/data/_indexes/activity-feed.json +34 -34
  8. package/data/_indexes/catalog-summaries.json +9 -9
  9. package/data/_indexes/chains.json +249 -11
  10. package/data/_indexes/frequency.json +63 -5
  11. package/data/_indexes/jurisdiction-map.json +13 -3
  12. package/data/_indexes/section-offsets.json +1171 -1027
  13. package/data/_indexes/summary-cards.json +2 -2
  14. package/data/_indexes/token-budget.json +232 -152
  15. package/data/atlas-ttps.json +189 -1
  16. package/data/cve-catalog.json +34 -22
  17. package/data/cwe-catalog.json +290 -1
  18. package/data/d3fend-catalog.json +163 -1
  19. package/data/framework-control-gaps.json +243 -0
  20. package/data/playbooks/containers.json +23 -5
  21. package/data/playbooks/cred-stores.json +9 -9
  22. package/data/playbooks/crypto.json +8 -8
  23. package/data/playbooks/hardening.json +46 -10
  24. package/data/playbooks/library-author.json +16 -20
  25. package/data/playbooks/mcp.json +1 -0
  26. package/data/playbooks/runtime.json +7 -7
  27. package/data/playbooks/sbom.json +11 -11
  28. package/data/playbooks/secrets.json +4 -4
  29. package/data/rfc-references.json +144 -0
  30. package/lib/playbook-runner.js +119 -35
  31. package/lib/prefetch.js +27 -6
  32. package/lib/refresh-external.js +32 -9
  33. package/lib/schemas/skill-frontmatter.schema.json +2 -2
  34. package/manifest-snapshot.json +1 -1
  35. package/manifest.json +73 -73
  36. package/orchestrator/index.js +1 -1
  37. package/package.json +2 -1
  38. package/sbom.cdx.json +6 -6
  39. package/scripts/check-sbom-currency.js +87 -0
  40. package/scripts/check-test-coverage.README.md +148 -0
  41. package/scripts/check-test-coverage.js +476 -0
  42. package/scripts/hooks/pre-commit.sh +19 -0
  43. package/scripts/predeploy.js +14 -30
  44. package/skills/age-gates-child-safety/skill.md +3 -0
  45. package/skills/ai-attack-surface/skill.md +29 -1
  46. package/skills/ai-c2-detection/skill.md +30 -1
  47. package/skills/ai-risk-management/skill.md +3 -0
  48. package/skills/api-security/skill.md +3 -0
  49. package/skills/attack-surface-pentest/skill.md +3 -0
  50. package/skills/cloud-security/skill.md +3 -0
  51. package/skills/compliance-theater/skill.md +6 -0
  52. package/skills/container-runtime-security/skill.md +3 -0
  53. package/skills/coordinated-vuln-disclosure/skill.md +8 -1
  54. package/skills/defensive-countermeasure-mapping/skill.md +1 -1
  55. package/skills/dlp-gap-analysis/skill.md +3 -0
  56. package/skills/email-security-anti-phishing/skill.md +9 -1
  57. package/skills/exploit-scoring/skill.md +6 -0
  58. package/skills/identity-assurance/skill.md +6 -1
  59. package/skills/incident-response-playbook/skill.md +8 -2
  60. package/skills/kernel-lpe-triage/skill.md +24 -4
  61. package/skills/mcp-agent-trust/skill.md +28 -1
  62. package/skills/mlops-security/skill.md +3 -0
  63. package/skills/ot-ics-security/skill.md +3 -0
  64. package/skills/policy-exception-gen/skill.md +6 -0
  65. package/skills/rag-pipeline-security/skill.md +30 -1
  66. package/skills/researcher/skill.md +6 -0
  67. package/skills/sector-energy/skill.md +3 -0
  68. package/skills/sector-federal-government/skill.md +3 -0
  69. package/skills/sector-financial/skill.md +3 -0
  70. package/skills/sector-healthcare/skill.md +3 -0
  71. package/skills/security-maturity-tiers/skill.md +25 -1
  72. package/skills/skill-update-loop/skill.md +38 -0
  73. package/skills/supply-chain-integrity/skill.md +3 -0
  74. package/skills/threat-model-currency/skill.md +4 -0
  75. package/skills/threat-modeling-methodology/skill.md +3 -0
  76. package/skills/webapp-security/skill.md +3 -0
  77. package/skills/zeroday-gap-learn/skill.md +6 -0
@@ -0,0 +1,148 @@
1
+ # Diff-coverage analyzer
2
+
3
+ `scripts/check-test-coverage.js` compares the changed surface in a diff
4
+ against the `tests/` tree and reports any surface change that has no
5
+ matching test reference. Zero dependencies, Node 24 stdlib only, runs
6
+ identically on Windows + Linux.
7
+
8
+ ## Usage
9
+
10
+ ```bash
11
+ # Default: HEAD vs origin/main, human output.
12
+ node scripts/check-test-coverage.js
13
+
14
+ # Staged changes only (pre-commit shape).
15
+ node scripts/check-test-coverage.js --staged
16
+
17
+ # Compare HEAD vs a custom base.
18
+ node scripts/check-test-coverage.js --base v0.12.6
19
+
20
+ # Machine-readable.
21
+ node scripts/check-test-coverage.js --json
22
+
23
+ # Surface findings without failing the gate.
24
+ node scripts/check-test-coverage.js --warn-only
25
+ ```
26
+
27
+ ## What it covers
28
+
29
+ | Source file | Surface extracted |
30
+ | --------------------------------- | ------------------------------------------------------- |
31
+ | `bin/exceptd.js` | CLI verbs (`COMMANDS` map + `PLAYBOOK_VERBS` set), flags |
32
+ | `lib/*.js`, `orchestrator/*.js`, `scripts/*.js` | `module.exports = { ... }` identifiers |
33
+ | `data/playbooks/*.json` | `phases.detect.indicators[].id`, `phases.look.artifacts[].id` |
34
+ | `data/cve-catalog.json` | CVE entries whose `iocs` field added / removed / changed |
35
+
36
+ For each added / removed / modified surface, the analyzer searches every
37
+ `*.js` and `*.json` file under `tests/` for a reference to the surface
38
+ identifier (quoted literal for verbs / playbook IDs, raw substring for
39
+ flags + CVE IDs, `require()` site plus identifier reference for lib
40
+ exports). Missing references become findings.
41
+
42
+ ## Allowlist
43
+
44
+ Changes in these locations are accepted without a covering test:
45
+
46
+ - `*.md` outside `data/`, `.gitignore`, `.npmrc`, `.editorconfig`
47
+ - `CHANGELOG.md` / `README.md` / `CONTRIBUTING.md` / `SECURITY.md` / `LICENSE` / `NOTICE` / `CODE_OF_CONDUCT.md` / `AGENTS.md` / `CLAUDE.md`
48
+ - Whitespace-only diffs (detected via `git diff --ignore-all-space --ignore-blank-lines`)
49
+ - Any file under `tests/` (no test-of-tests recursion)
50
+ - `skills/<name>/skill.md` (signature gate already covers content integrity)
51
+ - `.github/workflows/*.yml` — surfaced as **manual review required** rather than as a finding, because workflow steps don't pattern-match cleanly to test code paths
52
+
53
+ ## Integration: predeploy gate
54
+
55
+ Append this entry to `GATES` in `scripts/predeploy.js` (placement: after
56
+ the existing tarball-verify gate, before the e2e-scenarios gate or at the
57
+ end — the gates are independent):
58
+
59
+ ```js
60
+ {
61
+ name: "Diff coverage (no-MVP: feature changes require test coverage)",
62
+ command: process.execPath,
63
+ args: [path.join(ROOT, "scripts", "check-test-coverage.js")],
64
+ ciJobName: "Diff coverage",
65
+ },
66
+ ```
67
+
68
+ The gate passes when no findings are produced and fails with the
69
+ analyzer's human-readable output when findings exist. Use `--warn-only`
70
+ during the rollout window if you want the gate informational before it
71
+ becomes blocking.
72
+
73
+ A matching CI job needs to land in `.github/workflows/ci.yml` so
74
+ `tests/predeploy.test.js` (which asserts the local runner mirrors the CI
75
+ workflow) stays green:
76
+
77
+ ```yaml
78
+ diff-coverage:
79
+ name: Diff coverage
80
+ runs-on: ubuntu-latest
81
+ steps:
82
+ - uses: actions/checkout@v4
83
+ with: { fetch-depth: 0 }
84
+ - uses: actions/setup-node@v4
85
+ with: { node-version: '24' }
86
+ - run: node scripts/check-test-coverage.js --base origin/main
87
+ ```
88
+
89
+ ## Integration: pre-commit hook
90
+
91
+ `scripts/hooks/pre-commit.sh` invokes the analyzer in `--staged` mode.
92
+ Not installed by default. Opt in with:
93
+
94
+ ```bash
95
+ git config core.hooksPath scripts/hooks
96
+ chmod +x scripts/hooks/pre-commit.sh # Linux only
97
+ ```
98
+
99
+ Bypass for a single commit (acceptable when the covering test will land
100
+ in a follow-up before push):
101
+
102
+ ```bash
103
+ git commit --no-verify
104
+ ```
105
+
106
+ ## AGENTS.md addition — Hard Rule
107
+
108
+ Drop-in markdown for the next available Hard Rule number (currently
109
+ adding as **Rule #15** if the existing list ends at #14):
110
+
111
+ ```markdown
112
+ ### Rule #15 — Test coverage on every diff
113
+
114
+ Every feature change — added, removed, or modified — must land with a
115
+ matching test reference in the same PR. The shapes the gate enforces:
116
+
117
+ | Change | Required test reference |
118
+ | ------------------------------------------------ | ------------------------------------------------------- |
119
+ | New CLI verb in `bin/exceptd.js` | Quoted verb literal in a `tests/*.test.js` file |
120
+ | New CLI flag | Flag literal (e.g. `--my-flag`) somewhere in `tests/` |
121
+ | New / removed `module.exports` identifier | `require('…/<lib>')` + identifier reference |
122
+ | New `phases.detect.indicators[].id` in a playbook | Quoted ID literal in `tests/e2e-scenarios/*/expect.json` or any `tests/*.test.js` |
123
+ | New / changed `iocs` field on a CVE entry | CVE ID + the word `iocs` in the same test file |
124
+
125
+ Mechanical enforcement lives in `scripts/check-test-coverage.js` and runs
126
+ as the 15th gate of `npm run predeploy`. Whitespace-only diffs, docs,
127
+ workflow YAML, and skill body changes are allowlisted (skill bodies are
128
+ covered by the Ed25519 signature gate).
129
+
130
+ The gate is blocking. `--warn-only` exists for the rollout window only;
131
+ once the gate is wired into CI, do not bypass with `--no-verify` or
132
+ `--warn-only` — add the covering test first.
133
+
134
+ This rule is additive to **Hard Rule #11 (No-MVP ban)**: shipping a new
135
+ playbook indicator or CLI surface without a regression test is the
136
+ same shape of incomplete-feature ship that #11 forbids.
137
+ ```
138
+
139
+ ## Smoke results against the current repo
140
+
141
+ Run from repo root:
142
+
143
+ ```bash
144
+ node scripts/check-test-coverage.js
145
+ ```
146
+
147
+ Expected on a clean working tree at `v0.12.7`: zero changed files, zero
148
+ findings, exit 0.
@@ -0,0 +1,476 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * scripts/check-test-coverage.js
5
+ *
6
+ * Diff-aware test-coverage gate. Compares the changed surface in the
7
+ * working tree (or a staged set, or any --base..HEAD range) against the
8
+ * tests/ tree and reports any surface change that lacks a covering test.
9
+ *
10
+ * Surfaces detected:
11
+ * - bin/exceptd.js CLI verbs / flags (COMMANDS / PLAYBOOK_VERBS)
12
+ * - lib/*.js, orchestrator/*.js,
13
+ * scripts/*.js exported functions (module.exports = {...})
14
+ * - data/playbooks/*.json detect.indicators[].id + look.artifacts[].id
15
+ * - data/cve-catalog.json CVE entries whose iocs field changed
16
+ *
17
+ * Categorization (no test required):
18
+ * - *.md outside data/, .gitignore, .npmrc, .editorconfig
19
+ * - CHANGELOG.md / README.md / CONTRIBUTING.md / SECURITY.md
20
+ * - whitespace-only diffs (re-run with --ignore-all-space)
21
+ * - tests/** changes (no recursion)
22
+ * - .github/workflows/*.yml surfaced as manual-review-required
23
+ * - skills/<name>/skill.md satisfied by Ed25519 verify gate
24
+ *
25
+ * Exit codes:
26
+ * 0 no uncovered surface (or --warn-only)
27
+ * 1 uncovered surface detected
28
+ * 2 runner error (bad flag, git failure, etc.)
29
+ *
30
+ * Flags:
31
+ * --base <ref> compare HEAD against <ref> (default: origin/main)
32
+ * --staged use the staged index against HEAD
33
+ * --json emit machine-readable report on stdout
34
+ * --warn-only print but never exit non-zero
35
+ * --help, -h this help
36
+ */
37
+
38
+ const fs = require("fs");
39
+ const path = require("path");
40
+ const childProc = require("child_process");
41
+
42
+ const ROOT = path.resolve(__dirname, "..");
43
+
44
+ // --- Flag parsing -----------------------------------------------------------
45
+
46
+ function parseArgs(argv) {
47
+ const out = { base: "origin/main", staged: false, json: false, warnOnly: false };
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const a = argv[i];
50
+ if (a === "--base") out.base = argv[++i];
51
+ else if (a === "--staged") out.staged = true;
52
+ else if (a === "--json") out.json = true;
53
+ else if (a === "--warn-only") out.warnOnly = true;
54
+ else if (a === "--help" || a === "-h") out.help = true;
55
+ else if (a === "--repo") out.repo = argv[++i]; // test-only: override ROOT
56
+ else throw new Error(`Unknown flag: ${a}`);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function printHelp() {
62
+ const banner =
63
+ "Usage: node scripts/check-test-coverage.js [--base <ref>] [--staged]\n" +
64
+ " [--json] [--warn-only]\n" +
65
+ "\n" +
66
+ "See file header for full surface + categorization rules.\n";
67
+ process.stdout.write(banner);
68
+ }
69
+
70
+ // --- Git plumbing -----------------------------------------------------------
71
+
72
+ function git(args, cwd) {
73
+ const r = childProc.spawnSync("git", args, { cwd, encoding: "utf8" });
74
+ if (r.status !== 0) {
75
+ const err = new Error("git " + args.join(" ") + " failed: " + (r.stderr || r.stdout));
76
+ err.git = true;
77
+ throw err;
78
+ }
79
+ return r.stdout;
80
+ }
81
+
82
+ // v0.12.8: resolve the diff anchor ONCE up front and thread the resolved SHA
83
+ // through every per-file computation. Pre-fix, listChangedFiles() resolved
84
+ // `opts.base` to a merge-base but fileDiff()/fileBefore() still used the raw
85
+ // `opts.base` ref — so if origin/main advanced past the merge-base between
86
+ // the file-list call and the per-file diff calls, the analyzer compared
87
+ // per-file content against a newer upstream tree than the file list itself
88
+ // was derived from. Result: false "added/removed" surface findings or real
89
+ // findings masked. Codex P1 flag on PR #2 of v0.12.8.
90
+ function resolveBaseRef(opts, cwd) {
91
+ if (opts.staged) return null; // staged mode uses --cached / HEAD throughout
92
+ try {
93
+ const mb = git(["merge-base", "HEAD", opts.base], cwd).trim();
94
+ if (mb) return mb;
95
+ } catch { /* base may not exist locally; fall back to ref literal */ }
96
+ return opts.base;
97
+ }
98
+
99
+ function listChangedFiles(opts, cwd, resolvedBase) {
100
+ if (opts.staged) {
101
+ return git(["diff", "--name-status", "--cached"], cwd)
102
+ .split("\n").filter(Boolean).map(parseNameStatus);
103
+ }
104
+ return git(["diff", "--name-status", resolvedBase + "..HEAD"], cwd)
105
+ .split("\n").filter(Boolean).map(parseNameStatus);
106
+ }
107
+
108
+ function parseNameStatus(line) {
109
+ const parts = line.split("\t");
110
+ const status = parts[0][0]; // A/M/D/R/...
111
+ return { status, file: parts[parts.length - 1] };
112
+ }
113
+
114
+ function fileDiff(opts, file, cwd, ignoreWs, resolvedBase) {
115
+ const args = ["diff", "-U0"];
116
+ if (ignoreWs) args.push("--ignore-all-space", "--ignore-blank-lines");
117
+ if (opts.staged) args.push("--cached");
118
+ else args.push(resolvedBase + "..HEAD");
119
+ args.push("--", file);
120
+ try { return git(args, cwd); } catch { return ""; }
121
+ }
122
+
123
+ function fileAtRef(file, ref, cwd) {
124
+ const r = childProc.spawnSync("git", ["show", ref + ":" + file], { cwd, encoding: "utf8" });
125
+ if (r.status !== 0) return null;
126
+ return r.stdout;
127
+ }
128
+
129
+ function fileBefore(opts, file, cwd, resolvedBase) {
130
+ if (opts.staged) return fileAtRef(file, "HEAD", cwd);
131
+ return fileAtRef(file, resolvedBase, cwd);
132
+ }
133
+
134
+ function fileAfter(opts, file, cwd) {
135
+ if (opts.staged) {
136
+ // Staged content lives in the index. `git show :file` returns it.
137
+ return fileAtRef(file, "", cwd) || readMaybe(path.join(cwd, file));
138
+ }
139
+ return readMaybe(path.join(cwd, file));
140
+ }
141
+
142
+ function readMaybe(p) {
143
+ try { return fs.readFileSync(p, "utf8"); } catch { return null; }
144
+ }
145
+
146
+ // --- Categorization ---------------------------------------------------------
147
+
148
+ const DOCS_ALWAYS_GREEN = new Set([
149
+ "CHANGELOG.md", "README.md", "CONTRIBUTING.md", "SECURITY.md",
150
+ "LICENSE", "NOTICE", "CODE_OF_CONDUCT.md", "AGENTS.md", "CLAUDE.md",
151
+ "SUPPORT.md", "MIGRATING.md", ".gitignore", ".npmrc", ".editorconfig",
152
+ ]);
153
+
154
+ function categorize(file) {
155
+ const norm = file.replace(/\\/g, "/");
156
+ if (DOCS_ALWAYS_GREEN.has(norm)) return "docs";
157
+ if (norm.startsWith("tests/")) return "test"; // no recursion
158
+ if (norm.startsWith("docs/")) return "docs";
159
+ if (norm.endsWith(".md") && !norm.startsWith("data/")) return "docs";
160
+ if (norm.startsWith(".github/workflows/")) return "workflow";
161
+ if (norm.startsWith("skills/") && norm.endsWith("/skill.md")) return "skill";
162
+ if (norm === "bin/exceptd.js") return "cli";
163
+ if (norm.startsWith("lib/") && norm.endsWith(".js")) return "lib";
164
+ if (norm.startsWith("orchestrator/") && norm.endsWith(".js")) return "lib";
165
+ if (norm.startsWith("scripts/") && norm.endsWith(".js")) return "lib";
166
+ if (norm.startsWith("data/playbooks/") && norm.endsWith(".json")) return "playbook";
167
+ if (norm === "data/cve-catalog.json") return "cve-catalog";
168
+ return "other";
169
+ }
170
+
171
+ function isWhitespaceOnly(opts, file, cwd, resolvedBase) {
172
+ const wsBlind = fileDiff(opts, file, cwd, true, resolvedBase);
173
+ return wsBlind.split("\n").filter(l => l.startsWith("+") || l.startsWith("-"))
174
+ .filter(l => !l.startsWith("+++") && !l.startsWith("---")).length === 0;
175
+ }
176
+
177
+ // --- Surface extraction -----------------------------------------------------
178
+
179
+ function extractCliSurface(content) {
180
+ if (!content) return { verbs: new Set(), flags: new Set() };
181
+ const verbs = new Set();
182
+ const flags = new Set();
183
+ // Only scan the COMMANDS = {...} block and PLAYBOOK_VERBS Set to avoid
184
+ // picking up arbitrary keys from elsewhere.
185
+ const cmdBlock = content.match(/const COMMANDS = \{([\s\S]*?)\n\};/);
186
+ if (cmdBlock) {
187
+ const re = /^\s*"?([a-zA-Z][\w-]+)"?\s*:/gm;
188
+ let m;
189
+ while ((m = re.exec(cmdBlock[1])) !== null) verbs.add(m[1]);
190
+ }
191
+ const playbookBlock = content.match(/const PLAYBOOK_VERBS = new Set\(\[([\s\S]*?)\]\);/);
192
+ if (playbookBlock) {
193
+ const re = /"([a-zA-Z][\w-]+)"/g;
194
+ let m;
195
+ while ((m = re.exec(playbookBlock[1])) !== null) verbs.add(m[1]);
196
+ }
197
+ const flagRe = /(--[a-zA-Z][\w-]+)/g;
198
+ let m;
199
+ while ((m = flagRe.exec(content)) !== null) flags.add(m[1]);
200
+ for (const f of ["--help", "--version"]) flags.delete(f);
201
+ return { verbs, flags };
202
+ }
203
+
204
+ function diffSets(before, after) {
205
+ const added = new Set();
206
+ const removed = new Set();
207
+ for (const v of after) if (!before.has(v)) added.add(v);
208
+ for (const v of before) if (!after.has(v)) removed.add(v);
209
+ return { added, removed };
210
+ }
211
+
212
+ function extractLibExports(content) {
213
+ if (!content) return new Set();
214
+ const out = new Set();
215
+ // v0.12.9: strip block + line comments before matching `module.exports`
216
+ // so a doc-comment example like `module.exports = {...}` inside a /** */
217
+ // block does not shadow the real exports lower in the file. Pre-fix, the
218
+ // analyzer's own file matched a 3-char doc-comment fragment first and
219
+ // returned an empty export set — any source that mentions `module.exports`
220
+ // in a JSDoc/banner block hit the same bug. After stripping comments,
221
+ // the `module.exports = {...}` match runs against real code only.
222
+ const stripped = content
223
+ .replace(/\/\*[\s\S]*?\*\//g, "")
224
+ .replace(/^\s*\/\/.*$/gm, "");
225
+ const m = stripped.match(/module\.exports\s*=\s*\{([^}]+)\}/);
226
+ if (m) {
227
+ for (const tok of m[1].split(",")) {
228
+ const id = tok.split(":")[0].trim();
229
+ if (/^[a-zA-Z_$][\w$]*$/.test(id)) out.add(id);
230
+ }
231
+ }
232
+ const re = /module\.exports\.([a-zA-Z_$][\w$]*)\s*=/g;
233
+ let mm;
234
+ while ((mm = re.exec(stripped)) !== null) out.add(mm[1]);
235
+ const re2 = /^exports\.([a-zA-Z_$][\w$]*)\s*=/gm;
236
+ while ((mm = re2.exec(stripped)) !== null) out.add(mm[1]);
237
+ return out;
238
+ }
239
+
240
+ function extractPlaybookIds(content) {
241
+ if (!content) return { indicators: new Set(), artifacts: new Set() };
242
+ let obj;
243
+ try { obj = JSON.parse(content); }
244
+ catch { return { indicators: new Set(), artifacts: new Set() }; }
245
+ const ind = new Set();
246
+ const arts = new Set();
247
+ const detect = obj && obj.phases && obj.phases.detect && obj.phases.detect.indicators;
248
+ if (Array.isArray(detect)) for (const i of detect) if (i && i.id) ind.add(i.id);
249
+ const look = obj && obj.phases && obj.phases.look && obj.phases.look.artifacts;
250
+ if (Array.isArray(look)) for (const a of look) if (a && a.id) arts.add(a.id);
251
+ return { indicators: ind, artifacts: arts };
252
+ }
253
+
254
+ function extractCveIocChanges(beforeStr, afterStr) {
255
+ const before = safeParse(beforeStr) || {};
256
+ const after = safeParse(afterStr) || {};
257
+ const changed = new Set();
258
+ const ids = new Set([...Object.keys(before), ...Object.keys(after)]);
259
+ for (const id of ids) {
260
+ if (!/^CVE-\d{4}-\d+/.test(id)) continue;
261
+ const b = JSON.stringify((before[id] && before[id].iocs) || null);
262
+ const a = JSON.stringify((after[id] && after[id].iocs) || null);
263
+ if (b !== a) changed.add(id);
264
+ }
265
+ return changed;
266
+ }
267
+
268
+ function safeParse(s) { try { return s ? JSON.parse(s) : null; } catch { return null; } }
269
+
270
+ // --- Test corpus + coverage probes ------------------------------------------
271
+
272
+ function loadTestCorpus(cwd) {
273
+ const root = path.join(cwd, "tests");
274
+ if (!fs.existsSync(root)) return "";
275
+ const acc = [];
276
+ walk(root, p => {
277
+ const norm = p.replace(/\\/g, "/");
278
+ if (/\.(js|json)$/.test(norm)) {
279
+ try { acc.push(fs.readFileSync(p, "utf8")); } catch { /* ignore unreadable */ }
280
+ }
281
+ });
282
+ return acc.join("\n\x00\n");
283
+ }
284
+
285
+ function walk(dir, fn) {
286
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
287
+ const p = path.join(dir, entry.name);
288
+ if (entry.isDirectory()) walk(p, fn);
289
+ else fn(p);
290
+ }
291
+ }
292
+
293
+ function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
294
+
295
+ function coversCliVerb(corpus, verb) {
296
+ const v = escapeRe(verb);
297
+ const quoted = new RegExp("['\"`]" + v + "['\"`]");
298
+ return quoted.test(corpus);
299
+ }
300
+
301
+ function coversCliFlag(corpus, flag) {
302
+ return corpus.includes(flag);
303
+ }
304
+
305
+ function coversLibExport(corpus, libRel, ident) {
306
+ const baseName = path.basename(libRel).replace(/\.js$/, "");
307
+ const baseFile = path.basename(libRel); // e.g. "check-sbom-currency.js"
308
+ const identRe = new RegExp("\\b" + escapeRe(ident) + "\\b");
309
+ // Primary: a `require()` that mentions the module name AND a reference
310
+ // to the identifier anywhere in the test corpus. Catches the canonical
311
+ // `const { foo } = require('../lib/x')` test shape.
312
+ const requireRe = new RegExp("require\\([^)]*" + escapeRe(baseName) + "[^)]*\\)");
313
+ if (requireRe.test(corpus) && identRe.test(corpus)) return true;
314
+ // v0.12.9: a test that spawns the script under test (e.g.
315
+ // `spawnSync(node, [".../scripts/check-sbom-currency.js", ...])`) is
316
+ // real coverage too. Accept that shape when the corpus references the
317
+ // full filename AND the identifier elsewhere. The `.js` suffix is what
318
+ // distinguishes a real spawn-path from an arbitrary mention of the
319
+ // module base name.
320
+ if (corpus.includes(baseFile) && identRe.test(corpus)) return true;
321
+ return false;
322
+ }
323
+
324
+ function coversPlaybookId(corpus, id) {
325
+ const q = new RegExp("['\"`]" + escapeRe(id) + "['\"`]");
326
+ return q.test(corpus);
327
+ }
328
+
329
+ function coversCveIoc(corpus, cveId) {
330
+ if (!corpus.includes(cveId)) return false;
331
+ return /\biocs\b/i.test(corpus);
332
+ }
333
+
334
+ // --- Main analyzer ----------------------------------------------------------
335
+
336
+ function analyze(opts) {
337
+ const cwd = opts.repo || ROOT;
338
+ // v0.12.8: resolve the diff anchor ONCE and thread it through every
339
+ // per-file call so listChangedFiles + fileDiff + fileBefore all agree on
340
+ // the same SHA. Otherwise origin/main advancing past the merge-base
341
+ // between calls produces false add/remove findings.
342
+ const resolvedBase = resolveBaseRef(opts, cwd);
343
+ const changed = listChangedFiles(opts, cwd, resolvedBase);
344
+ const corpus = loadTestCorpus(cwd);
345
+
346
+ const findings = [];
347
+ const allowlisted = [];
348
+ const manualReview = [];
349
+
350
+ for (const ch of changed) {
351
+ const cat = categorize(ch.file);
352
+ if (cat === "docs" || cat === "test") {
353
+ allowlisted.push({ file: ch.file, reason: cat });
354
+ continue;
355
+ }
356
+ if (cat === "skill") { allowlisted.push({ file: ch.file, reason: "skill-signed" }); continue; }
357
+ if (cat === "workflow") { manualReview.push({ file: ch.file, reason: "workflow" }); continue; }
358
+ if (cat === "other") { allowlisted.push({ file: ch.file, reason: "out-of-scope" }); continue; }
359
+ if (ch.status !== "D" && isWhitespaceOnly(opts, ch.file, cwd, resolvedBase)) {
360
+ allowlisted.push({ file: ch.file, reason: "whitespace-only" });
361
+ continue;
362
+ }
363
+
364
+ const before = fileBefore(opts, ch.file, cwd, resolvedBase);
365
+ const after = ch.status === "D" ? null : fileAfter(opts, ch.file, cwd);
366
+
367
+ if (cat === "cli") {
368
+ const b = extractCliSurface(before);
369
+ const a = extractCliSurface(after);
370
+ const verbsDiff = diffSets(b.verbs, a.verbs);
371
+ const flagsDiff = diffSets(b.flags, a.flags);
372
+ for (const v of verbsDiff.added) if (!coversCliVerb(corpus, v))
373
+ findings.push({ file: ch.file, kind: "cli-verb", surface: v, change: "added" });
374
+ for (const v of verbsDiff.removed) if (coversCliVerb(corpus, v))
375
+ findings.push({ file: ch.file, kind: "cli-verb", surface: v, change: "removed-but-test-remains" });
376
+ for (const f of flagsDiff.added) if (!coversCliFlag(corpus, f))
377
+ findings.push({ file: ch.file, kind: "cli-flag", surface: f, change: "added" });
378
+ for (const f of flagsDiff.removed) if (coversCliFlag(corpus, f))
379
+ findings.push({ file: ch.file, kind: "cli-flag", surface: f, change: "removed-but-test-remains" });
380
+ } else if (cat === "lib") {
381
+ const b = extractLibExports(before);
382
+ const a = extractLibExports(after);
383
+ const d = diffSets(b, a);
384
+ for (const id of d.added) if (!coversLibExport(corpus, ch.file, id))
385
+ findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "added" });
386
+ for (const id of d.removed) if (coversLibExport(corpus, ch.file, id))
387
+ findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "removed-but-test-remains" });
388
+ } else if (cat === "playbook") {
389
+ const b = extractPlaybookIds(before);
390
+ const a = extractPlaybookIds(after);
391
+ const ind = diffSets(b.indicators, a.indicators);
392
+ const arts = diffSets(b.artifacts, a.artifacts);
393
+ for (const id of ind.added) if (!coversPlaybookId(corpus, id))
394
+ findings.push({ file: ch.file, kind: "playbook-indicator", surface: id, change: "added" });
395
+ for (const id of arts.added) if (!coversPlaybookId(corpus, id))
396
+ findings.push({ file: ch.file, kind: "playbook-artifact", surface: id, change: "added" });
397
+ for (const id of ind.removed) if (coversPlaybookId(corpus, id))
398
+ findings.push({ file: ch.file, kind: "playbook-indicator", surface: id, change: "removed-but-test-remains" });
399
+ } else if (cat === "cve-catalog") {
400
+ const ids = extractCveIocChanges(before, after);
401
+ for (const id of ids) if (!coversCveIoc(corpus, id))
402
+ findings.push({ file: ch.file, kind: "cve-ioc", surface: id, change: "iocs-modified" });
403
+ }
404
+ }
405
+
406
+ return { findings, allowlisted, manualReview, totalChanged: changed.length };
407
+ }
408
+
409
+ // --- Output -----------------------------------------------------------------
410
+
411
+ function emitHuman(report) {
412
+ const out = [];
413
+ out.push("Diff coverage analyzer — " + report.totalChanged + " changed file(s)");
414
+ out.push(" Allowlisted: " + report.allowlisted.length +
415
+ " Manual-review: " + report.manualReview.length +
416
+ " Findings: " + report.findings.length);
417
+ if (report.manualReview.length) {
418
+ out.push("");
419
+ out.push("Manual review required:");
420
+ for (const m of report.manualReview) out.push(" - " + m.file + " [" + m.reason + "]");
421
+ }
422
+ if (report.findings.length) {
423
+ out.push("");
424
+ out.push("Uncovered surface changes:");
425
+ for (const f of report.findings) {
426
+ out.push(" [" + f.kind + "] " + f.file + " '" + f.surface + "' (" + f.change + ")");
427
+ }
428
+ out.push("");
429
+ out.push("Each item above adds, removes, or modifies a surface that has no");
430
+ out.push("matching reference in tests/. Add a regression test before merge,");
431
+ out.push("or move the change into the allowlist if it is genuinely test-exempt.");
432
+ } else {
433
+ out.push("");
434
+ out.push("OK: every changed surface has a matching test reference.");
435
+ }
436
+ process.stdout.write(out.join("\n") + "\n");
437
+ }
438
+
439
+ function main() {
440
+ let opts;
441
+ try { opts = parseArgs(process.argv.slice(2)); }
442
+ catch (e) { process.stderr.write(e.message + "\n"); process.exitCode = 2; return; }
443
+ if (opts.help) { printHelp(); return; }
444
+
445
+ let report;
446
+ try { report = analyze(opts); }
447
+ catch (e) {
448
+ if (opts.json) process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + "\n");
449
+ else process.stderr.write("check-test-coverage: " + e.message + "\n");
450
+ process.exitCode = 2;
451
+ return;
452
+ }
453
+
454
+ if (opts.json) {
455
+ process.stdout.write(JSON.stringify({
456
+ ok: report.findings.length === 0,
457
+ total_changed: report.totalChanged,
458
+ findings: report.findings,
459
+ allowlisted: report.allowlisted,
460
+ manual_review: report.manualReview,
461
+ }) + "\n");
462
+ } else {
463
+ emitHuman(report);
464
+ }
465
+
466
+ if (report.findings.length > 0 && !opts.warnOnly) process.exitCode = 1;
467
+ }
468
+
469
+ if (require.main === module) main();
470
+
471
+ module.exports = {
472
+ analyze, parseArgs, categorize,
473
+ extractCliSurface, extractLibExports, extractPlaybookIds, extractCveIocChanges,
474
+ coversCliVerb, coversCliFlag, coversLibExport, coversPlaybookId, coversCveIoc,
475
+ DOCS_ALWAYS_GREEN,
476
+ };
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/hooks/pre-commit.sh
3
+ #
4
+ # Diff-coverage gate as a pre-commit hook. NOT installed by default.
5
+ # To opt in:
6
+ # git config core.hooksPath scripts/hooks
7
+ #
8
+ # To bypass once (acceptable when you genuinely have no covering test
9
+ # yet and intend to add one before push):
10
+ # git commit --no-verify
11
+ #
12
+ # Exits non-zero when staged changes add a CLI verb, CLI flag, lib
13
+ # export, playbook indicator, or CVE iocs field that has no matching
14
+ # reference in tests/. Run `node scripts/check-test-coverage.js --staged`
15
+ # manually for the full report.
16
+ set -e
17
+
18
+ ROOT="$(git rev-parse --show-toplevel)"
19
+ node "$ROOT/scripts/check-test-coverage.js" --staged