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