@blamejs/exceptd-skills 0.19.33 → 0.19.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/bin/exceptd.js +896 -2824
- package/data/_indexes/_meta.json +2 -2
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -152
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +35 -114
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -412
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +1 -1
- package/sbom.cdx.json +241 -241
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -140
|
@@ -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
|
-
*
|
|
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].
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
54
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
45
|
-
*
|
|
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
|
|
70
|
-
* the
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
111
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
}
|
|
@@ -2,43 +2,11 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* operator-facing-surface rules includes string literals that ship to
|
|
11
|
-
* operators — CLI `--help` text, error messages, and test descriptions —
|
|
12
|
-
* not just `//` comments. The scan therefore tests the WHOLE line, so a
|
|
13
|
-
* `version: '0.18.7'` data literal or a `--flag (v0.18.7)` help string
|
|
14
|
-
* counts the same as a `// v0.18.7` comment. Genuinely-load-bearing
|
|
15
|
-
* version references (real test fixtures, deprecation timelines) get the
|
|
16
|
-
* file added to COMMENT_EXEMPT below. The authoritative version surfaces
|
|
17
|
-
* are:
|
|
18
|
-
*
|
|
19
|
-
* 1. package.json / manifest.json `"version"` field
|
|
20
|
-
* 2. CHANGELOG.md `## X.Y.Z` headings
|
|
21
|
-
* 3. git tags
|
|
22
|
-
* 4. CLI `version` verb output (reads from package.json)
|
|
23
|
-
*
|
|
24
|
-
* Anywhere else, `// v0.13.22` / `Pre-v0.13.22` / `*-v0_13_22.test.js`
|
|
25
|
-
* is phase residue — operators don't have the roadmap, version tags
|
|
26
|
-
* rot the moment the next release lands, and `git clone` ships every
|
|
27
|
-
* comment to operators along with the code.
|
|
28
|
-
*
|
|
29
|
-
* The check uses a baseline snapshot (`tests/.version-tag-baseline.
|
|
30
|
-
* json`) capturing current violation counts per file. Future scans
|
|
31
|
-
* compare against the baseline:
|
|
32
|
-
*
|
|
33
|
-
* - Filename violations beyond baseline → fail.
|
|
34
|
-
* - Line violations beyond baseline (in any file) → fail.
|
|
35
|
-
* - Violations strictly within baseline → ok.
|
|
36
|
-
* - Violations below baseline (drift reduced) → ok +
|
|
37
|
-
* suggestion to refresh the baseline.
|
|
38
|
-
*
|
|
39
|
-
* Refresh: `node scripts/check-version-tags.js --update-baseline`.
|
|
40
|
-
*
|
|
41
|
-
* Wired into `npm run predeploy` as a gate.
|
|
5
|
+
* Predeploy gate refusing new version-stamped lines and filenames in the tracked
|
|
6
|
+
* source tree. Whole lines, not just comments: a stamp in a data literal or a
|
|
7
|
+
* help string is residue too, and a load-bearing one is exempted by path in
|
|
8
|
+
* COMMENT_EXEMPT rather than by narrowing the scan. Counts must not rise above
|
|
9
|
+
* tests/.version-tag-baseline.json; refresh it with `--update-baseline`.
|
|
42
10
|
*/
|
|
43
11
|
|
|
44
12
|
const fs = require("node:fs");
|
|
@@ -48,24 +16,14 @@ const { execFileSync } = require("node:child_process");
|
|
|
48
16
|
const ROOT = path.join(__dirname, "..");
|
|
49
17
|
const BASELINE_PATH = path.join(ROOT, "tests", ".version-tag-baseline.json");
|
|
50
18
|
|
|
51
|
-
// Directories we do not walk at all.
|
|
52
19
|
const SKIP_DIRS = new Set([
|
|
53
20
|
"node_modules", ".git", ".keys", ".cache", ".scratch",
|
|
54
21
|
"data", "vendor", ".husky",
|
|
55
22
|
]);
|
|
56
23
|
|
|
57
|
-
// File extensions we scan for comment violations.
|
|
58
24
|
const SCAN_EXTS = new Set([".js", ".cjs", ".mjs", ".md"]);
|
|
59
25
|
|
|
60
|
-
// Paths
|
|
61
|
-
// - CHANGELOG headings are how operators navigate the file
|
|
62
|
-
// - package.json / manifest.json carry the canonical version field
|
|
63
|
-
// - manifest-snapshot.json + sbom.cdx.json contain version-pinned
|
|
64
|
-
// metadata (the SBOM IS a version-stamped manifest)
|
|
65
|
-
// - lib/version-pins.js is a version-constant lookup table
|
|
66
|
-
// - This checker itself documents what it forbids
|
|
67
|
-
// - .git-blame-ignore-revs carries commit hashes, not version tags,
|
|
68
|
-
// but is conventional config the user maintains
|
|
26
|
+
// Paths where a version reference is load-bearing.
|
|
69
27
|
const COMMENT_EXEMPT = new Set([
|
|
70
28
|
"package.json",
|
|
71
29
|
"manifest.json",
|
|
@@ -74,45 +32,20 @@ const COMMENT_EXEMPT = new Set([
|
|
|
74
32
|
"CHANGELOG.md",
|
|
75
33
|
"lib/version-pins.js",
|
|
76
34
|
"scripts/check-version-tags.js",
|
|
77
|
-
//
|
|
78
|
-
// extraction + the shorter-vs-longer prefix-collision guard, so its fixtures
|
|
79
|
-
// MUST embed real `## X.Y.Z` headings (e.g. 0.15.5 vs 0.15.50) — load-bearing
|
|
80
|
-
// test data, not sprinkled release tags.
|
|
35
|
+
// Fixtures embed real `## X.Y.Z` headings, including a prefix collision.
|
|
81
36
|
"tests/check-changelog-extract.test.js",
|
|
82
|
-
//
|
|
83
|
-
// tags that exist with no published release (outage-recovery bumps), so the
|
|
84
|
-
// heading-completeness check can skip them — load-bearing references to git
|
|
85
|
-
// tags, an authoritative version surface.
|
|
37
|
+
// Allowlists the exact versions of tags with no published release.
|
|
86
38
|
"scripts/check-changelog-extract.js",
|
|
87
|
-
//
|
|
88
|
-
// shows an example ack naming a target version, and its test compares real
|
|
89
|
-
// X.Y.Z transitions (patch vs minor vs major vs downgrade). Those version
|
|
90
|
-
// literals are load-bearing data, not sprinkled release tags.
|
|
39
|
+
// Version comparison is the subject: real X.Y.Z transitions under test.
|
|
91
40
|
"scripts/check-version-bump.js",
|
|
92
41
|
"tests/version-bump-cadence.test.js",
|
|
93
|
-
// The
|
|
94
|
-
// IPv4 / longer-run boundaries and the PHASE_RESIDUE_RES / FILENAME_VERSION_RE
|
|
95
|
-
// / countLineViolations exports, so it MUST embed literal stamps like
|
|
96
|
-
// `0.18.9.`, `0.18.99`, `Pre-0.13.22`, and `foo-v0_13_2.test.js` as the inputs
|
|
97
|
-
// under test — load-bearing data for the detector's boundary cases, not
|
|
98
|
-
// sprinkled release tags.
|
|
42
|
+
// The detector's own boundary cases appear literally as the inputs under test.
|
|
99
43
|
"tests/check-version-tags.test.js",
|
|
100
44
|
]);
|
|
101
45
|
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
// ARE still scanned: a new file a contributor is about to commit is exactly
|
|
106
|
-
// what the gate must catch. Computed via `git check-ignore` over the walked set.
|
|
107
|
-
// Returns the ignored subset, or NULL when git cannot answer.
|
|
108
|
-
//
|
|
109
|
-
// "No path matched" and "the question could not be asked" are different
|
|
110
|
-
// results and must not collapse into the same empty set. Without a repository
|
|
111
|
-
// — a build context that omits .git/, or git not installed — an empty set
|
|
112
|
-
// silently reclassifies every local-only file as part of the shipped surface,
|
|
113
|
-
// so the gate reports violations in files a clone never contains. Returning
|
|
114
|
-
// null lets the caller say it could not determine the surface instead of
|
|
115
|
-
// asserting a wrong one.
|
|
46
|
+
// The ignored subset of `relPaths`, or null when git cannot answer. "No path
|
|
47
|
+
// matched" and "the question could not be asked" must not collapse into the same
|
|
48
|
+
// empty set, which would reclassify every local-only file as shipped surface.
|
|
116
49
|
function gitIgnoredSet(relPaths) {
|
|
117
50
|
if (!relPaths.length) return new Set();
|
|
118
51
|
try {
|
|
@@ -122,9 +55,7 @@ function gitIgnoredSet(relPaths) {
|
|
|
122
55
|
});
|
|
123
56
|
return new Set(out.split(/\r?\n/).filter(Boolean));
|
|
124
57
|
} catch (e) {
|
|
125
|
-
// Exit 1 with no stderr is git's
|
|
126
|
-
// answer, and an empty set is correct. Anything else (git missing, not a
|
|
127
|
-
// repository, .git absent) means the question went unanswered.
|
|
58
|
+
// Exit 1 with no stderr is git's "no path matched" — a real answer, not a failure.
|
|
128
59
|
const status = e && typeof e.status === "number" ? e.status : null;
|
|
129
60
|
const stderr = e && e.stderr ? String(e.stderr).trim() : "";
|
|
130
61
|
const out = e && e.stdout ? String(e.stdout) : "";
|
|
@@ -133,20 +64,11 @@ function gitIgnoredSet(relPaths) {
|
|
|
133
64
|
}
|
|
134
65
|
}
|
|
135
66
|
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
// `
|
|
139
|
-
// The trailing lookahead rejects a longer minor/patch digit (so `0.18.99`
|
|
140
|
-
// still matches, but the stamp can't be part of a wider number) and a
|
|
141
|
-
// dot-followed-by-digit (an IPv4 next octet / longer dotted-numeric run, e.g.
|
|
142
|
-
// `127.0.0.1`, whose `0.0.1` tail would otherwise register). A sentence-ending
|
|
143
|
-
// period after the patch (dot followed by non-digit / end-of-line, e.g.
|
|
144
|
-
// `// fixed in 0.18.9.`) is NOT excluded — that is exactly the version residue
|
|
145
|
-
// the gate must catch. The leading `(?<![\d.])` lookbehind keeps the IPv4
|
|
146
|
-
// suppression on the other side.
|
|
67
|
+
// A pre-1.0 project version, `v0.13.22` or bare; a non-0.x external version such
|
|
68
|
+
// as CycloneDX `1.6` misses. The lookarounds keep the stamp out of a wider number
|
|
69
|
+
// or a dotted run like `127.0.0.1`, whose tail would otherwise register.
|
|
147
70
|
const VERSION_TAG_RE = /(?<![\d.])v?0\.\d+\.\d+(?!\d)(?!\.\d)/;
|
|
148
71
|
|
|
149
|
-
// Phase residue patterns — broader than just version tags.
|
|
150
72
|
const PHASE_RESIDUE_RES = [
|
|
151
73
|
/\bcycle\s+\d+\b/i, // "cycle 13 P3 F3"
|
|
152
74
|
/\bphase\s+\d+(\.\d+)+\b/i,// "phase 9.11k"
|
|
@@ -173,12 +95,6 @@ function walk(dir, results = []) {
|
|
|
173
95
|
return results;
|
|
174
96
|
}
|
|
175
97
|
|
|
176
|
-
// Counts version-stamp lines in a file. Intentionally WHOLE-LINE, not
|
|
177
|
-
// comment-only: a 0.x stamp inside a shipped string literal (CLI --help text,
|
|
178
|
-
// an error message, a test description) is operator-readable residue just like
|
|
179
|
-
// a `//` comment, so it counts the same. A file with a genuinely load-bearing
|
|
180
|
-
// version literal (real test fixture, deprecation timeline) is exempted by path
|
|
181
|
-
// in COMMENT_EXEMPT, not by narrowing the scan.
|
|
182
98
|
function countLineViolations(rel) {
|
|
183
99
|
if (COMMENT_EXEMPT.has(rel)) return 0;
|
|
184
100
|
const ext = path.extname(rel);
|
|
@@ -199,16 +115,11 @@ function countLineViolations(rel) {
|
|
|
199
115
|
function scanCurrent() {
|
|
200
116
|
const files = walk(ROOT);
|
|
201
117
|
const ignored = gitIgnoredSet(files);
|
|
202
|
-
// Without git
|
|
203
|
-
// indistinguishable from tracked ones, so any result would be a guess.
|
|
204
|
-
// Report that rather than emit findings the baseline cannot be compared to.
|
|
118
|
+
// Without git, local-only files are indistinguishable from tracked ones.
|
|
205
119
|
if (ignored === null) return { byFile: {}, filenameViolations: [], surfaceUnknown: true };
|
|
206
120
|
const byFile = {};
|
|
207
121
|
const filenameViolations = [];
|
|
208
122
|
for (const rel of files) {
|
|
209
|
-
// Skip git-ignored, local-only files that `git clone` never ships.
|
|
210
|
-
// Untracked-but-not-ignored files are still scanned — a new file about to
|
|
211
|
-
// be committed is exactly what the gate guards.
|
|
212
123
|
if (ignored.has(rel)) continue;
|
|
213
124
|
if (FILENAME_VERSION_RE.test(rel)) filenameViolations.push(rel);
|
|
214
125
|
const n = countLineViolations(rel);
|
|
@@ -248,15 +159,8 @@ function main() {
|
|
|
248
159
|
const current = scanCurrent();
|
|
249
160
|
|
|
250
161
|
if (current.surfaceUnknown) {
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
// Automation is the one place this must not degrade to a skip. This gate
|
|
255
|
-
// runs inside predeploy, and predeploy guards the publish job, so a
|
|
256
|
-
// silently-skipped run there stops enforcing on exactly the path that
|
|
257
|
-
// ships. Locally — a container built without .git, a tarball inspection —
|
|
258
|
-
// skipping is the honest answer, because the shipped surface genuinely is
|
|
259
|
-
// not knowable there and failing would only punish the harness.
|
|
162
|
+
// A baseline written from this scan would bake in the wrong surface. In
|
|
163
|
+
// automation this fails rather than skips: predeploy guards publishing.
|
|
260
164
|
const inAutomation = process.env.CI === "true" || !!process.env.GITHUB_ACTIONS;
|
|
261
165
|
if (inAutomation) {
|
|
262
166
|
console.error("[check-version-tags] FAIL — no git repository available, so the shipped");
|
|
@@ -291,8 +195,6 @@ function main() {
|
|
|
291
195
|
|
|
292
196
|
const regressions = [];
|
|
293
197
|
|
|
294
|
-
// Filename regressions: any new filename matching the pattern that
|
|
295
|
-
// wasn't in the baseline.
|
|
296
198
|
for (const rel of current.filenameViolations) {
|
|
297
199
|
if (!baseline.filenameViolations.includes(rel)) {
|
|
298
200
|
regressions.push({
|
|
@@ -303,7 +205,6 @@ function main() {
|
|
|
303
205
|
}
|
|
304
206
|
}
|
|
305
207
|
|
|
306
|
-
// Comment regressions: per-file count grew.
|
|
307
208
|
for (const [rel, n] of Object.entries(current.byFile)) {
|
|
308
209
|
const prior = baseline.byFile[rel] || 0;
|
|
309
210
|
if (n > prior) {
|
|
@@ -317,11 +218,9 @@ function main() {
|
|
|
317
218
|
}
|
|
318
219
|
}
|
|
319
220
|
|
|
320
|
-
// Files newly added to the violation set (not in baseline at all).
|
|
321
221
|
for (const rel of Object.keys(current.byFile)) {
|
|
322
222
|
if (!(rel in baseline.byFile)) {
|
|
323
223
|
const n = current.byFile[rel];
|
|
324
|
-
// Skip if already captured as a count regression above.
|
|
325
224
|
if (regressions.some(r => r.path === rel)) continue;
|
|
326
225
|
regressions.push({
|
|
327
226
|
kind: "comment",
|