@blamejs/exceptd-skills 0.19.33 → 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 (119) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/auto-discovery.js +56 -286
  5. package/lib/canonical-eq.js +7 -40
  6. package/lib/citation-resolve.js +22 -70
  7. package/lib/collectors/ai-api.js +20 -54
  8. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  9. package/lib/collectors/citation-hygiene.js +72 -210
  10. package/lib/collectors/containers.js +41 -130
  11. package/lib/collectors/cred-stores.js +31 -115
  12. package/lib/collectors/crypto-codebase.js +55 -138
  13. package/lib/collectors/crypto.js +24 -54
  14. package/lib/collectors/hardening.js +20 -78
  15. package/lib/collectors/kernel.js +16 -46
  16. package/lib/collectors/library-author.js +57 -206
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +34 -106
  20. package/lib/collectors/scan-excludes.js +31 -138
  21. package/lib/collectors/secrets.js +62 -178
  22. package/lib/cross-ref-api.js +39 -123
  23. package/lib/currency-severity.js +8 -27
  24. package/lib/cve-batch.js +13 -21
  25. package/lib/cve-cli.js +13 -20
  26. package/lib/cve-curation.js +72 -239
  27. package/lib/cve-regression-watcher.js +29 -152
  28. package/lib/cvss.js +13 -54
  29. package/lib/doctor-bucketing.js +3 -19
  30. package/lib/exit-codes.js +10 -42
  31. package/lib/flag-suggest.js +7 -25
  32. package/lib/framework-gap.js +35 -114
  33. package/lib/gap-detectors.js +37 -159
  34. package/lib/id-validation.js +9 -30
  35. package/lib/job-queue.js +13 -36
  36. package/lib/lint-skills.js +64 -232
  37. package/lib/playbook-runner.js +693 -2095
  38. package/lib/prefetch.js +100 -376
  39. package/lib/refresh-external.js +199 -627
  40. package/lib/refresh-network.js +75 -307
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +77 -145
  43. package/lib/sign.js +43 -229
  44. package/lib/source-advisories.js +43 -194
  45. package/lib/source-ghsa.js +37 -120
  46. package/lib/source-osv.js +94 -266
  47. package/lib/ttp-mapper.js +14 -24
  48. package/lib/upstream-check-cli.js +10 -28
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +43 -119
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +69 -275
  55. package/lib/validate-vendor.js +16 -49
  56. package/lib/verify.js +56 -286
  57. package/lib/version-pins.js +5 -34
  58. package/lib/worker-pool.js +11 -30
  59. package/lib/xml-tokenizer.js +47 -152
  60. package/manifest.json +53 -53
  61. package/orchestrator/dispatcher.js +17 -68
  62. package/orchestrator/event-bus.js +11 -74
  63. package/orchestrator/index.js +138 -412
  64. package/orchestrator/pipeline.js +28 -85
  65. package/orchestrator/scanner.js +34 -138
  66. package/orchestrator/scheduler.js +20 -84
  67. package/package.json +1 -1
  68. package/sbom.cdx.json +241 -241
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +6 -16
  72. package/scripts/backfill-theater-test.js +7 -64
  73. package/scripts/bootstrap.js +12 -44
  74. package/scripts/build-indexes.js +40 -154
  75. package/scripts/builders/activity-feed.js +4 -14
  76. package/scripts/builders/catalog-summaries.js +3 -10
  77. package/scripts/builders/currency.js +7 -20
  78. package/scripts/builders/cwe-chains.js +7 -30
  79. package/scripts/builders/did-ladders.js +6 -13
  80. package/scripts/builders/frequency.js +5 -19
  81. package/scripts/builders/jurisdiction-clocks.js +6 -25
  82. package/scripts/builders/recipes.js +6 -14
  83. package/scripts/builders/section-offsets.js +13 -51
  84. package/scripts/builders/stale-content.js +7 -28
  85. package/scripts/builders/summary-cards.js +8 -29
  86. package/scripts/builders/theater-fingerprints.js +12 -27
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +11 -54
  89. package/scripts/check-catalog-gap-budget.js +15 -32
  90. package/scripts/check-changelog-extract.js +18 -48
  91. package/scripts/check-codebase-patterns-currency.js +6 -22
  92. package/scripts/check-codebase-patterns.js +50 -143
  93. package/scripts/check-epss-consistency.js +9 -64
  94. package/scripts/check-framework-gap-coverage.js +13 -31
  95. package/scripts/check-manifest-snapshot.js +13 -73
  96. package/scripts/check-sbom-currency.js +44 -142
  97. package/scripts/check-test-count.js +15 -52
  98. package/scripts/check-test-coverage.js +66 -197
  99. package/scripts/check-test-subjects.js +21 -62
  100. package/scripts/check-ttp-references.js +14 -38
  101. package/scripts/check-ttp-upstream.js +8 -40
  102. package/scripts/check-version-bump.js +9 -61
  103. package/scripts/check-version-tags.js +20 -121
  104. package/scripts/predeploy.js +38 -184
  105. package/scripts/refresh-manifest-snapshot.js +16 -38
  106. package/scripts/refresh-mitre-atlas.js +3 -8
  107. package/scripts/refresh-mitre-attack.js +1 -8
  108. package/scripts/refresh-mitre-d3fend.js +3 -9
  109. package/scripts/refresh-mitre-ics-attack.js +3 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +2 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +40 -137
  114. package/scripts/release.js +69 -232
  115. package/scripts/run-e2e-scenarios.js +24 -71
  116. package/scripts/sync-manifest-metadata.js +10 -34
  117. package/scripts/sync-package-description.js +8 -17
  118. package/scripts/validate-vendor-online.js +13 -44
  119. package/scripts/verify-shipped-tarball.js +35 -140
