@blamejs/exceptd-skills 0.19.33 → 0.19.35

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 +28 -0
  2. package/bin/exceptd.js +895 -2828
  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 +170 -76
  8. package/lib/collectors/cicd-pipeline-compromise.js +113 -136
  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 +198 -211
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +130 -118
  20. package/lib/collectors/scan-excludes.js +33 -139
  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 -155
  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 +39 -113
  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 +88 -236
  37. package/lib/playbook-runner.js +759 -2107
  38. package/lib/prefetch.js +101 -376
  39. package/lib/refresh-external.js +199 -633
  40. package/lib/refresh-network.js +78 -311
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +85 -146
  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 +28 -27
  48. package/lib/upstream-check-cli.js +36 -29
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +52 -121
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +78 -286
  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 -413
  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 +242 -242
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +29 -28
  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 +21 -31
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +26 -57
  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 +63 -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 +62 -81
  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 +83 -198
  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 +7 -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 +7 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +7 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +63 -148
  114. package/scripts/release.js +69 -234
  115. package/scripts/run-e2e-scenarios.js +26 -73
  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 -141
@@ -1,27 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  /**
4
- * check-test-subjects.js — bidirectional test↔subject gate (and reorg driver).
4
+ * Bidirectional test↔subject gate. Every tests/<x>.test.js must name a real
5
+ * subject and every subject must have a test. Subjects are derived from the
6
+ * codebase, not a hand-maintained list.
5
7
  *
6
- * Every test file must be named after a real SUBJECT the codebase actually has,
7
- * and every subject must have a test. A "subject" is derived dynamically from
8
- * the codebase so this list is never hand-maintained:
9
- * - a source MODULE basename (lib/x.js -> x; lib/collectors/x.js -> x and
10
- * collectors-x; orchestrator/index.js -> orchestrator; bin/exceptd.js -> cli)
11
- * - an exported FUNCTION / CLASS name (kebab-cased) — per-function granularity
12
- * - a data PRIMITIVE: a data/*.json catalog FILE, plus each catalog ENTRY that
13
- * is itself a primitive — every CVE/MAL/GHSA id in data/cve-catalog.json and
14
- * every playbook in data/playbooks/ — so one CVE == one test file
15
- * - a .github/workflows/*.yml WORKFLOW (release -> release-workflow, etc.)
16
- * - a CLI verb dispatched by bin/exceptd.js
17
- *
18
- * FORWARD violation : a tests/<x>.test.js where <x> is not a valid subject.
19
- * REVERSE violation : a subject (module / CVE / playbook / workflow) with no
20
- * tests/<subject>.test.js.
21
- *
22
- * Run with --worklist for the machine-readable reorg work list (JSON on stdout).
23
- * Run with no flag for a human summary; exits non-zero while any violation
24
- * remains (so once the suite conforms this becomes a standing predeploy gate).
8
+ * A FORWARD violation is a test file naming no subject; a REVERSE violation is
9
+ * a subject with no test. --worklist writes the machine-readable list to
10
+ * stdout; either violation exits non-zero.
25
11
  */
26
12
  const fs = require("node:fs");
27
13
  const path = require("node:path");
