@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,23 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  /**
4
- * scripts/check-catalog-gap-budget.js
4
+ * Predeploy gate: runs the gap detectors and asserts no class exceeds its
5
+ * budget. Exit 0 within budget, 1 regressed, 2 internal error.
5
6
  *
6
- * Predeploy / CI gate that runs the v0.13.21 extended gap detectors
7
- * and asserts no class exceeds its budget. Mirrors the budget in
8
- * tests/shipped-catalog-integrity.test.js but runs as a standalone
9
- * predeploy gate so the check is visible in the gate summary even
10
- * when the broader test suite is skipped (or is the gate that's
11
- * failing for an unrelated reason).
12
- *
13
- * Exit codes:
14
- * 0 — every extended class within budget
15
- * 1 — at least one class regressed
16
- * 2 — internal error
17
- *
18
- * The budget is intentionally duplicated (here + integrity test) for
19
- * fail-loud-at-two-levels. Operators see the regression in BOTH the
20
- * test-suite output AND the predeploy gate-summary table.
7
+ * The budget is duplicated in tests/shipped-catalog-integrity.test.js so a
8
+ * regression shows in both the test output and the gate-summary table, and so
9
+ * the gate still reports when the suite is skipped or failing elsewhere. Both
10
+ * copies move together.
21
11
  */
22
12
 
23
13
  const path = require("path");
@@ -47,16 +37,13 @@ function loadAll() {
47
37
  };
48
38
  }
49
39
 
