@blamejs/exceptd-skills 0.18.6 → 0.18.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 (63) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/bin/exceptd.js +364 -119
  3. package/data/_indexes/_meta.json +22 -3
  4. package/data/cve-catalog.json +25 -0
  5. package/data/playbooks/framework.json +2 -2
  6. package/data/playbooks/post-quantum-migration.json +1 -1
  7. package/lib/auto-discovery.js +30 -10
  8. package/lib/collectors/ai-api.js +9 -2
  9. package/lib/collectors/cicd-pipeline-compromise.js +24 -5
  10. package/lib/collectors/cred-stores.js +17 -4
  11. package/lib/collectors/crypto.js +9 -2
  12. package/lib/collectors/hardening.js +9 -2
  13. package/lib/collectors/library-author.js +29 -5
  14. package/lib/collectors/mcp.js +9 -2
  15. package/lib/collectors/runtime.js +9 -2
  16. package/lib/collectors/sbom.js +28 -15
  17. package/lib/collectors/scan-excludes.js +25 -0
  18. package/lib/collectors/secrets.js +40 -4
  19. package/lib/cve-curation.js +84 -8
  20. package/lib/lint-skills.js +75 -3
  21. package/lib/playbook-runner.js +443 -50
  22. package/lib/prefetch.js +22 -2
  23. package/lib/refresh-external.js +32 -1
  24. package/lib/refresh-network.js +235 -26
  25. package/lib/schemas/cve-catalog.schema.json +5 -0
  26. package/lib/scoring.js +141 -21
  27. package/lib/sign.js +107 -29
  28. package/lib/source-advisories.js +23 -5
  29. package/lib/source-ghsa.js +25 -1
  30. package/lib/source-osv.js +26 -1
  31. package/lib/upstream-check.js +1 -1
  32. package/lib/validate-cve-catalog.js +30 -4
  33. package/lib/validate-indexes.js +135 -29
  34. package/lib/validate-playbooks.js +19 -7
  35. package/lib/validate-vendor.js +69 -8
  36. package/lib/verify.js +23 -6
  37. package/manifest.json +53 -53
  38. package/orchestrator/dispatcher.js +14 -3
  39. package/orchestrator/index.js +100 -20
  40. package/orchestrator/scanner.js +8 -0
  41. package/package.json +1 -1
  42. package/sbom.cdx.json +130 -130
  43. package/scripts/audit-cross-skill.js +1 -1
  44. package/scripts/bootstrap.js +1 -0
  45. package/scripts/build-indexes.js +84 -11
  46. package/scripts/check-agents-md-collectors.js +41 -13
  47. package/scripts/check-changelog-extract.js +4 -4
  48. package/scripts/check-codebase-patterns.js +19 -5
  49. package/scripts/check-manifest-snapshot.js +74 -30
  50. package/scripts/check-sbom-currency.js +25 -5
  51. package/scripts/check-test-count.js +26 -7
  52. package/scripts/check-test-coverage.js +44 -4
  53. package/scripts/check-version-tags.js +27 -8
  54. package/scripts/predeploy.js +1 -1
  55. package/scripts/refresh-manifest-snapshot.js +14 -4
  56. package/scripts/refresh-reverse-refs.js +7 -1
  57. package/scripts/refresh-sbom.js +1 -1
  58. package/scripts/release.js +3 -3
  59. package/scripts/run-e2e-scenarios.js +18 -8
  60. package/scripts/validate-vendor-online.js +28 -2
  61. package/scripts/verify-shipped-tarball.js +65 -6
  62. package/sources/validators/cve-validator.js +17 -1
  63. package/vendor/blamejs/_PROVENANCE.json +4 -2
@@ -263,4 +263,4 @@ if (issues.length === 0) {
263
263
  } else {
264
264
  for (const i of issues) console.log(" • " + i);
265
265
  }
266
- process.exit(issues.length === 0 ? 0 : 1);
266
+ process.exit(issues.length === 0 ? 0 : 1); // allow:process-exit-after-stdout-write — top-level local audit script; the issue list above is human-read on a TTY, not a piped --json result channel
@@ -121,6 +121,7 @@ function run(label, scriptPath, scriptArgs) {
121
121
  });
122
122
  } catch (err) {
123
123
  console.error(`[bootstrap] FAILED at step "${label}".`);
124
+ // allow:process-exit-after-stdout-write — local first-run setup script; the step banner above is human-read on a TTY (stdio:'inherit'), not a piped --json result channel
124
125
  process.exit(err.status && Number.isInteger(err.status) ? err.status : 1);
125
126
  }
126
127
  }
@@ -69,7 +69,14 @@ function sha256(buf) {
69
69
  }
70
70
 
