@blamejs/exceptd-skills 0.19.32 → 0.19.34

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 (127) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +8 -8
  4. package/data/_indexes/activity-feed.json +2 -2
  5. package/data/_indexes/catalog-summaries.json +7 -7
  6. package/data/_indexes/chains.json +60118 -0
  7. package/data/attack-techniques.json +267 -7
  8. package/data/cve-catalog.json +9991 -3
  9. package/data/cwe-catalog.json +109 -2
  10. package/data/framework-control-gaps.json +578 -3
  11. package/data/zeroday-lessons.json +8330 -1
  12. package/lib/auto-discovery.js +56 -286
  13. package/lib/canonical-eq.js +7 -40
  14. package/lib/citation-resolve.js +22 -70
  15. package/lib/collectors/ai-api.js +20 -54
  16. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  17. package/lib/collectors/citation-hygiene.js +72 -210
  18. package/lib/collectors/containers.js +41 -130
  19. package/lib/collectors/cred-stores.js +31 -115
  20. package/lib/collectors/crypto-codebase.js +55 -138
  21. package/lib/collectors/crypto.js +24 -54
  22. package/lib/collectors/hardening.js +20 -78
  23. package/lib/collectors/kernel.js +16 -46
  24. package/lib/collectors/library-author.js +57 -206
  25. package/lib/collectors/mcp.js +24 -70
  26. package/lib/collectors/runtime.js +24 -86
  27. package/lib/collectors/sbom.js +34 -106
  28. package/lib/collectors/scan-excludes.js +31 -138
  29. package/lib/collectors/secrets.js +62 -178
  30. package/lib/cross-ref-api.js +39 -123
  31. package/lib/currency-severity.js +8 -27
  32. package/lib/cve-batch.js +13 -21
  33. package/lib/cve-cli.js +13 -20
  34. package/lib/cve-curation.js +72 -239
  35. package/lib/cve-regression-watcher.js +29 -152
  36. package/lib/cvss.js +13 -54
  37. package/lib/doctor-bucketing.js +3 -19
  38. package/lib/exit-codes.js +10 -42
  39. package/lib/flag-suggest.js +7 -25
  40. package/lib/framework-gap.js +35 -114
  41. package/lib/gap-detectors.js +37 -159
  42. package/lib/id-validation.js +9 -30
  43. package/lib/job-queue.js +13 -36
  44. package/lib/lint-skills.js +64 -232
  45. package/lib/playbook-runner.js +693 -2095
  46. package/lib/prefetch.js +100 -376
  47. package/lib/refresh-external.js +199 -627
  48. package/lib/refresh-network.js +75 -307
  49. package/lib/rfc-cli.js +23 -68
  50. package/lib/scoring.js +77 -145
  51. package/lib/sign.js +43 -229
  52. package/lib/source-advisories.js +43 -194
  53. package/lib/source-ghsa.js +37 -120
  54. package/lib/source-osv.js +94 -266
  55. package/lib/ttp-mapper.js +14 -24
  56. package/lib/upstream-check-cli.js +10 -28
  57. package/lib/upstream-check.js +19 -44
  58. package/lib/validate-catalog-meta.js +17 -61
  59. package/lib/validate-cve-catalog.js +43 -119
  60. package/lib/validate-indexes.js +25 -76
  61. package/lib/validate-package.js +16 -62
  62. package/lib/validate-playbooks.js +69 -275
  63. package/lib/validate-vendor.js +16 -49
  64. package/lib/verify.js +56 -286
  65. package/lib/version-pins.js +5 -34
  66. package/lib/worker-pool.js +11 -30
  67. package/lib/xml-tokenizer.js +47 -152
  68. package/manifest.json +53 -53
  69. package/orchestrator/dispatcher.js +17 -68
  70. package/orchestrator/event-bus.js +11 -74
  71. package/orchestrator/index.js +138 -412
  72. package/orchestrator/pipeline.js +28 -85
  73. package/orchestrator/scanner.js +34 -138
  74. package/orchestrator/scheduler.js +20 -84
  75. package/package.json +2 -2
  76. package/sbom.cdx.json +253 -253
  77. package/scripts/audit-catalog-gaps.js +9 -62
  78. package/scripts/audit-cross-skill.js +5 -31
  79. package/scripts/audit-perf.js +6 -16
  80. package/scripts/backfill-theater-test.js +7 -64
  81. package/scripts/bootstrap.js +12 -44
  82. package/scripts/build-indexes.js +40 -154
  83. package/scripts/builders/activity-feed.js +4 -14
  84. package/scripts/builders/catalog-summaries.js +3 -10
  85. package/scripts/builders/currency.js +7 -20
  86. package/scripts/builders/cwe-chains.js +7 -30
  87. package/scripts/builders/did-ladders.js +6 -13
  88. package/scripts/builders/frequency.js +5 -19
  89. package/scripts/builders/jurisdiction-clocks.js +6 -25
  90. package/scripts/builders/recipes.js +6 -14
  91. package/scripts/builders/section-offsets.js +13 -51
  92. package/scripts/builders/stale-content.js +7 -28
  93. package/scripts/builders/summary-cards.js +8 -29
  94. package/scripts/builders/theater-fingerprints.js +12 -27
  95. package/scripts/builders/token-budget.js +4 -31
  96. package/scripts/check-agents-md-collectors.js +11 -54
  97. package/scripts/check-catalog-gap-budget.js +15 -32
  98. package/scripts/check-changelog-extract.js +18 -48
  99. package/scripts/check-codebase-patterns-currency.js +6 -22
  100. package/scripts/check-codebase-patterns.js +50 -143
  101. package/scripts/check-epss-consistency.js +9 -64
  102. package/scripts/check-framework-gap-coverage.js +13 -31
  103. package/scripts/check-manifest-snapshot.js +13 -73
  104. package/scripts/check-sbom-currency.js +44 -142
  105. package/scripts/check-test-count.js +15 -52
  106. package/scripts/check-test-coverage.js +66 -197
  107. package/scripts/check-test-subjects.js +21 -62
  108. package/scripts/check-ttp-references.js +14 -38
  109. package/scripts/check-ttp-upstream.js +8 -40
  110. package/scripts/check-version-bump.js +9 -61
  111. package/scripts/check-version-tags.js +20 -121
  112. package/scripts/predeploy.js +38 -184
  113. package/scripts/refresh-manifest-snapshot.js +16 -38
  114. package/scripts/refresh-mitre-atlas.js +3 -8
  115. package/scripts/refresh-mitre-attack.js +1 -8
  116. package/scripts/refresh-mitre-d3fend.js +3 -9
  117. package/scripts/refresh-mitre-ics-attack.js +3 -8
  118. package/scripts/refresh-reverse-refs.js +27 -94
  119. package/scripts/refresh-rfc-index.js +2 -10
  120. package/scripts/refresh-sbom.js +31 -161
  121. package/scripts/refresh-upstream-catalogs.js +40 -137
  122. package/scripts/release.js +69 -232
  123. package/scripts/run-e2e-scenarios.js +24 -71
  124. package/scripts/sync-manifest-metadata.js +10 -34
  125. package/scripts/sync-package-description.js +8 -17
  126. package/scripts/validate-vendor-online.js +13 -44
  127. package/scripts/verify-shipped-tarball.js +35 -140