50
- // Per-class regression budgets. Kept in sync with the canonical version
51
- // in tests/shipped-catalog-integrity.test.js.
40
+ // Per-class regression budgets, mirrored in
41
+ // tests/shipped-catalog-integrity.test.js.
52
42
  const BUDGET = {
53
43
  "content-quality": 12,
54
- // temporal-staleness now measures only maintainer-controllable data-freshness
55
- // fields (source_verified > 180d, last_updated > 365d, epss_date > 90d). The
56
- // calendar-driven KEV-due-passed sub-check was removed — it was an external
57
- // operator-remediation date, not catalog freshness, and grew without bound as
58
- // the catalog aged and KEV drafts got curated. Actual is 0 with fresh data;
59
- // 10 leaves headroom for entries aging past a threshold before a refresh.
44
+ // temporal-staleness counts only the maintainer-controllable freshness fields,
45
+ // which sit at 0 on fresh data; the headroom covers entries aging past a
46
+ // threshold between refreshes.
60
47
  "temporal-staleness": 10,
61
48
  "logical-consistency": 5,
62
49
  "cross-ref-completeness": 5,
@@ -71,20 +58,16 @@ function main() {
71
58
  for (const f of all) byClass[f.class] = (byClass[f.class] || 0) + 1;
72
59
  const regressions = [];
73
60
 
74
- // Fail-closed contract (codex P2 PR #61): every class actually
75
- // emitted by the detector must have a budget entry. If a future
76
- // 8th detector lands without a budget update, the gate fires with
77
- // an unbudgeted-class error instead of silently passing.
61
+ // Fail-closed: a class the detectors emit without a budget entry fires an
62
+ // unbudgeted-class error rather than passing unmeasured.
78
63
  const unbudgeted = [];
79
64
  for (const cls of Object.keys(byClass)) {
80
65
  if (!(cls in BUDGET)) {
81
66
  unbudgeted.push({ class: cls, count: byClass[cls] });
82
67
  }
83
68
  }
84
- // Inverse check: every class declared by the detector module's
85
- // canonical class list must appear in BUDGET (covers the case where
86
- // a new class produces zero findings on this run but still needs
87
- // an explicit budget so a future regression caps fail-closed).
69
+ // The inverse: a class declared in DETECTOR_CLASSES needs a budget even when
70
+ // it emits nothing on this run, or the first regression has no cap to hit.
88
71
  const missingBudget = [];
89
72
  if (Array.isArray(D.DETECTOR_CLASSES)) {
90
73
  for (const cls of D.DETECTOR_CLASSES) {
@@ -2,34 +2,14 @@
2
2
  'use strict';
3
3
 
4
4
  /**
5
- * Release-notes extraction + quality gate.
5
+ * Release-notes extraction + quality gate. .github/workflows/release.yml publishes
6
+ * the GitHub Release body by awk-extracting the `## <version> …` CHANGELOG section,
7
+ * falling back to "Release of v<version>." when the extract is empty. This runs the
8
+ * SAME extraction before tag-push and lints what it finds, so a malformed or
9
+ * internal-narrative section fails here rather than shipping as the public body.
6
10
  *
7
- * The release workflow (.github/workflows/release.yml) publishes the GitHub
8
- * Release body by awk-extracting the `## <version> ...` CHANGELOG section
9
- * between the version heading and the next `## ` heading, falling back to a
10
- * generic "Release of v<version>." line if the extract is empty. This gate
11
- * runs that SAME extraction locally before tag-push, and additionally lints
12
- * the extracted notes for operator-facing quality, so a malformed or
13
- * internal-narrative-laced section fails here rather than shipping as the
14
- * public release body.
15
- *
16
- * Two layers:
17
- * 1. EXTRACT — the `## <version> — <date>` section exists, is non-empty
18
- * (won't trigger the workflow's "Release of v…" fallback),
19
- * the heading version matches package.json, and the heading
20
- * carries an ISO date.
21
- * 2. LINT — the extracted body is operator-facing-clean: no internal
22
- * phase/pass/slice/sweep narrative, no agent-dispatch /
23
- * conversation residue, no tautological "all tests pass"
24
- * noise. (Mirrors the operator-facing discipline; the release
25
- * body is the most public surface there is.)
26
- *
27
- * Exit: process.exitCode 0 on pass, 1 on any failure. Functions are exported
28
- * for fixture-based testing (no subprocess needed).
29
- *
30
- * Usage:
31
- * node scripts/check-changelog-extract.js # uses package.json version
32
- * node scripts/check-changelog-extract.js <version> # explicit MAJOR.MINOR.PATCH
11
+ * process.exitCode 0 on pass, 1 on any failure. Takes an optional
12
+ * MAJOR.MINOR.PATCH argument, defaulting to the package.json version.
33
13
  */
34
14
 
35
15
  const fs = require('node:fs');
@@ -39,10 +19,9 @@ const ROOT = path.resolve(__dirname, '..');
39
19
  const CHANGELOG = path.join(ROOT, 'CHANGELOG.md');
40
20
  const PACKAGE_JSON = path.join(ROOT, 'package.json');
41
21
 
42
- // Replicates the release.yml awk: capture lines AFTER the `## <version> `
43
- // heading up to (not including) the next `## ` heading. The trailing space in
44
- // the heading match mirrors the workflow's `"^## " v " "` so a shorter version
45
- // heading can't accidentally match a longer one that shares its prefix.
22
+ // Replicates the release.yml awk: lines after the `## <version> ` heading up to
23
+ // the next `## `. The trailing space mirrors the workflow's `"^## " v " "`, so a
24
+ // shorter version heading cannot match a longer one sharing its prefix.
46
25
  function extractSection(text, version) {
47
26
  const lines = text.split(/\r?\n/);
48
27
  const out = [];
@@ -56,8 +35,7 @@ function extractSection(text, version) {
56
35
  }
57
36
  if (startRe.test(ln)) capturing = true;
58
37
  }
59
- // Trim leading/trailing blank lines (awk keeps them; the body is the same
60
- // either way, but trimming makes the non-empty test honest).
38
+ // awk keeps the blank lines; trimming them makes the non-empty test honest.
61
39
  while (out.length && out[0].trim() === '') out.shift();
62
40
  while (out.length && out[out.length - 1].trim() === '') out.pop();
63
41
  return out;
@@ -69,10 +47,8 @@ function headingLine(text, version) {
69
47
  return text.split(/\r?\n/).find((l) => re.test(l)) || null;
70
48
  }
71
49
 
72
- // Operator-facing forbidden patterns. Tight, high-confidence internal-narrative
73
- // markers only — must not false-positive on legitimate operator prose (e.g. a
74
- // bare "phase" in "multi-phase attack" is fine; "Phase 9" is the tell). Each
75
- // entry: { id, re, why }.
50
+ // Operator-facing forbidden patterns: high-confidence markers only, since a false
51
+ // positive blocks a release. "multi-phase attack" is prose; "Phase 9" is the tell.
76
52
  const FORBIDDEN = [
77
53
  { id: 'phase-number', re: /\bphase\s+\d/i, why: 'internal phase number (operators have no roadmap)' },
78
54
  { id: 'pass-number', re: /\b(?:audit|curation|drift|fix|bug)?[- ]?pass\s+\d/i, why: 'internal pass/batch number' },
@@ -100,17 +76,11 @@ function readPackageVersion() {
100
76
  return JSON.parse(fs.readFileSync(PACKAGE_JSON, 'utf8')).version;
101
77
  }
102
78
 
103
- // Every previously released version must keep its own `## <version> ` heading.
104
- // The release flow edits the TOP of the file; an edit that replaces the prior
105
- // release's heading instead of inserting above it silently merges that
106
- // release's notes into the new section — the extract then spans multiple
107
- // releases and the public release body republishes old notes under the new
108
- // version. Tags are the authoritative record of what was released.
109
- // Tags whose release never published: the tag-push event was dropped (e.g.
110
- // a GitHub Actions outage) and — because the v* ruleset forbids re-pushing a
111
- // tag — the recovery is a version bump re-released with the same notes under
112
- // the NEW heading. The orphan tag therefore legitimately has no CHANGELOG
113
- // entry of its own. Tag exists, npm/GitHub Release do not.
79
+ // Every released version keeps its own `## <version> ` heading; tags are the
80
+ // authoritative record of which versions those are. An orphan is a tag whose
81
+ // release never published: the v* ruleset forbids re-pushing a tag, so the
82
+ // recovery was a version bump carrying the same notes under the NEW heading, and
83
+ // such a tag legitimately has no CHANGELOG entry of its own.
114
84
  const ORPHAN_RELEASE_TAGS = new Set(['0.13.111', '0.15.25']);
115
85
 
116
86
  function releasedVersionsFromTags() {
@@ -1,24 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  /**
4
- * check-codebase-patterns-currency.js — advisory drift detector between
5
- * exceptd's adopted codebase-pattern classes and the upstream catalog they
6
- * were derived from (the sibling blamejs codebase-patterns test).
4
+ * Advisory drift detector between exceptd's adopted codebase-pattern classes
5
+ * and the sibling blamejs codebase-patterns test they derive from. A class
6
+ * present upstream but absent from UPSTREAM_TRIAGED is new and wants triage.
7
7
  *
8
- * exceptd's grep gate (scripts/check-codebase-patterns.js) ships a scoped
9
- * subset of the upstream pattern classes; the rest were triaged as either
10
- * already-owned by another exceptd gate, helper-dependent, or out of scope
11
- * for a local-file-read security CLI. UPSTREAM_TRIAGED below records every
12
- * class that triage covered. When upstream grows a NEW class not in that set,
13
- * this check flags it so the maintainer can decide whether to adopt it — the
14
- * same forcing function the GitHub-actions / vendored-bundle currency checks
15
- * provide for those surfaces.
16
- *
17
- * Advisory by design: it never fails a release. It exits 0 and prints a
18
- * NOTICE when upstream has drifted, and exits 0 silently when the sibling
19
- * repo is not present (a fresh clone / CI runner without the sibling). Point
20
- * it elsewhere with EXCEPTD_UPSTREAM_PATTERNS env var if the sibling lives at
21
- * a non-default path.
8
+ * Always exits 0 — it prints a NOTICE on drift and exits silently when the
9
+ * sibling repo is absent. EXCEPTD_UPSTREAM_PATTERNS overrides its path.
22
10
  */
23
11
 
24
12
  const fs = require("node:fs");
@@ -26,10 +14,7 @@ const path = require("node:path");
26
14
 
27
15
  const ROOT = path.resolve(__dirname, "..");
28
16
 
29
- // The upstream allow-class registry as triaged during the codebase-patterns adoption.
30
- // Every key here was classified (adopted / already-owned / helper-dependent /
31
- // out-of-scope). A class appearing upstream but absent here is NEW and wants
32
- // triage. Refresh this list (and re-triage the delta) when this check fires.
17
+ // Refresh this list, re-triaging the delta, when this check fires.
33
18
  const UPSTREAM_TRIAGED = Object.freeze([ // keep-sorted
34
19
  "ai-disclosure-on-request-without-requested-gate",
35
20
  "archive-gz-without-safedecompress",
@@ -83,7 +68,6 @@ function upstreamPatternsPath() {
83
68
  return path.resolve(ROOT, "..", "blamejs", "test", "layer-0-primitives", "codebase-patterns.test.js");
84
69
  }
85
70
 
86
- // Extract the allow-class keys from the upstream VALID_ALLOW_CLASSES literal.
87
71
  function upstreamClasses(src) {
88
72
  const m = src.match(/VALID_ALLOW_CLASSES\s*=\s*(?:Object\.freeze\()?\{([\s\S]*?)\}/);
89
73
  if (!m) return null;
@@ -1,39 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  /**
4
- * check-codebase-patterns.js — grep-gate enforcement for code-shape bug
5
- * classes that have recurred across exceptd releases. One run surfaces every
6
- * class as a single numbered report instead of dying on the first hit.
4
+ * Grep gate for code-shape bug classes that recur across releases; CLASSES
5
+ * below is the registry.
7
6
  *
8
- * Shipped v1 classes:
9
- * - process-exit-after-stdout-write : a library-callable function writes to
10
- * the result channel (process.stdout.write / console.log) and then calls
11
- * process.exit(), which truncates the buffered write when stdout is
12
- * piped. Route through `safeExit(EXIT_CODES.X); return;` (lib/exit-codes).
13
- * This is the stdout-flush-truncation class the validate-cves fix closed by hand.
14
- * - dynamic-regex : `new RegExp(<non-literal>)` — a ReDoS sink when the
15
- * pattern derives from operator input. Use a static literal, or anchor +
16
- * length-cap the input, or mark the site `// allow:dynamic-regex —
17
- * <reason>` when the source is a trusted bundled schema.
18
- * - orphan-allow-class : an `// allow:<class>` marker whose class is not in
19
- * VALID_ALLOW_CLASSES, or is missing the `— <reason>` tail. A typo'd
20
- * marker suppresses nothing, so the underlying violation would ship
21
- * unflagged — this meta-guard keeps the marker mechanism trustworthy.
22
- * - unsorted-marked-array : a flat string array tagged `// keep-sorted` that
23
- * drifted out of alphabetical order. Opt-in — only marked arrays are
24
- * checked, so a one-time allowlist sort becomes a standing guarantee.
25
- * - misaligned-marked-run : a `// keep-aligned` const/weight table whose
26
- * `=`/`:` assignment columns are not all equal. Opt-in, same shape.
27
- *
28
- * Exceptions live at the violation site, not in this file:
7
+ * Exceptions live at the violation site:
29
8
  * - file-level, in the first 50 lines: // codebase-patterns:allow-file <class> — <reason>
30
9
  * - per-line, on the same line or up to 2 lines above: // allow:<class> — <reason>
31
10
  *
32
- * NOT covered here (owned elsewhere — do not duplicate):
33
- * - internal phase/version vocabulary in comments -> scripts/check-version-tags.js
34
- * - process.exit on the top-level CLI dispatch -> tests/safe-exit-grep.test.js
35
- * - anti-coincidence test assertions -> scripts/check-test-coverage.js
36
- * - internal-path leaks in operator output -> tests/operator-leak-grep.test.js
11
+ * Owned elsewhere: phase/version vocabulary (check-version-tags.js), CLI-dispatch
12
+ * process.exit (tests/safe-exit-grep.test.js), test assertions
13
+ * (check-test-coverage.js), operator-output path leaks (operator-leak-grep.test.js).
37
14
  */
38
15
 
39
16
  const fs = require("node:fs");
@@ -41,8 +18,8 @@ const path = require("node:path");
41
18
 
42
19
  const ROOT = path.resolve(__dirname, "..");
43
20
 
44
- // The classes that accept an `// allow:<class>` marker. orphan-allow-class is
45
- // the meta-guard itself and is intentionally NOT a markable class.
21
+ // Classes that accept an `// allow:<class>` marker. orphan-allow-class is the
22
+ // meta-guard itself, so it is not markable.
46
23
  const VALID_ALLOW_CLASSES = Object.freeze({
47
24
  "process-exit-after-stdout-write": true,
48
25
  "dynamic-regex": true,
@@ -57,8 +34,6 @@ const EXCLUDE_DIRS = new Set([
57
34
  "data", ".test-output", ".keys", "keys", "coverage",
58
35
  ]);
59
36
 
60
- // ---- file walk -----------------------------------------------------------
61
-
62
37
  function relPath(abs) {
63
38
  return path.relative(ROOT, abs).split(path.sep).join("/");
64
39
  }
@@ -104,18 +79,15 @@ function readLines(rel) {
104
79
  return lines;
105
80
  }
106
81
 
107
- // Strip a trailing `//` line comment for code-shape detection (so a class
108
- // name mentioned in a comment doesn't arm a detector). String-aware: a `//`
109
- // inside a quoted string (e.g. a `http://` URL) is NOT a comment, so the
110
- // scanner skips string contents — otherwise the rest of the line, including a
111
- // real `process.exit(...)` / `new RegExp(...)`, was silently truncated away and
112
- // the detector never fired.
82
+ // Strip a trailing `//` line comment so a class name mentioned in a comment
83
+ // can't arm a detector. String-aware: a `//` inside a quoted string (a `http://`
84
+ // URL) is not a comment — truncating there hides a real hit later on the line.
113
85
  function stripLineComment(line) {
114
86
  let inStr = null; // active quote char, or null
115
87
  for (let i = 0; i < line.length; i++) {
116
88
  const ch = line[i];
117
89
  if (inStr) {
118
- if (ch === "\\") { i++; continue; } // skip the escaped char
90
+ if (ch === "\\") { i++; continue; }
119
91
  if (ch === inStr) inStr = null;
120
92
  } else if (ch === "'" || ch === '"' || ch === "`") {
121
93
  inStr = ch;
@@ -126,8 +98,6 @@ function stripLineComment(line) {
126
98
  return line;
127
99
  }
128
100
 
129
- // ---- allow-marker engine -------------------------------------------------
130
-
131
101
  function hasFileAllow(rel, cls) {
132
102
  const head = readLines(rel).slice(0, 50);
133
103
  const re = new RegExp("codebase-patterns:allow-file\\s+" + cls + "\\b");
@@ -147,23 +117,10 @@ function filterMarkers(hits, cls) {
147
117
  return hits.filter((h) => !hasFileAllow(h.file, cls) && !hasLineAllow(h.file, h.line, cls));
148
118
  }
149
119
 
150
- // ---- require.main block ranges -------------------------------------------
151
-
152
- // Count `{` / `}` in `line` that are in REAL CODE context, advancing a
153
- // stateful tokenizer that tracks string / template / comment regions across
154
- // lines. Braces inside a single/double/template string, a `//` line comment,
155
- // or a `/* */` block comment do NOT affect depth — otherwise a `{` or `}`
156
- // typed inside a string literal in the require.main block miscounts the brace
157
- // balance and the computed block range slides onto an unrelated later function
158
- // (whose process.exit() is then wrongly treated as a CLI-entry exit and not
159
- // flagged). `inTemplate` and `inBlockComment` are the cross-line states a
160
- // per-line stripper cannot model, so the tokenizer state object is threaded
161
- // line-to-line by the caller.
162
- //
163
- // `state` is mutated in place: { inSingle, inDouble, inTemplate, inBlock,
164
- // templateDepth } — `templateDepth` tracks `${ … }` interpolation nesting so
165
- // the closing `}` of an interpolation is treated as template punctuation, not
166
- // a code brace, while braces INSIDE the interpolation expression still count.
120
+ // Counts `{` / `}` in REAL CODE context only: a brace inside a string, template
121
+ // or comment must not move the depth, or the computed require.main range slides
122
+ // onto a later function. `state` is mutated in place and threaded line to line,
123
+ // since `inTemplate` / `inBlock` are cross-line states.
167
124
  function countCodeBraces(line, state) {
168
125
  let delta = 0;
169
126
  for (let i = 0; i < line.length; i++) {
@@ -190,13 +147,12 @@ function countCodeBraces(line, state) {
190
147
  // Enter an interpolation expression: braces inside ARE code.
191
148
  state.templateExpr.push(0);
192
149
  state.inTemplate = false;
193
- i++; // skip the `{`; the `${` opener is template punctuation
150
+ i++;
194
151
  continue;
195
152
  }
196
153
  continue;
197
154
  }
198
- // Code context (possibly inside a template interpolation expression).
199
- if (ch === "/" && next === "/") break; // `//` — rest of the line is a comment
155
+ if (ch === "/" && next === "/") break;
200
156
  if (ch === "/" && next === "*") { state.inBlock = true; i++; continue; }
201
157
  if (ch === "'") { state.inSingle = true; continue; }
202
158
  if (ch === '"') { state.inDouble = true; continue; }
@@ -222,17 +178,12 @@ function newBraceState() {
222
178
  return { inSingle: false, inDouble: false, inTemplate: false, inBlock: false, templateExpr: [] };
223
179
  }
224
180
 
225
- // Line ranges (1-based, inclusive) of `if (require.main === module) { ... }`
226
- // blocks — the dual-mode CLI-entry section where synchronous-print-then-exit
227
- // is correct. process.exit there is owned by tests/safe-exit-grep.test.js and
228
- // is not a library-surface concern.
181
+ // Line ranges (1-based, inclusive) of `if (require.main === module) { ... }` blocks,
182
+ // where print-then-exit is correct — owned by tests/safe-exit-grep.test.js.
229
183
  function requireMainRanges(lines) {
230
184
  const ranges = [];
231
185
  for (let i = 0; i < lines.length; i++) {
232
186
  if (/\brequire\.main\s*===\s*module\b/.test(lines[i])) {
233
- // Find the opening brace (same line or next few), then balance —
234
- // string/comment/template-aware so braces inside literals don't skew the
235
- // depth (see countCodeBraces).
236
187
  let depth = 0;
237
188
  let started = false;
238
189
  let j = i;
@@ -252,17 +203,10 @@ function inRanges(ranges, lineNo) {
252
203
  return ranges.some(([a, b]) => lineNo >= a && lineNo <= b);
253
204
  }
254
205
 
255
- // ---- detectors -----------------------------------------------------------
256
-
257
- // A line that opens a new function body (so a backward stdout-write scan stops
258
- // at the enclosing function and doesn't arm an exit from an unrelated earlier
259
- // function). The bare-identifier (third) alternative matches a declaration /
260
- // method-shorthand opener (`foo() {`, `async bar() {`), but it must REFUSE
261
- // control-flow openers (`for (…) {`, `if (…) {`, `while/switch/catch (…) {`):
262
- // a control-flow block sitting between a stdout write and a process.exit() is
263
- // inside the SAME function, so stopping the backward scan there would wrongly
264
- // leave the exit unflagged. The negative lookahead excludes the control-flow
265
- // keywords; `function` and arrow alternatives are unchanged.
206
+ // Opens a new function body, so the backward stdout-write scan stops at the
207
+ // enclosing function. The bare-identifier alternative must REFUSE control-flow
208
+ // openers (`if (…) {`, `for (…) {`): those sit inside the SAME function, and
209
+ // stopping there leaves a real exit-after-write unflagged.
266
210
  const FUNCTION_START = /(^|[^.\w])function\b|=>\s*\{?\s*$|^\s*(async\s+)?(?!(?:if|for|while|switch|catch|do|else|with|finally|return)\b)[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/;
267
211
 
268
212
  function detectProcessExitAfterStdout(files) {
@@ -275,8 +219,7 @@ function detectProcessExitAfterStdout(files) {
275
219
  if (!/\bprocess\.exit\s*\(/.test(code)) continue;
276
220
  const lineNo = i + 1;
277
221
  if (inRanges(mainRanges, lineNo)) continue; // CLI-entry block: legitimate
278
- // Scan backward within the enclosing function for a result-channel
279
- // write (console.log / process.stdout.write). Stop at a function start.
222
+ // Scan backward within the enclosing function for a result-channel write.
280
223
  let sawStdout = false;
281
224
  for (let k = i - 1; k >= 0 && k >= i - 60; k--) {
282
225
  const prev = stripLineComment(lines[k]);
@@ -291,11 +234,8 @@ function detectProcessExitAfterStdout(files) {
291
234
  return filterMarkers(hits, "process-exit-after-stdout-write");
292
235
  }
293
236
 
294
- // The first non-whitespace char of the first arg is `"`, `'`, or `/` => a
295
- // string/regex literal => static, safe. Anything else (an identifier, a `(`,
296
- // a backtick template) is operator-derivable and flagged. Backtick is NOT
297
- // exempt — a template literal can interpolate operator input, so it must be
298
- // flagged the same as a bare identifier.
237
+ // A first arg opening with `"`, `'` or `/` is a literal, so static and safe.
238
+ // Backtick is NOT exempt — a template literal can interpolate operator input.
299
239
  function isStaticRegexFirstChar(ch) {
300
240
  return ch === '"' || ch === "'" || ch === "/";
301
241
  }
@@ -312,23 +252,17 @@ function detectDynamicRegex(files) {
312
252
  hits.push({ file: rel, line: i + 1, content: lines[i].trim() });
313
253
  continue;
314
254
  }
315
- // Multi-line form: `new RegExp(` ends the (comment-stripped) line with the
316
- // open paren as the last token, and the pattern arg is on a following
317
- // line. The single-line match above can't see the first-arg char, so it
318
- // would silently pass a dynamic RegExp whose argument starts next line.
319
- // Look ahead, skipping blank and comment-only lines (capped at 5), and
320
- // inspect the first code line's first non-whitespace char.
255
+ // Multi-line form: the pattern arg starts on a later line, so look ahead
256
+ // past blank and comment-only lines, capped at 5.
321
257
  if (!/\bnew RegExp\s*\(\s*$/.test(code)) continue;
322
258
  let firstChar = null;
323
259
  for (let k = i + 1; k <= i + 5 && k < lines.length; k++) {
324
260
  const ahead = stripLineComment(lines[k]).replace(/^\s+/, "");
325
- if (ahead === "") continue; // blank or comment-only — skip
261
+ if (ahead === "") continue;
326
262
  firstChar = ahead[0];
327
263
  break;
328
264
  }
329
- // A `new RegExp(` with nothing parseable after it within the cap is
330
- // suspicious — flag conservatively. Otherwise apply the SAME literal
331
- // exemption as the single-line path.
265
+ // Nothing parseable within the cap is suspicious — flag it.
332
266
  if (firstChar !== null && isStaticRegexFirstChar(firstChar)) continue;
333
267
  hits.push({ file: rel, line: i + 1, content: lines[i].trim() });
334
268
  }
@@ -336,13 +270,9 @@ function detectDynamicRegex(files) {
336
270
  return filterMarkers(hits, "dynamic-regex");
337
271
  }
338
272
 
339
- // Raw bidi-override / zero-width / invisible / null codepoints embedded as
340
- // literals in source — the Trojan-Source class (CVE-2021-42574). A literal
341
- // such codepoint is invisible in review and can reorder or hide code. Source
342
- // should emit them programmatically (via vendor/blamejs/codepoint-class) or
343
- // escape them (\uXXXX), never type them literally. The range table holds only
344
- // numeric codepoints + the regex is built from escapes, so this detector's own
345
- // source is clean (and the file self-skips below regardless).
273
+ // Raw bidi-override / zero-width / invisible / null codepoints typed as literals
274
+ // — the Trojan-Source class (CVE-2021-42574). Source emits them via
275
+ // vendor/blamejs/codepoint-class or a \uXXXX escape instead.
346
276
  const _BIDI_LITERAL_RANGES = [
347
277
  [0x202A, 0x202E], [0x2066, 0x2069], 0x200E, 0x200F, 0x061C, // bidi overrides + isolates
348
278
  0x200B, 0x200C, 0x200D, 0x00AD, 0x2060, 0xFEFF, // zero-width / invisible
@@ -378,13 +308,8 @@ function detectOrphanAllowClass(files) {
378
308
  const cmt = lines[i].indexOf("//");
379
309
  if (cmt === -1) continue;
380
310
  const comment = lines[i].slice(cmt);
381
- // Validate BOTH marker forms with the same class + reason rules:
382
- // per-line: allow:<class> — <reason>
383
- // file-level: codebase-patterns:allow-file <class> — <reason>
384
- // The file-level form is the broadest exemption (it suppresses every hit
385
- // of its class in the file), so a reason-less or unknown-class file-level
386
- // marker must be caught here too — otherwise it would suppress silently
387
- // and never reach the per-line orphan check.
311
+ // Both marker forms carry the same class + reason rules. The file-level
312
+ // form suppresses every hit of its class, so it must be caught here.
388
313
  const fileLevel = comment.match(/\bcodebase-patterns:allow-file\s+([a-z0-9-]+)\b(.*)$/);
389
314
  const perLine = comment.match(/\ballow:([a-z0-9-]+)\b(.*)$/);
390
315
  const m = fileLevel || perLine;
@@ -402,16 +327,11 @@ function detectOrphanAllowClass(files) {
402
327
  return hits;
403
328
  }
404
329
 
405
- // ---- opt-in readability detectors (preventative) -------------------------
406
- // These fire ONLY on sites that explicitly opt in via a marker, so unmarked
407
- // code is never flagged. They turn a one-time cleanup (sorting an allowlist,
408
- // aligning a const table) into a standing guarantee: mark the cleaned site and
409
- // the gate keeps it clean.
330
+ // The two detectors below fire only on sites that opt in via a marker, so
331
+ // unmarked code is never flagged.
410
332
 
411
- // `// keep-sorted` marks a flat string-literal array that must stay
412
- // alphabetically sorted (e.g. an allowlist). Only arrays whose opening line
413
- // carries the marker are checked; arrays containing object/nested elements are
414
- // skipped (not a flat string list).
333
+ // `// keep-sorted` marks a flat string-literal array that must stay alphabetically
334
+ // sorted; an array with object or nested elements is skipped.
415
335
  function scanUnsortedMarkedArray(rel, lines) {
416
336
  const hits = [];
417
337
  for (let i = 0; i < lines.length; i++) {
@@ -428,7 +348,7 @@ function scanUnsortedMarkedArray(rel, lines) {
428
348
  body += " " + seg;
429
349
  if (started && depth <= 0) break;
430
350
  }
431
- if (/[{]/.test(body)) continue; // object/nested elements — not a flat string array
351
+ if (/[{]/.test(body)) continue;
432
352
  const strs = [];
433
353
  const re = /(['"])((?:\\.|(?!\1).)*)\1/g;
434
354
  let m;
@@ -452,9 +372,8 @@ function detectUnsortedMarkedArray(files) {
452
372
  }
453
373
 
454
374
  // `// keep-aligned` marks a contiguous run of `IDENT = value` / `IDENT: value`
455
- // lines (a const/weight table) whose assignment columns must all line up. The
456
- // run is the lines immediately after the marker, until a blank or non-assignment
457
- // line. Opt-in, so only deliberately-aligned tables are enforced.
375
+ // lines whose assignment columns must all line up. The run starts at the line
376
+ // after the marker and ends at the first blank or non-assignment line.
458
377
  function scanMisalignedMarkedRun(rel, lines) {
459
378
  const hits = [];
460
379
  for (let i = 0; i < lines.length; i++) {
@@ -487,22 +406,13 @@ function detectMisalignedMarkedRun(files) {
487
406
  return hits;
488
407
  }
489
408
 
490
- // ---- hand-rolled SQL in a file that talks to a database ------------------
491
- //
492
- // exceptd ships no database today, so this is a FORWARD guard: the moment a
493
- // file imports a SQL driver, any SQL STATEMENT (`"SELECT …"`) or CLAUSE built
494
- // by string concatenation (`… + " WHERE " + …`) is a parameterization/injection
495
- // sink and must use the driver's bound-parameter API instead. The SQL-driver
496
- // import is the gate — a SQL-looking string in a file with no driver executes
497
- // nothing, so prose that merely begins with "Update …" / "Delete …" in a
498
- // non-DB file is never scanned (no false positives). A trusted static DDL
499
- // string can opt out with `// allow:hand-rolled-sql — <reason>`.
409
+ // Forward guard: the driver import is the gate, so prose merely beginning
410
+ // "Update …" in a non-DB file is never scanned. In a file that does import one,
411
+ // a statement or a concatenated clause is an injection sink.
500
412
  const SQL_DRIVER_IMPORT = /require\(\s*["'](?:node:sqlite|better-sqlite3|sqlite3|sqlite|pg|mysql2?|knex|sequelize|drizzle-orm|postgres|@libsql\/[\w.-]+)(?:\/[^"']*)?["']\s*\)|\bfrom\s+["'](?:node:sqlite|better-sqlite3|pg|mysql2?|knex|sequelize|drizzle-orm)(?:\/[^"']*)?["']/;
501
413
  const SQL_STMT_START = /(["'`])\s*(?:SELECT\b|INSERT\s+(?:INTO|OR)\b|REPLACE\s+INTO\b|UPDATE\s+["'`]?[A-Za-z_]|DELETE\s+FROM\b|CREATE\s+(?:TABLE|UNIQUE\s+INDEX|INDEX|TRIGGER|VIRTUAL\s+TABLE)\b|ALTER\s+TABLE\b|DROP\s+(?:TABLE|TRIGGER|INDEX)\b|MERGE\s+INTO\b)/i;
502
- // Trailing-concat form tolerates an embedded SQL string-quote inside the
503
- // clause (e.g. `" WHERE name = 'x' " + id`) by scanning to the first `+`
504
- // concatenation operator rather than requiring the clause string to close on
505
- // the same quote with no interior quotes.
414
+ // The trailing-concat form tolerates a quote inside the clause
415
+ // (`" WHERE name = 'x' " + id`) by scanning to the first `+`.
506
416
  const SQL_CLAUSE_FRAG = /(?:\+\s*["'`]\s*(?:SET|FROM|WHERE|VALUES|ORDER\s+BY|GROUP\s+BY|HAVING|RETURNING|LIMIT|OFFSET|ON\s+CONFLICT|(?:INNER\s+|LEFT\s+|RIGHT\s+|CROSS\s+)?JOIN)\b|["'`]\s*(?:SET|FROM|WHERE|VALUES\s*\(|ORDER\s+BY|GROUP\s+BY|HAVING|RETURNING|ON\s+CONFLICT|(?:INNER\s+|LEFT\s+|RIGHT\s+|CROSS\s+)?JOIN)\b[^+]*\+)/i;
507
417
  function detectHandRolledSql(files) {
508
418
  const hits = [];
@@ -564,11 +474,8 @@ const CLASSES = [
564
474
  ];
565
475
 
566
476
  function main() {
567
- // Fail closed if the scan universe is empty. Each detector scopes to a subset
568
- // of these roots and silently finds no hits when its root list is
569
- // unreadable/empty — so a wholesale missing source tree would make every
570
- // class report "clean" and the gate pass without scanning anything (the
571
- // absent-input false-pass class). Refuse to call zero-files-scanned "clean".
477
+ // Fail closed on an empty scan universe: each detector silently finds no hits
478
+ // when its roots are unreadable. Zero files scanned is not "clean".
572
479
  const universe = filesUnder(["bin/exceptd.js", "lib", "orchestrator", "scripts"]);
573
480
  if (universe.length === 0) {
574
481
  console.error("[check-codebase-patterns] FAIL — zero source files found under bin/lib/orchestrator/scripts; refusing to report clean without scanning anything.");