@@ -42,22 +28,16 @@ function deriveSubjects() {
42
28
  if (e.isDirectory()) { walkSrc(rel); continue; }
43
29
  if (!e.name.endsWith(".js")) continue;
44
30
  const base = e.name.replace(/\.js$/, "");
45
- // index.js is a directory entry point; the directory/canonical subject
46
- // covers it, so treat the bare "index" basename as an alias, not a
47
- // separately reverse-required module.
31
+ // A bare "index" is an alias, not a reverse-required module.
48
32
  add(base, (base === "index" ? "alias:" : "module:") + rel);
49
33
  const parent = path.basename(path.dirname(rel));
50
- // parent-prefixed name (collectors-x, builders-x, validators-x) is an
51
- // ALIAS of the canonical basename subject — a valid test target, but the
52
- // canonical <base>.test.js already satisfies coverage, so don't double-
53
- // count the alias as its own reverse gap.
34
+ // A parent-prefixed name (collectors-x) is an alias of the canonical
35
+ // basename: a valid test target, never its own reverse gap.
54
36
  if (!["lib", "scripts", "orchestrator", "bin"].includes(parent)) add(parent + "-" + base, "alias:" + rel);
55
37
  const txt = read(rel);
56
38
  for (const m of txt.matchAll(/(?:^|\n)\s*(?:async\s+)?(?:function|class)\s+([A-Za-z_$][\w$]*)/g)) add(camelKebab(m[1]), "fn:" + rel);
57
- // Capture the FULL module.exports object via a brace-balanced scan. A
58
- // non-greedy /\{([\s\S]*?)\}/ stops at the first nested `}` and drops
59
- // every export name after it (the brace-truncation class), under-deriving
60
- // subjects so a real export silently has no required test.
39
+ // Brace-balanced scan, not a non-greedy /\{([\s\S]*?)\}/ — that stops at
40
+ // the first nested `}` and drops every export after it.
61
41
  const expAt = txt.search(/module\.exports\s*=\s*\{/);
62
42
  if (expAt >= 0) {
63
43
  const open = txt.indexOf("{", expAt);
@@ -70,43 +50,28 @@ function deriveSubjects() {
70
50
  ["lib", "orchestrator", "scripts", "bin", "sources/validators"].forEach(walkSrc);
71
51
  add("orchestrator", "module:orchestrator/index.js");
72
52
  add("cli", "module:bin/exceptd.js");
73
- // Vendored (pinned third-party) modules are valid test SUBJECTS but are not
74
- // reverse-required — we don't force a dedicated test per vendored file.
53
+ // Vendored modules are valid test subjects but not reverse-required.
75
54
  (function walkVendor(d) { for (const e of ls(d)) { const rel = d + "/" + e.name; if (e.isDirectory()) walkVendor(rel); else if (e.name.endsWith(".js")) add(e.name.replace(/\.js$/, ""), "vendor:" + rel); } })("vendor");
76
55
 
77
- // CLI verbs — both the switch-case form and the dispatch-table form
78
- // (verb: () => path.join(...)) that bin/exceptd.js uses for most subcommands.
56
+ // Both dispatch forms bin/exceptd.js uses: switch-case and the `verb: () =>` table.
79
57
  const cliSrc = read("bin/exceptd.js");
80
58
  for (const m of cliSrc.matchAll(/case\s+['"]([a-z][a-z0-9-]+)['"]/g)) { add("cli-" + m[1], "cli-verb"); add(m[1], "cli-verb"); }
81
59
  for (const m of cliSrc.matchAll(/^\s*["']?([a-z][a-z0-9-]+)["']?:\s*\(\)\s*=>/gm)) { add("cli-" + m[1], "cli-verb"); add(m[1], "cli-verb"); }
82
60
 
83
- // data catalog files
84
61
  for (const e of ls("data")) if (e.isFile() && e.name.endsWith(".json")) add(e.name.replace(/\.json$/, ""), "data");
85
- // data ENTRY primitives: every CVE id + every playbook
86
- // CVE-primitive subjects. An unreadable / malformed / empty catalog must NOT
87
- // silently derive zero CVE subjects — that would let the reverse-coverage gate
88
- // PASS with no CVE coverage at all (the absent-input false-pass class). Fail
89
- // loud instead.
62
+ // A zero-subject derivation throws rather than passing reverse coverage empty.
90
63
  let cveDerived = 0;
91
64
  try { const cat = JSON.parse(read("data/cve-catalog.json")); for (const k of Object.keys(cat)) if (k !== "_meta") { add(k.toLowerCase(), "cve-primitive"); cveDerived++; } }
92
65
  catch (e) { throw new Error("check-test-subjects: cannot read/parse data/cve-catalog.json — refusing to derive subjects (reverse coverage would falsely pass with no CVE coverage): " + e.message); }
93
66
  if (cveDerived === 0) throw new Error("check-test-subjects: data/cve-catalog.json yielded zero CVE entries — refusing to let reverse coverage pass with no CVE coverage.");
94
- // Playbook-primitive subjects. Mirror the CVE-catalog guard above: an
95
- // unreadable / empty data/playbooks must NOT silently derive zero playbook
96
- // subjects — that lets the reverse-coverage gate pass with no playbook
97
- // coverage (the same absent-input false-pass class). Fail loud instead.
67
+ // Same floor for playbooks.
98
68
  let pbDerived = 0;
99
69
  for (const e of ls("data/playbooks")) if (e.isFile() && e.name.endsWith(".json")) { const b = e.name.replace(/\.json$/, ""); add(b, "playbook-primitive"); add("playbook-" + b, "alias:playbook"); pbDerived++; }
100
70
  if (pbDerived === 0) throw new Error("check-test-subjects: data/playbooks/ yielded zero playbooks — refusing to let reverse coverage pass with no playbook coverage.");
101
- // workflows
102
71
  for (const e of ls(".github/workflows")) if (/\.ya?ml$/.test(e.name)) { const b = e.name.replace(/\.ya?ml$/, ""); add(b, "workflow"); add(b + "-workflow", "workflow"); }
103
72
 
104
- // Repo-artifact subjects: shipped root config/doc files, the docker build
105
- // context, the agents/ directory, and aggregate catalog directories. A test
106
- // that pins one of these artifacts (its content, counts, or cross-references)
107
- // is named after a durable subject, not a release — so these are valid test
108
- // targets. Kind is not module/cve/playbook, so they are NOT reverse-required
109
- // (we don't force a dedicated test per doc file).
73
+ // Repo artifacts — valid test targets, but their kind is not
74
+ // module/cve/playbook, so none of them is reverse-required.
110
75
  for (const f of ["package.json", "manifest.json", "manifest-snapshot.json", "README.md", "AGENTS.md", "SECURITY.md", "ARCHITECTURE.md", "CONTEXT.md", "CHANGELOG.md", "CONTRIBUTING.md", "CODE_OF_CONDUCT.md", "LICENSE", "NOTICE"]) {
111
76
  add(f.replace(/\.[^.]*$/, "").toLowerCase().replace(/_/g, "-"), "repo:" + f);
112
77
  }
@@ -116,12 +81,8 @@ function deriveSubjects() {
116
81
  add("playbooks", "aggregate:data/playbooks");
117
82
  add("workflows", "aggregate:.github/workflows");
118
83
  add("governance", "repo:governance-files"); // LICENSE/NOTICE/FUNDING/CoC/gitignore/gitleaks presence + integrity
119
- // Module-subject floor. The source walk over lib/orchestrator/scripts/bin
120
- // uses ls(), which returns [] on a read failure — so an unreadable source
121
- // tree would derive zero reverse-required module subjects and let the gate
122
- // pass with no module coverage (the absent-input false-pass class, same as
123
- // the CVE/playbook guards). The repo always has dozens of modules; zero is an
124
- // anomaly. Fail loud.
84
+ // Module floor: ls() returns [] on a read failure, so an unreadable source
85
+ // tree would otherwise pass the gate with no module coverage.
125
86
  let moduleCount = 0;
126
87
  for (const kind of subjects.values()) if (typeof kind === "string" && kind.startsWith("module:")) moduleCount++;
127
88
  if (moduleCount === 0) throw new Error("check-test-subjects: zero source-module subjects derived (lib/orchestrator/scripts/bin unreadable?) — refusing to let reverse coverage pass with no module coverage.");
@@ -142,10 +103,8 @@ function run() {
142
103
  for (const t of testFiles) if (!subjects.has(t.toLowerCase())) forward.push({ file: "tests/" + t + ".test.js", suggested: suggest(t) });
143
104
  const reverse = [];
144
105
  for (const [s, kind] of subjects) if (!testSet.has(s)) reverse.push({ subject: s, kind });
145
- // Reverse-REQUIRED subset: only module / cve / playbook subjects must have a
146
- // test (aliases, data files, cli verbs, repo artifacts are valid targets but
147
- // not reverse-required). The exit-code logic gates on THIS, consistently
148
- // across --worklist and the human/predeploy paths.
106
+ // Only module, cve and playbook subjects must have a test; the rest are valid
107
+ // targets, not requirements. Both output paths take their exit code from this.
149
108
  const reverseRequired = reverse.filter((x) => x.kind.startsWith("module:") || x.kind.startsWith("cve-primitive") || x.kind.startsWith("playbook-primitive"));
150
109
  return { subjects: subjects.size, forward, reverse, reverseRequired };
151
110
  }
@@ -2,30 +2,14 @@
2
2
  "use strict";
3
3
 
4
4
  /**
5
- * TTP reference-integrity gate.
5
+ * TTP reference-integrity gate. data/attack-techniques.json and
6
+ * data/atlas-ttps.json are the pinned copies of ATT&CK and ATLAS; every other file
7
+ * naming a technique refers into them, and this proves those references resolve.
8
+ * Resolution runs against the pins, not against MITRE:
9
+ * scripts/check-ttp-upstream.js is the network check on whether the pins
10
+ * themselves are current, and it cannot block a release. This one can.
6
11
  *
7
- * data/attack-techniques.json and data/atlas-ttps.json are the pinned copies of
8
- * ATT&CK and ATLAS. Every other file that names a technique — countermeasure
9
- * maps, DLP controls, playbooks, skill bodies, CVE entries — is referring INTO
10
- * those two catalogs. This gate proves those references resolve.
11
- *
12
- * The failure it exists for: MITRE retires and renumbers techniques between
13
- * releases. When a pin is bumped, the two source catalogs get remapped, but
14
- * references living in other files are easy to miss — nothing dereferences them
15
- * at runtime, so a stale id keeps rendering in operator output as if it were
16
- * current. It points at a MITRE page that no longer resolves, and any control
17
- * claiming to counter it is now mapped to nothing (AGENTS.md Hard Rule #4: no
18
- * orphaned controls). A bumped pin left exactly this residue in the D3FEND and
19
- * DLP maps, invisible to every other gate.
20
- *
21
- * The check is offline: it resolves references against the pinned catalogs, not
22
- * against MITRE. That is deliberate — scripts/check-ttp-upstream.js is the
23
- * network check that asks whether the PINS are current, and it cannot block a
24
- * release because it needs connectivity. This one can block, because a
25
- * reference that does not resolve against the pin we ship is broken no matter
26
- * what upstream says.
27
- *
28
- * Exit codes: 0 all references resolve, 1 unresolved references found.
12
+ * Exit 0 when every reference resolves, 1 when any does not.
29
13
  */
30
14
 
31
15
  const fs = require("node:fs");
@@ -34,11 +18,10 @@ const path = require("node:path");
34
18
  const ROOT = path.resolve(__dirname, "..");
35
19
 
36
20
  /**
37
- * ATT&CK ids are TNNNN[.NNN]; ATLAS ids are AML.TNNNN[.NNN]. The lookbehind
38
- * matters: without it the ATT&CK alternative matches the "T0017" inside
39
- * "AML.T0017" and reports a phantom bare-ATT&CK reference for every ATLAS id in
40
- * the tree. It covers the hyphen as well, because ATLAS ids also appear in
41
- * filenames, where the dot is not a legal separator.
21
+ * ATT&CK ids are TNNNN[.NNN]; ATLAS ids are AML.TNNNN[.NNN]. Without the
22
+ * lookbehind the ATT&CK alternative matches the "T0017" inside "AML.T0017" and
23
+ * reports a phantom bare-ATT&CK reference for every ATLAS id. It covers the
24
+ * hyphen too, since ATLAS ids also appear in filenames.
42
25
  */
43
26
  const TTP_PATTERN = /\bAML\.T\d{4}(?:\.\d{3})?\b|(?<!AML[.-])\bT\d{4}(?:\.\d{3})?\b/g;
44
27
 
@@ -50,17 +33,10 @@ const SEARCH_EXTS = new Set([".json", ".md", ".js"]);
50
33
  const SKIP_DIRS = new Set(["node_modules", "_indexes", "vendor", ".git"]);
51
34
 
52
35
  /**
53
- * Tokens that match the pattern without being references to a technique.
54
- * Every entry needs a reason: an unexplained allowlist is how a real stale id
55
- * eventually gets parked here to make the gate green.
36
+ * Tokens that match the pattern without referencing a technique. Every entry needs
37
+ * a reason: an unexplained allowlist is how a real stale id gets parked here.
56
38
  */
57
- const NOT_REFERENCES = [
58
- {
59
- id: "T1234",
60
- file: "lib/gap-detectors.js",
61
- why: "placeholder in a comment describing the reference-extraction pattern itself, not a claim about a technique",
62
- },
63
- ];
39
+ const NOT_REFERENCES = [];
64
40
 
65
41
  function loadKnownIds() {
66
42
  const known = new Set();
@@ -2,31 +2,11 @@
2
2
  "use strict";
3
3
 
4
4
  /**
5
- * scripts/check-ttp-upstream.js — validates every shipped TTP id against the
6
- * pinned upstream release.
5
+ * Validates every shipped TTP id in data/attack-techniques.json and
6
+ * data/atlas-ttps.json against the pinned upstream MITRE release. A network
7
+ * check, so it runs in the refresh workflow rather than in offline predeploy.
7
8
  *
8
- * Why this exists. The catalogs in data/attack-techniques.json and
9
- * data/atlas-ttps.json are curated mirrors of MITRE releases, and nothing
10
- * checked that the ids in them are ids MITRE actually publishes. The ATLAS
11
- * 2026.07 / ATT&CK 19.2 pin bump found eleven that were not: five ATT&CK
12
- * techniques revoked upstream — already revoked in 19.1, the version pinned at
13
- * the time, so they had been shipping revoked for a full release cycle — and
14
- * six that appear in no upstream domain at any version. Every one of them was
15
- * reachable by a skill telling an operator to map a finding to it, which is
16
- * exactly what Hard Rule #4 (no orphaned controls — every control maps to a
17
- * real TTP) exists to prevent.
18
- *
19
- * This is a NETWORK check, so it runs in the refresh workflow beside the pin
20
- * checker rather than in predeploy, which is offline. Report-only: it prints
21
- * findings and exits non-zero, leaving the decision to a maintainer, because a
22
- * revocation needs a judgement call about the successor rather than an
23
- * automatic rewrite.
24
- *
25
- * node scripts/check-ttp-upstream.js validate against the pins
26
- * node scripts/check-ttp-upstream.js --json machine-readable output
27
- *
28
- * Exit: 0 clean, 1 findings, 2 upstream unreachable (never a silent pass —
29
- * an unreachable upstream must not read as "all ids valid").
9
+ * Exit 0 clean, 1 findings, 2 upstream unreachable — never a silent pass.
30
10
  */
31
11
 
32
12
  const fs = require("node:fs");
@@ -41,14 +21,8 @@ function readJson(p) {
41
21
  }
42
22
 
43
23
  /**
44
- * A pinned version read out of a catalog is file data, and it is about to be
45
- * interpolated into an upstream URL. Constrain it to the exact shape each
46
- * project publishes before it can reach a request, so a malformed or tampered
47
- * `_meta` cannot steer the fetch — and so a typo'd pin fails here with a clear
48
- * message instead of as a puzzling 404 later.
49
- *
50
- * ATT&CK semver-ish major.minor 19.2
51
- * ATLAS CalVer YYYY.MM with optional .N 2026.07, 2025.11.2
24
+ * A pin is catalog data interpolated into an upstream URL, so its shape is
25
+ * bounded here: ATT&CK is major.minor, ATLAS is CalVer YYYY.MM with optional .N.
52
26
  */
53
27
  const VERSION_SHAPES = {
54
28
  attack: /^\d{1,3}\.\d{1,3}$/,
@@ -66,13 +40,8 @@ function assertPinShape(kind, value) {
66
40
  }
67
41
 
68
42
  /**
69
- * The only hosts this checker may ever contact. `assertPinShape` already bounds
70
- * the catalog-sourced version to a digit-and-dot shape before it reaches a URL,
71
- * but that guard sits a long way from the request — a later edit could add a
72
- * caller that skips it, and static analysis reasonably flags file data reaching
73
- * an outbound request when the barrier is that distant. Re-check the resolved
74
- * origin at the sink so the property is enforced where it matters and is
75
- * verifiable by reading ten lines rather than tracing the whole module.
43
+ * The only hosts this checker may contact. Re-checked at the sink even though
44
+ * `assertPinShape` bounds the version, so a later caller cannot route past it.
76
45
  */
77
46
  const ALLOWED_ORIGINS = new Set(["https://raw.githubusercontent.com"]);
78
47
 
@@ -89,7 +58,6 @@ function assertAllowedUrl(rawUrl) {
89
58
  `${[...ALLOWED_ORIGINS].join(", ")}`
90
59
  );
91
60
  }
92
- // Reject anything that could smuggle credentials or redirect the path.
93
61
  if (parsed.username || parsed.password || parsed.search || parsed.hash) {
94
62
  throw new Error(`refusing to fetch a URL carrying credentials or a query/fragment: ${parsed.origin}${parsed.pathname}`);
95
63
  }
@@ -2,45 +2,9 @@
2
2
  'use strict';
3
3
 
4
4
  /**
5
- * scripts/check-version-bump.js — patch-only-cadence predeploy gate.
6
- *
7
- * Why this exists. The project cadence is "patch is the only default bump; a
8
- * minor or major requires explicit human authorization." That rule lived in
9
- * the contributor guide and in maintainer memory — and was violated anyway
10
- * (two releases shipped as minors that should have been patches). A written
11
- * rule the tooling does not enforce is one a tired/automated contributor will
12
- * eventually skip. This gate makes the rule mechanical: an unauthorized
13
- * minor/major version bump fails predeploy and cannot ship.
14
- *
15
- * Hermetic by design. Authorization is a COMMITTED artifact
16
- * (tests/.version-bump-ack.json), not an environment variable, because
17
- * `npm run predeploy` runs in the release.yml validate job as well as locally.
18
- * An env-var scheme would either false-fail a legitimately-authorized minor in
19
- * CI or require per-release CI config. A committed ack file travels with the
20
- * checkout, so the gate enforces identically everywhere — and a minor bump
21
- * becomes a loud, reviewable line in the PR diff instead of a silent change to
22
- * a version string.
23
- *
24
- * Mechanism:
25
- * prev = the most recent OTHER `## X.Y.Z` heading in CHANGELOG.md
26
- * cur = package.json version (== the top CHANGELOG heading; the version-sync
27
- * gate enforces that match separately)
28
- * classify prev -> cur as patch | minor | major | none | downgrade
29
- * - patch / none -> pass (the default; zero ceremony)
30
- * - downgrade / bad -> fail (versions only move forward)
31
- * - minor / major -> pass ONLY if tests/.version-bump-ack.json names
32
- * the exact `cur` version with the matching type;
33
- * otherwise fail with remediation.
34
- *
35
- * To authorize a minor (only after the user explicitly asks for one):
36
- * echo '{"version":"0.19.0","type":"minor"}' > tests/.version-bump-ack.json
37
- * and commit it. The ack is version-specific, so a stale ack cannot authorize
38
- * a different future bump.
39
- *
40
- * Output:
41
- * stdout: structured JSON when --json, else a one-line summary
42
- * exit 0: patch/none, or an authorized minor/major
43
- * exit 1: unauthorized minor/major, downgrade, or unparseable version
5
+ * Patch-only-cadence gate. A minor or major fails unless the committed
6
+ * tests/.version-bump-ack.json names that exact version, so an ack travels with
7
+ * the checkout and a stale one cannot authorize a later bump.
44
8
  */
45
9
 
46
10
  const fs = require('fs');
@@ -57,7 +21,6 @@ function parseSemver(v) {
57
21
  return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
58
22
  }
59
23
 
60
- // Classify the transition prev -> cur. Pure; exported for unit tests.
61
24
  function classifyBump(prev, cur) {
62
25
  const a = parseSemver(prev);
63
26
  const b = parseSemver(cur);
@@ -68,8 +31,7 @@ function classifyBump(prev, cur) {
68
31
  return 'none';
69
32
  }
70
33
 
71
- // Decide whether a bump is allowed given the committed ack (or null). Pure;
72
- // exported for unit tests. ack = { version, type } | null.
34
+ // `ack` is { version, type } or null.
73
35
  function checkBump(prev, cur, ack) {
74
36
  if (!prev) return { ok: true, bump: 'initial', reason: 'no previous version recorded' };
75
37
  const bump = classifyBump(prev, cur);
@@ -82,7 +44,6 @@ function checkBump(prev, cur, ack) {
82
44
  if (bump === 'patch' || bump === 'none') {
83
45
  return { ok: true, bump, reason: `${bump} bump (${prev} -> ${cur})` };
84
46
  }
85
- // minor or major — requires explicit committed authorization for this exact version.
86
47
  if (ack && ack.version === cur && ack.type === bump) {
87
48
  return { ok: true, bump, reason: `${bump} bump authorized for ${cur} via tests/.version-bump-ack.json` };
88
49
  }
@@ -93,7 +54,7 @@ function checkBump(prev, cur, ack) {
93
54
  };
94
55
  }
95
56
 
96
- // Extract `## X.Y.Z` version headings from CHANGELOG.md, in document order.
57
+ // Document order — newest first, which determinePrevious relies on.
97
58
  function changelogVersions(text) {
98
59
  const out = [];
99
60
  const re = /^##\s+(\d+\.\d+\.\d+)\b/gm;
@@ -107,14 +68,8 @@ function suggestPatch(prev) {
107
68
  return a ? `${a.major}.${a.minor}.${a.patch + 1}` : null;
108
69
  }
109
70
 
110
- // Resolve the previous version from CHANGELOG text, or fail closed. Pure;
111
- // exported for unit tests. A release gate must NOT treat an unreadable or
112
- // heading-less CHANGELOG as a "first release" — doing so silently authorized
113
- // ANY bump (including an unapproved major), because checkBump(null, ...) takes
114
- // the initial-release pass. `text` is the CHANGELOG contents, or null when the
115
- // read failed. Returns { ok, prev?, reason? }: ok:false fails the gate; ok:true
116
- // yields prev, which is null ONLY for a genuine first release whose sole
117
- // `## X.Y.Z` heading equals cur (versions === [cur]).
71
+ // Fails closed: an unreadable or heading-less CHANGELOG must not look like a
72
+ // first release, because checkBump(null, ...) then authorizes any bump.
118
73
  function determinePrevious(text, cur) {
119
74
  if (text == null) {
120
75
  return { ok: false, reason: 'CHANGELOG.md could not be read' };
@@ -149,12 +104,7 @@ function main() {
149
104
  }
150
105
  const cur = pkg.version;
151
106
 
152
- // Fail closed when CHANGELOG is unreadable or carries no version headings.
153
- // Swallowing a read error to '' previously made versions=[] -> prev=null ->
154
- // checkBump's initial-release pass, so a missing/malformed CHANGELOG silently
155
- // authorized any bump. A genuine first release still passes: its sole
156
- // `## X.Y.Z` heading equals cur, so determinePrevious returns prev=null with
157
- // ok:true and checkBump takes the legitimate initial path.
107
+ // null on a read failure, never '' — determinePrevious fails closed on null.
158
108
  let changelogText = null;
159
109
  try { changelogText = fs.readFileSync(CHANGELOG_PATH, 'utf8'); } catch (_e) { changelogText = null; }
160
110
  const prevRes = determinePrevious(changelogText, cur);
@@ -163,7 +113,6 @@ function main() {
163
113
  process.exitCode = 1;
164
114
  return;
165
115
  }
166
- // prev = the most recent heading that differs from the current version.
167
116
  const prev = prevRes.prev;
168
117
 
169
118
  const ack = readAck();
@@ -191,8 +140,7 @@ function main() {
191
140
  if (patch) process.stderr.write(`[check-version-bump] If this should be a patch, set the version to ${patch}.\n`);
192
141
  process.stderr.write(`[check-version-bump] If the user explicitly authorized a ${res.bump}, commit tests/.version-bump-ack.json = {"version":"${cur}","type":"${res.bump}"}.\n`);
193
142
  }
194
- // process.exitCode (not process.exit) so the buffered stdout write above
195
- // is not truncated when stdout is piped (the stdout-flush-truncation class).
143
+ // `process.exitCode`, not process.exit(): exit truncates the buffered stdout write.
196
144
  process.exitCode = 1;
197
145
  return;
198
146
  }