@@ -1,38 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  /**
4
- * scripts/check-test-coverage.js
4
+ * Diff-aware test-coverage gate. Compares the changed surface in the working
5
+ * tree (or a staged set, or any --base..HEAD range) against the tests/ tree
6
+ * and reports any surface change that lacks a covering test.
5
7
  *
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.
8
+ * Surfaces: CLI verbs and flags in bin/exceptd.js, exported functions in
9
+ * lib / orchestrator / scripts, playbook detect-indicator and look-artifact
10
+ * ids, and CVE entries whose iocs changed. Docs, tooling dotfiles, tests/
11
+ * itself and derived indexes are allowlisted; workflows, manifests, schemas,
12
+ * SBOM and unclassified files are surfaced as manual-review, never auto-green.
9
13
  *
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
14
+ * Exit codes: 0 clean or --warn-only, 1 uncovered surface, 2 runner error.
36
15
  */
37
16
 
38
17
  const fs = require("fs");
@@ -41,8 +20,6 @@ const childProc = require("child_process");
41
20
 
42
21
  const ROOT = path.resolve(__dirname, "..");
43
22
 
44
- // --- Flag parsing -----------------------------------------------------------
45
-
46
23
  function parseArgs(argv) {
47
24
  const out = { base: "origin/main", staged: false, json: false, warnOnly: false };
48
25
  for (let i = 0; i < argv.length; i++) {
@@ -67,8 +44,6 @@ function printHelp() {
67
44
  process.stdout.write(banner);
68
45
  }
69
46
 
70
- // --- Git plumbing -----------------------------------------------------------
71
-
72
47
  function git(args, cwd) {
73
48
  const r = childProc.spawnSync("git", args, { cwd, encoding: "utf8" });
74
49
  if (r.status !== 0) {
@@ -79,29 +54,14 @@ function git(args, cwd) {
79
54
  return r.stdout;
80
55
  }
81
56
 
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.
57
+ // The diff anchor resolves ONCE and the resolved SHA threads through every
58
+ // per-file call: passing the raw ref lets origin/main advance mid-run, comparing
59
+ // content against a newer tree than the file list came from.
90
60
  function resolveBaseRef(opts, cwd) {
91
61
  if (opts.staged) return null; // staged mode uses --cached / HEAD throughout
92
- // F14 — fall back gracefully when origin/main is unreachable. The
93
- // original implementation tried `merge-base HEAD <opts.base>` and, on
94
- // failure, returned opts.base verbatim — which then failed every
95
- // subsequent git invocation, surfacing as a runner-level error. In CI
96
- // (full clones) the original ref usually resolves; on a developer
97
- // laptop without `origin/main` configured (fresh clone, detached
98
- // worktree, alternative remote name) the gate would fail entirely.
99
- //
100
- // Order of preference:
101
- // 1. merge-base against the requested base
102
- // 2. requested base verbatim, if `git rev-parse --verify` resolves it
103
- // 3. local `main` HEAD if it exists
104
- // 4. HEAD~1 as a last resort (single-commit diff)
62
+ // origin/main is not always reachable — fresh clone, detached worktree, remote
63
+ // under another name — and an unresolvable ref fails every later git call as a
64
+ // runner error rather than a coverage result.
105
65
  const tryResolve = (ref) => {
106
66
  try {
107
67
  git(["merge-base", "HEAD", ref], cwd).trim();
@@ -160,12 +120,9 @@ function fileDiff(opts, file, cwd, ignoreWs, resolvedBase) {
160
120
  }
161
121
 
162
122
  function fileAtRef(file, ref, cwd) {
163
- // v0.13.18: bumped maxBuffer from the Node default (1 MiB on Windows)
164
- // to 64 MiB so large catalog files (data/rfc-references.json is ~3 MiB;
165
- // data/cve-catalog.json is ~600 KiB) don't ENOBUFS-truncate. A null
166
- // return is the documented "missing" sentinel — silent truncation
167
- // would make every entry in the live file appear as a fresh add and
168
- // generate hundreds of bogus diff-coverage findings.
123
+ // maxBuffer far above Node's 1 MiB default: an ENOBUFS-truncated read of a
124
+ // multi-MiB catalog makes every entry in the live file look freshly added.
125
+ // Null is the "missing" sentinel, so a failure never returns partial content.
169
126
  const r = childProc.spawnSync("git", ["show", ref + ":" + file], {
170
127
  cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024
171
128
  });
@@ -190,23 +147,14 @@ function readMaybe(p) {
190
147
  try { return fs.readFileSync(p, "utf8"); } catch { return null; }
191
148
  }
192
149
 
193
- // --- Categorization ---------------------------------------------------------
194
-
195
- // Mechanical / contributor-only docs the gate auto-allows: their content
196
- // has no operator-facing semantic surface (CONTRIBUTING is for PRs;
197
- // LICENSE / NOTICE / CODE_OF_CONDUCT are boilerplate; .gitignore / .npmrc
198
- // / .editorconfig are tooling). Edits here never need a regression test.
150
+ // Contributor-only docs and tooling dotfiles: no semantic surface to test.
199
151
  const DOCS_ALWAYS_GREEN = new Set([
200
152
  "CONTRIBUTING.md", "LICENSE", "NOTICE", "CODE_OF_CONDUCT.md",
201
153
  "SUPPORT.md", ".gitignore", ".npmrc", ".editorconfig",
202
154
  ]);
203
155
 
204
- // Operator-facing docs (release notes, install instructions, security
205
- // disclosure policy, migration guides, AI-assistant ground truth) must not
206
- // auto-green — a PR could otherwise land deceptive copy here without any
207
- // reviewer signal. Downgrade to manual-review so the diff surfaces in the
208
- // gate output — a human (or the maintainer reviewing the bot summary) at
209
- // least sees the change exists.
156
+ // Operator-facing docs must not auto-green — a PR could otherwise land
157
+ // deceptive copy with no reviewer signal — so they downgrade to manual-review.
210
158
  const DOCS_MANUAL_REVIEW = new Set([
211
159
  "CHANGELOG.md", "README.md", "SECURITY.md", "MIGRATING.md", "AGENTS.md",
212
160
  ]);
@@ -226,20 +174,14 @@ function categorize(file) {
226
174
  if (norm.startsWith("scripts/") && norm.endsWith(".js")) return "lib";
227
175
  if (norm.startsWith("data/playbooks/") && norm.endsWith(".json")) return "playbook";
228
176
  if (norm === "data/cve-catalog.json") return "cve-catalog";
229
- // F11 — files matching catalog/schema/SBOM shapes are surfaced for manual
230
- // review rather than silent allowlist. These changes (manifest.json,
231
- // schemas/*, data/*.json, sbom.cdx.json, manifest-snapshot.*) can carry
232
- // semantic surface but the analyzer has no syntactic surface extractor
233
- // for them — humans should look.
177
+ // Shapes carrying semantic surface the analyzer has no extractor for: a human
178
+ // looks, instead of an allowlist.
234
179
  if (norm === "manifest.json") return "manual-review";
235
180
  if (norm === "manifest-snapshot.json") return "manual-review";
236
181
  if (norm === "manifest-snapshot.sha256") return "manual-review";
237
182
  if (norm === "sbom.cdx.json") return "manual-review";
238
183
  if (norm.startsWith("lib/schemas/")) return "manual-review";
239
- // v0.12.14: data/_indexes/ is auto-regenerated from data/ + manifest by
240
- // `npm run build-indexes`; the source-of-truth diff is in the data/
241
- // files themselves. Allowlist the derived index files so they don't
242
- // perpetually surface as manual-review on every release commit.
184
+ // data/_indexes/ is regenerated; the reviewable diff is in the data/ sources.
243
185
  if (norm.startsWith("data/_indexes/")) return "allowlist-derived";
244
186
  if (norm.startsWith("data/") && norm.endsWith(".json")) return "manual-review";
245
187
  if (norm === "package.json") return "manual-review";
@@ -252,14 +194,11 @@ function isWhitespaceOnly(opts, file, cwd, resolvedBase) {
252
194
  .filter(l => !l.startsWith("+++") && !l.startsWith("---")).length === 0;
253
195
  }
254
196
 
255
- // --- Surface extraction -----------------------------------------------------
256
-
257
197
  function extractCliSurface(content) {
258
198
  if (!content) return { verbs: new Set(), flags: new Set() };
259
199
  const verbs = new Set();
260
200
  const flags = new Set();
261
- // Only scan the COMMANDS = {...} block and PLAYBOOK_VERBS Set to avoid
262
- // picking up arbitrary keys from elsewhere.
201
+ // Only the COMMANDS block and PLAYBOOK_VERBS Set, not arbitrary keys elsewhere.
263
202
  const cmdBlock = content.match(/const COMMANDS = \{([\s\S]*?)\n\};/);
264
203
  if (cmdBlock) {
265
204
  const re = /^\s*"?([a-zA-Z][\w-]+)"?\s*:/gm;
@@ -272,18 +211,22 @@ function extractCliSurface(content) {
272
211
  let m;
273
212
  while ((m = re.exec(playbookBlock[1])) !== null) verbs.add(m[1]);
274
213
  }
275
- // REMOVED_VERBS keys are still part of the CLI surface: invoking one returns
276
- // a structured refusal envelope (a real, test-covered contract). Counting
277
- // them keeps a verb in the surface set when its vestigial COMMANDS entry is
278
- // dropped — otherwise removing dead COMMANDS table rows for already-retired
279
- // verbs reads as a fresh "removed-but-test-remains" against the refusal test.
214
+ // REMOVED_VERBS keys are still CLI surface: invoking one returns a structured
215
+ // refusal that tests cover. Counting them stops a dropped COMMANDS row reading
216
+ // as a fresh "removed-but-test-remains" against the refusal test.
280
217
  const removedBlock = content.match(/const REMOVED_VERBS = \{([\s\S]*?)\n\};/);
281
218
  if (removedBlock) {
282
219
  for (const m of removedBlock[1].matchAll(/^\s*"?([a-zA-Z][\w-]+)"?\s*:/gm)) verbs.add(m[1]);
283
220
  }
221
+ // Scans the whole file, prose included, so a flag named in a comment counts as
222
+ // surface. A trailing hyphen means the match stopped at a line break mid-name
223
+ // (`--attest-` wrapping to `ownership`), which is never a flag — admitting one
224
+ // makes deleting that comment read as a removed flag.
284
225
  const flagRe = /(--[a-zA-Z][\w-]+)/g;
285
226
  let m;
286
- while ((m = flagRe.exec(content)) !== null) flags.add(m[1]);
227
+ while ((m = flagRe.exec(content)) !== null) {
228
+ if (!m[1].endsWith("-")) flags.add(m[1]);
229
+ }
287
230
  for (const f of ["--help", "--version"]) flags.delete(f);
288
231
  return { verbs, flags };
289
232
  }
@@ -299,27 +242,18 @@ function diffSets(before, after) {
299
242
  function extractLibExports(content) {
300
243
  if (!content) return new Set();
301
244
  const out = new Set();
302
- // v0.12.9: strip block + line comments before matching `module.exports`
303
- // so a doc-comment example like `module.exports = {...}` inside a /** */
304
- // block does not shadow the real exports lower in the file. Pre-fix, the
305
- // analyzer's own file matched a 3-char doc-comment fragment first and
306
- // returned an empty export set — any source that mentions `module.exports`
307
- // in a JSDoc/banner block hit the same bug. After stripping comments,
308
- // the `module.exports = {...}` match runs against real code only.
245
+ // Strip comments first: a `module.exports = {...}` inside a doc comment
246
+ // otherwise shadows the real exports and the export set comes back empty.
309
247
  const stripped = content
310
248
  .replace(/\/\*[\s\S]*?\*\//g, "")
311
249
  .replace(/^\s*\/\/.*$/gm, "");
312
- // Capture the `module.exports = { ... }` body with brace-balancing so a
313
- // nested object/array member (e.g. `{ CONFIG: { a: 1 }, x, y }`) does not
314
- // truncate the export list at the first inner `}` and hide later exports —
315
- // which would let an uncovered new export ship green (the gate's blind spot).
250
+ // Brace-balanced so a nested member (`{ CONFIG: { a: 1 }, x, y }`) does not
251
+ // truncate the list at the first inner `}` and hide later exports.
316
252
  const objStart = stripped.search(/module\.exports\s*=\s*\{/);
317
253
  if (objStart !== -1) {
318
254
  const openIdx = stripped.indexOf("{", objStart);
319
- // String-aware brace balance: a `}` inside a string/template value (e.g.
320
- // `{ PATTERN: "a}b", realExport }`) must NOT close the object early and
321
- // hide the exports that follow — that blind spot let an uncovered export
322
- // ship green.
255
+ // String-aware: a `}` inside a value (`{ PATTERN: "a}b", realExport }`)
256
+ // must not close the object early and hide the exports that follow.
323
257
  let depth = 0, end = -1, inStr = null;
324
258
  for (let i = openIdx; i < stripped.length; i++) {
325
259
  const ch = stripped[i];
@@ -334,8 +268,7 @@ function extractLibExports(content) {
334
268
  }
335
269
  if (end !== -1) {
336
270
  const body = stripped.slice(openIdx + 1, end);
337
- // String-aware member split: a `,` or bracket inside a string value must
338
- // not split a member or skew the bracket depth.
271
+ // String-aware split: a `,` or bracket inside a string must not split a member.
339
272
  let d = 0, cur = "", sInStr = null;
340
273
  const members = [];
341
274
  for (let i = 0; i < body.length; i++) {
@@ -356,12 +289,9 @@ function extractLibExports(content) {
356
289
  for (const tok of members) {
357
290
  const id = tok.split(":")[0].trim();
358
291
  if (/^[a-zA-Z_$][\w$]*$/.test(id)) { out.add(id); continue; }
359
- // Method-shorthand member (`fn(a){...}`, `async load(){}`, `get x(){}`,
360
- // `*gen(){}`): the colon-split above fails the id test because the token
361
- // is `name(...)...`, so the export name would be silently dropped and a
362
- // new exported method would ship with no diff-coverage requirement.
363
- // Recover the name as the identifier immediately before the first `(`,
364
- // after any leading modifier keyword (async/get/set) or generator `*`.
292
+ // Method-shorthand member (`fn(a){}`, `async load(){}`, `*gen(){}`): the
293
+ // colon split leaves `name(...)`, so recover the name before the first
294
+ // `(`, past any modifier keyword or generator `*`.
365
295
  const beforeParen = tok.split("(")[0].trim();
366
296
  const parts = beforeParen.split(/\s+/);
367
297
  const cand = parts[parts.length - 1].replace(/^\*/, "").trim();
@@ -369,12 +299,8 @@ function extractLibExports(content) {
369
299
  }
370
300
  }
371
301
  }
372
- // Single-identifier whole-module export (`module.exports = mainFn;`): the
373
- // entire public surface of a lib file is one assigned function. The object
374
- // extractor above matches nothing, so without this the file's only export is
375
- // invisible to the gate and a change to it requires no test. Capture the bare
376
- // identifier on the RHS of `module.exports =` when it is not an object/array/
377
- // function-expression/arrow (those are handled elsewhere or have no name).
302
+ // Whole-module export (`module.exports = mainFn;`): the object extractor above
303
+ // matches nothing, so without this the file's only export is invisible.
378
304
  const singleIdent = stripped.match(/module\.exports\s*=\s*([a-zA-Z_$][\w$]*)\s*;/);
379
305
  if (singleIdent && !/^(function|async|class)$/.test(singleIdent[1])) {
380
306
  out.add(singleIdent[1]);
@@ -401,13 +327,8 @@ function extractPlaybookIds(content) {
401
327
  return { indicators: ind, artifacts: arts };
402
328
  }
403
329
 
404
- // Canonical-form recursive equality replaces JSON.stringify comparison.
405
- // Pre-v0.13.20 the comparator was JSON.stringify(before.iocs) !==
406
- // JSON.stringify(after.iocs) — non-canonical: key order, trailing
407
- // whitespace, and numeric format differences all flagged as "changed"
408
- // when the operator made no semantic change. Symptoms were patched
409
- // twice with skip rules (_auto_imported, _iocs_stub) instead of fixing
410
- // the comparator. v0.13.20 fixes the root cause.
330
+ // Canonical equality, not JSON.stringify: key order, whitespace and numeric
331
+ // formatting are not semantic changes to an iocs block.
411
332
  const { canonicalEqual } = require("../lib/canonical-eq");
412
333
 
413
334
  function extractCveIocChanges(beforeStr, afterStr) {
@@ -417,10 +338,8 @@ function extractCveIocChanges(beforeStr, afterStr) {
417
338
  const ids = new Set([...Object.keys(before), ...Object.keys(after)]);
418
339
  for (const id of ids) {
419
340
  if (!/^CVE-\d{4}-\d+/.test(id)) continue;
420
- // v0.13.18 retained skip rule: bulk-imported rows whose IoCs are
421
- // stub-by-design on both sides — pure intake-class events, not
422
- // operator curation. Removing this would surface every fresh KEV
423
- // bulk-import as a per-CVE iocs-modified finding.
341
+ // Rows auto-imported on BOTH sides hold stub IoCs by design; without the
342
+ // skip, every fresh KEV bulk-import surfaces as a per-CVE finding.
424
343
  const beforeAuto = !!(before[id] && before[id]._auto_imported);
425
344
  const afterAuto = !!(after[id] && after[id]._auto_imported);
426
345
  if (beforeAuto && afterAuto) continue;
@@ -433,8 +352,6 @@ function extractCveIocChanges(beforeStr, afterStr) {
433
352
 
434
353
  function safeParse(s) { try { return s ? JSON.parse(s) : null; } catch { return null; } }
435
354
 
436
- // --- Test corpus + coverage probes ------------------------------------------
437
-
438
355
  function loadTestCorpus(cwd) {
439
356
  const root = path.join(cwd, "tests");
440
357
  if (!fs.existsSync(root)) return { joined: "", files: [] };
@@ -473,32 +390,21 @@ function coversCliFlag(corpus, flag) {
473
390
  return corpus.includes(flag);
474
391
  }
475
392
 
476
- // F10 — same-file context check. A test corpus is no longer treated as
477
- // one giant string for lib-export coverage: the identifier must appear
478
- // inside a real test block (`test(`, `it(`, `describe(`, or an `assert(`
479
- // argument) within the SAME file that issues the matching require().
480
- // Pre-fix: an `assert.equal(...)` mention in one test file plus a stray
481
- // `require('../lib/x')` in a completely different test file counted as
482
- // coverage. That's not coverage — it's textual coincidence.
483
- //
484
- // `corpus` may be either a string (legacy joined corpus, used by
485
- // CLI/playbook/CVE coverage probes) or the structured shape
486
- // `{ joined, files }` produced by loadTestCorpus().
393
+ // Coverage needs same-file context: the identifier must appear inside a real
394
+ // test block in the SAME file that requires the module, or a stray require() in
395
+ // one file plus a mention in another reads as coverage. `corpus` is either the
396
+ // structured `{ joined, files }` from loadTestCorpus or a legacy joined string.
487
397
  function coversLibExport(corpus, libRel, ident) {
488
398
  const baseName = path.basename(libRel).replace(/\.js$/, "");
489
- const baseFile = path.basename(libRel); // e.g. "check-sbom-currency.js"
399
+ const baseFile = path.basename(libRel);
490
400
  const identRe = new RegExp("\\b" + escapeRe(ident) + "\\b");
491
401
  const requireRe = new RegExp("require\\([^)]*" + escapeRe(baseName) + "[^)]*\\)");
492
- // Accept the structured shape (preferred). Walk files individually.
493
402
  if (corpus && Array.isArray(corpus.files)) {
494
403
  for (const f of corpus.files) {
495
404
  const hasRequire = requireRe.test(f.content);
496
405
  const mentionsSpawnPath = f.content.includes(baseFile);
497
406
  if (!hasRequire && !mentionsSpawnPath) continue;
498
407
  if (!identRe.test(f.content)) continue;
499
- // F10 — require the identifier appears inside a test block in this
500
- // file. Recognise `test(`, `it(`, `describe(`, or `assert(` (or any
501
- // `assert.<member>(`) bracketed argument that mentions the ident.
502
408
  if (mentionsIdentInTestContext(f.content, ident)) return true;
503
409
  }
504
410
  return false;
@@ -510,16 +416,11 @@ function coversLibExport(corpus, libRel, ident) {
510
416
  return false;
511
417
  }
512
418
 
513
- // Returns true when `ident` appears as a token inside the body of any
514
- // `test( ... )`, `it( ... )`, `describe( ... )`, `assert( ... )` or
515
- // `assert.<member>( ... )` call in the file. We approximate "the body of
516
- // the call" by finding the opening paren after the keyword, then walking
517
- // matched parens until the call closes. This is a syntactic-enough check
518
- // for vanilla JavaScript tests; the goal is to refuse "ident only appears
519
- // in a top-level comment" while still accepting `assert.deepEqual(foo, ...)`.
419
+ // True when `ident` appears as a token inside the parenthesised body of a
420
+ // `test(`, `it(`, `describe(`, `assert(` or `assert.<member>(` call. The paren
421
+ // walk is approximate by design.
520
422
  function mentionsIdentInTestContext(content, ident) {
521
423
  const tokenRe = new RegExp("\\b" + escapeRe(ident) + "\\b");
522
- // Quick reject: file does not mention the identifier at all.
523
424
  if (!tokenRe.test(content)) return false;
524
425
  const callRe = /\b(test|it|describe|assert(?:\.[A-Za-z_$][\w$]*)?)\s*\(/g;
525
426
  let m;
@@ -556,33 +457,15 @@ function coversCveIoc(corpus, cveId) {
556
457
  return /\biocs\b/i.test(corpus);
557
458
  }
558
459
 
559
- // --- Main analyzer ----------------------------------------------------------
560
-
561
- // --- Class-level lint: ban coincidence-passing notEqual(r.status, 0) --------
562
- //
563
- // Anti-coincidence rule: every exit-code assertion must pin the
564
- // EXACT code. `assert.notEqual(r.status, 0)` silently passes when an
565
- // unrelated failure produces ANY non-zero exit, hiding the regression the
566
- // test was meant to catch. This lint walks tests/*.test.js and rejects the
567
- // pattern outright. The `// allow-notEqual: <reason>` opt-out on the same
568
- // line is the escape hatch for genuine refusal-pins (asserting NOT a
569
- // specific code) — those must justify themselves inline.
570
- //
571
- // Pattern hits any of:
572
- // assert.notEqual(r.status, 0)
573
- // assert.notEqual(result.status, 0, '...')
574
- // assert.notEqual(foo.status, 2, 'must not be unknown-cmd') ← also refused
575
- // unless the same line ends with `// allow-notEqual: <reason>`.
576
- //
577
- // Structural lint replaces a per-instance hunt across 25+ test sites — keeps
578
- // new tests / new ports from regressing into coincidence-passing assertions.
579
- // Fix the class, not the instance.
460
+ // Every exit-code assertion must pin the EXACT code: `assert.notEqual(r.status,
461
+ // 0)` passes on any non-zero exit, hiding the regression the test was written
462
+ // for. The only opt-out is `// allow-notEqual: <reason>` on the same line, for a
463
+ // genuine refusal-pin that asserts NOT a specific code.
580
464
  function scanForCoincidenceAsserts(cwd) {
581
465
  const out = [];
582
466
  const testsDir = path.join(cwd, "tests");
583
467
  if (!fs.existsSync(testsDir)) return out;
584
- // Match `assert.notEqual( <ident>.status` — the receiver name varies
585
- // (r, r1, result, child, etc.) but the .status access is the signal.
468
+ // The receiver name varies (r, r1, result, child); the `.status` access is the signal.
586
469
  const banRe = /assert\.notEqual\s*\(\s*[A-Za-z_$][\w$]*\.status\b/;
587
470
  const allowRe = /\/\/\s*allow-notEqual\s*:/;
588
471
  const skipPrefix = "_helpers"; // helpers may legitimately reference the pattern
@@ -611,10 +494,6 @@ function scanForCoincidenceAsserts(cwd) {
611
494
 
612
495
  function analyze(opts) {
613
496
  const cwd = opts.repo || ROOT;
614
- // v0.12.8: resolve the diff anchor ONCE and thread it through every
615
- // per-file call so listChangedFiles + fileDiff + fileBefore all agree on
616
- // the same SHA. Otherwise origin/main advancing past the merge-base
617
- // between calls produces false add/remove findings.
618
497
  const resolvedBase = resolveBaseRef(opts, cwd);
619
498
  const changed = listChangedFiles(opts, cwd, resolvedBase);
620
499
  const corpusObj = loadTestCorpus(cwd);
@@ -632,10 +511,7 @@ function analyze(opts) {
632
511
  }
633
512
  if (cat === "skill") { allowlisted.push({ file: ch.file, reason: "skill-signed" }); continue; }
634
513
  if (cat === "workflow") { manualReview.push({ file: ch.file, reason: "workflow" }); continue; }
635
- // F11 — data catalogs, schemas, manifests, SBOM go to manual review
636
- // instead of being silently allowlisted. They show up in CI output.
637
514
  if (cat === "manual-review") { manualReview.push({ file: ch.file, reason: "manual-review" }); continue; }
638
- // v0.12.14: derived index files allowlist (auto-regenerated artifacts).
639
515
  if (cat === "allowlist-derived") { allowlisted.push({ file: ch.file, reason: "derived-artifact" }); continue; }
640
516
  if (cat === "other") { manualReview.push({ file: ch.file, reason: "unclassified" }); continue; }
641
517
  if (ch.status !== "D" && isWhitespaceOnly(opts, ch.file, cwd, resolvedBase)) {
@@ -663,8 +539,6 @@ function analyze(opts) {
663
539
  const b = extractLibExports(before);
664
540
  const a = extractLibExports(after);
665
541
  const d = diffSets(b, a);
666
- // F10 — pass the structured corpus so coversLibExport can enforce
667
- // same-file require()+identifier-in-test-context coverage.
668
542
  for (const id of d.added) if (!coversLibExport(corpusObj, ch.file, id))
669
543
  findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "added" });
670
544
  for (const id of d.removed) if (coversLibExport(corpusObj, ch.file, id))
@@ -687,11 +561,8 @@ function analyze(opts) {
687
561
  }
688
562
  }
689
563
 
690
- // Class-level lint: ban `notEqual(<ident>.status, N)` outside of
691
- // refusal-pin allowlist comments. Runs irrespective of the diff —
692
- // a coincidence-passing assert that lands via a non-test-coverage
693
- // path (someone hand-edits a tests/ file in a docs-only commit) is
694
- // still a regression vector the gate must catch.
564
+ // Runs irrespective of the diff: a coincidence-passing assert can land by a
565
+ // path this analyzer never inspects.
695
566
  const coincidenceFindings = scanForCoincidenceAsserts(cwd);
696
567
  for (const f of coincidenceFindings) {
697
568
  findings.push({
@@ -705,8 +576,6 @@ function analyze(opts) {
705
576
  return { findings, allowlisted, manualReview, totalChanged: changed.length };
706
577
  }
707
578
 
708
- // --- Output -----------------------------------------------------------------
709
-
710
579
  function emitHuman(report) {
711
580
  const out = [];
712
581
  out.push("Diff coverage analyzer — " + report.totalChanged + " changed file(s)");
@@ -1,27 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  /**
4
- * check-test-subjects.js — bidirectional test↔subject gate (and reorg driver).
4
+ * Bidirectional test↔subject gate. Every tests/<x>.test.js must name a real
5
+ * subject and every subject must have a test. Subjects are derived from the
6
+ * codebase, not a hand-maintained list.
5
7
  *
6
- * Every test file must be named after a real SUBJECT the codebase actually has,
7
- * and every subject must have a test. A "subject" is derived dynamically from
8
- * the codebase so this list is never hand-maintained:
9
- * - a source MODULE basename (lib/x.js -> x; lib/collectors/x.js -> x and
10
- * collectors-x; orchestrator/index.js -> orchestrator; bin/exceptd.js -> cli)
11
- * - an exported FUNCTION / CLASS name (kebab-cased) — per-function granularity
12
- * - a data PRIMITIVE: a data/*.json catalog FILE, plus each catalog ENTRY that
13
- * is itself a primitive — every CVE/MAL/GHSA id in data/cve-catalog.json and
14
- * every playbook in data/playbooks/ — so one CVE == one test file
15
- * - a .github/workflows/*.yml WORKFLOW (release -> release-workflow, etc.)
16
- * - a CLI verb dispatched by bin/exceptd.js
17
- *
18
- * FORWARD violation : a tests/<x>.test.js where <x> is not a valid subject.
19
- * REVERSE violation : a subject (module / CVE / playbook / workflow) with no
20
- * tests/<subject>.test.js.
21
- *
22
- * Run with --worklist for the machine-readable reorg work list (JSON on stdout).
23
- * Run with no flag for a human summary; exits non-zero while any violation
24
- * remains (so once the suite conforms this becomes a standing predeploy gate).
8
+ * A FORWARD violation is a test file naming no subject; a REVERSE violation is
9
+ * a subject with no test. --worklist writes the machine-readable list to
10
+ * stdout; either violation exits non-zero.
25
11
  */
26
12
  const fs = require("node:fs");
27
13
  const path = require("node:path");
@@ -42,22 +28,16 @@ function deriveSubjects() {
42
28
  if (e.isDirectory()) { walkSrc(rel); continue; }
43
29
  if (!e.name.endsWith(".js")) continue;
44
30
  const base = e.name.replace(/\.js$/, "");
45
- // index.js is a directory entry point; the directory/canonical subject
46
- // covers it, so treat the bare "index" basename as an alias, not a
47
- // separately reverse-required module.
31
+ // A bare "index" is an alias, not a reverse-required module.
48
32
  add(base, (base === "index" ? "alias:" : "module:") + rel);
49
33
  const parent = path.basename(path.dirname(rel));
50
- // parent-prefixed name (collectors-x, builders-x, validators-x) is an
51
- // ALIAS of the canonical basename subject — a valid test target, but the
52
- // canonical <base>.test.js already satisfies coverage, so don't double-
53
- // count the alias as its own reverse gap.
34
+ // A parent-prefixed name (collectors-x) is an alias of the canonical
35
+ // basename: a valid test target, never its own reverse gap.
54
36
  if (!["lib", "scripts", "orchestrator", "bin"].includes(parent)) add(parent + "-" + base, "alias:" + rel);
55
37
  const txt = read(rel);
56
38
  for (const m of txt.matchAll(/(?:^|\n)\s*(?:async\s+)?(?:function|class)\s+([A-Za-z_$][\w$]*)/g)) add(camelKebab(m[1]), "fn:" + rel);
57
- // Capture the FULL module.exports object via a brace-balanced scan. A
58
- // non-greedy /\{([\s\S]*?)\}/ stops at the first nested `}` and drops
59
- // every export name after it (the brace-truncation class), under-deriving
60
- // subjects so a real export silently has no required test.
39
+ // Brace-balanced scan, not a non-greedy /\{([\s\S]*?)\}/ — that stops at
40
+ // the first nested `}` and drops every export after it.
61
41
  const expAt = txt.search(/module\.exports\s*=\s*\{/);
62
42
  if (expAt >= 0) {
63
43
  const open = txt.indexOf("{", expAt);
@@ -70,43 +50,28 @@ function deriveSubjects() {
70
50
  ["lib", "orchestrator", "scripts", "bin", "sources/validators"].forEach(walkSrc);
71
51
  add("orchestrator", "module:orchestrator/index.js");
72
52
  add("cli", "module:bin/exceptd.js");
73
- // Vendored (pinned third-party) modules are valid test SUBJECTS but are not
74
- // reverse-required — we don't force a dedicated test per vendored file.
53
+ // Vendored modules are valid test subjects but not reverse-required.
75
54
  (function walkVendor(d) { for (const e of ls(d)) { const rel = d + "/" + e.name; if (e.isDirectory()) walkVendor(rel); else if (e.name.endsWith(".js")) add(e.name.replace(/\.js$/, ""), "vendor:" + rel); } })("vendor");
76
55
 
77
- // CLI verbs — both the switch-case form and the dispatch-table form
78
- // (verb: () => path.join(...)) that bin/exceptd.js uses for most subcommands.
56
+ // Both dispatch forms bin/exceptd.js uses: switch-case and the `verb: () =>` table.
79
57
  const cliSrc = read("bin/exceptd.js");
80
58
  for (const m of cliSrc.matchAll(/case\s+['"]([a-z][a-z0-9-]+)['"]/g)) { add("cli-" + m[1], "cli-verb"); add(m[1], "cli-verb"); }
81
59
  for (const m of cliSrc.matchAll(/^\s*["']?([a-z][a-z0-9-]+)["']?:\s*\(\)\s*=>/gm)) { add("cli-" + m[1], "cli-verb"); add(m[1], "cli-verb"); }
82
60
 
83
- // data catalog files
84
61
  for (const e of ls("data")) if (e.isFile() && e.name.endsWith(".json")) add(e.name.replace(/\.json$/, ""), "data");
85
- // data ENTRY primitives: every CVE id + every playbook
86
- // CVE-primitive subjects. An unreadable / malformed / empty catalog must NOT
87
- // silently derive zero CVE subjects — that would let the reverse-coverage gate
88
- // PASS with no CVE coverage at all (the absent-input false-pass class). Fail
89
- // loud instead.
62
+ // A zero-subject derivation throws rather than passing reverse coverage empty.
90
63
  let cveDerived = 0;
91
64
  try { const cat = JSON.parse(read("data/cve-catalog.json")); for (const k of Object.keys(cat)) if (k !== "_meta") { add(k.toLowerCase(), "cve-primitive"); cveDerived++; } }
92
65
  catch (e) { throw new Error("check-test-subjects: cannot read/parse data/cve-catalog.json — refusing to derive subjects (reverse coverage would falsely pass with no CVE coverage): " + e.message); }
93
66
  if (cveDerived === 0) throw new Error("check-test-subjects: data/cve-catalog.json yielded zero CVE entries — refusing to let reverse coverage pass with no CVE coverage.");
94
- // Playbook-primitive subjects. Mirror the CVE-catalog guard above: an
95
- // unreadable / empty data/playbooks must NOT silently derive zero playbook
96
- // subjects — that lets the reverse-coverage gate pass with no playbook
97
- // coverage (the same absent-input false-pass class). Fail loud instead.
67
+ // Same floor for playbooks.
98
68
  let pbDerived = 0;
99
69
  for (const e of ls("data/playbooks")) if (e.isFile() && e.name.endsWith(".json")) { const b = e.name.replace(/\.json$/, ""); add(b, "playbook-primitive"); add("playbook-" + b, "alias:playbook"); pbDerived++; }
100
70
  if (pbDerived === 0) throw new Error("check-test-subjects: data/playbooks/ yielded zero playbooks — refusing to let reverse coverage pass with no playbook coverage.");
101
- // workflows
102
71
  for (const e of ls(".github/workflows")) if (/\.ya?ml$/.test(e.name)) { const b = e.name.replace(/\.ya?ml$/, ""); add(b, "workflow"); add(b + "-workflow", "workflow"); }
103
72
 
104
- // Repo-artifact subjects: shipped root config/doc files, the docker build
105
- // context, the agents/ directory, and aggregate catalog directories. A test
106
- // that pins one of these artifacts (its content, counts, or cross-references)
107
- // is named after a durable subject, not a release — so these are valid test
108
- // targets. Kind is not module/cve/playbook, so they are NOT reverse-required
109
- // (we don't force a dedicated test per doc file).
73
+ // Repo artifacts — valid test targets, but their kind is not
74
+ // module/cve/playbook, so none of them is reverse-required.
110
75
  for (const f of ["package.json", "manifest.json", "manifest-snapshot.json", "README.md", "AGENTS.md", "SECURITY.md", "ARCHITECTURE.md", "CONTEXT.md", "CHANGELOG.md", "CONTRIBUTING.md", "CODE_OF_CONDUCT.md", "LICENSE", "NOTICE"]) {
111
76
  add(f.replace(/\.[^.]*$/, "").toLowerCase().replace(/_/g, "-"), "repo:" + f);
112
77
  }
@@ -116,12 +81,8 @@ function deriveSubjects() {
116
81
  add("playbooks", "aggregate:data/playbooks");
117
82
  add("workflows", "aggregate:.github/workflows");
118
83
  add("governance", "repo:governance-files"); // LICENSE/NOTICE/FUNDING/CoC/gitignore/gitleaks presence + integrity
119
- // Module-subject floor. The source walk over lib/orchestrator/scripts/bin
120
- // uses ls(), which returns [] on a read failure — so an unreadable source
121
- // tree would derive zero reverse-required module subjects and let the gate
122
- // pass with no module coverage (the absent-input false-pass class, same as
123
- // the CVE/playbook guards). The repo always has dozens of modules; zero is an
124
- // anomaly. Fail loud.
84
+ // Module floor: ls() returns [] on a read failure, so an unreadable source
85
+ // tree would otherwise pass the gate with no module coverage.
125
86
  let moduleCount = 0;
126
87
  for (const kind of subjects.values()) if (typeof kind === "string" && kind.startsWith("module:")) moduleCount++;
127
88
  if (moduleCount === 0) throw new Error("check-test-subjects: zero source-module subjects derived (lib/orchestrator/scripts/bin unreadable?) — refusing to let reverse coverage pass with no module coverage.");
@@ -142,10 +103,8 @@ function run() {
142
103
  for (const t of testFiles) if (!subjects.has(t.toLowerCase())) forward.push({ file: "tests/" + t + ".test.js", suggested: suggest(t) });
143
104
  const reverse = [];
144
105
  for (const [s, kind] of subjects) if (!testSet.has(s)) reverse.push({ subject: s, kind });
145
- // Reverse-REQUIRED subset: only module / cve / playbook subjects must have a
146
- // test (aliases, data files, cli verbs, repo artifacts are valid targets but
147
- // not reverse-required). The exit-code logic gates on THIS, consistently
148
- // across --worklist and the human/predeploy paths.
106
+ // Only module, cve and playbook subjects must have a test; the rest are valid
107
+ // targets, not requirements. Both output paths take their exit code from this.
149
108
  const reverseRequired = reverse.filter((x) => x.kind.startsWith("module:") || x.kind.startsWith("cve-primitive") || x.kind.startsWith("playbook-primitive"));
150
109
  return { subjects: subjects.size, forward, reverse, reverseRequired };
151
110
  }