@blamejs/exceptd-skills 0.12.6 → 0.12.8

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 (65) hide show
  1. package/AGENTS.md +14 -0
  2. package/CHANGELOG.md +97 -0
  3. package/bin/exceptd.js +189 -52
  4. package/data/_indexes/_meta.json +37 -37
  5. package/data/_indexes/activity-feed.json +26 -26
  6. package/data/_indexes/catalog-summaries.json +8 -8
  7. package/data/_indexes/chains.json +238 -0
  8. package/data/_indexes/frequency.json +63 -5
  9. package/data/_indexes/jurisdiction-map.json +13 -3
  10. package/data/_indexes/section-offsets.json +881 -845
  11. package/data/_indexes/summary-cards.json +2 -2
  12. package/data/_indexes/token-budget.json +145 -125
  13. package/data/atlas-ttps.json +189 -1
  14. package/data/cwe-catalog.json +290 -1
  15. package/data/d3fend-catalog.json +163 -1
  16. package/data/framework-control-gaps.json +243 -0
  17. package/data/playbooks/containers.json +23 -5
  18. package/data/playbooks/cred-stores.json +9 -9
  19. package/data/playbooks/crypto.json +8 -8
  20. package/data/playbooks/hardening.json +46 -10
  21. package/data/playbooks/library-author.json +16 -20
  22. package/data/playbooks/mcp.json +64 -1
  23. package/data/playbooks/runtime.json +7 -7
  24. package/data/playbooks/sbom.json +11 -11
  25. package/data/playbooks/secrets.json +4 -4
  26. package/data/rfc-references.json +144 -0
  27. package/lib/refresh-external.js +25 -5
  28. package/lib/schemas/skill-frontmatter.schema.json +2 -2
  29. package/manifest-snapshot.json +1 -1
  30. package/manifest.json +67 -67
  31. package/package.json +2 -1
  32. package/sbom.cdx.json +6 -6
  33. package/scripts/check-sbom-currency.js +87 -0
  34. package/scripts/check-test-coverage.README.md +148 -0
  35. package/scripts/check-test-coverage.js +455 -0
  36. package/scripts/hooks/pre-commit.sh +19 -0
  37. package/scripts/predeploy.js +16 -30
  38. package/skills/age-gates-child-safety/skill.md +3 -0
  39. package/skills/ai-attack-surface/skill.md +4 -1
  40. package/skills/ai-c2-detection/skill.md +6 -1
  41. package/skills/ai-risk-management/skill.md +3 -0
  42. package/skills/api-security/skill.md +3 -0
  43. package/skills/attack-surface-pentest/skill.md +3 -0
  44. package/skills/cloud-security/skill.md +3 -0
  45. package/skills/container-runtime-security/skill.md +3 -0
  46. package/skills/coordinated-vuln-disclosure/skill.md +8 -1
  47. package/skills/defensive-countermeasure-mapping/skill.md +1 -1
  48. package/skills/dlp-gap-analysis/skill.md +3 -0
  49. package/skills/email-security-anti-phishing/skill.md +9 -1
  50. package/skills/identity-assurance/skill.md +6 -1
  51. package/skills/incident-response-playbook/skill.md +8 -2
  52. package/skills/kernel-lpe-triage/skill.md +24 -4
  53. package/skills/mcp-agent-trust/skill.md +4 -1
  54. package/skills/mlops-security/skill.md +3 -0
  55. package/skills/ot-ics-security/skill.md +3 -0
  56. package/skills/rag-pipeline-security/skill.md +3 -0
  57. package/skills/sector-energy/skill.md +3 -0
  58. package/skills/sector-federal-government/skill.md +3 -0
  59. package/skills/sector-financial/skill.md +3 -0
  60. package/skills/sector-healthcare/skill.md +3 -0
  61. package/skills/security-maturity-tiers/skill.md +19 -1
  62. package/skills/skill-update-loop/skill.md +32 -0
  63. package/skills/supply-chain-integrity/skill.md +3 -0
  64. package/skills/threat-modeling-methodology/skill.md +3 -0
  65. package/skills/webapp-security/skill.md +3 -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,455 @@
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
+ const m = content.match(/module\.exports\s*=\s*\{([^}]+)\}/);
216
+ if (m) {
217
+ for (const tok of m[1].split(",")) {
218
+ const id = tok.split(":")[0].trim();
219
+ if (/^[a-zA-Z_$][\w$]*$/.test(id)) out.add(id);
220
+ }
221
+ }
222
+ const re = /module\.exports\.([a-zA-Z_$][\w$]*)\s*=/g;
223
+ let mm;
224
+ while ((mm = re.exec(content)) !== null) out.add(mm[1]);
225
+ const re2 = /^exports\.([a-zA-Z_$][\w$]*)\s*=/gm;
226
+ while ((mm = re2.exec(content)) !== null) out.add(mm[1]);
227
+ return out;
228
+ }
229
+
230
+ function extractPlaybookIds(content) {
231
+ if (!content) return { indicators: new Set(), artifacts: new Set() };
232
+ let obj;
233
+ try { obj = JSON.parse(content); }
234
+ catch { return { indicators: new Set(), artifacts: new Set() }; }
235
+ const ind = new Set();
236
+ const arts = new Set();
237
+ const detect = obj && obj.phases && obj.phases.detect && obj.phases.detect.indicators;
238
+ if (Array.isArray(detect)) for (const i of detect) if (i && i.id) ind.add(i.id);
239
+ const look = obj && obj.phases && obj.phases.look && obj.phases.look.artifacts;
240
+ if (Array.isArray(look)) for (const a of look) if (a && a.id) arts.add(a.id);
241
+ return { indicators: ind, artifacts: arts };
242
+ }
243
+
244
+ function extractCveIocChanges(beforeStr, afterStr) {
245
+ const before = safeParse(beforeStr) || {};
246
+ const after = safeParse(afterStr) || {};
247
+ const changed = new Set();
248
+ const ids = new Set([...Object.keys(before), ...Object.keys(after)]);
249
+ for (const id of ids) {
250
+ if (!/^CVE-\d{4}-\d+/.test(id)) continue;
251
+ const b = JSON.stringify((before[id] && before[id].iocs) || null);
252
+ const a = JSON.stringify((after[id] && after[id].iocs) || null);
253
+ if (b !== a) changed.add(id);
254
+ }
255
+ return changed;
256
+ }
257
+
258
+ function safeParse(s) { try { return s ? JSON.parse(s) : null; } catch { return null; } }
259
+
260
+ // --- Test corpus + coverage probes ------------------------------------------
261
+
262
+ function loadTestCorpus(cwd) {
263
+ const root = path.join(cwd, "tests");
264
+ if (!fs.existsSync(root)) return "";
265
+ const acc = [];
266
+ walk(root, p => {
267
+ const norm = p.replace(/\\/g, "/");
268
+ if (/\.(js|json)$/.test(norm)) {
269
+ try { acc.push(fs.readFileSync(p, "utf8")); } catch { /* ignore unreadable */ }
270
+ }
271
+ });
272
+ return acc.join("\n\x00\n");
273
+ }
274
+
275
+ function walk(dir, fn) {
276
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
277
+ const p = path.join(dir, entry.name);
278
+ if (entry.isDirectory()) walk(p, fn);
279
+ else fn(p);
280
+ }
281
+ }
282
+
283
+ function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
284
+
285
+ function coversCliVerb(corpus, verb) {
286
+ const v = escapeRe(verb);
287
+ const quoted = new RegExp("['\"`]" + v + "['\"`]");
288
+ return quoted.test(corpus);
289
+ }
290
+
291
+ function coversCliFlag(corpus, flag) {
292
+ return corpus.includes(flag);
293
+ }
294
+
295
+ function coversLibExport(corpus, libRel, ident) {
296
+ const baseName = path.basename(libRel).replace(/\.js$/, "");
297
+ const requireRe = new RegExp("require\\([^)]*" + escapeRe(baseName) + "[^)]*\\)");
298
+ if (!requireRe.test(corpus)) return false;
299
+ const identRe = new RegExp("\\b" + escapeRe(ident) + "\\b");
300
+ return identRe.test(corpus);
301
+ }
302
+
303
+ function coversPlaybookId(corpus, id) {
304
+ const q = new RegExp("['\"`]" + escapeRe(id) + "['\"`]");
305
+ return q.test(corpus);
306
+ }
307
+
308
+ function coversCveIoc(corpus, cveId) {
309
+ if (!corpus.includes(cveId)) return false;
310
+ return /\biocs\b/i.test(corpus);
311
+ }
312
+
313
+ // --- Main analyzer ----------------------------------------------------------
314
+
315
+ function analyze(opts) {
316
+ const cwd = opts.repo || ROOT;
317
+ // v0.12.8: resolve the diff anchor ONCE and thread it through every
318
+ // per-file call so listChangedFiles + fileDiff + fileBefore all agree on
319
+ // the same SHA. Otherwise origin/main advancing past the merge-base
320
+ // between calls produces false add/remove findings.
321
+ const resolvedBase = resolveBaseRef(opts, cwd);
322
+ const changed = listChangedFiles(opts, cwd, resolvedBase);
323
+ const corpus = loadTestCorpus(cwd);
324
+
325
+ const findings = [];
326
+ const allowlisted = [];
327
+ const manualReview = [];
328
+
329
+ for (const ch of changed) {
330
+ const cat = categorize(ch.file);
331
+ if (cat === "docs" || cat === "test") {
332
+ allowlisted.push({ file: ch.file, reason: cat });
333
+ continue;
334
+ }
335
+ if (cat === "skill") { allowlisted.push({ file: ch.file, reason: "skill-signed" }); continue; }
336
+ if (cat === "workflow") { manualReview.push({ file: ch.file, reason: "workflow" }); continue; }
337
+ if (cat === "other") { allowlisted.push({ file: ch.file, reason: "out-of-scope" }); continue; }
338
+ if (ch.status !== "D" && isWhitespaceOnly(opts, ch.file, cwd, resolvedBase)) {
339
+ allowlisted.push({ file: ch.file, reason: "whitespace-only" });
340
+ continue;
341
+ }
342
+
343
+ const before = fileBefore(opts, ch.file, cwd, resolvedBase);
344
+ const after = ch.status === "D" ? null : fileAfter(opts, ch.file, cwd);
345
+
346
+ if (cat === "cli") {
347
+ const b = extractCliSurface(before);
348
+ const a = extractCliSurface(after);
349
+ const verbsDiff = diffSets(b.verbs, a.verbs);
350
+ const flagsDiff = diffSets(b.flags, a.flags);
351
+ for (const v of verbsDiff.added) if (!coversCliVerb(corpus, v))
352
+ findings.push({ file: ch.file, kind: "cli-verb", surface: v, change: "added" });
353
+ for (const v of verbsDiff.removed) if (coversCliVerb(corpus, v))
354
+ findings.push({ file: ch.file, kind: "cli-verb", surface: v, change: "removed-but-test-remains" });
355
+ for (const f of flagsDiff.added) if (!coversCliFlag(corpus, f))
356
+ findings.push({ file: ch.file, kind: "cli-flag", surface: f, change: "added" });
357
+ for (const f of flagsDiff.removed) if (coversCliFlag(corpus, f))
358
+ findings.push({ file: ch.file, kind: "cli-flag", surface: f, change: "removed-but-test-remains" });
359
+ } else if (cat === "lib") {
360
+ const b = extractLibExports(before);
361
+ const a = extractLibExports(after);
362
+ const d = diffSets(b, a);
363
+ for (const id of d.added) if (!coversLibExport(corpus, ch.file, id))
364
+ findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "added" });
365
+ for (const id of d.removed) if (coversLibExport(corpus, ch.file, id))
366
+ findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "removed-but-test-remains" });
367
+ } else if (cat === "playbook") {
368
+ const b = extractPlaybookIds(before);
369
+ const a = extractPlaybookIds(after);
370
+ const ind = diffSets(b.indicators, a.indicators);
371
+ const arts = diffSets(b.artifacts, a.artifacts);
372
+ for (const id of ind.added) if (!coversPlaybookId(corpus, id))
373
+ findings.push({ file: ch.file, kind: "playbook-indicator", surface: id, change: "added" });
374
+ for (const id of arts.added) if (!coversPlaybookId(corpus, id))
375
+ findings.push({ file: ch.file, kind: "playbook-artifact", surface: id, change: "added" });
376
+ for (const id of ind.removed) if (coversPlaybookId(corpus, id))
377
+ findings.push({ file: ch.file, kind: "playbook-indicator", surface: id, change: "removed-but-test-remains" });
378
+ } else if (cat === "cve-catalog") {
379
+ const ids = extractCveIocChanges(before, after);
380
+ for (const id of ids) if (!coversCveIoc(corpus, id))
381
+ findings.push({ file: ch.file, kind: "cve-ioc", surface: id, change: "iocs-modified" });
382
+ }
383
+ }
384
+
385
+ return { findings, allowlisted, manualReview, totalChanged: changed.length };
386
+ }
387
+
388
+ // --- Output -----------------------------------------------------------------
389
+
390
+ function emitHuman(report) {
391
+ const out = [];
392
+ out.push("Diff coverage analyzer — " + report.totalChanged + " changed file(s)");
393
+ out.push(" Allowlisted: " + report.allowlisted.length +
394
+ " Manual-review: " + report.manualReview.length +
395
+ " Findings: " + report.findings.length);
396
+ if (report.manualReview.length) {
397
+ out.push("");
398
+ out.push("Manual review required:");
399
+ for (const m of report.manualReview) out.push(" - " + m.file + " [" + m.reason + "]");
400
+ }
401
+ if (report.findings.length) {
402
+ out.push("");
403
+ out.push("Uncovered surface changes:");
404
+ for (const f of report.findings) {
405
+ out.push(" [" + f.kind + "] " + f.file + " '" + f.surface + "' (" + f.change + ")");
406
+ }
407
+ out.push("");
408
+ out.push("Each item above adds, removes, or modifies a surface that has no");
409
+ out.push("matching reference in tests/. Add a regression test before merge,");
410
+ out.push("or move the change into the allowlist if it is genuinely test-exempt.");
411
+ } else {
412
+ out.push("");
413
+ out.push("OK: every changed surface has a matching test reference.");
414
+ }
415
+ process.stdout.write(out.join("\n") + "\n");
416
+ }
417
+
418
+ function main() {
419
+ let opts;
420
+ try { opts = parseArgs(process.argv.slice(2)); }
421
+ catch (e) { process.stderr.write(e.message + "\n"); process.exitCode = 2; return; }
422
+ if (opts.help) { printHelp(); return; }
423
+
424
+ let report;
425
+ try { report = analyze(opts); }
426
+ catch (e) {
427
+ if (opts.json) process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + "\n");
428
+ else process.stderr.write("check-test-coverage: " + e.message + "\n");
429
+ process.exitCode = 2;
430
+ return;
431
+ }
432
+
433
+ if (opts.json) {
434
+ process.stdout.write(JSON.stringify({
435
+ ok: report.findings.length === 0,
436
+ total_changed: report.totalChanged,
437
+ findings: report.findings,
438
+ allowlisted: report.allowlisted,
439
+ manual_review: report.manualReview,
440
+ }) + "\n");
441
+ } else {
442
+ emitHuman(report);
443
+ }
444
+
445
+ if (report.findings.length > 0 && !opts.warnOnly) process.exitCode = 1;
446
+ }
447
+
448
+ if (require.main === module) main();
449
+
450
+ module.exports = {
451
+ analyze, parseArgs, categorize,
452
+ extractCliSurface, extractLibExports, extractPlaybookIds, extractCveIocChanges,
453
+ coversCliVerb, coversCliFlag, coversLibExport, coversPlaybookId, coversCveIoc,
454
+ DOCS_ALWAYS_GREEN,
455
+ };
@@ -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
@@ -118,7 +118,7 @@ const GATES = [
118
118
  {
119
119
  name: "SBOM currency check (sbom.cdx.json vs. live surface)",
120
120
  command: process.execPath,
121
- args: ["-e", sbomCurrencyChecker()],
121
+ args: [path.join(ROOT, "scripts", "check-sbom-currency.js")],
122
122
  ciJobName: "Data integrity (catalog + manifest snapshot)",
123
123
  },
124
124
  {
@@ -151,37 +151,23 @@ const GATES = [
151
151
  ciJobName: "Data integrity (catalog + manifest snapshot)",
152
152
  requiresKeys: true,
153
153
  },
154
+ {
155
+ // v0.12.8 — AGENTS.md hard rule #15 (e2e no-MVP). Every diff that
156
+ // touches a CLI verb, CLI flag, lib/orchestrator/scripts export,
157
+ // playbook indicator, or CVE iocs field must land with a covering
158
+ // test reference in the same PR. The analyzer parses git diff against
159
+ // origin/main, classifies each change shape, and fails if a covered
160
+ // surface lacks a test literal anywhere under tests/. Run with
161
+ // --warn-only during the v0.12.8 → v0.12.9 rollout window so the gate
162
+ // surfaces gaps without blocking; flip to blocking after one release
163
+ // cycle.
164
+ name: "Diff coverage (feature changes require test coverage)",
165
+ command: process.execPath,
166
+ args: [path.join(ROOT, "scripts", "check-test-coverage.js"), "--warn-only"],
167
+ ciJobName: "Diff coverage",
168
+ },
154
169
  ];
155
170
 
156
- /* Inline checker, run as `node -e`, so the predeploy gate stays one
157
- * file and the SBOM regen logic stays in scripts/refresh-sbom.js
158
- * (single source of truth). Compares the persisted sbom.cdx.json
159
- * against the live skill_count + catalog_count derived from
160
- * manifest.json + data/. Exits nonzero on drift, with a hint to run
161
- * `npm run refresh-sbom`. */
162
- function sbomCurrencyChecker() {
163
- return [
164
- "const fs=require('fs');const path=require('path');",
165
- "const root=" + JSON.stringify(ROOT) + ";",
166
- "const sbomPath=path.join(root,'sbom.cdx.json');",
167
- "if(!fs.existsSync(sbomPath)){console.error('sbom.cdx.json not found — run `npm run refresh-sbom`.');process.exit(1);}",
168
- "const sbom=JSON.parse(fs.readFileSync(sbomPath,'utf8'));",
169
- "const manifest=JSON.parse(fs.readFileSync(path.join(root,'manifest.json'),'utf8'));",
170
- "const dataDir=path.join(root,'data');",
171
- "const liveCatalogs=fs.readdirSync(dataDir).filter(f=>f.endsWith('.json')).length;",
172
- "const liveSkills=Array.isArray(manifest.skills)?manifest.skills.length:0;",
173
- "const props=Object.fromEntries((sbom.metadata&&sbom.metadata.properties||[]).map(p=>[p.name,p.value]));",
174
- "const sbomCatalogs=Number(props['exceptd:catalog:count']);",
175
- "const sbomSkills=Number(props['exceptd:skill:count']);",
176
- "let drift=false;",
177
- "if(sbomCatalogs!==liveCatalogs){console.error(`SBOM catalog count ${sbomCatalogs} != live ${liveCatalogs}`);drift=true;}",
178
- "if(sbomSkills!==liveSkills){console.error(`SBOM skill count ${sbomSkills} != live ${liveSkills}`);drift=true;}",
179
- "if(sbom.bomFormat!=='CycloneDX'||sbom.specVersion!=='1.6'){console.error('SBOM is not CycloneDX 1.6');drift=true;}",
180
- "if(drift){console.error('Run `npm run refresh-sbom` to regenerate sbom.cdx.json.');process.exit(1);}",
181
- "console.log(`SBOM current — ${sbomSkills} skills, ${sbomCatalogs} catalogs.`);",
182
- ].join("");
183
- }
184
-
185
171
  function runGate(gate) {
186
172
  if (gate.requiresKeys) {
187
173
  const pubKey = path.join(ROOT, "keys", "public.pem");