@blamejs/exceptd-skills 0.12.11 → 0.12.15
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 +243 -0
- package/bin/exceptd.js +299 -48
- package/data/_indexes/_meta.json +49 -48
- package/data/_indexes/activity-feed.json +13 -5
- package/data/_indexes/catalog-summaries.json +51 -29
- package/data/_indexes/chains.json +3238 -3210
- package/data/_indexes/frequency.json +3 -0
- package/data/_indexes/jurisdiction-map.json +5 -3
- package/data/_indexes/section-offsets.json +712 -685
- package/data/_indexes/theater-fingerprints.json +1 -1
- package/data/_indexes/token-budget.json +355 -340
- package/data/atlas-ttps.json +144 -129
- package/data/attack-techniques.json +339 -0
- package/data/cve-catalog.json +515 -475
- package/data/cwe-catalog.json +1081 -759
- package/data/exploit-availability.json +63 -15
- package/data/framework-control-gaps.json +867 -843
- package/data/rfc-references.json +276 -276
- package/keys/EXPECTED_FINGERPRINT +1 -0
- package/lib/auto-discovery.js +21 -4
- package/lib/cross-ref-api.js +39 -6
- package/lib/cve-curation.js +505 -47
- package/lib/lint-skills.js +217 -15
- package/lib/playbook-runner.js +1224 -183
- package/lib/prefetch.js +121 -8
- package/lib/refresh-external.js +261 -95
- package/lib/refresh-network.js +208 -18
- package/lib/schemas/manifest.schema.json +16 -0
- package/lib/scoring.js +83 -7
- package/lib/sign.js +112 -3
- package/lib/source-ghsa.js +219 -37
- package/lib/source-osv.js +381 -122
- package/lib/validate-catalog-meta.js +64 -9
- package/lib/validate-cve-catalog.js +213 -7
- package/lib/validate-indexes.js +88 -37
- package/lib/validate-playbooks.js +469 -0
- package/lib/verify.js +313 -16
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -0
- package/manifest.json +73 -73
- package/orchestrator/dispatcher.js +21 -1
- package/orchestrator/event-bus.js +52 -8
- package/orchestrator/index.js +279 -20
- package/orchestrator/pipeline.js +63 -2
- package/orchestrator/scanner.js +32 -10
- package/orchestrator/scheduler.js +196 -20
- package/package.json +3 -1
- package/sbom.cdx.json +9 -9
- package/scripts/check-manifest-snapshot.js +32 -0
- package/scripts/check-sbom-currency.js +65 -3
- package/scripts/check-test-coverage.js +142 -19
- package/scripts/predeploy.js +110 -40
- package/scripts/refresh-manifest-snapshot.js +55 -4
- package/scripts/validate-vendor-online.js +169 -0
- package/scripts/verify-shipped-tarball.js +106 -3
- package/skills/ai-attack-surface/skill.md +18 -10
- package/skills/ai-c2-detection/skill.md +7 -2
- package/skills/ai-risk-management/skill.md +5 -4
- package/skills/api-security/skill.md +3 -3
- package/skills/attack-surface-pentest/skill.md +5 -5
- package/skills/cloud-security/skill.md +1 -1
- package/skills/compliance-theater/skill.md +8 -8
- package/skills/container-runtime-security/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +5 -1
- package/skills/email-security-anti-phishing/skill.md +1 -1
- package/skills/exploit-scoring/skill.md +18 -18
- package/skills/framework-gap-analysis/skill.md +6 -6
- package/skills/global-grc/skill.md +3 -2
- package/skills/identity-assurance/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +4 -4
- package/skills/kernel-lpe-triage/skill.md +21 -2
- package/skills/mcp-agent-trust/skill.md +17 -10
- package/skills/mlops-security/skill.md +2 -1
- package/skills/ot-ics-security/skill.md +1 -1
- package/skills/policy-exception-gen/skill.md +3 -3
- package/skills/pqc-first/skill.md +1 -1
- package/skills/rag-pipeline-security/skill.md +7 -3
- package/skills/researcher/skill.md +20 -3
- package/skills/sector-energy/skill.md +1 -1
- package/skills/sector-federal-government/skill.md +1 -1
- package/skills/sector-financial/skill.md +3 -3
- package/skills/sector-healthcare/skill.md +2 -2
- package/skills/security-maturity-tiers/skill.md +7 -7
- package/skills/skill-update-loop/skill.md +19 -3
- package/skills/supply-chain-integrity/skill.md +1 -1
- package/skills/threat-model-currency/skill.md +11 -11
- package/skills/threat-modeling-methodology/skill.md +3 -3
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +51 -7
- package/vendor/blamejs/_PROVENANCE.json +4 -1
- package/vendor/blamejs/worker-pool.js +38 -0
|
@@ -89,10 +89,49 @@ function git(args, cwd) {
|
|
|
89
89
|
// findings masked. Codex P1 flag on PR #2 of v0.12.8.
|
|
90
90
|
function resolveBaseRef(opts, cwd) {
|
|
91
91
|
if (opts.staged) return null; // staged mode uses --cached / HEAD throughout
|
|
92
|
+
// F14 — fall back gracefully when origin/main is unreachable. The
|
|
93
|
+
// original implementation tried `merge-base HEAD <opts.base>` and, on
|
|
94
|
+
// failure, returned opts.base verbatim — which then failed every
|
|
95
|
+
// subsequent git invocation, surfacing as a runner-level error. In CI
|
|
96
|
+
// (full clones) the original ref usually resolves; on a developer
|
|
97
|
+
// laptop without `origin/main` configured (fresh clone, detached
|
|
98
|
+
// worktree, alternative remote name) the gate would fail entirely.
|
|
99
|
+
//
|
|
100
|
+
// Order of preference:
|
|
101
|
+
// 1. merge-base against the requested base
|
|
102
|
+
// 2. requested base verbatim, if `git rev-parse --verify` resolves it
|
|
103
|
+
// 3. local `main` HEAD if it exists
|
|
104
|
+
// 4. HEAD~1 as a last resort (single-commit diff)
|
|
105
|
+
const tryResolve = (ref) => {
|
|
106
|
+
try {
|
|
107
|
+
git(["merge-base", "HEAD", ref], cwd).trim();
|
|
108
|
+
return ref;
|
|
109
|
+
} catch { /* not resolvable */ }
|
|
110
|
+
try {
|
|
111
|
+
git(["rev-parse", "--verify", ref], cwd).trim();
|
|
112
|
+
return ref;
|
|
113
|
+
} catch { return null; }
|
|
114
|
+
};
|
|
92
115
|
try {
|
|
93
116
|
const mb = git(["merge-base", "HEAD", opts.base], cwd).trim();
|
|
94
117
|
if (mb) return mb;
|
|
95
|
-
} catch { /*
|
|
118
|
+
} catch { /* fall through */ }
|
|
119
|
+
const direct = tryResolve(opts.base);
|
|
120
|
+
if (direct) return direct;
|
|
121
|
+
const local = tryResolve("main");
|
|
122
|
+
if (local) {
|
|
123
|
+
process.stderr.write(
|
|
124
|
+
`[check-test-coverage] WARN: ${opts.base} unreachable; falling back to local main\n`
|
|
125
|
+
);
|
|
126
|
+
return local;
|
|
127
|
+
}
|
|
128
|
+
const parent = tryResolve("HEAD~1");
|
|
129
|
+
if (parent) {
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
`[check-test-coverage] WARN: ${opts.base} unreachable and no local main; falling back to HEAD~1\n`
|
|
132
|
+
);
|
|
133
|
+
return parent;
|
|
134
|
+
}
|
|
96
135
|
return opts.base;
|
|
97
136
|
}
|
|
98
137
|
|
|
@@ -165,6 +204,23 @@ function categorize(file) {
|
|
|
165
204
|
if (norm.startsWith("scripts/") && norm.endsWith(".js")) return "lib";
|
|
166
205
|
if (norm.startsWith("data/playbooks/") && norm.endsWith(".json")) return "playbook";
|
|
167
206
|
if (norm === "data/cve-catalog.json") return "cve-catalog";
|
|
207
|
+
// F11 — files matching catalog/schema/SBOM shapes are surfaced for manual
|
|
208
|
+
// review rather than silent allowlist. These changes (manifest.json,
|
|
209
|
+
// schemas/*, data/*.json, sbom.cdx.json, manifest-snapshot.*) can carry
|
|
210
|
+
// semantic surface but the analyzer has no syntactic surface extractor
|
|
211
|
+
// for them — humans should look.
|
|
212
|
+
if (norm === "manifest.json") return "manual-review";
|
|
213
|
+
if (norm === "manifest-snapshot.json") return "manual-review";
|
|
214
|
+
if (norm === "manifest-snapshot.sha256") return "manual-review";
|
|
215
|
+
if (norm === "sbom.cdx.json") return "manual-review";
|
|
216
|
+
if (norm.startsWith("lib/schemas/")) return "manual-review";
|
|
217
|
+
// v0.12.14: data/_indexes/ is auto-regenerated from data/ + manifest by
|
|
218
|
+
// `npm run build-indexes`; the source-of-truth diff is in the data/
|
|
219
|
+
// files themselves. Allowlist the derived index files so they don't
|
|
220
|
+
// perpetually surface as manual-review on every release commit.
|
|
221
|
+
if (norm.startsWith("data/_indexes/")) return "allowlist-derived";
|
|
222
|
+
if (norm.startsWith("data/") && norm.endsWith(".json")) return "manual-review";
|
|
223
|
+
if (norm === "package.json") return "manual-review";
|
|
168
224
|
return "other";
|
|
169
225
|
}
|
|
170
226
|
|
|
@@ -271,15 +327,20 @@ function safeParse(s) { try { return s ? JSON.parse(s) : null; } catch { return
|
|
|
271
327
|
|
|
272
328
|
function loadTestCorpus(cwd) {
|
|
273
329
|
const root = path.join(cwd, "tests");
|
|
274
|
-
if (!fs.existsSync(root)) return "";
|
|
330
|
+
if (!fs.existsSync(root)) return { joined: "", files: [] };
|
|
275
331
|
const acc = [];
|
|
332
|
+
const files = [];
|
|
276
333
|
walk(root, p => {
|
|
277
334
|
const norm = p.replace(/\\/g, "/");
|
|
278
335
|
if (/\.(js|json)$/.test(norm)) {
|
|
279
|
-
try {
|
|
336
|
+
try {
|
|
337
|
+
const content = fs.readFileSync(p, "utf8");
|
|
338
|
+
acc.push(content);
|
|
339
|
+
files.push({ path: norm, content });
|
|
340
|
+
} catch { /* ignore unreadable */ }
|
|
280
341
|
}
|
|
281
342
|
});
|
|
282
|
-
return acc.join("\n\x00\n");
|
|
343
|
+
return { joined: acc.join("\n\x00\n"), files };
|
|
283
344
|
}
|
|
284
345
|
|
|
285
346
|
function walk(dir, fn) {
|
|
@@ -302,22 +363,76 @@ function coversCliFlag(corpus, flag) {
|
|
|
302
363
|
return corpus.includes(flag);
|
|
303
364
|
}
|
|
304
365
|
|
|
366
|
+
// F10 — same-file context check. A test corpus is no longer treated as
|
|
367
|
+
// one giant string for lib-export coverage: the identifier must appear
|
|
368
|
+
// inside a real test block (`test(`, `it(`, `describe(`, or an `assert(`
|
|
369
|
+
// argument) within the SAME file that issues the matching require().
|
|
370
|
+
// Pre-fix: an `assert.equal(...)` mention in one test file plus a stray
|
|
371
|
+
// `require('../lib/x')` in a completely different test file counted as
|
|
372
|
+
// coverage. That's not coverage — it's textual coincidence.
|
|
373
|
+
//
|
|
374
|
+
// `corpus` may be either a string (legacy joined corpus, used by
|
|
375
|
+
// CLI/playbook/CVE coverage probes) or the structured shape
|
|
376
|
+
// `{ joined, files }` produced by loadTestCorpus().
|
|
305
377
|
function coversLibExport(corpus, libRel, ident) {
|
|
306
378
|
const baseName = path.basename(libRel).replace(/\.js$/, "");
|
|
307
379
|
const baseFile = path.basename(libRel); // e.g. "check-sbom-currency.js"
|
|
308
380
|
const identRe = new RegExp("\\b" + escapeRe(ident) + "\\b");
|
|
309
|
-
// Primary: a `require()` that mentions the module name AND a reference
|
|
310
|
-
// to the identifier anywhere in the test corpus. Catches the canonical
|
|
311
|
-
// `const { foo } = require('../lib/x')` test shape.
|
|
312
381
|
const requireRe = new RegExp("require\\([^)]*" + escapeRe(baseName) + "[^)]*\\)");
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
382
|
+
// Accept the structured shape (preferred). Walk files individually.
|
|
383
|
+
if (corpus && Array.isArray(corpus.files)) {
|
|
384
|
+
for (const f of corpus.files) {
|
|
385
|
+
const hasRequire = requireRe.test(f.content);
|
|
386
|
+
const mentionsSpawnPath = f.content.includes(baseFile);
|
|
387
|
+
if (!hasRequire && !mentionsSpawnPath) continue;
|
|
388
|
+
if (!identRe.test(f.content)) continue;
|
|
389
|
+
// F10 — require the identifier appears inside a test block in this
|
|
390
|
+
// file. Recognise `test(`, `it(`, `describe(`, or `assert(` (or any
|
|
391
|
+
// `assert.<member>(`) bracketed argument that mentions the ident.
|
|
392
|
+
if (mentionsIdentInTestContext(f.content, ident)) return true;
|
|
393
|
+
}
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
// Fallback: legacy joined-string corpus.
|
|
397
|
+
const joined = typeof corpus === "string" ? corpus : (corpus && corpus.joined) || "";
|
|
398
|
+
if (requireRe.test(joined) && identRe.test(joined)) return true;
|
|
399
|
+
if (joined.includes(baseFile) && identRe.test(joined)) return true;
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Returns true when `ident` appears as a token inside the body of any
|
|
404
|
+
// `test( ... )`, `it( ... )`, `describe( ... )`, `assert( ... )` or
|
|
405
|
+
// `assert.<member>( ... )` call in the file. We approximate "the body of
|
|
406
|
+
// the call" by finding the opening paren after the keyword, then walking
|
|
407
|
+
// matched parens until the call closes. This is a syntactic-enough check
|
|
408
|
+
// for vanilla JavaScript tests; the goal is to refuse "ident only appears
|
|
409
|
+
// in a top-level comment" while still accepting `assert.deepEqual(foo, ...)`.
|
|
410
|
+
function mentionsIdentInTestContext(content, ident) {
|
|
411
|
+
const tokenRe = new RegExp("\\b" + escapeRe(ident) + "\\b");
|
|
412
|
+
// Quick reject: file does not mention the identifier at all.
|
|
413
|
+
if (!tokenRe.test(content)) return false;
|
|
414
|
+
const callRe = /\b(test|it|describe|assert(?:\.[A-Za-z_$][\w$]*)?)\s*\(/g;
|
|
415
|
+
let m;
|
|
416
|
+
while ((m = callRe.exec(content)) !== null) {
|
|
417
|
+
const start = m.index + m[0].length; // pointer to first char inside (
|
|
418
|
+
let depth = 1;
|
|
419
|
+
let i = start;
|
|
420
|
+
let inStr = null;
|
|
421
|
+
while (i < content.length && depth > 0) {
|
|
422
|
+
const c = content[i];
|
|
423
|
+
if (inStr) {
|
|
424
|
+
if (c === "\\") { i += 2; continue; }
|
|
425
|
+
if (c === inStr) inStr = null;
|
|
426
|
+
} else {
|
|
427
|
+
if (c === '"' || c === "'" || c === "`") inStr = c;
|
|
428
|
+
else if (c === "(") depth++;
|
|
429
|
+
else if (c === ")") depth--;
|
|
430
|
+
}
|
|
431
|
+
i++;
|
|
432
|
+
}
|
|
433
|
+
const body = content.slice(start, i - 1);
|
|
434
|
+
if (tokenRe.test(body)) return true;
|
|
435
|
+
}
|
|
321
436
|
return false;
|
|
322
437
|
}
|
|
323
438
|
|
|
@@ -341,7 +456,8 @@ function analyze(opts) {
|
|
|
341
456
|
// between calls produces false add/remove findings.
|
|
342
457
|
const resolvedBase = resolveBaseRef(opts, cwd);
|
|
343
458
|
const changed = listChangedFiles(opts, cwd, resolvedBase);
|
|
344
|
-
const
|
|
459
|
+
const corpusObj = loadTestCorpus(cwd);
|
|
460
|
+
const corpus = corpusObj.joined;
|
|
345
461
|
|
|
346
462
|
const findings = [];
|
|
347
463
|
const allowlisted = [];
|
|
@@ -355,7 +471,12 @@ function analyze(opts) {
|
|
|
355
471
|
}
|
|
356
472
|
if (cat === "skill") { allowlisted.push({ file: ch.file, reason: "skill-signed" }); continue; }
|
|
357
473
|
if (cat === "workflow") { manualReview.push({ file: ch.file, reason: "workflow" }); continue; }
|
|
358
|
-
|
|
474
|
+
// F11 — data catalogs, schemas, manifests, SBOM go to manual review
|
|
475
|
+
// instead of being silently allowlisted. They show up in CI output.
|
|
476
|
+
if (cat === "manual-review") { manualReview.push({ file: ch.file, reason: "manual-review" }); continue; }
|
|
477
|
+
// v0.12.14: derived index files allowlist (auto-regenerated artifacts).
|
|
478
|
+
if (cat === "allowlist-derived") { allowlisted.push({ file: ch.file, reason: "derived-artifact" }); continue; }
|
|
479
|
+
if (cat === "other") { manualReview.push({ file: ch.file, reason: "unclassified" }); continue; }
|
|
359
480
|
if (ch.status !== "D" && isWhitespaceOnly(opts, ch.file, cwd, resolvedBase)) {
|
|
360
481
|
allowlisted.push({ file: ch.file, reason: "whitespace-only" });
|
|
361
482
|
continue;
|
|
@@ -381,9 +502,11 @@ function analyze(opts) {
|
|
|
381
502
|
const b = extractLibExports(before);
|
|
382
503
|
const a = extractLibExports(after);
|
|
383
504
|
const d = diffSets(b, a);
|
|
384
|
-
|
|
505
|
+
// F10 — pass the structured corpus so coversLibExport can enforce
|
|
506
|
+
// same-file require()+identifier-in-test-context coverage.
|
|
507
|
+
for (const id of d.added) if (!coversLibExport(corpusObj, ch.file, id))
|
|
385
508
|
findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "added" });
|
|
386
|
-
for (const id of d.removed) if (coversLibExport(
|
|
509
|
+
for (const id of d.removed) if (coversLibExport(corpusObj, ch.file, id))
|
|
387
510
|
findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "removed-but-test-remains" });
|
|
388
511
|
} else if (cat === "playbook") {
|
|
389
512
|
const b = extractPlaybookIds(before);
|
package/scripts/predeploy.js
CHANGED
|
@@ -19,6 +19,15 @@
|
|
|
19
19
|
* Single-source-of-truth: the GATES list below mirrors the job sequence
|
|
20
20
|
* in .github/workflows/ci.yml. Test coverage in tests/predeploy.test.js
|
|
21
21
|
* asserts the two stay in sync.
|
|
22
|
+
*
|
|
23
|
+
* Audit G F5 — when the manifest-snapshot gate fails, the fix is NOT to
|
|
24
|
+
* run `npm run refresh-snapshot` blindly. The refresh script now refuses
|
|
25
|
+
* unless the operator passes `--commit-only` or sets
|
|
26
|
+
* EXCEPTD_SNAPSHOT_AUDIT_ACK=1. This is intentional: a failing snapshot
|
|
27
|
+
* gate means a breaking change was detected, and an accidental refresh
|
|
28
|
+
* would silently rewrite the baseline. Read the breaking-change list
|
|
29
|
+
* first, then run `node scripts/refresh-manifest-snapshot.js --commit-only`
|
|
30
|
+
* if the change is intentional.
|
|
22
31
|
*/
|
|
23
32
|
|
|
24
33
|
const { execFileSync } = require("child_process");
|
|
@@ -62,28 +71,15 @@ const GATES = [
|
|
|
62
71
|
args: [path.join(ROOT, "lib", "validate-cve-catalog.js")],
|
|
63
72
|
ciJobName: "Data integrity (catalog + manifest snapshot)",
|
|
64
73
|
},
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
ciJobName: "Data integrity (catalog + manifest snapshot)",
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
name: "Validate offline RFC catalog state",
|
|
78
|
-
command: process.execPath,
|
|
79
|
-
args: [
|
|
80
|
-
path.join(ROOT, "orchestrator", "index.js"),
|
|
81
|
-
"validate-rfcs",
|
|
82
|
-
"--offline",
|
|
83
|
-
"--no-fail",
|
|
84
|
-
],
|
|
85
|
-
ciJobName: "Data integrity (catalog + manifest snapshot)",
|
|
86
|
-
},
|
|
74
|
+
// Audit G F13 — the "validate-cves --offline --no-fail" and
|
|
75
|
+
// "validate-rfcs --offline --no-fail" gates were enumeration-only sanity
|
|
76
|
+
// checks: `--no-fail` forced them to always exit 0, so they never blocked
|
|
77
|
+
// a release on a real catalog problem. The deep catalog validation is
|
|
78
|
+
// already performed by the gate above (`lib/validate-cve-catalog.js`),
|
|
79
|
+
// including cross-catalog reference resolution after this same audit.
|
|
80
|
+
// Keeping the no-op gates as predeploy steps inflated the gate count for
|
|
81
|
+
// no marginal value and risked false confidence ("X gates passed"). They
|
|
82
|
+
// are removed in v0.12.14; document the removal in CHANGELOG.
|
|
87
83
|
{
|
|
88
84
|
name: "Manifest snapshot gate (breaking-change detector)",
|
|
89
85
|
command: process.execPath,
|
|
@@ -97,9 +93,13 @@ const GATES = [
|
|
|
97
93
|
ciJobName: "Lint skill files",
|
|
98
94
|
},
|
|
99
95
|
{
|
|
100
|
-
// Informational
|
|
101
|
-
//
|
|
102
|
-
// a
|
|
96
|
+
// Informational — surfaces the forward_watch horizon across all skills.
|
|
97
|
+
// Audit G F12: an exit code of 0 means "ok", 1 means "items present
|
|
98
|
+
// (informational)", 2+ means a runtime error in the gate itself.
|
|
99
|
+
// The runner now distinguishes the two: 0/1 stay informational, 2+
|
|
100
|
+
// surface as a real failure. Pre-fix, any non-zero exit was rolled up
|
|
101
|
+
// as informational, which hid crashes (a 137 OOM looked the same as
|
|
102
|
+
// "found 12 items to review").
|
|
103
103
|
name: "Forward-watch aggregator (informational)",
|
|
104
104
|
command: process.execPath,
|
|
105
105
|
args: [
|
|
@@ -108,6 +108,7 @@ const GATES = [
|
|
|
108
108
|
],
|
|
109
109
|
ciJobName: "Data integrity (catalog + manifest snapshot)",
|
|
110
110
|
informational: true,
|
|
111
|
+
informationalMaxExitCode: 1,
|
|
111
112
|
},
|
|
112
113
|
{
|
|
113
114
|
name: "Validate catalog _meta (tlp + source_confidence + freshness_policy)",
|
|
@@ -164,6 +165,19 @@ const GATES = [
|
|
|
164
165
|
args: [path.join(ROOT, "scripts", "check-test-coverage.js")],
|
|
165
166
|
ciJobName: "Diff coverage",
|
|
166
167
|
},
|
|
168
|
+
{
|
|
169
|
+
// v0.12.12 — Validate every playbook in data/playbooks/ against the
|
|
170
|
+
// JSON schema + cross-playbook + cross-catalog references. Runs as
|
|
171
|
+
// informational (warnings, not failures) for v0.12.12 so the patch-
|
|
172
|
+
// class release can land without retroactively breaking schema-drift
|
|
173
|
+
// cases that operators have not yet reconciled. v0.13.0 will flip
|
|
174
|
+
// `informational: false`.
|
|
175
|
+
name: "Validate playbooks (schema + cross-refs, informational)",
|
|
176
|
+
command: process.execPath,
|
|
177
|
+
args: [path.join(ROOT, "lib", "validate-playbooks.js")],
|
|
178
|
+
ciJobName: "Validate playbooks",
|
|
179
|
+
informational: true,
|
|
180
|
+
},
|
|
167
181
|
];
|
|
168
182
|
|
|
169
183
|
function runGate(gate) {
|
|
@@ -174,26 +188,70 @@ function runGate(gate) {
|
|
|
174
188
|
status: "skipped",
|
|
175
189
|
reason:
|
|
176
190
|
"keys/public.pem missing — run `npm run bootstrap` to generate keys + sign skills.",
|
|
191
|
+
durationMs: 0,
|
|
177
192
|
};
|
|
178
193
|
}
|
|
179
194
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
195
|
+
const t0 = Date.now();
|
|
196
|
+
// Audit G F21 — spawn the child with piped stdio + tee to the parent so we
|
|
197
|
+
// can count `WARN ` lines for the summary table. We still want the live
|
|
198
|
+
// output, so each chunk is forwarded as it arrives.
|
|
199
|
+
const { spawnSync } = require("child_process");
|
|
200
|
+
const r = spawnSync(gate.command, gate.args, {
|
|
201
|
+
cwd: ROOT,
|
|
202
|
+
encoding: "utf8",
|
|
203
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
204
|
+
});
|
|
205
|
+
const durationMs = Date.now() - t0;
|
|
206
|
+
if (r.stdout) process.stdout.write(r.stdout);
|
|
207
|
+
if (r.stderr) process.stderr.write(r.stderr);
|
|
208
|
+
// Count WARN-labelled lines in the combined stream so the summary table
|
|
209
|
+
// can surface them. Lint / validate output uses "WARN " at line start;
|
|
210
|
+
// count both the table form and an inline "[warn]" form.
|
|
211
|
+
const combined = (r.stdout || "") + (r.stderr || "");
|
|
212
|
+
const warnCount = (
|
|
213
|
+
combined.match(/^WARN\b/gm) || []
|
|
214
|
+
).length + (
|
|
215
|
+
combined.match(/\[warn\]/g) || []
|
|
216
|
+
).length;
|
|
217
|
+
if (r.status === 0) {
|
|
218
|
+
return { status: "passed", durationMs, warnCount };
|
|
219
|
+
}
|
|
220
|
+
// Audit G F12 — gates may declare informationalMaxExitCode to distinguish
|
|
221
|
+
// "soft signal" (exit codes 0..N) from "crash" (> N). Default behaviour
|
|
222
|
+
// for an informational gate without that field stays the same.
|
|
223
|
+
if (gate.informational) {
|
|
224
|
+
const ceil = typeof gate.informationalMaxExitCode === "number"
|
|
225
|
+
? gate.informationalMaxExitCode
|
|
226
|
+
: Infinity;
|
|
227
|
+
if (r.status !== null && r.status > ceil) {
|
|
185
228
|
return {
|
|
186
|
-
status: "
|
|
187
|
-
exitCode:
|
|
188
|
-
message:
|
|
229
|
+
status: "failed",
|
|
230
|
+
exitCode: r.status,
|
|
231
|
+
message: `informational gate crashed (exit ${r.status} > informationalMaxExitCode=${ceil})`,
|
|
232
|
+
durationMs,
|
|
233
|
+
warnCount,
|
|
189
234
|
};
|
|
190
235
|
}
|
|
191
236
|
return {
|
|
192
|
-
status: "
|
|
193
|
-
exitCode:
|
|
194
|
-
|
|
237
|
+
status: "informational",
|
|
238
|
+
exitCode: r.status ?? null,
|
|
239
|
+
durationMs,
|
|
240
|
+
warnCount,
|
|
195
241
|
};
|
|
196
242
|
}
|
|
243
|
+
return {
|
|
244
|
+
status: "failed",
|
|
245
|
+
exitCode: r.status ?? null,
|
|
246
|
+
message: r.error ? r.error.message : `exit ${r.status}`,
|
|
247
|
+
durationMs,
|
|
248
|
+
warnCount,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function fmtMs(ms) {
|
|
253
|
+
if (typeof ms !== "number" || !Number.isFinite(ms)) return "";
|
|
254
|
+
return `${ms} ms`;
|
|
197
255
|
}
|
|
198
256
|
|
|
199
257
|
function main() {
|
|
@@ -202,17 +260,19 @@ function main() {
|
|
|
202
260
|
process.stdout.write(`\n=== ${gate.name} ===\n`);
|
|
203
261
|
const outcome = runGate(gate);
|
|
204
262
|
results.push({ gate, outcome });
|
|
263
|
+
const timing = fmtMs(outcome.durationMs);
|
|
264
|
+
const timingSuffix = timing ? ` (${timing})` : "";
|
|
205
265
|
if (outcome.status === "skipped") {
|
|
206
266
|
process.stdout.write(` ⊘ skipped — ${outcome.reason}\n`);
|
|
207
267
|
} else if (outcome.status === "passed") {
|
|
208
|
-
process.stdout.write(` ✓ passed\n`);
|
|
268
|
+
process.stdout.write(` ✓ passed${timingSuffix}\n`);
|
|
209
269
|
} else if (outcome.status === "informational") {
|
|
210
270
|
process.stdout.write(
|
|
211
|
-
` ℹ informational (exit ${outcome.exitCode ?? "?"}) — not failing the run\n`
|
|
271
|
+
` ℹ informational (exit ${outcome.exitCode ?? "?"})${timingSuffix} — not failing the run\n`
|
|
212
272
|
);
|
|
213
273
|
} else {
|
|
214
274
|
process.stdout.write(
|
|
215
|
-
` ✗ failed (exit ${outcome.exitCode ?? "?"}): ${outcome.message}\n`
|
|
275
|
+
` ✗ failed (exit ${outcome.exitCode ?? "?"})${timingSuffix}: ${outcome.message}\n`
|
|
216
276
|
);
|
|
217
277
|
}
|
|
218
278
|
}
|
|
@@ -232,8 +292,18 @@ function main() {
|
|
|
232
292
|
: outcome.status === "informational"
|
|
233
293
|
? "ℹ"
|
|
234
294
|
: "✗";
|
|
295
|
+
const timing = fmtMs(outcome.durationMs);
|
|
296
|
+
const timingSuffix = timing ? ` (${timing})` : "";
|
|
297
|
+
// F21 — surface WARN counts so a gate that "passed (3 warnings)" is
|
|
298
|
+
// distinguishable from one that passed cleanly. Pre-fix, warnings
|
|
299
|
+
// printed by individual gates (validate-cve-catalog, lint-skills,
|
|
300
|
+
// validate-playbooks) scrolled past invisible in the summary.
|
|
301
|
+
const warnSuffix =
|
|
302
|
+
outcome.warnCount && outcome.warnCount > 0
|
|
303
|
+
? ` (${outcome.warnCount} warning${outcome.warnCount === 1 ? "" : "s"})`
|
|
304
|
+
: "";
|
|
235
305
|
process.stdout.write(
|
|
236
|
-
` ${icon} ${gate.name.padEnd(widest)} ${outcome.status}\n`
|
|
306
|
+
` ${icon} ${gate.name.padEnd(widest)} ${outcome.status}${warnSuffix}${timingSuffix}\n`
|
|
237
307
|
);
|
|
238
308
|
}
|
|
239
309
|
|
|
@@ -11,10 +11,22 @@
|
|
|
11
11
|
* blindly — read the breaking-change list first. A breaking change is
|
|
12
12
|
* a surface narrowing every downstream consumer needs to know about.
|
|
13
13
|
*
|
|
14
|
+
* Audit G F5 — commitOnly mode. Pass `--commit-only` (or set the env
|
|
15
|
+
* EXCEPTD_SNAPSHOT_AUDIT_ACK=1) to acknowledge that the operator
|
|
16
|
+
* deliberately wants to overwrite the committed snapshot. When neither
|
|
17
|
+
* flag nor env is set AND the snapshot would actually change, the
|
|
18
|
+
* script refuses and emits a structured diff hint. This stops an
|
|
19
|
+
* accidental `npm run refresh-snapshot` (run as muscle-memory while
|
|
20
|
+
* triaging a failing gate) from masking a real breaking change.
|
|
21
|
+
*
|
|
14
22
|
* Usage:
|
|
15
|
-
* node scripts/refresh-manifest-snapshot.js
|
|
16
|
-
*
|
|
17
|
-
*
|
|
23
|
+
* node scripts/refresh-manifest-snapshot.js # dry-shows the diff
|
|
24
|
+
* EXCEPTD_SNAPSHOT_AUDIT_ACK=1 \
|
|
25
|
+
* node scripts/refresh-manifest-snapshot.js # writes the new snapshot
|
|
26
|
+
* node scripts/refresh-manifest-snapshot.js --commit-only # same thing, on argv
|
|
27
|
+
*
|
|
28
|
+
* The flag is documented in scripts/predeploy.js so contributors see it
|
|
29
|
+
* the moment the snapshot gate fails.
|
|
18
30
|
*/
|
|
19
31
|
|
|
20
32
|
const fs = require("fs");
|
|
@@ -50,8 +62,47 @@ function captureSurface(manifest) {
|
|
|
50
62
|
|
|
51
63
|
const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8"));
|
|
52
64
|
const snapshot = captureSurface(manifest);
|
|
65
|
+
const newJson = JSON.stringify(snapshot, null, 2) + "\n";
|
|
66
|
+
|
|
67
|
+
// F5 — refuse to overwrite an existing snapshot unless the operator
|
|
68
|
+
// has explicitly acknowledged the rewrite (env or --commit-only flag).
|
|
69
|
+
const argv = process.argv.slice(2);
|
|
70
|
+
const commitOnly =
|
|
71
|
+
argv.includes("--commit-only") ||
|
|
72
|
+
process.env.EXCEPTD_SNAPSHOT_AUDIT_ACK === "1";
|
|
53
73
|
|
|
54
|
-
fs.
|
|
74
|
+
if (fs.existsSync(SNAPSHOT_PATH) && !commitOnly) {
|
|
75
|
+
const current = fs.readFileSync(SNAPSHOT_PATH, "utf8");
|
|
76
|
+
// Normalise the _generated_at timestamp for comparison — that field
|
|
77
|
+
// changes every run and shouldn't trigger the guard.
|
|
78
|
+
const stripGenerated = (s) => s.replace(
|
|
79
|
+
/"_generated_at":\s*"[^"]+",?\s*\n?/, ""
|
|
80
|
+
);
|
|
81
|
+
if (stripGenerated(current) === stripGenerated(newJson)) {
|
|
82
|
+
console.log("[refresh-manifest-snapshot] snapshot unchanged — nothing to do.");
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
process.stderr.write(
|
|
86
|
+
"[refresh-manifest-snapshot] REFUSING to overwrite manifest-snapshot.json — " +
|
|
87
|
+
"the captured surface differs from the committed snapshot.\n" +
|
|
88
|
+
" Re-run with `--commit-only` (or EXCEPTD_SNAPSHOT_AUDIT_ACK=1) to confirm " +
|
|
89
|
+
"the rewrite is intentional. The check-manifest-snapshot.js gate exists to " +
|
|
90
|
+
"force a deliberate decision about removed skills / triggers / refs before " +
|
|
91
|
+
"the baseline is rewritten.\n"
|
|
92
|
+
);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fs.writeFileSync(SNAPSHOT_PATH, newJson, "utf8");
|
|
55
97
|
|
|
56
98
|
console.log(`[refresh-manifest-snapshot] wrote ${snapshot.skill_count} skills to manifest-snapshot.json`);
|
|
57
99
|
console.log("[refresh-manifest-snapshot] commit this file alongside the surface change.");
|
|
100
|
+
|
|
101
|
+
// Audit G F23 — write a tracked SHA-256 of the snapshot so the
|
|
102
|
+
// check-manifest-snapshot.js gate can verify integrity (no hand edits
|
|
103
|
+
// after refresh).
|
|
104
|
+
const crypto = require("crypto");
|
|
105
|
+
const snapshotSha = crypto.createHash("sha256").update(newJson).digest("hex");
|
|
106
|
+
const snapshotShaPath = path.join(ROOT, "manifest-snapshot.sha256");
|
|
107
|
+
fs.writeFileSync(snapshotShaPath, snapshotSha + " manifest-snapshot.json\n", "utf8");
|
|
108
|
+
console.log(`[refresh-manifest-snapshot] wrote integrity hash to manifest-snapshot.sha256`);
|