@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
|
@@ -1,38 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Diff-aware test-coverage gate. Compares the changed surface in the working
|
|
5
|
+
* tree (or a staged set, or any --base..HEAD range) against the tests/ tree
|
|
6
|
+
* and reports any surface change that lacks a covering test.
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* Surfaces: CLI verbs and flags in bin/exceptd.js, exported functions in
|
|
9
|
+
* lib / orchestrator / scripts, playbook detect-indicator and look-artifact
|
|
10
|
+
* ids, and CVE entries whose iocs changed. Docs, tooling dotfiles, tests/
|
|
11
|
+
* itself and derived indexes are allowlisted; workflows, manifests, schemas,
|
|
12
|
+
* SBOM and unclassified files are surfaced as manual-review, never auto-green.
|
|
9
13
|
*
|
|
10
|
-
*
|
|
11
|
-
* - bin/exceptd.js CLI verbs / flags (COMMANDS / PLAYBOOK_VERBS)
|
|
12
|
-
* - lib/*.js, orchestrator/*.js,
|
|
13
|
-
* scripts/*.js exported functions (module.exports = {...})
|
|
14
|
-
* - data/playbooks/*.json detect.indicators[].id + look.artifacts[].id
|
|
15
|
-
* - data/cve-catalog.json CVE entries whose iocs field changed
|
|
16
|
-
*
|
|
17
|
-
* Categorization (no test required):
|
|
18
|
-
* - *.md outside data/, .gitignore, .npmrc, .editorconfig
|
|
19
|
-
* - CHANGELOG.md / README.md / CONTRIBUTING.md / SECURITY.md
|
|
20
|
-
* - whitespace-only diffs (re-run with --ignore-all-space)
|
|
21
|
-
* - tests/** changes (no recursion)
|
|
22
|
-
* - .github/workflows/*.yml surfaced as manual-review-required
|
|
23
|
-
* - skills/<name>/skill.md satisfied by Ed25519 verify gate
|
|
24
|
-
*
|
|
25
|
-
* Exit codes:
|
|
26
|
-
* 0 no uncovered surface (or --warn-only)
|
|
27
|
-
* 1 uncovered surface detected
|
|
28
|
-
* 2 runner error (bad flag, git failure, etc.)
|
|
29
|
-
*
|
|
30
|
-
* Flags:
|
|
31
|
-
* --base <ref> compare HEAD against <ref> (default: origin/main)
|
|
32
|
-
* --staged use the staged index against HEAD
|
|
33
|
-
* --json emit machine-readable report on stdout
|
|
34
|
-
* --warn-only print but never exit non-zero
|
|
35
|
-
* --help, -h this help
|
|
14
|
+
* Exit codes: 0 clean or --warn-only, 1 uncovered surface, 2 runner error.
|
|
36
15
|
*/
|
|
37
16
|
|
|
38
17
|
const fs = require("fs");
|
|
@@ -41,8 +20,6 @@ const childProc = require("child_process");
|
|
|
41
20
|
|
|
42
21
|
const ROOT = path.resolve(__dirname, "..");
|
|
43
22
|
|
|
44
|
-
// --- Flag parsing -----------------------------------------------------------
|
|
45
|
-
|
|
46
23
|
function parseArgs(argv) {
|
|
47
24
|
const out = { base: "origin/main", staged: false, json: false, warnOnly: false };
|
|
48
25
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -67,8 +44,6 @@ function printHelp() {
|
|
|
67
44
|
process.stdout.write(banner);
|
|
68
45
|
}
|
|
69
46
|
|
|
70
|
-
// --- Git plumbing -----------------------------------------------------------
|
|
71
|
-
|
|
72
47
|
function git(args, cwd) {
|
|
73
48
|
const r = childProc.spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
74
49
|
if (r.status !== 0) {
|
|
@@ -79,29 +54,14 @@ function git(args, cwd) {
|
|
|
79
54
|
return r.stdout;
|
|
80
55
|
}
|
|
81
56
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
// `opts.base` ref — so if origin/main advanced past the merge-base between
|
|
86
|
-
// the file-list call and the per-file diff calls, the analyzer compared
|
|
87
|
-
// per-file content against a newer upstream tree than the file list itself
|
|
88
|
-
// was derived from. Result: false "added/removed" surface findings or real
|
|
89
|
-
// findings masked. Codex P1 flag on PR #2 of v0.12.8.
|
|
57
|
+
// The diff anchor resolves ONCE and the resolved SHA threads through every
|
|
58
|
+
// per-file call: passing the raw ref lets origin/main advance mid-run, comparing
|
|
59
|
+
// content against a newer tree than the file list came from.
|
|
90
60
|
function resolveBaseRef(opts, cwd) {
|
|
91
61
|
if (opts.staged) return null; // staged mode uses --cached / HEAD throughout
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
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)
|
|
62
|
+
// origin/main is not always reachable — fresh clone, detached worktree, remote
|
|
63
|
+
// under another name — and an unresolvable ref fails every later git call as a
|
|
64
|
+
// runner error rather than a coverage result.
|
|
105
65
|
const tryResolve = (ref) => {
|
|
106
66
|
try {
|
|
107
67
|
git(["merge-base", "HEAD", ref], cwd).trim();
|
|
@@ -160,12 +120,9 @@ function fileDiff(opts, file, cwd, ignoreWs, resolvedBase) {
|
|
|
160
120
|
}
|
|
161
121
|
|
|
162
122
|
function fileAtRef(file, ref, cwd) {
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
// return is the documented "missing" sentinel — silent truncation
|
|
167
|
-
// would make every entry in the live file appear as a fresh add and
|
|
168
|
-
// generate hundreds of bogus diff-coverage findings.
|
|
123
|
+
// maxBuffer far above Node's 1 MiB default: an ENOBUFS-truncated read of a
|
|
124
|
+
// multi-MiB catalog makes every entry in the live file look freshly added.
|
|
125
|
+
// Null is the "missing" sentinel, so a failure never returns partial content.
|
|
169
126
|
const r = childProc.spawnSync("git", ["show", ref + ":" + file], {
|
|
170
127
|
cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024
|
|
171
128
|
});
|
|
@@ -190,23 +147,14 @@ function readMaybe(p) {
|
|
|
190
147
|
try { return fs.readFileSync(p, "utf8"); } catch { return null; }
|
|
191
148
|
}
|
|
192
149
|
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
// Mechanical / contributor-only docs the gate auto-allows: their content
|
|
196
|
-
// has no operator-facing semantic surface (CONTRIBUTING is for PRs;
|
|
197
|
-
// LICENSE / NOTICE / CODE_OF_CONDUCT are boilerplate; .gitignore / .npmrc
|
|
198
|
-
// / .editorconfig are tooling). Edits here never need a regression test.
|
|
150
|
+
// Contributor-only docs and tooling dotfiles: no semantic surface to test.
|
|
199
151
|
const DOCS_ALWAYS_GREEN = new Set([
|
|
200
152
|
"CONTRIBUTING.md", "LICENSE", "NOTICE", "CODE_OF_CONDUCT.md",
|
|
201
153
|
"SUPPORT.md", ".gitignore", ".npmrc", ".editorconfig",
|
|
202
154
|
]);
|
|
203
155
|
|
|
204
|
-
// Operator-facing docs
|
|
205
|
-
//
|
|
206
|
-
// auto-green — a PR could otherwise land deceptive copy here without any
|
|
207
|
-
// reviewer signal. Downgrade to manual-review so the diff surfaces in the
|
|
208
|
-
// gate output — a human (or the maintainer reviewing the bot summary) at
|
|
209
|
-
// least sees the change exists.
|
|
156
|
+
// Operator-facing docs must not auto-green — a PR could otherwise land
|
|
157
|
+
// deceptive copy with no reviewer signal — so they downgrade to manual-review.
|
|
210
158
|
const DOCS_MANUAL_REVIEW = new Set([
|
|
211
159
|
"CHANGELOG.md", "README.md", "SECURITY.md", "MIGRATING.md", "AGENTS.md",
|
|
212
160
|
]);
|
|
@@ -226,20 +174,14 @@ function categorize(file) {
|
|
|
226
174
|
if (norm.startsWith("scripts/") && norm.endsWith(".js")) return "lib";
|
|
227
175
|
if (norm.startsWith("data/playbooks/") && norm.endsWith(".json")) return "playbook";
|
|
228
176
|
if (norm === "data/cve-catalog.json") return "cve-catalog";
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
// schemas/*, data/*.json, sbom.cdx.json, manifest-snapshot.*) can carry
|
|
232
|
-
// semantic surface but the analyzer has no syntactic surface extractor
|
|
233
|
-
// for them — humans should look.
|
|
177
|
+
// Shapes carrying semantic surface the analyzer has no extractor for: a human
|
|
178
|
+
// looks, instead of an allowlist.
|
|
234
179
|
if (norm === "manifest.json") return "manual-review";
|
|
235
180
|
if (norm === "manifest-snapshot.json") return "manual-review";
|
|
236
181
|
if (norm === "manifest-snapshot.sha256") return "manual-review";
|
|
237
182
|
if (norm === "sbom.cdx.json") return "manual-review";
|
|
238
183
|
if (norm.startsWith("lib/schemas/")) return "manual-review";
|
|
239
|
-
//
|
|
240
|
-
// `npm run build-indexes`; the source-of-truth diff is in the data/
|
|
241
|
-
// files themselves. Allowlist the derived index files so they don't
|
|
242
|
-
// perpetually surface as manual-review on every release commit.
|
|
184
|
+
// data/_indexes/ is regenerated; the reviewable diff is in the data/ sources.
|
|
243
185
|
if (norm.startsWith("data/_indexes/")) return "allowlist-derived";
|
|
244
186
|
if (norm.startsWith("data/") && norm.endsWith(".json")) return "manual-review";
|
|
245
187
|
if (norm === "package.json") return "manual-review";
|
|
@@ -252,14 +194,11 @@ function isWhitespaceOnly(opts, file, cwd, resolvedBase) {
|
|
|
252
194
|
.filter(l => !l.startsWith("+++") && !l.startsWith("---")).length === 0;
|
|
253
195
|
}
|
|
254
196
|
|
|
255
|
-
// --- Surface extraction -----------------------------------------------------
|
|
256
|
-
|
|
257
197
|
function extractCliSurface(content) {
|
|
258
198
|
if (!content) return { verbs: new Set(), flags: new Set() };
|
|
259
199
|
const verbs = new Set();
|
|
260
200
|
const flags = new Set();
|
|
261
|
-
// Only
|
|
262
|
-
// picking up arbitrary keys from elsewhere.
|
|
201
|
+
// Only the COMMANDS block and PLAYBOOK_VERBS Set, not arbitrary keys elsewhere.
|
|
263
202
|
const cmdBlock = content.match(/const COMMANDS = \{([\s\S]*?)\n\};/);
|
|
264
203
|
if (cmdBlock) {
|
|
265
204
|
const re = /^\s*"?([a-zA-Z][\w-]+)"?\s*:/gm;
|
|
@@ -272,18 +211,22 @@ function extractCliSurface(content) {
|
|
|
272
211
|
let m;
|
|
273
212
|
while ((m = re.exec(playbookBlock[1])) !== null) verbs.add(m[1]);
|
|
274
213
|
}
|
|
275
|
-
// REMOVED_VERBS keys are still
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
// dropped — otherwise removing dead COMMANDS table rows for already-retired
|
|
279
|
-
// verbs reads as a fresh "removed-but-test-remains" against the refusal test.
|
|
214
|
+
// REMOVED_VERBS keys are still CLI surface: invoking one returns a structured
|
|
215
|
+
// refusal that tests cover. Counting them stops a dropped COMMANDS row reading
|
|
216
|
+
// as a fresh "removed-but-test-remains" against the refusal test.
|
|
280
217
|
const removedBlock = content.match(/const REMOVED_VERBS = \{([\s\S]*?)\n\};/);
|
|
281
218
|
if (removedBlock) {
|
|
282
219
|
for (const m of removedBlock[1].matchAll(/^\s*"?([a-zA-Z][\w-]+)"?\s*:/gm)) verbs.add(m[1]);
|
|
283
220
|
}
|
|
221
|
+
// Scans the whole file, prose included, so a flag named in a comment counts as
|
|
222
|
+
// surface. A trailing hyphen means the match stopped at a line break mid-name
|
|
223
|
+
// (`--attest-` wrapping to `ownership`), which is never a flag — admitting one
|
|
224
|
+
// makes deleting that comment read as a removed flag.
|
|
284
225
|
const flagRe = /(--[a-zA-Z][\w-]+)/g;
|
|
285
226
|
let m;
|
|
286
|
-
while ((m = flagRe.exec(content)) !== null)
|
|
227
|
+
while ((m = flagRe.exec(content)) !== null) {
|
|
228
|
+
if (!m[1].endsWith("-")) flags.add(m[1]);
|
|
229
|
+
}
|
|
287
230
|
for (const f of ["--help", "--version"]) flags.delete(f);
|
|
288
231
|
return { verbs, flags };
|
|
289
232
|
}
|
|
@@ -299,27 +242,18 @@ function diffSets(before, after) {
|
|
|
299
242
|
function extractLibExports(content) {
|
|
300
243
|
if (!content) return new Set();
|
|
301
244
|
const out = new Set();
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
// block does not shadow the real exports lower in the file. Pre-fix, the
|
|
305
|
-
// analyzer's own file matched a 3-char doc-comment fragment first and
|
|
306
|
-
// returned an empty export set — any source that mentions `module.exports`
|
|
307
|
-
// in a JSDoc/banner block hit the same bug. After stripping comments,
|
|
308
|
-
// the `module.exports = {...}` match runs against real code only.
|
|
245
|
+
// Strip comments first: a `module.exports = {...}` inside a doc comment
|
|
246
|
+
// otherwise shadows the real exports and the export set comes back empty.
|
|
309
247
|
const stripped = content
|
|
310
248
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
311
249
|
.replace(/^\s*\/\/.*$/gm, "");
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
// truncate the export list at the first inner `}` and hide later exports —
|
|
315
|
-
// which would let an uncovered new export ship green (the gate's blind spot).
|
|
250
|
+
// Brace-balanced so a nested member (`{ CONFIG: { a: 1 }, x, y }`) does not
|
|
251
|
+
// truncate the list at the first inner `}` and hide later exports.
|
|
316
252
|
const objStart = stripped.search(/module\.exports\s*=\s*\{/);
|
|
317
253
|
if (objStart !== -1) {
|
|
318
254
|
const openIdx = stripped.indexOf("{", objStart);
|
|
319
|
-
// String-aware
|
|
320
|
-
//
|
|
321
|
-
// hide the exports that follow — that blind spot let an uncovered export
|
|
322
|
-
// ship green.
|
|
255
|
+
// String-aware: a `}` inside a value (`{ PATTERN: "a}b", realExport }`)
|
|
256
|
+
// must not close the object early and hide the exports that follow.
|
|
323
257
|
let depth = 0, end = -1, inStr = null;
|
|
324
258
|
for (let i = openIdx; i < stripped.length; i++) {
|
|
325
259
|
const ch = stripped[i];
|
|
@@ -334,8 +268,7 @@ function extractLibExports(content) {
|
|
|
334
268
|
}
|
|
335
269
|
if (end !== -1) {
|
|
336
270
|
const body = stripped.slice(openIdx + 1, end);
|
|
337
|
-
// String-aware
|
|
338
|
-
// not split a member or skew the bracket depth.
|
|
271
|
+
// String-aware split: a `,` or bracket inside a string must not split a member.
|
|
339
272
|
let d = 0, cur = "", sInStr = null;
|
|
340
273
|
const members = [];
|
|
341
274
|
for (let i = 0; i < body.length; i++) {
|
|
@@ -356,12 +289,9 @@ function extractLibExports(content) {
|
|
|
356
289
|
for (const tok of members) {
|
|
357
290
|
const id = tok.split(":")[0].trim();
|
|
358
291
|
if (/^[a-zA-Z_$][\w$]*$/.test(id)) { out.add(id); continue; }
|
|
359
|
-
// Method-shorthand member (`fn(a){
|
|
360
|
-
//
|
|
361
|
-
//
|
|
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 `*`.
|
|
292
|
+
// Method-shorthand member (`fn(a){}`, `async load(){}`, `*gen(){}`): the
|
|
293
|
+
// colon split leaves `name(...)`, so recover the name before the first
|
|
294
|
+
// `(`, past any modifier keyword or generator `*`.
|
|
365
295
|
const beforeParen = tok.split("(")[0].trim();
|
|
366
296
|
const parts = beforeParen.split(/\s+/);
|
|
367
297
|
const cand = parts[parts.length - 1].replace(/^\*/, "").trim();
|
|
@@ -369,12 +299,8 @@ function extractLibExports(content) {
|
|
|
369
299
|
}
|
|
370
300
|
}
|
|
371
301
|
}
|
|
372
|
-
//
|
|
373
|
-
//
|
|
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).
|
|
302
|
+
// Whole-module export (`module.exports = mainFn;`): the object extractor above
|
|
303
|
+
// matches nothing, so without this the file's only export is invisible.
|
|
378
304
|
const singleIdent = stripped.match(/module\.exports\s*=\s*([a-zA-Z_$][\w$]*)\s*;/);
|
|
379
305
|
if (singleIdent && !/^(function|async|class)$/.test(singleIdent[1])) {
|
|
380
306
|
out.add(singleIdent[1]);
|
|
@@ -401,13 +327,8 @@ function extractPlaybookIds(content) {
|
|
|
401
327
|
return { indicators: ind, artifacts: arts };
|
|
402
328
|
}
|
|
403
329
|
|
|
404
|
-
// Canonical
|
|
405
|
-
//
|
|
406
|
-
// JSON.stringify(after.iocs) — non-canonical: key order, trailing
|
|
407
|
-
// whitespace, and numeric format differences all flagged as "changed"
|
|
408
|
-
// when the operator made no semantic change. Symptoms were patched
|
|
409
|
-
// twice with skip rules (_auto_imported, _iocs_stub) instead of fixing
|
|
410
|
-
// the comparator. v0.13.20 fixes the root cause.
|
|
330
|
+
// Canonical equality, not JSON.stringify: key order, whitespace and numeric
|
|
331
|
+
// formatting are not semantic changes to an iocs block.
|
|
411
332
|
const { canonicalEqual } = require("../lib/canonical-eq");
|
|
412
333
|
|
|
413
334
|
function extractCveIocChanges(beforeStr, afterStr) {
|
|
@@ -417,10 +338,8 @@ function extractCveIocChanges(beforeStr, afterStr) {
|
|
|
417
338
|
const ids = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
418
339
|
for (const id of ids) {
|
|
419
340
|
if (!/^CVE-\d{4}-\d+/.test(id)) continue;
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
// operator curation. Removing this would surface every fresh KEV
|
|
423
|
-
// bulk-import as a per-CVE iocs-modified finding.
|
|
341
|
+
// Rows auto-imported on BOTH sides hold stub IoCs by design; without the
|
|
342
|
+
// skip, every fresh KEV bulk-import surfaces as a per-CVE finding.
|
|
424
343
|
const beforeAuto = !!(before[id] && before[id]._auto_imported);
|
|
425
344
|
const afterAuto = !!(after[id] && after[id]._auto_imported);
|
|
426
345
|
if (beforeAuto && afterAuto) continue;
|
|
@@ -433,8 +352,6 @@ function extractCveIocChanges(beforeStr, afterStr) {
|
|
|
433
352
|
|
|
434
353
|
function safeParse(s) { try { return s ? JSON.parse(s) : null; } catch { return null; } }
|
|
435
354
|
|
|
436
|
-
// --- Test corpus + coverage probes ------------------------------------------
|
|
437
|
-
|
|
438
355
|
function loadTestCorpus(cwd) {
|
|
439
356
|
const root = path.join(cwd, "tests");
|
|
440
357
|
if (!fs.existsSync(root)) return { joined: "", files: [] };
|
|
@@ -473,32 +390,21 @@ function coversCliFlag(corpus, flag) {
|
|
|
473
390
|
return corpus.includes(flag);
|
|
474
391
|
}
|
|
475
392
|
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
// Pre-fix: an `assert.equal(...)` mention in one test file plus a stray
|
|
481
|
-
// `require('../lib/x')` in a completely different test file counted as
|
|
482
|
-
// coverage. That's not coverage — it's textual coincidence.
|
|
483
|
-
//
|
|
484
|
-
// `corpus` may be either a string (legacy joined corpus, used by
|
|
485
|
-
// CLI/playbook/CVE coverage probes) or the structured shape
|
|
486
|
-
// `{ joined, files }` produced by loadTestCorpus().
|
|
393
|
+
// Coverage needs same-file context: the identifier must appear inside a real
|
|
394
|
+
// test block in the SAME file that requires the module, or a stray require() in
|
|
395
|
+
// one file plus a mention in another reads as coverage. `corpus` is either the
|
|
396
|
+
// structured `{ joined, files }` from loadTestCorpus or a legacy joined string.
|
|
487
397
|
function coversLibExport(corpus, libRel, ident) {
|
|
488
398
|
const baseName = path.basename(libRel).replace(/\.js$/, "");
|
|
489
|
-
const baseFile = path.basename(libRel);
|
|
399
|
+
const baseFile = path.basename(libRel);
|
|
490
400
|
const identRe = new RegExp("\\b" + escapeRe(ident) + "\\b");
|
|
491
401
|
const requireRe = new RegExp("require\\([^)]*" + escapeRe(baseName) + "[^)]*\\)");
|
|
492
|
-
// Accept the structured shape (preferred). Walk files individually.
|
|
493
402
|
if (corpus && Array.isArray(corpus.files)) {
|
|
494
403
|
for (const f of corpus.files) {
|
|
495
404
|
const hasRequire = requireRe.test(f.content);
|
|
496
405
|
const mentionsSpawnPath = f.content.includes(baseFile);
|
|
497
406
|
if (!hasRequire && !mentionsSpawnPath) continue;
|
|
498
407
|
if (!identRe.test(f.content)) continue;
|
|
499
|
-
// F10 — require the identifier appears inside a test block in this
|
|
500
|
-
// file. Recognise `test(`, `it(`, `describe(`, or `assert(` (or any
|
|
501
|
-
// `assert.<member>(`) bracketed argument that mentions the ident.
|
|
502
408
|
if (mentionsIdentInTestContext(f.content, ident)) return true;
|
|
503
409
|
}
|
|
504
410
|
return false;
|
|
@@ -510,16 +416,11 @@ function coversLibExport(corpus, libRel, ident) {
|
|
|
510
416
|
return false;
|
|
511
417
|
}
|
|
512
418
|
|
|
513
|
-
//
|
|
514
|
-
// `test(
|
|
515
|
-
//
|
|
516
|
-
// the call" by finding the opening paren after the keyword, then walking
|
|
517
|
-
// matched parens until the call closes. This is a syntactic-enough check
|
|
518
|
-
// for vanilla JavaScript tests; the goal is to refuse "ident only appears
|
|
519
|
-
// in a top-level comment" while still accepting `assert.deepEqual(foo, ...)`.
|
|
419
|
+
// True when `ident` appears as a token inside the parenthesised body of a
|
|
420
|
+
// `test(`, `it(`, `describe(`, `assert(` or `assert.<member>(` call. The paren
|
|
421
|
+
// walk is approximate by design.
|
|
520
422
|
function mentionsIdentInTestContext(content, ident) {
|
|
521
423
|
const tokenRe = new RegExp("\\b" + escapeRe(ident) + "\\b");
|
|
522
|
-
// Quick reject: file does not mention the identifier at all.
|
|
523
424
|
if (!tokenRe.test(content)) return false;
|
|
524
425
|
const callRe = /\b(test|it|describe|assert(?:\.[A-Za-z_$][\w$]*)?)\s*\(/g;
|
|
525
426
|
let m;
|
|
@@ -556,33 +457,15 @@ function coversCveIoc(corpus, cveId) {
|
|
|
556
457
|
return /\biocs\b/i.test(corpus);
|
|
557
458
|
}
|
|
558
459
|
|
|
559
|
-
//
|
|
560
|
-
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
// Anti-coincidence rule: every exit-code assertion must pin the
|
|
564
|
-
// EXACT code. `assert.notEqual(r.status, 0)` silently passes when an
|
|
565
|
-
// unrelated failure produces ANY non-zero exit, hiding the regression the
|
|
566
|
-
// test was meant to catch. This lint walks tests/*.test.js and rejects the
|
|
567
|
-
// pattern outright. The `// allow-notEqual: <reason>` opt-out on the same
|
|
568
|
-
// line is the escape hatch for genuine refusal-pins (asserting NOT a
|
|
569
|
-
// specific code) — those must justify themselves inline.
|
|
570
|
-
//
|
|
571
|
-
// Pattern hits any of:
|
|
572
|
-
// assert.notEqual(r.status, 0)
|
|
573
|
-
// assert.notEqual(result.status, 0, '...')
|
|
574
|
-
// assert.notEqual(foo.status, 2, 'must not be unknown-cmd') ← also refused
|
|
575
|
-
// unless the same line ends with `// allow-notEqual: <reason>`.
|
|
576
|
-
//
|
|
577
|
-
// Structural lint replaces a per-instance hunt across 25+ test sites — keeps
|
|
578
|
-
// new tests / new ports from regressing into coincidence-passing assertions.
|
|
579
|
-
// Fix the class, not the instance.
|
|
460
|
+
// Every exit-code assertion must pin the EXACT code: `assert.notEqual(r.status,
|
|
461
|
+
// 0)` passes on any non-zero exit, hiding the regression the test was written
|
|
462
|
+
// for. The only opt-out is `// allow-notEqual: <reason>` on the same line, for a
|
|
463
|
+
// genuine refusal-pin that asserts NOT a specific code.
|
|
580
464
|
function scanForCoincidenceAsserts(cwd) {
|
|
581
465
|
const out = [];
|
|
582
466
|
const testsDir = path.join(cwd, "tests");
|
|
583
467
|
if (!fs.existsSync(testsDir)) return out;
|
|
584
|
-
//
|
|
585
|
-
// (r, r1, result, child, etc.) but the .status access is the signal.
|
|
468
|
+
// The receiver name varies (r, r1, result, child); the `.status` access is the signal.
|
|
586
469
|
const banRe = /assert\.notEqual\s*\(\s*[A-Za-z_$][\w$]*\.status\b/;
|
|
587
470
|
const allowRe = /\/\/\s*allow-notEqual\s*:/;
|
|
588
471
|
const skipPrefix = "_helpers"; // helpers may legitimately reference the pattern
|
|
@@ -611,10 +494,6 @@ function scanForCoincidenceAsserts(cwd) {
|
|
|
611
494
|
|
|
612
495
|
function analyze(opts) {
|
|
613
496
|
const cwd = opts.repo || ROOT;
|
|
614
|
-
// v0.12.8: resolve the diff anchor ONCE and thread it through every
|
|
615
|
-
// per-file call so listChangedFiles + fileDiff + fileBefore all agree on
|
|
616
|
-
// the same SHA. Otherwise origin/main advancing past the merge-base
|
|
617
|
-
// between calls produces false add/remove findings.
|
|
618
497
|
const resolvedBase = resolveBaseRef(opts, cwd);
|
|
619
498
|
const changed = listChangedFiles(opts, cwd, resolvedBase);
|
|
620
499
|
const corpusObj = loadTestCorpus(cwd);
|
|
@@ -632,10 +511,7 @@ function analyze(opts) {
|
|
|
632
511
|
}
|
|
633
512
|
if (cat === "skill") { allowlisted.push({ file: ch.file, reason: "skill-signed" }); continue; }
|
|
634
513
|
if (cat === "workflow") { manualReview.push({ file: ch.file, reason: "workflow" }); continue; }
|
|
635
|
-
// F11 — data catalogs, schemas, manifests, SBOM go to manual review
|
|
636
|
-
// instead of being silently allowlisted. They show up in CI output.
|
|
637
514
|
if (cat === "manual-review") { manualReview.push({ file: ch.file, reason: "manual-review" }); continue; }
|
|
638
|
-
// v0.12.14: derived index files allowlist (auto-regenerated artifacts).
|
|
639
515
|
if (cat === "allowlist-derived") { allowlisted.push({ file: ch.file, reason: "derived-artifact" }); continue; }
|
|
640
516
|
if (cat === "other") { manualReview.push({ file: ch.file, reason: "unclassified" }); continue; }
|
|
641
517
|
if (ch.status !== "D" && isWhitespaceOnly(opts, ch.file, cwd, resolvedBase)) {
|
|
@@ -663,8 +539,6 @@ function analyze(opts) {
|
|
|
663
539
|
const b = extractLibExports(before);
|
|
664
540
|
const a = extractLibExports(after);
|
|
665
541
|
const d = diffSets(b, a);
|
|
666
|
-
// F10 — pass the structured corpus so coversLibExport can enforce
|
|
667
|
-
// same-file require()+identifier-in-test-context coverage.
|
|
668
542
|
for (const id of d.added) if (!coversLibExport(corpusObj, ch.file, id))
|
|
669
543
|
findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "added" });
|
|
670
544
|
for (const id of d.removed) if (coversLibExport(corpusObj, ch.file, id))
|
|
@@ -687,11 +561,8 @@ function analyze(opts) {
|
|
|
687
561
|
}
|
|
688
562
|
}
|
|
689
563
|
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
// a coincidence-passing assert that lands via a non-test-coverage
|
|
693
|
-
// path (someone hand-edits a tests/ file in a docs-only commit) is
|
|
694
|
-
// still a regression vector the gate must catch.
|
|
564
|
+
// Runs irrespective of the diff: a coincidence-passing assert can land by a
|
|
565
|
+
// path this analyzer never inspects.
|
|
695
566
|
const coincidenceFindings = scanForCoincidenceAsserts(cwd);
|
|
696
567
|
for (const f of coincidenceFindings) {
|
|
697
568
|
findings.push({
|
|
@@ -705,8 +576,6 @@ function analyze(opts) {
|
|
|
705
576
|
return { findings, allowlisted, manualReview, totalChanged: changed.length };
|
|
706
577
|
}
|
|
707
578
|
|
|
708
|
-
// --- Output -----------------------------------------------------------------
|
|
709
|
-
|
|
710
579
|
function emitHuman(report) {
|
|
711
580
|
const out = [];
|
|
712
581
|
out.push("Diff coverage analyzer — " + report.totalChanged + " changed file(s)");
|
|
@@ -1,27 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Bidirectional test↔subject gate. Every tests/<x>.test.js must name a real
|
|
5
|
+
* subject and every subject must have a test. Subjects are derived from the
|
|
6
|
+
* codebase, not a hand-maintained list.
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - a source MODULE basename (lib/x.js -> x; lib/collectors/x.js -> x and
|
|
10
|
-
* collectors-x; orchestrator/index.js -> orchestrator; bin/exceptd.js -> cli)
|
|
11
|
-
* - an exported FUNCTION / CLASS name (kebab-cased) — per-function granularity
|
|
12
|
-
* - a data PRIMITIVE: a data/*.json catalog FILE, plus each catalog ENTRY that
|
|
13
|
-
* is itself a primitive — every CVE/MAL/GHSA id in data/cve-catalog.json and
|
|
14
|
-
* every playbook in data/playbooks/ — so one CVE == one test file
|
|
15
|
-
* - a .github/workflows/*.yml WORKFLOW (release -> release-workflow, etc.)
|
|
16
|
-
* - a CLI verb dispatched by bin/exceptd.js
|
|
17
|
-
*
|
|
18
|
-
* FORWARD violation : a tests/<x>.test.js where <x> is not a valid subject.
|
|
19
|
-
* REVERSE violation : a subject (module / CVE / playbook / workflow) with no
|
|
20
|
-
* tests/<subject>.test.js.
|
|
21
|
-
*
|
|
22
|
-
* Run with --worklist for the machine-readable reorg work list (JSON on stdout).
|
|
23
|
-
* Run with no flag for a human summary; exits non-zero while any violation
|
|
24
|
-
* remains (so once the suite conforms this becomes a standing predeploy gate).
|
|
8
|
+
* A FORWARD violation is a test file naming no subject; a REVERSE violation is
|
|
9
|
+
* a subject with no test. --worklist writes the machine-readable list to
|
|
10
|
+
* stdout; either violation exits non-zero.
|
|
25
11
|
*/
|
|
26
12
|
const fs = require("node:fs");
|
|
27
13
|
const path = require("node:path");
|
|
@@ -42,22 +28,16 @@ function deriveSubjects() {
|
|
|
42
28
|
if (e.isDirectory()) { walkSrc(rel); continue; }
|
|
43
29
|
if (!e.name.endsWith(".js")) continue;
|
|
44
30
|
const base = e.name.replace(/\.js$/, "");
|
|
45
|
-
// index
|
|
46
|
-
// covers it, so treat the bare "index" basename as an alias, not a
|
|
47
|
-
// separately reverse-required module.
|
|
31
|
+
// A bare "index" is an alias, not a reverse-required module.
|
|
48
32
|
add(base, (base === "index" ? "alias:" : "module:") + rel);
|
|
49
33
|
const parent = path.basename(path.dirname(rel));
|
|
50
|
-
// parent-prefixed name (collectors-x
|
|
51
|
-
//
|
|
52
|
-
// canonical <base>.test.js already satisfies coverage, so don't double-
|
|
53
|
-
// count the alias as its own reverse gap.
|
|
34
|
+
// A parent-prefixed name (collectors-x) is an alias of the canonical
|
|
35
|
+
// basename: a valid test target, never its own reverse gap.
|
|
54
36
|
if (!["lib", "scripts", "orchestrator", "bin"].includes(parent)) add(parent + "-" + base, "alias:" + rel);
|
|
55
37
|
const txt = read(rel);
|
|
56
38
|
for (const m of txt.matchAll(/(?:^|\n)\s*(?:async\s+)?(?:function|class)\s+([A-Za-z_$][\w$]*)/g)) add(camelKebab(m[1]), "fn:" + rel);
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// every export name after it (the brace-truncation class), under-deriving
|
|
60
|
-
// subjects so a real export silently has no required test.
|
|
39
|
+
// Brace-balanced scan, not a non-greedy /\{([\s\S]*?)\}/ — that stops at
|
|
40
|
+
// the first nested `}` and drops every export after it.
|
|
61
41
|
const expAt = txt.search(/module\.exports\s*=\s*\{/);
|
|
62
42
|
if (expAt >= 0) {
|
|
63
43
|
const open = txt.indexOf("{", expAt);
|
|
@@ -70,43 +50,28 @@ function deriveSubjects() {
|
|
|
70
50
|
["lib", "orchestrator", "scripts", "bin", "sources/validators"].forEach(walkSrc);
|
|
71
51
|
add("orchestrator", "module:orchestrator/index.js");
|
|
72
52
|
add("cli", "module:bin/exceptd.js");
|
|
73
|
-
// Vendored
|
|
74
|
-
// reverse-required — we don't force a dedicated test per vendored file.
|
|
53
|
+
// Vendored modules are valid test subjects but not reverse-required.
|
|
75
54
|
(function walkVendor(d) { for (const e of ls(d)) { const rel = d + "/" + e.name; if (e.isDirectory()) walkVendor(rel); else if (e.name.endsWith(".js")) add(e.name.replace(/\.js$/, ""), "vendor:" + rel); } })("vendor");
|
|
76
55
|
|
|
77
|
-
//
|
|
78
|
-
// (verb: () => path.join(...)) that bin/exceptd.js uses for most subcommands.
|
|
56
|
+
// Both dispatch forms bin/exceptd.js uses: switch-case and the `verb: () =>` table.
|
|
79
57
|
const cliSrc = read("bin/exceptd.js");
|
|
80
58
|
for (const m of cliSrc.matchAll(/case\s+['"]([a-z][a-z0-9-]+)['"]/g)) { add("cli-" + m[1], "cli-verb"); add(m[1], "cli-verb"); }
|
|
81
59
|
for (const m of cliSrc.matchAll(/^\s*["']?([a-z][a-z0-9-]+)["']?:\s*\(\)\s*=>/gm)) { add("cli-" + m[1], "cli-verb"); add(m[1], "cli-verb"); }
|
|
82
60
|
|
|
83
|
-
// data catalog files
|
|
84
61
|
for (const e of ls("data")) if (e.isFile() && e.name.endsWith(".json")) add(e.name.replace(/\.json$/, ""), "data");
|
|
85
|
-
//
|
|
86
|
-
// CVE-primitive subjects. An unreadable / malformed / empty catalog must NOT
|
|
87
|
-
// silently derive zero CVE subjects — that would let the reverse-coverage gate
|
|
88
|
-
// PASS with no CVE coverage at all (the absent-input false-pass class). Fail
|
|
89
|
-
// loud instead.
|
|
62
|
+
// A zero-subject derivation throws rather than passing reverse coverage empty.
|
|
90
63
|
let cveDerived = 0;
|
|
91
64
|
try { const cat = JSON.parse(read("data/cve-catalog.json")); for (const k of Object.keys(cat)) if (k !== "_meta") { add(k.toLowerCase(), "cve-primitive"); cveDerived++; } }
|
|
92
65
|
catch (e) { throw new Error("check-test-subjects: cannot read/parse data/cve-catalog.json — refusing to derive subjects (reverse coverage would falsely pass with no CVE coverage): " + e.message); }
|
|
93
66
|
if (cveDerived === 0) throw new Error("check-test-subjects: data/cve-catalog.json yielded zero CVE entries — refusing to let reverse coverage pass with no CVE coverage.");
|
|
94
|
-
//
|
|
95
|
-
// unreadable / empty data/playbooks must NOT silently derive zero playbook
|
|
96
|
-
// subjects — that lets the reverse-coverage gate pass with no playbook
|
|
97
|
-
// coverage (the same absent-input false-pass class). Fail loud instead.
|
|
67
|
+
// Same floor for playbooks.
|
|
98
68
|
let pbDerived = 0;
|
|
99
69
|
for (const e of ls("data/playbooks")) if (e.isFile() && e.name.endsWith(".json")) { const b = e.name.replace(/\.json$/, ""); add(b, "playbook-primitive"); add("playbook-" + b, "alias:playbook"); pbDerived++; }
|
|
100
70
|
if (pbDerived === 0) throw new Error("check-test-subjects: data/playbooks/ yielded zero playbooks — refusing to let reverse coverage pass with no playbook coverage.");
|
|
101
|
-
// workflows
|
|
102
71
|
for (const e of ls(".github/workflows")) if (/\.ya?ml$/.test(e.name)) { const b = e.name.replace(/\.ya?ml$/, ""); add(b, "workflow"); add(b + "-workflow", "workflow"); }
|
|
103
72
|
|
|
104
|
-
// Repo
|
|
105
|
-
//
|
|
106
|
-
// that pins one of these artifacts (its content, counts, or cross-references)
|
|
107
|
-
// is named after a durable subject, not a release — so these are valid test
|
|
108
|
-
// targets. Kind is not module/cve/playbook, so they are NOT reverse-required
|
|
109
|
-
// (we don't force a dedicated test per doc file).
|
|
73
|
+
// Repo artifacts — valid test targets, but their kind is not
|
|
74
|
+
// module/cve/playbook, so none of them is reverse-required.
|
|
110
75
|
for (const f of ["package.json", "manifest.json", "manifest-snapshot.json", "README.md", "AGENTS.md", "SECURITY.md", "ARCHITECTURE.md", "CONTEXT.md", "CHANGELOG.md", "CONTRIBUTING.md", "CODE_OF_CONDUCT.md", "LICENSE", "NOTICE"]) {
|
|
111
76
|
add(f.replace(/\.[^.]*$/, "").toLowerCase().replace(/_/g, "-"), "repo:" + f);
|
|
112
77
|
}
|
|
@@ -116,12 +81,8 @@ function deriveSubjects() {
|
|
|
116
81
|
add("playbooks", "aggregate:data/playbooks");
|
|
117
82
|
add("workflows", "aggregate:.github/workflows");
|
|
118
83
|
add("governance", "repo:governance-files"); // LICENSE/NOTICE/FUNDING/CoC/gitignore/gitleaks presence + integrity
|
|
119
|
-
// Module
|
|
120
|
-
//
|
|
121
|
-
// tree would derive zero reverse-required module subjects and let the gate
|
|
122
|
-
// pass with no module coverage (the absent-input false-pass class, same as
|
|
123
|
-
// the CVE/playbook guards). The repo always has dozens of modules; zero is an
|
|
124
|
-
// anomaly. Fail loud.
|
|
84
|
+
// Module floor: ls() returns [] on a read failure, so an unreadable source
|
|
85
|
+
// tree would otherwise pass the gate with no module coverage.
|
|
125
86
|
let moduleCount = 0;
|
|
126
87
|
for (const kind of subjects.values()) if (typeof kind === "string" && kind.startsWith("module:")) moduleCount++;
|
|
127
88
|
if (moduleCount === 0) throw new Error("check-test-subjects: zero source-module subjects derived (lib/orchestrator/scripts/bin unreadable?) — refusing to let reverse coverage pass with no module coverage.");
|
|
@@ -142,10 +103,8 @@ function run() {
|
|
|
142
103
|
for (const t of testFiles) if (!subjects.has(t.toLowerCase())) forward.push({ file: "tests/" + t + ".test.js", suggested: suggest(t) });
|
|
143
104
|
const reverse = [];
|
|
144
105
|
for (const [s, kind] of subjects) if (!testSet.has(s)) reverse.push({ subject: s, kind });
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// not reverse-required). The exit-code logic gates on THIS, consistently
|
|
148
|
-
// across --worklist and the human/predeploy paths.
|
|
106
|
+
// Only module, cve and playbook subjects must have a test; the rest are valid
|
|
107
|
+
// targets, not requirements. Both output paths take their exit code from this.
|
|
149
108
|
const reverseRequired = reverse.filter((x) => x.kind.startsWith("module:") || x.kind.startsWith("cve-primitive") || x.kind.startsWith("playbook-primitive"));
|
|
150
109
|
return { subjects: subjects.size, forward, reverse, reverseRequired };
|
|
151
110
|
}
|