@@ -1,21 +1,8 @@
1
1
  "use strict";
2
2
  /**
3
- * scripts/check-sbom-currency.js
4
- *
5
- * Predeploy gate: assert sbom.cdx.json is current against the live skill +
6
- * catalog counts. Drift means an SBOM regen was forgotten — operators
7
- * downloading the tarball would see counts that disagree with the actual
8
- * manifest.json and data/*.json contents.
9
- *
10
- * Anchors to ROOT in this order of preference:
11
- * 1. `--root <dir>` on argv (testability — staged tempdir layouts).
12
- * 2. `EXCEPTD_ROOT` environment variable.
13
- * 3. `path.join(__dirname, '..')` — the running script's parent dir.
14
- *
15
- * Exit 0 on current SBOM, 1 on any drift (catalog count, skill count, or
16
- * CycloneDX-format mismatch).
17
- *
18
- * No external dependencies. Node 24 stdlib only.
3
+ * Predeploy gate: sbom.cdx.json must be current against the live skill and
4
+ * catalog counts, the recorded file hashes and the shipped-file set — drift
5
+ * means the tarball ships an SBOM that disagrees with its own contents.
19
6
  */
20
7
 
21
8
  const fs = require("fs");
@@ -31,16 +18,10 @@ function resolveRoot(argv) {
31
18
  return path.join(__dirname, "..");
32
19
  }
33
20
 