71
71
  function writeJson(name, obj) {
72
- fs.writeFileSync(path.join(IDX, name), JSON.stringify(obj, null, 2) + "\n", "utf8");
72
+ // Atomic write: a crash / disk-full / SIGKILL mid-write would otherwise leave
73
+ // a truncated JSON output on disk. Write to a temp sibling and rename — rename
74
+ // is atomic on POSIX and Windows, so a reader (or the next build's
75
+ // parse-check) only ever sees the complete old or complete new file.
76
+ const abs = path.join(IDX, name);
77
+ const tmp = `${abs}.tmp-${process.pid}`;
78
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", "utf8");
79
+ fs.renameSync(tmp, abs);
73
80
  }
74
81
 
75
82
  function readJson(p) {
@@ -565,6 +572,25 @@ function outputsAffectedBy(changedPaths) {
565
572
  return affected;
566
573
  }
567
574
 
575
+ // An output's absence/corruption is itself a rebuild trigger. --changed keys
576
+ // on source-hash deltas, but a derived file that was deleted, truncated, or
577
+ // left unparseable must be regenerated even when every source is byte-identical
578
+ // — otherwise the no-op path leaves a broken tree while writeMeta records it as
579
+ // fresh (and reports a 0 count for the file it never rebuilt). Mirrors the
580
+ // existence/parse check in lib/validate-indexes.js verifyOutputs.
581
+ function outputsMissingOrCorrupt() {
582
+ const broken = new Set();
583
+ for (const o of OUTPUTS) {
584
+ const p = path.join(IDX, o.file);
585
+ let ok = fs.existsSync(p);
586
+ if (ok) {
587
+ try { readJson(p); } catch { ok = false; }
588
+ }
589
+ if (!ok) broken.add(o.name);
590
+ }
591
+ return broken;
592
+ }
593
+
568
594
  function withDependencyClosure(names) {
569
595
  // Pull in any dependsOn entries (e.g. token-budget needs section-offsets).
570
596
  const closure = new Set(names);
@@ -643,10 +669,41 @@ async function runBuilders(ctx, names, opts) {
643
669
  return results;
644
670
  }
645
671
 
646
- function writeMeta(ctx, results) {
672
+ function writeMeta(ctx, results, opts = {}) {
673
+ const prior = loadPriorMeta();
647
674
  const sourceFiles = [...liveSourceSet(ctx)];
648
- const sourceHashes = {};
649
- for (const p of sourceFiles) sourceHashes[p] = sha256(fs.readFileSync(ABS(p)));
675
+ const computedSourceHashes = {};
676
+ for (const p of sourceFiles) computedSourceHashes[p] = sha256(fs.readFileSync(ABS(p)));
677
+
678
+ // A `--only` build regenerates an ARBITRARY subset of outputs (not the set
679
+ // implied by source changes), so the un-rebuilt outputs are not guaranteed to
680
+ // be consistent with the current sources. Preserve the prior source_hashes in
681
+ // that case, so validate-indexes still detects drift and demands a full
682
+ // rebuild — overwriting them with current hashes would fail OPEN, recording
683
+ // stale outputs as fresh. (--changed rebuilds exactly the change-affected
684
+ // outputs and a full build rebuilds all, so their current hashes are accurate.)
685
+ const partial = !!(opts && opts.only);
686
+ const sourceHashes = (partial && prior && prior.source_hashes && typeof prior.source_hashes === "object")
687
+ ? prior.source_hashes
688
+ : computedSourceHashes;
689
+
690
+ const outputsList = OUTPUTS.map((o) => o.file).sort();
691
+
692
+ // Determinism: preserve the prior `generated_at` when the inputs are
693
+ // byte-identical to the last build (same source hashes AND same output set).
694
+ // validate-indexes keys freshness on `source_hashes`/`outputs`, never on the
695
+ // timestamp, so re-stamping a no-op run with a fresh wall-clock value adds
696
+ // no freshness signal and only produces a spurious git diff — contradicting
697
+ // the documented "identical inputs always produce identical outputs"
698
+ // contract for --changed (and the idempotence the predeploy gate relies on).
699
+ // Reuse the prior timestamp on a genuine no-op; mint a new one only when the
700
+ // hashed surface actually moved (or there is no prior meta to inherit from).
701
+ const sourcesUnchanged = prior && prior.source_hashes &&
702
+ JSON.stringify(prior.source_hashes) === JSON.stringify(sourceHashes) &&
703
+ JSON.stringify(prior.outputs || []) === JSON.stringify(outputsList);
704
+ const generatedAt = sourcesUnchanged && typeof prior.generated_at === "string"
705
+ ? prior.generated_at
706
+ : new Date().toISOString();
650
707
 
651
708
  // Stats are computed from in-memory results when available, else from disk
652
709
  // (covers --only / --changed runs that didn't rebuild every output).
@@ -683,12 +740,17 @@ function writeMeta(ctx, results) {
683
740
 
684
741
  const meta = {
685
742
  schema_version: "1.1.0",
686
- generated_at: new Date().toISOString(),
743
+ generated_at: generatedAt,
687
744
  generator: "scripts/build-indexes.js",
688
- source_count: sourceFiles.length,
745
+ source_count: Object.keys(sourceHashes).length,
689
746
  source_hashes: sourceHashes,
690
747
  skill_count: ctx.skills.length,
691
748
  catalog_count: ctx.catalogFiles.length,
749
+ // The derived index files this build produces. validate-indexes confirms
750
+ // every one still exists and parses — source hashes alone do not detect a
751
+ // deleted/truncated/corrupted OUTPUT (an index file removed from a clean
752
+ // source tree would otherwise pass the freshness gate as "current").
753
+ outputs: outputsList,
692
754
  index_stats: {
693
755
  xref_entries: xrefStats,
694
756
  trigger_table_entries: Object.keys(trigger).length,
@@ -742,12 +804,23 @@ async function main() {
742
804
  const changed = changedSources(ctx, prior);
743
805
  log(`changed sources: ${changed.length}`);
744
806
  const affected = outputsAffectedBy(changed);
807
+ // Union in any output that no longer exists on disk or fails to parse.
808
+ // A deleted/corrupt output is a rebuild trigger independent of source
809
+ // hashes — without this, --changed reports "nothing to do" and writeMeta
810
+ // would claim freshness for a tree that's actually broken.
811
+ const broken = outputsMissingOrCorrupt();
812
+ for (const name of broken) affected.add(name);
813
+ if (broken.size > 0) log(`missing/corrupt outputs to regenerate: ${[...broken].sort().join(", ")}`);
745
814
  chosen = withDependencyClosure(affected);
746
815
  if (chosen.size === 0) {
747
816
  log("build-indexes: no outputs need rebuilding (sources unchanged)");
748
- // Still rewrite _meta.json with the same hashes — preserves freshness
749
- // semantics for the predeploy gate even when nothing else changed.
750
- writeMeta(ctx, {});
817
+ // Rewrite _meta.json so its hash table + outputs list self-heal against
818
+ // the live source set (e.g. an old-format _meta that predates a field).
819
+ // writeMeta preserves the prior `generated_at` when the hashed surface is
820
+ // byte-identical, so a genuine no-op leaves the file unchanged — no
821
+ // spurious timestamp-only diff. Re-stamping would add no freshness signal
822
+ // (validate-indexes keys on source_hashes/outputs, not the timestamp).
823
+ writeMeta(ctx, {}, opts);
751
824
  return;
752
825
  }
753
826
  } else {
@@ -757,7 +830,7 @@ async function main() {
757
830
  log(`build-indexes — ${chosen.size} output(s) ${opts.parallel ? "in parallel" : "sequential"}${opts.changed ? " (--changed)" : ""}${opts.only ? ` (--only ${opts.only})` : ""}`);
758
831
 
759
832
  const results = await runBuilders(ctx, chosen, opts);
760
- writeMeta(ctx, results);
833
+ writeMeta(ctx, results, opts);
761
834
 
762
835
  log(`build-indexes — done`);
763
836
  }
@@ -769,4 +842,4 @@ if (require.main === module) {
769
842
  });
770
843
  }
771
844
 
772
- module.exports = { OUTPUTS, loadSources, runBuilders, writeMeta };
845
+ module.exports = { OUTPUTS, loadSources, runBuilders, writeMeta, outputsMissingOrCorrupt };
@@ -54,25 +54,53 @@ function main() {
54
54
  return;
55
55
  }
56
56
 
57
- let collectorFiles;
57
+ let jsFiles;
58
58
  try {
59
- collectorFiles = fs.readdirSync(COLLECTOR_DIR)
60
- .filter(f => f.endsWith(".js"))
61
- // A collector is a module exporting a collect() function. Shared
62
- // helpers under lib/collectors/ (e.g. scan-excludes.js, the directory-
63
- // walk exclusion policy) are not collectors and must not inflate the
64
- // count or be required in the AGENTS.md enumeration.
65
- .filter(f => {
66
- try { return typeof require(path.join(COLLECTOR_DIR, f)).collect === "function"; }
67
- catch { return false; }
68
- })
69
- .map(f => `lib/collectors/${f}`)
70
- .sort();
59
+ jsFiles = fs.readdirSync(COLLECTOR_DIR).filter(f => f.endsWith(".js")).sort();
71
60
  } catch (e) {
72
61
  console.error(`[check-agents-md-collectors] cannot read ${COLLECTOR_DIR}: ${e.message}`);
73
62
  process.exitCode = 2;
74
63
  return;
75
64
  }
65
+
66
+ // Classify every lib/collectors/*.js into exactly one of three buckets:
67
+ // - collector: require() succeeds AND exports a collect() function
68
+ // (counted; must appear in the AGENTS.md enumeration).
69
+ // - helper: require() succeeds but exports no collect() function
70
+ // (e.g. scan-excludes.js, the directory-walk exclusion
71
+ // policy) — legitimately excluded from the count.
72
+ // - load-error: require() THROWS (syntax error, bad top-level require,
73
+ // init-time exception). A broken collector must NOT be
74
+ // silently dropped: doing so excludes it from BOTH the
75
+ // count and the enumeration cross-check, so a file that
76
+ // still ships in the tarball passes the gate undetected.
77
+ // Surface it as a parse error (exit 2) naming the file.
78
+ const collectorFiles = [];
79
+ const loadErrors = [];
80
+ for (const f of jsFiles) {
81
+ let mod;
82
+ try {
83
+ mod = require(path.join(COLLECTOR_DIR, f));
84
+ } catch (e) {
85
+ loadErrors.push(`lib/collectors/${f}: ${e.message.split("\n")[0]}`);
86
+ continue;
87
+ }
88
+ if (typeof mod.collect === "function") {
89
+ collectorFiles.push(`lib/collectors/${f}`);
90
+ }
91
+ }
92
+ collectorFiles.sort();
93
+
94
+ if (loadErrors.length > 0) {
95
+ console.error(
96
+ `[check-agents-md-collectors] cannot load ${loadErrors.length} module(s) in lib/collectors/ ` +
97
+ `- a require-time failure must not be silently excluded from the count + enumeration check:\n ` +
98
+ loadErrors.join("\n ")
99
+ );
100
+ process.exitCode = 2;
101
+ return;
102
+ }
103
+
76
104
  const onDiskCount = collectorFiles.length;
77
105
 
78
106
  const para = agents.match(/(\b[A-Z][a-z]+)\s+reference collectors ship today\s*\(([^)]+)\)/);
@@ -47,7 +47,7 @@ function extractSection(text, version) {
47
47
  const lines = text.split(/\r?\n/);
48
48
  const out = [];
49
49
  let capturing = false;
50
- const startRe = new RegExp('^## ' + version.replace(/\./g, '\\.') + ' ');
50
+ const startRe = new RegExp('^## ' + version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ' ');
51
51
  for (const ln of lines) {
52
52
  if (capturing) {
53
53
  if (/^## /.test(ln)) break;
@@ -65,7 +65,7 @@ function extractSection(text, version) {
65
65
 
66
66
  // Returns the `## <version> — <date>` heading line for the version, or null.
67
67
  function headingLine(text, version) {
68
- const re = new RegExp('^## ' + version.replace(/\./g, '\\.') + ' ');
68
+ const re = new RegExp('^## ' + version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ' ');
69
69
  return text.split(/\r?\n/).find((l) => re.test(l)) || null;
70
70
  }
71
71
 
@@ -82,7 +82,7 @@ const FORBIDDEN = [
82
82
  { id: 'agent-dispatch', re: /\b(?:sub-?agent|parallel agent|agent dispatch|fan(?:ned)?[ -]out|multi-agent)\b/i, why: 'implementation detail (agent/parallelization)' },
83
83
  { id: 'conversation-residue', re: /\b(?:as discussed|per your|operator-confirmed|as you (?:noted|requested)|per the conversation|PR feedback:)\b/i, why: 'conversation residue (invisible to the reader)' },
84
84
  { id: 'process-narrative', re: /\b(?:audit-derived|post-phase-\d|as part of the \d|the \d+-gap closure)\b/i, why: 'internal-process narrative' },
85
- { id: 'tautological-green', re: /\b(?:all tests (?:pass|passing|green)|CI green|smoke \+ e2e (?:clean|pass)|tests? (?:are )?passing)\b/i, why: 'tautological pass/green claim (noise — the release exists)' },
85
+ { id: 'tautological-green', re: /\b(?:all(?:\s+\d+)?\s+(?:tests?|checks?|gates?|suites?)\s+(?:pass(?:ing)?|green)|(?:the\s+)?(?:whole|entire|full)\s+(?:test\s+)?suite\s+(?:is\s+)?(?:pass(?:ing|ed|es)?|green)|every\s+(?:test|check|gate)\s+(?:pass(?:es|ing|ed)?|is\s+green)|\d+\s*\/\s*\d+\s+(?:tests?|gates?|checks?)\s+(?:pass(?:ing|es|ed)?|green)|CI green|smoke \+ e2e (?:clean|pass)|tests? (?:are )?passing)\b/i, why: 'tautological pass/green claim (noise — the release exists). Covers the numbered ("all 288 tests green"), count ("288/288 tests pass", "21/21 gates pass"), and synonym ("full suite green", "every check passes") forms of the same banned claim.' },
86
86
  ];
87
87
 
88
88
  function lintOperatorClean(sectionLines) {
@@ -152,7 +152,7 @@ function main() {
152
152
  return;
153
153
  }
154
154
  // Heading must carry an ISO date: `## <version> — YYYY-MM-DD`.
155
- if (!new RegExp('^## ' + version.replace(/\./g, '\\.') + ' [—-] \\d{4}-\\d{2}-\\d{2}\\s*$').test(heading)) {
155
+ if (!new RegExp('^## ' + version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ' [—-] \\d{4}-\\d{2}-\\d{2}\\s*$').test(heading)) {
156
156
  console.error('[check-changelog-extract] FAIL: heading does not match `## ' + version + ' — YYYY-MM-DD`:');
157
157
  console.error('[check-changelog-extract] got: ' + JSON.stringify(heading));
158
158
  process.exitCode = 1;
@@ -104,11 +104,25 @@ function readLines(rel) {
104
104
  }
105
105
 
106
106
  // Strip a trailing `//` line comment for code-shape detection (so a class
107
- // name mentioned in a comment doesn't arm a detector). Leaves string contents
108
- // alone enough for the coarse line-level checks here.
107
+ // name mentioned in a comment doesn't arm a detector). String-aware: a `//`
108
+ // inside a quoted string (e.g. a `http://` URL) is NOT a comment, so the
109
+ // scanner skips string contents — otherwise the rest of the line, including a
110
+ // real `process.exit(...)` / `new RegExp(...)`, was silently truncated away and
111
+ // the detector never fired.
109
112
  function stripLineComment(line) {
110
- const idx = line.indexOf("//");
111
- return idx === -1 ? line : line.slice(0, idx);
113
+ let inStr = null; // active quote char, or null
114
+ for (let i = 0; i < line.length; i++) {
115
+ const ch = line[i];
116
+ if (inStr) {
117
+ if (ch === "\\") { i++; continue; } // skip the escaped char
118
+ if (ch === inStr) inStr = null;
119
+ } else if (ch === "'" || ch === '"' || ch === "`") {
120
+ inStr = ch;
121
+ } else if (ch === "/" && line[i + 1] === "/") {
122
+ return line.slice(0, i);
123
+ }
124
+ }
125
+ return line;
112
126
  }
113
127
 
114
128
  // ---- allow-marker engine -------------------------------------------------
@@ -172,7 +186,7 @@ const FUNCTION_START = /(^|[^.\w])function\b|=>\s*\{?\s*$|^\s*(async\s+)?[A-Za-z
172
186
 
173
187
  function detectProcessExitAfterStdout(files) {
174
188
  const hits = [];
175
- for (const rel of (files || filesUnder(["lib", "orchestrator"]))) {
189
+ for (const rel of (files || filesUnder(["bin/exceptd.js", "lib", "orchestrator", "scripts"]))) {
176
190
  const lines = readLines(rel);
177
191
  const mainRanges = requireMainRanges(lines);
178
192
  for (let i = 0; i < lines.length; i++) {
@@ -37,7 +37,6 @@ const crypto = require("crypto");
37
37
  const ROOT = path.join(__dirname, "..");
38
38
  const MANIFEST_PATH = path.join(ROOT, "manifest.json");
39
39
  const SNAPSHOT_PATH = path.join(ROOT, "manifest-snapshot.json");
40
- const SNAPSHOT_SHA_PATH = path.join(ROOT, "manifest-snapshot.sha256");
41
40
 
42
41
  function captureSurface(manifest) {
43
42
  // Public surface = the set of facts downstream consumers may have
@@ -171,7 +170,71 @@ function formatDiff(result) {
171
170
  return lines.join("\n");
172
171
  }
173
172
 
174
- module.exports = { captureSurface, diff, formatDiff };
173
+ /**
174
+ * Verify the on-disk snapshot still hashes to the value recorded in its
175
+ * .sha256 sidecar. The sidecar is the only thing that catches a hand-edit
176
+ * of manifest-snapshot.json that bypassed refresh-manifest-snapshot.js
177
+ * (the surface diff alone is defeated by editing manifest.json AND the
178
+ * baseline in lockstep — e.g. to hide a removed skill/trigger). The
179
+ * sidecar pins the baseline's exact bytes so that lockstep edit no longer
180
+ * produces a matching pair.
181
+ *
182
+ * Pairing invariant: refresh-manifest-snapshot.js always writes BOTH the
183
+ * snapshot and its sidecar, and package.json `files` ships them together.
184
+ * So a snapshot present WITHOUT its sidecar is not a benign legacy state
185
+ * for any tree the current gate runs against (CI/predeploy run on the
186
+ * committed tree, where both are present; the shipped tarball ships both).
187
+ * An absent sidecar next to a present snapshot is the integrity-evasion
188
+ * shape — treat it as a failure, symmetric with the present-but-mismatch
189
+ * failure. Returns { ok, error } so callers can surface a hard exit.
190
+ *
191
+ * @param {string} root repo root containing the snapshot + sidecar
192
+ * @returns {{ok: boolean, error: (string|null)}}
193
+ */
194
+ function checkSnapshotIntegrity(root) {
195
+ const snapshotPath = path.join(root, "manifest-snapshot.json");
196
+ const shaPath = path.join(root, "manifest-snapshot.sha256");
197
+
198
+ if (!fs.existsSync(snapshotPath)) {
199
+ // No snapshot at all — the caller's baseline-read handles this as a
200
+ // distinct error. Nothing for the integrity check to anchor against.
201
+ return { ok: true, error: null };
202
+ }
203
+
204
+ if (!fs.existsSync(shaPath)) {
205
+ return {
206
+ ok: false,
207
+ error:
208
+ "manifest-snapshot.sha256 missing while manifest-snapshot.json is present — " +
209
+ "the integrity sidecar that detects a hand-edited baseline is gone. " +
210
+ "The two ship as a pair (package.json `files`) and refresh-manifest-snapshot.js " +
211
+ "always writes both; an absent sidecar next to a present snapshot is the " +
212
+ "integrity-evasion shape, not a benign state. " +
213
+ "Re-run `node scripts/refresh-manifest-snapshot.js --commit-only` to regenerate it.",
214
+ };
215
+ }
216
+
217
+ const expectedLine = fs.readFileSync(shaPath, "utf8").trim();
218
+ const expectedSha = expectedLine.split(/\s+/)[0];
219
+ const liveSha = crypto
220
+ .createHash("sha256")
221
+ .update(fs.readFileSync(snapshotPath))
222
+ .digest("hex");
223
+ if (expectedSha !== liveSha) {
224
+ return {
225
+ ok: false,
226
+ error:
227
+ `manifest-snapshot.json integrity check FAILED ` +
228
+ `(expected ${expectedSha.slice(0, 12)}…, live ${liveSha.slice(0, 12)}…). ` +
229
+ "Someone edited manifest-snapshot.json without running refresh-manifest-snapshot.js. " +
230
+ "Re-run `node scripts/refresh-manifest-snapshot.js --commit-only` to regenerate.",
231
+ };
232
+ }
233
+
234
+ return { ok: true, error: null };
235
+ }
236
+
237
+ module.exports = { captureSurface, diff, formatDiff, checkSnapshotIntegrity };
175
238
 
176
239
  if (require.main === module) {
177
240
  try {
@@ -190,34 +253,15 @@ if (require.main === module) {
190
253
  process.exit(2);
191
254
  }
192
255
 
193
- // when manifest-snapshot.sha256 is present, validate that
194
- // the on-disk snapshot still hashes to the recorded value. Catches a
195
- // hand-edit of manifest-snapshot.json that bypassed refresh-manifest-
196
- // snapshot.js (so the F5 commit-only guard never had a chance to fire).
197
- // The file is OPTIONAL: when absent, the gate warns-and-continues so
198
- // pre-v0.12.14 trees still work.
199
- if (fs.existsSync(SNAPSHOT_SHA_PATH)) {
200
- const expectedLine = fs.readFileSync(SNAPSHOT_SHA_PATH, "utf8").trim();
201
- const expectedSha = expectedLine.split(/\s+/)[0];
202
- const liveSha = crypto
203
- .createHash("sha256")
204
- .update(fs.readFileSync(SNAPSHOT_PATH))
205
- .digest("hex");
206
- if (expectedSha !== liveSha) {
207
- console.error(
208
- "[check-manifest-snapshot] manifest-snapshot.json integrity check FAILED " +
209
- `(expected ${expectedSha.slice(0, 12)}…, live ${liveSha.slice(0, 12)}…). ` +
210
- "Someone edited manifest-snapshot.json without running refresh-manifest-snapshot.js. " +
211
- "Re-run `node scripts/refresh-manifest-snapshot.js --commit-only` to regenerate."
212
- );
213
- process.exit(1);
214
- }
215
- } else {
216
- console.warn(
217
- "[check-manifest-snapshot] WARN: manifest-snapshot.sha256 missing — " +
218
- "integrity check skipped. Run `node scripts/refresh-manifest-snapshot.js --commit-only` " +
219
- "to generate it."
220
- );
256
+ // Integrity gate: the snapshot must hash to its recorded sidecar value,
257
+ // and the sidecar must be present whenever the snapshot is. A missing
258
+ // sidecar next to a present snapshot is itself a failure — it is the
259
+ // only thing that would have caught a lockstep hand-edit of manifest.json
260
+ // + the baseline, so allowing it to be deleted re-opens that bypass.
261
+ const integrity = checkSnapshotIntegrity(ROOT);
262
+ if (!integrity.ok) {
263
+ console.error("[check-manifest-snapshot] " + integrity.error);
264
+ process.exit(1);
221
265
  }
222
266
 
223
267
  const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8"));
@@ -141,7 +141,14 @@ function checkSbomCurrency(root) {
141
141
  // and the skill count were. Pin them to the live values so a stale
142
142
  // description (e.g. after an auto-refresh changed a count) fails the gate.
143
143
  const catalogMatch = description.match(/(\d+)\s+catalogs?\b/i);
144
- if (catalogMatch && Number(catalogMatch[1]) !== liveCatalogs) {
144
+ if (!catalogMatch) {
145
+ // Symmetric with the entry/skill tokens: absence fails CLOSED. A reworded
146
+ // description (or an auto-refresh that dropped the token) must not silently
147
+ // skip the count check — that is the asymmetric-absent fail-open class.
148
+ errors.push(
149
+ "SBOM description is missing the catalog-count token (N catalogs) — regenerate via `npm run refresh-sbom`"
150
+ );
151
+ } else if (Number(catalogMatch[1]) !== liveCatalogs) {
145
152
  errors.push(
146
153
  `SBOM description catalog count is ${Number(catalogMatch[1])} but live data/ has ${liveCatalogs} catalogs — description is stale; update package.json.description and \`npm run refresh-sbom\``
147
154
  );
@@ -157,10 +164,23 @@ function checkSbomCurrency(root) {
157
164
  }
158
165
  })();
159
166
  const jurisdictionMatch = description.match(/(\d+)\s+jurisdictions?\b/i);
160
- if (liveJurisdictions !== null && jurisdictionMatch && Number(jurisdictionMatch[1]) !== liveJurisdictions) {
161
- errors.push(
162
- `SBOM description jurisdiction count is ${Number(jurisdictionMatch[1])} but live global-frameworks.json has ${liveJurisdictions} — description is stale; update package.json.description and \`npm run refresh-sbom\``
163
- );
167
+ // Only enforce the jurisdiction token when the live source exists — a partial
168
+ // `--root` tree without global-frameworks.json (liveJurisdictions === null)
169
+ // skips the check rather than failing, matching catalogEntryCount's null-skip.
170
+ // When the source IS present, absence of the token fails CLOSED (the
171
+ // description token is the SBOM's only jurisdiction-count assertion — there is
172
+ // no structured jurisdiction property — so a dropped token would otherwise
173
+ // leave the count entirely unvalidated).
174
+ if (liveJurisdictions !== null) {
175
+ if (!jurisdictionMatch) {
176
+ errors.push(
177
+ "SBOM description is missing the jurisdiction-count token (N jurisdictions) — regenerate via `npm run refresh-sbom`"
178
+ );
179
+ } else if (Number(jurisdictionMatch[1]) !== liveJurisdictions) {
180
+ errors.push(
181
+ `SBOM description jurisdiction count is ${Number(jurisdictionMatch[1])} but live global-frameworks.json has ${liveJurisdictions} — description is stale; update package.json.description and \`npm run refresh-sbom\``
182
+ );
183
+ }
164
184
  }
165
185
 
166
186
  // Component-level cross-check (defense-in-depth). In normal operation
@@ -63,10 +63,13 @@ function countTests(filePath) {
63
63
  text = text.replace(/\/\*[\s\S]*?\*\//g, '');
64
64
  let count = 0;
65
65
  for (const rawLine of text.split('\n')) {
66
- // Drop a trailing line comment too (`test('x'); // disabled`). Best-effort:
67
- // a `//` inside a string literal would over-strip, which under-counts — the
68
- // SAFE direction for a no-silent-shrinkage gate.
69
- const stripped = rawLine.replace(/\/\/.*$/, '').trim();
66
+ // Blank out single-line string/template literal BODIES first so a `test(`
67
+ // mentioned inside a string (e.g. an assertion on output text, or this very
68
+ // file's docstring examples) is not miscounted as a declaration — that
69
+ // phantom inflated the baseline and could mask a real test deletion.
70
+ const noStrings = rawLine.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`/g, "''");
71
+ // Drop a trailing line comment too (`test('x'); // disabled`).
72
+ const stripped = noStrings.replace(/\/\/.*$/, '').trim();
70
73
  if (!stripped) continue;
71
74
  if (/(?<![A-Za-z0-9_$.])test(?:\.only|\.skip)?\s*\(/.test(stripped)) count++;
72
75
  }
@@ -77,17 +80,33 @@ function main() {
77
80
  const wantJson = process.argv.includes('--json');
78
81
  const wantUpdate = process.argv.includes('--update-baseline');
79
82
 
80
- if (!fs.existsSync(BASELINE_PATH)) {
83
+ // Read the baseline once and branch on the read RESULT, not on a prior
84
+ // existsSync probe. A separate existsSync(BASELINE_PATH)-then-read opens a
85
+ // check-then-use window (CodeQL js/file-system-race) where the file the
86
+ // gate decides about is not the file it later reads. ENOENT from the single
87
+ // read IS the "missing" signal — no second path access needed.
88
+ let baselineRaw = null;
89
+ try {
90
+ baselineRaw = fs.readFileSync(BASELINE_PATH, 'utf8');
91
+ } catch (e) {
92
+ if (e.code !== 'ENOENT') {
93
+ console.error(`[check-test-count] cannot read baseline: ${e.message}`);
94
+ process.exit(2);
95
+ }
96
+ // ENOENT — baseline absent.
81
97
  if (wantUpdate) {
82
98
  const files = listTestFiles(TESTS_DIR);
83
99
  const observed = files.reduce((n, f) => n + countTests(f), 0);
100
+ // Exclusive create ('wx'): fails with EEXIST if the file appeared
101
+ // between the read above and this write, so we never clobber a baseline
102
+ // a concurrent run just produced — atomic, no check-then-write window.
84
103
  fs.writeFileSync(BASELINE_PATH, JSON.stringify({
85
104
  baseline: observed,
86
105
  tolerance: 1,
87
106
  update_baseline_when_growth_exceeds: 20,
88
107
  notes: 'Operator-pinned canonical test count. Bump when new test files land in a release. See scripts/check-test-count.js for the contract.',
89
108
  recorded_at: new Date().toISOString().slice(0, 10),
90
- }, null, 2) + '\n', 'utf8');
109
+ }, null, 2) + '\n', { encoding: 'utf8', flag: 'wx' });
91
110
  console.error(`[check-test-count] wrote initial baseline: ${observed}`);
92
111
  process.exit(0);
93
112
  }
@@ -96,7 +115,7 @@ function main() {
96
115
  }
97
116
 
98
117
  let baselineFile;
99
- try { baselineFile = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')); }
118
+ try { baselineFile = JSON.parse(baselineRaw); }
100
119
  catch (e) {
101
120
  console.error(`[check-test-count] cannot parse baseline: ${e.message}`);
102
121
  process.exit(2);
@@ -316,17 +316,37 @@ function extractLibExports(content) {
316
316
  const objStart = stripped.search(/module\.exports\s*=\s*\{/);
317
317
  if (objStart !== -1) {
318
318
  const openIdx = stripped.indexOf("{", objStart);
319
- let depth = 0, end = -1;
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.
323
+ let depth = 0, end = -1, inStr = null;
320
324
  for (let i = openIdx; i < stripped.length; i++) {
321
325
  const ch = stripped[i];
326
+ if (inStr) {
327
+ if (ch === "\\") { i++; continue; }
328
+ if (ch === inStr) inStr = null;
329
+ continue;
330
+ }
331
+ if (ch === "'" || ch === '"' || ch === "`") { inStr = ch; continue; }
322
332
  if (ch === "{") depth++;
323
333
  else if (ch === "}") { depth--; if (depth === 0) { end = i; break; } }
324
334
  }
325
335
  if (end !== -1) {
326
336
  const body = stripped.slice(openIdx + 1, end);
327
- let d = 0, cur = "";
337
+ // String-aware member split: a `,` or bracket inside a string value must
338
+ // not split a member or skew the bracket depth.
339
+ let d = 0, cur = "", sInStr = null;
328
340
  const members = [];
329
- for (const ch of body) {
341
+ for (let i = 0; i < body.length; i++) {
342
+ const ch = body[i];
343
+ if (sInStr) {
344
+ cur += ch;
345
+ if (ch === "\\") { cur += (body[i + 1] || ""); i++; continue; }
346
+ if (ch === sInStr) sInStr = null;
347
+ continue;
348
+ }
349
+ if (ch === "'" || ch === '"' || ch === "`") { sInStr = ch; cur += ch; continue; }
330
350
  if (ch === "{" || ch === "[") d++;
331
351
  else if (ch === "}" || ch === "]") d--;
332
352
  if (ch === "," && d === 0) { members.push(cur); cur = ""; }
@@ -335,10 +355,30 @@ function extractLibExports(content) {
335
355
  members.push(cur);
336
356
  for (const tok of members) {
337
357
  const id = tok.split(":")[0].trim();
338
- if (/^[a-zA-Z_$][\w$]*$/.test(id)) out.add(id);
358
+ 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 `*`.
365
+ const beforeParen = tok.split("(")[0].trim();
366
+ const parts = beforeParen.split(/\s+/);
367
+ const cand = parts[parts.length - 1].replace(/^\*/, "").trim();
368
+ if (/^[a-zA-Z_$][\w$]*$/.test(cand)) out.add(cand);
339
369
  }
340
370
  }
341
371
  }
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).
378
+ const singleIdent = stripped.match(/module\.exports\s*=\s*([a-zA-Z_$][\w$]*)\s*;/);
379
+ if (singleIdent && !/^(function|async|class)$/.test(singleIdent[1])) {
380
+ out.add(singleIdent[1]);
381
+ }
342
382
  const re = /module\.exports\.([a-zA-Z_$][\w$]*)\s*=/g;
343
383
  let mm;
344
384
  while ((mm = re.exec(stripped)) !== null) out.add(mm[1]);