34
- // Entry count for a data/*.json catalog: keys minus the _meta sentinel. The
35
- // catalogs are objects keyed by entry id (CVE-…, CWE-…, T…, AML.T…, D3-…,
36
- // RFC-…) with a single _meta block, so the live entry total is the key count
37
- // excluding _meta.
21
+ // Live entry total for a data/*.json catalog, or null when the file is absent so
22
+ // a `--root` pointed at a partial tree skips that token instead of crashing.
38
23
  function catalogEntryCount(dataDir, file) {
39
24
  const p = path.join(dataDir, file);
40
- // A --root pointed at a partial tree (no such catalog file) skips that
41
- // token's check rather than crashing — catalog PRESENCE is asserted by
42
- // the cardinality check above and the per-component hash check below,
43
- // not by the description parser.
44
25
  if (!fs.existsSync(p)) return null;
45
26
  const j = JSON.parse(fs.readFileSync(p, "utf8"));
46
27
  if (Array.isArray(j)) return j.length;
@@ -50,18 +31,13 @@ function catalogEntryCount(dataDir, file) {
50
31
  return 0;
51
32
  }
52
33
 
53
- // Files/prefixes that refresh-sbom's expandAllowlist excludes from the shipped
54
- // file: component inventory. Kept in sync with scripts/refresh-sbom.js
55
- // (SELF_EXCLUDED + DERIVABLE_PREFIXES): the SBOM never hashes itself, and the
56
- // pre-computed index cache under data/_indexes/ is regenerated/test-mutated, so
57
- // it carries no per-file component. The completeness check below must apply the
58
- // SAME exclusions or it would demand a component for a file the generator never
59
- // emits one for.
34
+ // Mirrors scripts/refresh-sbom.js SELF_EXCLUDED + DERIVABLE_PREFIXES: the
35
+ // completeness check must apply the SAME exclusions or it demands a component
36
+ // the generator never emits.
60
37
  const SBOM_SELF_EXCLUDED = new Set(["sbom.cdx.json"]);
61
38
  const SBOM_DERIVABLE_PREFIXES = ["data/_indexes/"];
62
39
  // Mirrors refresh-sbom's ALWAYS_SHIPPED: npm adds package.json to every tarball
63
- // without it appearing in `files`, so the completeness check has to expect a
64
- // component for it the same way the generator now emits one.
40
+ // without it appearing in `files`, so a component is expected for it.
65
41
  const SBOM_ALWAYS_SHIPPED = ["package.json", "sources/README.md"];
66
42
 
67
43
  function sbomIsDerivable(rel) {
@@ -70,9 +46,7 @@ function sbomIsDerivable(rel) {
70
46
  );
71
47
  }
72
48
 
73
- // Recursively list every regular file under absDir, returned as absolute paths.
74
- // Mirrors refresh-sbom's walkFiles (which is root-agnostic — it walks whatever
75
- // absolute dir it is given).
49
+ // Mirrors refresh-sbom's walkFiles, walking whatever absolute dir it is handed.
76
50
  function walkFilesAbs(absDir) {
77
51
  const out = [];
78
52
  let entries;
@@ -86,15 +60,9 @@ function walkFilesAbs(absDir) {
86
60
  return out;
87
61
  }
88
62
 
89
- // Root-aware allowlist expansion: the shipped-file set that must each have a
90
- // file: component, computed against the TARGET tree (`root`), not the running
91
- // script's source repo. refresh-sbom's exported expandAllowlist is bound to its
92
- // own REPO_ROOT (it joins/relativizes against __dirname/..), so under a `--root`
93
- // target the completeness check would otherwise validate the SOURCE repo's file
94
- // list against the TARGET SBOM — the wrong tree. Replicating the expansion here
95
- // (same SELF_EXCLUDED + DERIVABLE_PREFIXES exclusions, same dedupe+sort) keeps
96
- // the gate honest under `--root` without reaching across into the generator's
97
- // module-level root.
63
+ // The shipped-file set that must each have a file: component, computed against
64
+ // the TARGET tree. refresh-sbom's expandAllowlist is bound to its own REPO_ROOT,
65
+ // so it would validate the source repo's file list against the target SBOM.
98
66
  function expandAllowlistAt(allowlist, root) {
99
67
  const abs = [];
100
68
  for (const entry of [...allowlist, ...SBOM_ALWAYS_SHIPPED]) {
@@ -114,11 +82,8 @@ function expandAllowlistAt(allowlist, root) {
114
82
  return rel;
115
83
  }
116
84
 
117
- // The description string embeds per-catalog ENTRY counts as free text, e.g.
118
- // "11 catalogs (439 CVEs / 177 CWEs / 805 ATT&CK + ICS / 170 ATLAS /
119
- // 468 D3FEND / 8888 RFCs)". Each token maps to one data/*.json catalog whose
120
- // live entry count must match. `label` is the regex-escaped text that follows
121
- // the number in the description.
85
+ // The description embeds per-catalog ENTRY counts as free text ("… 177 CWEs /
86
+ // 805 ATT&CK + ICS …"). `label` is the regex-escaped text after the number.
122
87
  const DESCRIPTION_ENTRY_TOKENS = [
123
88
  { file: "cve-catalog.json", label: "CVEs" },
124
89
  { file: "cwe-catalog.json", label: "CWEs" },
@@ -161,14 +126,8 @@ function checkSbomCurrency(root) {
161
126
  errors.push("SBOM is not CycloneDX 1.6");
162
127
  }
163
128
 
164
- // The SBOM ships per-catalog entry counts and a skill count embedded as free
165
- // text in metadata.component.description (propagated verbatim from
166
- // package.json). The numeric properties above only cover catalog/skill
167
- // CARDINALITY (file count + skill count), so a catalog's entry total can
168
- // drift past the count baked into the description while the dedicated SBOM
169
- // gate still passes. Parse each token out of the description and assert it
170
- // against the live entry count so a stale published-SBOM description fails
171
- // the gate.
129
+ // The numeric properties above cover only CARDINALITY, so an entry total can
130
+ // drift in the description while those still agree.
172
131
  const description =
173
132
  (sbom.metadata && sbom.metadata.component && sbom.metadata.component.description) || "";
174
133
  for (const { file, label } of DESCRIPTION_ENTRY_TOKENS) {
@@ -188,7 +147,6 @@ function checkSbomCurrency(root) {
188
147
  );
189
148
  }
190
149
  }
191
- // The skill count is embedded in the same description string ("N skills").
192
150
  const skillMatch = description.match(/(\d+)\s+skills\b/);
193
151
  if (!skillMatch) {
194
152
  errors.push(
@@ -200,15 +158,9 @@ function checkSbomCurrency(root) {
200
158
  );
201
159
  }
202
160
 
203
- // The "N catalogs" and "N jurisdictions" free-text counts in the same
204
- // description string were never validated — only the per-catalog entry tokens
205
- // and the skill count were. Pin them to the live values so a stale
206
- // description (e.g. after an auto-refresh changed a count) fails the gate.
207
161
  const catalogMatch = description.match(/(\d+)\s+catalogs?\b/i);
208
162
  if (!catalogMatch) {
209
- // Symmetric with the entry/skill tokens: absence fails CLOSED. A reworded
210
- // description (or an auto-refresh that dropped the token) must not silently
211
- // skip the count check — that is the asymmetric-absent fail-open class.
163
+ // Absence fails CLOSED: a reworded description must not skip the check.
212
164
  errors.push(
213
165
  "SBOM description is missing the catalog-count token (N catalogs) — regenerate via `npm run refresh-sbom`"
214
166
  );
@@ -220,21 +172,16 @@ function checkSbomCurrency(root) {
220
172
  const liveJurisdictions = (() => {
221
173
  try {
222
174
  const gf = JSON.parse(fs.readFileSync(path.join(dataDir, "global-frameworks.json"), "utf8"));
223
- // Non-underscore top-level keys — the canonical jurisdiction count the
224
- // README badge and catalog-summaries use.
175
+ // Non-underscore top-level keys — the count the README badge reports.
225
176
  return Object.keys(gf).filter((k) => !k.startsWith("_")).length;
226
177
  } catch {
227
178
  return null;
228
179
  }
229
180
  })();
230
181
  const jurisdictionMatch = description.match(/(\d+)\s+jurisdictions?\b/i);
231
- // Only enforce the jurisdiction token when the live source exists — a partial
232
- // `--root` tree without global-frameworks.json (liveJurisdictions === null)
233
- // skips the check rather than failing, matching catalogEntryCount's null-skip.
234
- // When the source IS present, absence of the token fails CLOSED (the
235
- // description token is the SBOM's only jurisdiction-count assertion — there is
236
- // no structured jurisdiction property — so a dropped token would otherwise
237
- // leave the count entirely unvalidated).
182
+ // A partial `--root` tree without global-frameworks.json skips the check. Where
183
+ // the source IS present the token is the SBOM's only jurisdiction assertion —
184
+ // no structured property carries it — so a dropped token fails CLOSED.
238
185
  if (liveJurisdictions !== null) {
239
186
  if (!jurisdictionMatch) {
240
187
  errors.push(
@@ -247,15 +194,9 @@ function checkSbomCurrency(root) {
247
194
  }
248
195
  }
249
196
 
250
- // Component-level cross-check (defense-in-depth). In normal operation
251
- // refresh-sbom emits NO per-skill "skill:" components — skill drift is caught
252
- // by the file:skills/<name>/skill.md and file:manifest.json content hashes in
253
- // the file: component pass below (a bumped or renamed skill changes those
254
- // bytes). This branch is therefore not exercised by a clean SBOM, but it is
255
- // retained as a tamper guard: a forged or buggy SBOM that injected a skill
256
- // component with a stale version (or a skill name no longer in the manifest)
257
- // is still caught here. Vendor components are validated against
258
- // vendor/blamejs/_PROVENANCE.json.
197
+ // A clean SBOM carries no "skill:" components — skill drift shows up in the
198
+ // file: hashes. This branch is the tamper guard for a forged SBOM that injects
199
+ // one with a stale version.
259
200
  const components = Array.isArray(sbom.components) ? sbom.components : [];
260
201
  const skillByName = new Map(
261
202
  (manifest.skills || []).map((s) => [s.name, s])
@@ -297,27 +238,17 @@ function checkSbomCurrency(root) {
297
238
  }
298
239
  }
299
240
 
300
- // v0.13.9: per-file SHA-256 integrity check. For every CycloneDX
301
- // component whose bom-ref begins with "file:", confirm the recorded
302
- // SHA-256 hash matches the live bytes on disk. Catches the class of
303
- // release-ordering bug where sbom.cdx.json was regenerated BEFORE the
304
- // final sign-all pass — the recorded manifest.json hash drifted from
305
- // the signed-and-committed bytes, but the count-based check above
306
- // could not see it. Codex P2 flag on PR #48 surfaced one instance;
307
- // this gate makes it unreachable.
241
+ // Every "file:" component's recorded hash must match the live bytes. This is
242
+ // what catches an SBOM regenerated BEFORE the final sign-all, where the
243
+ // recorded manifest.json hash drifts from the signed-and-committed bytes.
308
244
  let fileComponentsChecked = 0;
309
245
  const rootResolved = path.resolve(root);
310
246
  for (const comp of components) {
311
247
  const bomRef = typeof comp["bom-ref"] === "string" ? comp["bom-ref"] : "";
312
248
  if (!bomRef.startsWith("file:")) continue;
313
249
  const relPath = bomRef.slice("file:".length);
314
- // Codex P2 on PR #49: refuse bom-ref entries that escape the repo
315
- // root. The earlier implementation trusted `relPath` verbatim, so a
316
- // tampered or carelessly-edited sbom.cdx.json with `file:../outside`
317
- // would read + hash a path OUTSIDE the checkout — the gate would
318
- // either report "exists, hash matches" (silently weakening the
319
- // integrity guarantee) or "does not exist" without ever flagging the
320
- // attempted escape. Refuse early.
250
+ // A `file:../outside` bom-ref would otherwise hash a path outside the
251
+ // checkout and report "exists, hash matches" — never the escape.
321
252
  if (relPath.includes("..") || path.isAbsolute(relPath)) {
322
253
  errors.push(
323
254
  `SBOM file component "${relPath}" rejected: path must be repo-relative without ".." segments (path-traversal guard)`
@@ -325,9 +256,8 @@ function checkSbomCurrency(root) {
325
256
  continue;
326
257
  }
327
258
  const absPath = path.resolve(rootResolved, relPath);
328
- // Defense-in-depth: even if the textual check above passed, the
329
- // resolved path must still live under the root. Symlinks or future
330
- // changes to the textual filter would surface here.
259
+ // The resolved path must also land under root — a symlink, or a loosening of
260
+ // the textual filter above, surfaces here.
331
261
  const rel = path.relative(rootResolved, absPath);
332
262
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
333
263
  errors.push(
@@ -341,17 +271,8 @@ function checkSbomCurrency(root) {
341
271
  );
342
272
  continue;
343
273
  }
344
- // v0.13.12: verify SHA-256 AND SHA3-512 when present. SHA-256 is
345
- // the universal-tool contract (CycloneDX 1.6 default, Anchore /
346
- // Trivy / Dependency-Track / GitHub Dependency Graph). SHA3-512
347
- // is the SHA-3 family hedge, matching the existing key-fingerprint
348
- // pattern (lib/verify.js). Both must agree with the live bytes;
349
- // a mismatch on either fires the same drift error. A missing
350
- // SHA-256 is a hard error (the universal contract is the floor);
351
- // a missing SHA3-512 surfaces as a downgrade-attack warning so an
352
- // operator who intentionally strips the second hash from an
353
- // SBOM (post-quantum posture relaxation) sees it in the gate
354
- // output, not in the JSON downstream.
274
+ // SHA-256 is the universal-tool contract (CycloneDX 1.6 default, read by
275
+ // Anchore / Trivy / Dependency-Track); SHA3-512 is the SHA-3 family hedge.
355
276
  const sha256Entry = (comp.hashes || []).find((h) => h && h.alg === "SHA-256");
356
277
  const sha3Entry = (comp.hashes || []).find((h) => h && h.alg === "SHA3-512");
357
278
  if (!sha256Entry || typeof sha256Entry.content !== "string") {
@@ -367,12 +288,8 @@ function checkSbomCurrency(root) {
367
288
  `SBOM file component "${relPath}" SHA-256 drift: recorded ${sha256Entry.content.slice(0, 12)}…, live ${liveSha256.slice(0, 12)}… — re-sign skills (\`node $(exceptd path)/lib/sign.js sign-all\` from a contributor checkout) and then \`npm run refresh-sbom\`, in that order (sbom must regenerate AFTER the final sign).`,
368
289
  );
369
290
  }
370
- // Codex P1 on PR #52: the dual-hash contract requires SHA3-512 to be
371
- // PRESENT, not just verified when present. An attacker (or a careless
372
- // sbom-generator regression) that strips the SHA3-512 column would
373
- // silently pass the gate under an `if (sha3Entry)` guard, defeating
374
- // the downgrade defense the dual-hash design is supposed to provide.
375
- // Refuse absence as a hard error.
291
+ // Absence is a hard error, never an `if (sha3Entry)` guard: stripping the
292
+ // SHA3-512 column would otherwise pass and defeat the downgrade defense.
376
293
  if (!sha3Entry || typeof sha3Entry.content !== "string") {
377
294
  errors.push(
378
295
  `SBOM file component "${relPath}" lacks a SHA3-512 hash entry — the dual-hash contract (SHA-256 + SHA3-512) requires both algorithms on every file: component. Regenerate via \`npm run refresh-sbom\` (v0.13.12+).`
@@ -388,20 +305,12 @@ function checkSbomCurrency(root) {
388
305
  fileComponentsChecked++;
389
306
  }
390
307
 
391
- // Completeness + bundle-digest integrity. The per-file pass above verifies
392
- // every RECORDED file: component, but never checked that every SHIPPED file
393
- // (the package.json.files expansion) actually HAS a component — a
394
- // newly-shipped file would ship unhashed and silent. And the aggregate
395
- // bundle digest in metadata.component.hashes[] was never recomputed. Reuse
396
- // refresh-sbom's exact allowlist expansion + digest so the gate can't drift
397
- // from the generator.
308
+ // Completeness plus the aggregate bundle digest. The per-file pass verifies
309
+ // every RECORDED component, so without this a newly-shipped file carrying no
310
+ // component would go unhashed and silent.
398
311
  try {
399
- // bundleDigest operates purely on the file: component objects (no tree
400
- // walk), so it is root-agnostic and safe to reuse from the generator. The
401
- // allowlist expansion, by contrast, MUST run against the target `root`
402
- // (expandAllowlistAt below) — refresh-sbom's exported expandAllowlist is
403
- // pinned to its own source-repo REPO_ROOT and would validate the wrong tree
404
- // under `--root`.
312
+ // bundleDigest reads only the file: component objects, so it is
313
+ // root-agnostic; the allowlist expansion is not, hence expandAllowlistAt.
405
314
  const { bundleDigest } = require("./refresh-sbom");
406
315
  const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
407
316
  const expected = expandAllowlistAt(pkg.files || [], root);
@@ -416,16 +325,11 @@ function checkSbomCurrency(root) {
416
325
  );
417
326
  }
418
327
  }
419
- // Recompute the aggregate bundle digest from the file: components' recorded
420
- // SHA-256 hashes and compare to metadata.component.hashes[] (the per-file
421
- // pass already tied each recorded hash to live bytes).
328
+ // Recomputed from the recorded per-file SHA-256s, already tied to live bytes.
422
329
  const compHashes = (sbom.metadata && sbom.metadata.component && sbom.metadata.component.hashes) || [];
423
330
  const recorded = (compHashes.find((h) => h && h.alg === "SHA-256") || {}).content;
424
331
  if (fileComps.length) {
425
- // A missing aggregate digest is NOT benign: it is the bundle-as-a-whole
426
- // verification anchor. An absent one must fail the gate (symmetric with
427
- // the per-file SHA3-512 hard-error that defeats downgrade/strip attacks) —
428
- // previously an absent digest silently skipped the comparison entirely.
332
+ // The bundle-as-a-whole anchor: absence fails rather than skipping.
429
333
  if (!recorded) {
430
334
  errors.push(
431
335
  "SBOM metadata.component.hashes lacks a SHA-256 bundle digest — the aggregate verification anchor is missing; run `npm run refresh-sbom`"
@@ -459,9 +363,7 @@ function main() {
459
363
  if (!result.ok) {
460
364
  for (const e of result.errors) process.stderr.write(e + "\n");
461
365
  process.stderr.write("Run `npm run refresh-sbom` to regenerate sbom.cdx.json.\n");
462
- // v0.11.13 pattern: set exitCode + return so buffered stdout/stderr
463
- // writes drain before the event loop exits. process.exit() can
464
- // truncate piped output (CI log capture, JSON consumers).
366
+ // process.exitCode, not process.exit() — the exit can truncate a piped write.
465
367
  process.exitCode = 1;
466
368
  return;
467
369
  }
@@ -2,41 +2,15 @@
2
2
  'use strict';
3
3
 
4
4
  /**
5
- * scripts/check-test-count.js — v0.13.2 canonical-test-count predeploy gate.
5
+ * Canonical-test-count predeploy gate: catches test-set shrinkage the lint and
6
+ * diff-coverage gates cannot see.
6
7
  *
7
- * Why this exists. The v0.12 audit flagged that nothing in the suite asserts
8
- * "we expect N tests today." A deleted test file, a removed `test(` call, or a
9
- * misnamed file glob-excluded would all silently drop tests without anyone
10
- * noticing. The lint + diff-coverage gates catch source changes; this gate
11
- * catches test-set shrinkage.
8
+ * Counts DECLARATIONS statically across `tests/*.test.js` — `test(` and `it(`
9
+ * with their `.only` / `.skip` variants. `describe(` is NOT counted: a container
10
+ * is not a test. Blind spot: a test neutered in place still counts as one.
12
11
  *
13
- * Scope + blind spot. This counts test DECLARATIONS, so it detects deleted
14
- * files / removed `test(`/`it(` calls / glob-exclusions. It does NOT detect a
15
- * test neutered in place: `test('name', { skip: true }, fn)`, `test.skip(`,
16
- * and `it.skip(` all still count as one declaration, so flipping a running
17
- * test to permanently skipped leaves the count unchanged. Guarding against
18
- * skip-in-place would need runnable-vs-skipped tracking; that is out of scope
19
- * for this gate.
20
- *
21
- * Mechanism: count `test(`/`it(` declarations (including the `.only`/`.skip`
22
- * variants) across `tests/*.test.js` via static analysis (faster than
23
- * running). node:test supports both `test()` and the BDD-style `it()`/
24
- * `describe()` aliases as first-class declarations, and the suite mixes both,
25
- * so counting only `test(` was blind to every `it(` test. `describe(` is NOT
26
- * counted — those are containers, not tests, so counting them would inflate
27
- * the total and conflate removing a grouping block with removing a test.
28
- * Compare to a baseline pinned in `tests/.test-count-baseline.json`. Fail if
29
- * the observed count drops MORE than the configured tolerance (default 1)
30
- * below the baseline. Growth above baseline is fine; if the count grows by
31
- * more than `update_baseline_when_growth_exceeds`, surface a notice that the
32
- * baseline file should be refreshed (operator commits the refresh as part
33
- * of the release that added the tests).
34
- *
35
- * Output:
36
- * stdout: structured JSON when --json, else a one-line summary
37
- * exit 0: observed count is at or above baseline minus tolerance
38
- * exit 1: observed count dropped beyond tolerance — fail predeploy
39
- * exit 2: baseline file missing or malformed
12
+ * exit 0 at or above baseline minus tolerance, 1 when it drops further, 2 when
13
+ * the baseline file is missing or malformed.
40
14
  */
41
15
 
42
16
  const fs = require('fs');
@@ -63,16 +37,13 @@ function listTestFiles(dir) {
63
37
 
64
38
  function countTests(filePath) {
65
39
  let text = fs.readFileSync(filePath, 'utf8');
66
- // Strip block comments first so a `/* test('x'); */`-disabled test is not
67
- // counted — the most common "temporarily disable" action, which previously
68
- // defeated this gate's entire purpose (it only stripped whole-line `//`).
40
+ // Strip block comments first: commenting a test out is the usual way to
41
+ // disable one, and counting it anyway defeats the gate.
69
42
  text = text.replace(/\/\*[\s\S]*?\*\//g, '');
70
43
  let count = 0;
71
44
  for (const rawLine of text.split('\n')) {
72
- // Blank out single-line string/template literal BODIES first so a `test(`
73
- // mentioned inside a string (e.g. an assertion on output text, or this very
74
- // file's docstring examples) is not miscounted as a declaration — that
75
- // phantom inflated the baseline and could mask a real test deletion.
45
+ // Blank string and template bodies first, so a `test(` inside a string
46
+ // literal is not read as a declaration — a phantom inflates the baseline.
76
47
  const noStrings = rawLine.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`/g, "''");
77
48
  // Drop a trailing line comment too (`test('x'); // disabled`).
78
49
  const stripped = noStrings.replace(/\/\/.*$/, '').trim();
@@ -86,11 +57,8 @@ function main() {
86
57
  const wantJson = process.argv.includes('--json');
87
58
  const wantUpdate = process.argv.includes('--update-baseline');
88
59
 
89
- // Read the baseline once and branch on the read RESULT, not on a prior
90
- // existsSync probe. A separate existsSync(BASELINE_PATH)-then-read opens a
91
- // check-then-use window (CodeQL js/file-system-race) where the file the
92
- // gate decides about is not the file it later reads. ENOENT from the single
93
- // read IS the "missing" signal — no second path access needed.
60
+ // Branch on the read RESULT, never on a prior existsSync probe: ENOENT from
61
+ // this single read IS the "missing" signal.
94
62
  let baselineRaw = null;
95
63
  try {
96
64
  baselineRaw = fs.readFileSync(BASELINE_PATH, 'utf8');
@@ -99,13 +67,10 @@ function main() {
99
67
  console.error(`[check-test-count] cannot read baseline: ${e.message}`);
100
68
  process.exit(2);
101
69
  }
102
- // ENOENT — baseline absent.
103
70
  if (wantUpdate) {
104
71
  const files = listTestFiles(TESTS_DIR);
105
72
  const observed = files.reduce((n, f) => n + countTests(f), 0);
106
- // Exclusive create ('wx'): fails with EEXIST if the file appeared
107
- // between the read above and this write, so we never clobber a baseline
108
- // a concurrent run just produced — atomic, no check-then-write window.
73
+ // Exclusive create: EEXIST rather than clobbering a concurrent run's baseline.
109
74
  fs.writeFileSync(BASELINE_PATH, JSON.stringify({
110
75
  baseline: observed,
111
76
  tolerance: 1,
@@ -173,9 +138,7 @@ function main() {
173
138
  console.error(`[check-test-count] FAIL - test count dropped from ${baseline} to ${observed} (delta ${delta}, tolerance -${tolerance}).`);
174
139
  console.error('[check-test-count] Either a test file was accidentally removed, a test()/it() invocation was deleted, OR the baseline is stale.');
175
140
  console.error('[check-test-count] If the drop is intentional, run: node scripts/check-test-count.js --update-baseline');
176
- // exitCode + return (not process.exit) so the buffered stdout write above
177
- // (the --json result / one-line summary) drains before the event loop ends
178
- // — process.exit() can truncate piped output.
141
+ // `process.exitCode`, not `process.exit()`: the buffered stdout write must drain.
179
142
  process.exitCode = 1;
180
143
  return;
181
144
  }