@blamejs/exceptd-skills 0.12.7 → 0.12.9
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/AGENTS.md +15 -1
- package/ARCHITECTURE.md +21 -5
- package/CHANGELOG.md +150 -0
- package/README.md +1 -1
- package/bin/exceptd.js +416 -69
- package/data/_indexes/_meta.json +44 -44
- package/data/_indexes/activity-feed.json +34 -34
- package/data/_indexes/catalog-summaries.json +9 -9
- package/data/_indexes/chains.json +249 -11
- package/data/_indexes/frequency.json +63 -5
- package/data/_indexes/jurisdiction-map.json +13 -3
- package/data/_indexes/section-offsets.json +1171 -1027
- package/data/_indexes/summary-cards.json +2 -2
- package/data/_indexes/token-budget.json +232 -152
- package/data/atlas-ttps.json +189 -1
- package/data/cve-catalog.json +34 -22
- package/data/cwe-catalog.json +290 -1
- package/data/d3fend-catalog.json +163 -1
- package/data/framework-control-gaps.json +243 -0
- package/data/playbooks/containers.json +23 -5
- package/data/playbooks/cred-stores.json +9 -9
- package/data/playbooks/crypto.json +8 -8
- package/data/playbooks/hardening.json +46 -10
- package/data/playbooks/library-author.json +16 -20
- package/data/playbooks/mcp.json +1 -0
- package/data/playbooks/runtime.json +7 -7
- package/data/playbooks/sbom.json +11 -11
- package/data/playbooks/secrets.json +4 -4
- package/data/rfc-references.json +144 -0
- package/lib/playbook-runner.js +119 -35
- package/lib/prefetch.js +27 -6
- package/lib/refresh-external.js +32 -9
- package/lib/schemas/skill-frontmatter.schema.json +2 -2
- package/manifest-snapshot.json +1 -1
- package/manifest.json +73 -73
- package/orchestrator/index.js +1 -1
- package/package.json +2 -1
- package/sbom.cdx.json +6 -6
- package/scripts/check-sbom-currency.js +87 -0
- package/scripts/check-test-coverage.README.md +148 -0
- package/scripts/check-test-coverage.js +476 -0
- package/scripts/hooks/pre-commit.sh +19 -0
- package/scripts/predeploy.js +14 -30
- package/skills/age-gates-child-safety/skill.md +3 -0
- package/skills/ai-attack-surface/skill.md +29 -1
- package/skills/ai-c2-detection/skill.md +30 -1
- package/skills/ai-risk-management/skill.md +3 -0
- package/skills/api-security/skill.md +3 -0
- package/skills/attack-surface-pentest/skill.md +3 -0
- package/skills/cloud-security/skill.md +3 -0
- package/skills/compliance-theater/skill.md +6 -0
- package/skills/container-runtime-security/skill.md +3 -0
- package/skills/coordinated-vuln-disclosure/skill.md +8 -1
- package/skills/defensive-countermeasure-mapping/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +3 -0
- package/skills/email-security-anti-phishing/skill.md +9 -1
- package/skills/exploit-scoring/skill.md +6 -0
- package/skills/identity-assurance/skill.md +6 -1
- package/skills/incident-response-playbook/skill.md +8 -2
- package/skills/kernel-lpe-triage/skill.md +24 -4
- package/skills/mcp-agent-trust/skill.md +28 -1
- package/skills/mlops-security/skill.md +3 -0
- package/skills/ot-ics-security/skill.md +3 -0
- package/skills/policy-exception-gen/skill.md +6 -0
- package/skills/rag-pipeline-security/skill.md +30 -1
- package/skills/researcher/skill.md +6 -0
- package/skills/sector-energy/skill.md +3 -0
- package/skills/sector-federal-government/skill.md +3 -0
- package/skills/sector-financial/skill.md +3 -0
- package/skills/sector-healthcare/skill.md +3 -0
- package/skills/security-maturity-tiers/skill.md +25 -1
- package/skills/skill-update-loop/skill.md +38 -0
- package/skills/supply-chain-integrity/skill.md +3 -0
- package/skills/threat-model-currency/skill.md +4 -0
- package/skills/threat-modeling-methodology/skill.md +3 -0
- package/skills/webapp-security/skill.md +3 -0
- package/skills/zeroday-gap-learn/skill.md +6 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Diff-coverage analyzer
|
|
2
|
+
|
|
3
|
+
`scripts/check-test-coverage.js` compares the changed surface in a diff
|
|
4
|
+
against the `tests/` tree and reports any surface change that has no
|
|
5
|
+
matching test reference. Zero dependencies, Node 24 stdlib only, runs
|
|
6
|
+
identically on Windows + Linux.
|
|
7
|
+
|
|
8
|
+
## Usage
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
# Default: HEAD vs origin/main, human output.
|
|
12
|
+
node scripts/check-test-coverage.js
|
|
13
|
+
|
|
14
|
+
# Staged changes only (pre-commit shape).
|
|
15
|
+
node scripts/check-test-coverage.js --staged
|
|
16
|
+
|
|
17
|
+
# Compare HEAD vs a custom base.
|
|
18
|
+
node scripts/check-test-coverage.js --base v0.12.6
|
|
19
|
+
|
|
20
|
+
# Machine-readable.
|
|
21
|
+
node scripts/check-test-coverage.js --json
|
|
22
|
+
|
|
23
|
+
# Surface findings without failing the gate.
|
|
24
|
+
node scripts/check-test-coverage.js --warn-only
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## What it covers
|
|
28
|
+
|
|
29
|
+
| Source file | Surface extracted |
|
|
30
|
+
| --------------------------------- | ------------------------------------------------------- |
|
|
31
|
+
| `bin/exceptd.js` | CLI verbs (`COMMANDS` map + `PLAYBOOK_VERBS` set), flags |
|
|
32
|
+
| `lib/*.js`, `orchestrator/*.js`, `scripts/*.js` | `module.exports = { ... }` identifiers |
|
|
33
|
+
| `data/playbooks/*.json` | `phases.detect.indicators[].id`, `phases.look.artifacts[].id` |
|
|
34
|
+
| `data/cve-catalog.json` | CVE entries whose `iocs` field added / removed / changed |
|
|
35
|
+
|
|
36
|
+
For each added / removed / modified surface, the analyzer searches every
|
|
37
|
+
`*.js` and `*.json` file under `tests/` for a reference to the surface
|
|
38
|
+
identifier (quoted literal for verbs / playbook IDs, raw substring for
|
|
39
|
+
flags + CVE IDs, `require()` site plus identifier reference for lib
|
|
40
|
+
exports). Missing references become findings.
|
|
41
|
+
|
|
42
|
+
## Allowlist
|
|
43
|
+
|
|
44
|
+
Changes in these locations are accepted without a covering test:
|
|
45
|
+
|
|
46
|
+
- `*.md` outside `data/`, `.gitignore`, `.npmrc`, `.editorconfig`
|
|
47
|
+
- `CHANGELOG.md` / `README.md` / `CONTRIBUTING.md` / `SECURITY.md` / `LICENSE` / `NOTICE` / `CODE_OF_CONDUCT.md` / `AGENTS.md` / `CLAUDE.md`
|
|
48
|
+
- Whitespace-only diffs (detected via `git diff --ignore-all-space --ignore-blank-lines`)
|
|
49
|
+
- Any file under `tests/` (no test-of-tests recursion)
|
|
50
|
+
- `skills/<name>/skill.md` (signature gate already covers content integrity)
|
|
51
|
+
- `.github/workflows/*.yml` — surfaced as **manual review required** rather than as a finding, because workflow steps don't pattern-match cleanly to test code paths
|
|
52
|
+
|
|
53
|
+
## Integration: predeploy gate
|
|
54
|
+
|
|
55
|
+
Append this entry to `GATES` in `scripts/predeploy.js` (placement: after
|
|
56
|
+
the existing tarball-verify gate, before the e2e-scenarios gate or at the
|
|
57
|
+
end — the gates are independent):
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
{
|
|
61
|
+
name: "Diff coverage (no-MVP: feature changes require test coverage)",
|
|
62
|
+
command: process.execPath,
|
|
63
|
+
args: [path.join(ROOT, "scripts", "check-test-coverage.js")],
|
|
64
|
+
ciJobName: "Diff coverage",
|
|
65
|
+
},
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The gate passes when no findings are produced and fails with the
|
|
69
|
+
analyzer's human-readable output when findings exist. Use `--warn-only`
|
|
70
|
+
during the rollout window if you want the gate informational before it
|
|
71
|
+
becomes blocking.
|
|
72
|
+
|
|
73
|
+
A matching CI job needs to land in `.github/workflows/ci.yml` so
|
|
74
|
+
`tests/predeploy.test.js` (which asserts the local runner mirrors the CI
|
|
75
|
+
workflow) stays green:
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
diff-coverage:
|
|
79
|
+
name: Diff coverage
|
|
80
|
+
runs-on: ubuntu-latest
|
|
81
|
+
steps:
|
|
82
|
+
- uses: actions/checkout@v4
|
|
83
|
+
with: { fetch-depth: 0 }
|
|
84
|
+
- uses: actions/setup-node@v4
|
|
85
|
+
with: { node-version: '24' }
|
|
86
|
+
- run: node scripts/check-test-coverage.js --base origin/main
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Integration: pre-commit hook
|
|
90
|
+
|
|
91
|
+
`scripts/hooks/pre-commit.sh` invokes the analyzer in `--staged` mode.
|
|
92
|
+
Not installed by default. Opt in with:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
git config core.hooksPath scripts/hooks
|
|
96
|
+
chmod +x scripts/hooks/pre-commit.sh # Linux only
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Bypass for a single commit (acceptable when the covering test will land
|
|
100
|
+
in a follow-up before push):
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
git commit --no-verify
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## AGENTS.md addition — Hard Rule
|
|
107
|
+
|
|
108
|
+
Drop-in markdown for the next available Hard Rule number (currently
|
|
109
|
+
adding as **Rule #15** if the existing list ends at #14):
|
|
110
|
+
|
|
111
|
+
```markdown
|
|
112
|
+
### Rule #15 — Test coverage on every diff
|
|
113
|
+
|
|
114
|
+
Every feature change — added, removed, or modified — must land with a
|
|
115
|
+
matching test reference in the same PR. The shapes the gate enforces:
|
|
116
|
+
|
|
117
|
+
| Change | Required test reference |
|
|
118
|
+
| ------------------------------------------------ | ------------------------------------------------------- |
|
|
119
|
+
| New CLI verb in `bin/exceptd.js` | Quoted verb literal in a `tests/*.test.js` file |
|
|
120
|
+
| New CLI flag | Flag literal (e.g. `--my-flag`) somewhere in `tests/` |
|
|
121
|
+
| New / removed `module.exports` identifier | `require('…/<lib>')` + identifier reference |
|
|
122
|
+
| New `phases.detect.indicators[].id` in a playbook | Quoted ID literal in `tests/e2e-scenarios/*/expect.json` or any `tests/*.test.js` |
|
|
123
|
+
| New / changed `iocs` field on a CVE entry | CVE ID + the word `iocs` in the same test file |
|
|
124
|
+
|
|
125
|
+
Mechanical enforcement lives in `scripts/check-test-coverage.js` and runs
|
|
126
|
+
as the 15th gate of `npm run predeploy`. Whitespace-only diffs, docs,
|
|
127
|
+
workflow YAML, and skill body changes are allowlisted (skill bodies are
|
|
128
|
+
covered by the Ed25519 signature gate).
|
|
129
|
+
|
|
130
|
+
The gate is blocking. `--warn-only` exists for the rollout window only;
|
|
131
|
+
once the gate is wired into CI, do not bypass with `--no-verify` or
|
|
132
|
+
`--warn-only` — add the covering test first.
|
|
133
|
+
|
|
134
|
+
This rule is additive to **Hard Rule #11 (No-MVP ban)**: shipping a new
|
|
135
|
+
playbook indicator or CLI surface without a regression test is the
|
|
136
|
+
same shape of incomplete-feature ship that #11 forbids.
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Smoke results against the current repo
|
|
140
|
+
|
|
141
|
+
Run from repo root:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
node scripts/check-test-coverage.js
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Expected on a clean working tree at `v0.12.7`: zero changed files, zero
|
|
148
|
+
findings, exit 0.
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* scripts/check-test-coverage.js
|
|
5
|
+
*
|
|
6
|
+
* Diff-aware test-coverage gate. Compares the changed surface in the
|
|
7
|
+
* working tree (or a staged set, or any --base..HEAD range) against the
|
|
8
|
+
* tests/ tree and reports any surface change that lacks a covering test.
|
|
9
|
+
*
|
|
10
|
+
* Surfaces detected:
|
|
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
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const fs = require("fs");
|
|
39
|
+
const path = require("path");
|
|
40
|
+
const childProc = require("child_process");
|
|
41
|
+
|
|
42
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
43
|
+
|
|
44
|
+
// --- Flag parsing -----------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
function parseArgs(argv) {
|
|
47
|
+
const out = { base: "origin/main", staged: false, json: false, warnOnly: false };
|
|
48
|
+
for (let i = 0; i < argv.length; i++) {
|
|
49
|
+
const a = argv[i];
|
|
50
|
+
if (a === "--base") out.base = argv[++i];
|
|
51
|
+
else if (a === "--staged") out.staged = true;
|
|
52
|
+
else if (a === "--json") out.json = true;
|
|
53
|
+
else if (a === "--warn-only") out.warnOnly = true;
|
|
54
|
+
else if (a === "--help" || a === "-h") out.help = true;
|
|
55
|
+
else if (a === "--repo") out.repo = argv[++i]; // test-only: override ROOT
|
|
56
|
+
else throw new Error(`Unknown flag: ${a}`);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function printHelp() {
|
|
62
|
+
const banner =
|
|
63
|
+
"Usage: node scripts/check-test-coverage.js [--base <ref>] [--staged]\n" +
|
|
64
|
+
" [--json] [--warn-only]\n" +
|
|
65
|
+
"\n" +
|
|
66
|
+
"See file header for full surface + categorization rules.\n";
|
|
67
|
+
process.stdout.write(banner);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Git plumbing -----------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
function git(args, cwd) {
|
|
73
|
+
const r = childProc.spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
74
|
+
if (r.status !== 0) {
|
|
75
|
+
const err = new Error("git " + args.join(" ") + " failed: " + (r.stderr || r.stdout));
|
|
76
|
+
err.git = true;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
return r.stdout;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// v0.12.8: resolve the diff anchor ONCE up front and thread the resolved SHA
|
|
83
|
+
// through every per-file computation. Pre-fix, listChangedFiles() resolved
|
|
84
|
+
// `opts.base` to a merge-base but fileDiff()/fileBefore() still used the raw
|
|
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.
|
|
90
|
+
function resolveBaseRef(opts, cwd) {
|
|
91
|
+
if (opts.staged) return null; // staged mode uses --cached / HEAD throughout
|
|
92
|
+
try {
|
|
93
|
+
const mb = git(["merge-base", "HEAD", opts.base], cwd).trim();
|
|
94
|
+
if (mb) return mb;
|
|
95
|
+
} catch { /* base may not exist locally; fall back to ref literal */ }
|
|
96
|
+
return opts.base;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function listChangedFiles(opts, cwd, resolvedBase) {
|
|
100
|
+
if (opts.staged) {
|
|
101
|
+
return git(["diff", "--name-status", "--cached"], cwd)
|
|
102
|
+
.split("\n").filter(Boolean).map(parseNameStatus);
|
|
103
|
+
}
|
|
104
|
+
return git(["diff", "--name-status", resolvedBase + "..HEAD"], cwd)
|
|
105
|
+
.split("\n").filter(Boolean).map(parseNameStatus);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseNameStatus(line) {
|
|
109
|
+
const parts = line.split("\t");
|
|
110
|
+
const status = parts[0][0]; // A/M/D/R/...
|
|
111
|
+
return { status, file: parts[parts.length - 1] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function fileDiff(opts, file, cwd, ignoreWs, resolvedBase) {
|
|
115
|
+
const args = ["diff", "-U0"];
|
|
116
|
+
if (ignoreWs) args.push("--ignore-all-space", "--ignore-blank-lines");
|
|
117
|
+
if (opts.staged) args.push("--cached");
|
|
118
|
+
else args.push(resolvedBase + "..HEAD");
|
|
119
|
+
args.push("--", file);
|
|
120
|
+
try { return git(args, cwd); } catch { return ""; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fileAtRef(file, ref, cwd) {
|
|
124
|
+
const r = childProc.spawnSync("git", ["show", ref + ":" + file], { cwd, encoding: "utf8" });
|
|
125
|
+
if (r.status !== 0) return null;
|
|
126
|
+
return r.stdout;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function fileBefore(opts, file, cwd, resolvedBase) {
|
|
130
|
+
if (opts.staged) return fileAtRef(file, "HEAD", cwd);
|
|
131
|
+
return fileAtRef(file, resolvedBase, cwd);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function fileAfter(opts, file, cwd) {
|
|
135
|
+
if (opts.staged) {
|
|
136
|
+
// Staged content lives in the index. `git show :file` returns it.
|
|
137
|
+
return fileAtRef(file, "", cwd) || readMaybe(path.join(cwd, file));
|
|
138
|
+
}
|
|
139
|
+
return readMaybe(path.join(cwd, file));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readMaybe(p) {
|
|
143
|
+
try { return fs.readFileSync(p, "utf8"); } catch { return null; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Categorization ---------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
const DOCS_ALWAYS_GREEN = new Set([
|
|
149
|
+
"CHANGELOG.md", "README.md", "CONTRIBUTING.md", "SECURITY.md",
|
|
150
|
+
"LICENSE", "NOTICE", "CODE_OF_CONDUCT.md", "AGENTS.md", "CLAUDE.md",
|
|
151
|
+
"SUPPORT.md", "MIGRATING.md", ".gitignore", ".npmrc", ".editorconfig",
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
function categorize(file) {
|
|
155
|
+
const norm = file.replace(/\\/g, "/");
|
|
156
|
+
if (DOCS_ALWAYS_GREEN.has(norm)) return "docs";
|
|
157
|
+
if (norm.startsWith("tests/")) return "test"; // no recursion
|
|
158
|
+
if (norm.startsWith("docs/")) return "docs";
|
|
159
|
+
if (norm.endsWith(".md") && !norm.startsWith("data/")) return "docs";
|
|
160
|
+
if (norm.startsWith(".github/workflows/")) return "workflow";
|
|
161
|
+
if (norm.startsWith("skills/") && norm.endsWith("/skill.md")) return "skill";
|
|
162
|
+
if (norm === "bin/exceptd.js") return "cli";
|
|
163
|
+
if (norm.startsWith("lib/") && norm.endsWith(".js")) return "lib";
|
|
164
|
+
if (norm.startsWith("orchestrator/") && norm.endsWith(".js")) return "lib";
|
|
165
|
+
if (norm.startsWith("scripts/") && norm.endsWith(".js")) return "lib";
|
|
166
|
+
if (norm.startsWith("data/playbooks/") && norm.endsWith(".json")) return "playbook";
|
|
167
|
+
if (norm === "data/cve-catalog.json") return "cve-catalog";
|
|
168
|
+
return "other";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isWhitespaceOnly(opts, file, cwd, resolvedBase) {
|
|
172
|
+
const wsBlind = fileDiff(opts, file, cwd, true, resolvedBase);
|
|
173
|
+
return wsBlind.split("\n").filter(l => l.startsWith("+") || l.startsWith("-"))
|
|
174
|
+
.filter(l => !l.startsWith("+++") && !l.startsWith("---")).length === 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- Surface extraction -----------------------------------------------------
|
|
178
|
+
|
|
179
|
+
function extractCliSurface(content) {
|
|
180
|
+
if (!content) return { verbs: new Set(), flags: new Set() };
|
|
181
|
+
const verbs = new Set();
|
|
182
|
+
const flags = new Set();
|
|
183
|
+
// Only scan the COMMANDS = {...} block and PLAYBOOK_VERBS Set to avoid
|
|
184
|
+
// picking up arbitrary keys from elsewhere.
|
|
185
|
+
const cmdBlock = content.match(/const COMMANDS = \{([\s\S]*?)\n\};/);
|
|
186
|
+
if (cmdBlock) {
|
|
187
|
+
const re = /^\s*"?([a-zA-Z][\w-]+)"?\s*:/gm;
|
|
188
|
+
let m;
|
|
189
|
+
while ((m = re.exec(cmdBlock[1])) !== null) verbs.add(m[1]);
|
|
190
|
+
}
|
|
191
|
+
const playbookBlock = content.match(/const PLAYBOOK_VERBS = new Set\(\[([\s\S]*?)\]\);/);
|
|
192
|
+
if (playbookBlock) {
|
|
193
|
+
const re = /"([a-zA-Z][\w-]+)"/g;
|
|
194
|
+
let m;
|
|
195
|
+
while ((m = re.exec(playbookBlock[1])) !== null) verbs.add(m[1]);
|
|
196
|
+
}
|
|
197
|
+
const flagRe = /(--[a-zA-Z][\w-]+)/g;
|
|
198
|
+
let m;
|
|
199
|
+
while ((m = flagRe.exec(content)) !== null) flags.add(m[1]);
|
|
200
|
+
for (const f of ["--help", "--version"]) flags.delete(f);
|
|
201
|
+
return { verbs, flags };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function diffSets(before, after) {
|
|
205
|
+
const added = new Set();
|
|
206
|
+
const removed = new Set();
|
|
207
|
+
for (const v of after) if (!before.has(v)) added.add(v);
|
|
208
|
+
for (const v of before) if (!after.has(v)) removed.add(v);
|
|
209
|
+
return { added, removed };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function extractLibExports(content) {
|
|
213
|
+
if (!content) return new Set();
|
|
214
|
+
const out = new Set();
|
|
215
|
+
// v0.12.9: strip block + line comments before matching `module.exports`
|
|
216
|
+
// so a doc-comment example like `module.exports = {...}` inside a /** */
|
|
217
|
+
// block does not shadow the real exports lower in the file. Pre-fix, the
|
|
218
|
+
// analyzer's own file matched a 3-char doc-comment fragment first and
|
|
219
|
+
// returned an empty export set — any source that mentions `module.exports`
|
|
220
|
+
// in a JSDoc/banner block hit the same bug. After stripping comments,
|
|
221
|
+
// the `module.exports = {...}` match runs against real code only.
|
|
222
|
+
const stripped = content
|
|
223
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
224
|
+
.replace(/^\s*\/\/.*$/gm, "");
|
|
225
|
+
const m = stripped.match(/module\.exports\s*=\s*\{([^}]+)\}/);
|
|
226
|
+
if (m) {
|
|
227
|
+
for (const tok of m[1].split(",")) {
|
|
228
|
+
const id = tok.split(":")[0].trim();
|
|
229
|
+
if (/^[a-zA-Z_$][\w$]*$/.test(id)) out.add(id);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const re = /module\.exports\.([a-zA-Z_$][\w$]*)\s*=/g;
|
|
233
|
+
let mm;
|
|
234
|
+
while ((mm = re.exec(stripped)) !== null) out.add(mm[1]);
|
|
235
|
+
const re2 = /^exports\.([a-zA-Z_$][\w$]*)\s*=/gm;
|
|
236
|
+
while ((mm = re2.exec(stripped)) !== null) out.add(mm[1]);
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function extractPlaybookIds(content) {
|
|
241
|
+
if (!content) return { indicators: new Set(), artifacts: new Set() };
|
|
242
|
+
let obj;
|
|
243
|
+
try { obj = JSON.parse(content); }
|
|
244
|
+
catch { return { indicators: new Set(), artifacts: new Set() }; }
|
|
245
|
+
const ind = new Set();
|
|
246
|
+
const arts = new Set();
|
|
247
|
+
const detect = obj && obj.phases && obj.phases.detect && obj.phases.detect.indicators;
|
|
248
|
+
if (Array.isArray(detect)) for (const i of detect) if (i && i.id) ind.add(i.id);
|
|
249
|
+
const look = obj && obj.phases && obj.phases.look && obj.phases.look.artifacts;
|
|
250
|
+
if (Array.isArray(look)) for (const a of look) if (a && a.id) arts.add(a.id);
|
|
251
|
+
return { indicators: ind, artifacts: arts };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function extractCveIocChanges(beforeStr, afterStr) {
|
|
255
|
+
const before = safeParse(beforeStr) || {};
|
|
256
|
+
const after = safeParse(afterStr) || {};
|
|
257
|
+
const changed = new Set();
|
|
258
|
+
const ids = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
259
|
+
for (const id of ids) {
|
|
260
|
+
if (!/^CVE-\d{4}-\d+/.test(id)) continue;
|
|
261
|
+
const b = JSON.stringify((before[id] && before[id].iocs) || null);
|
|
262
|
+
const a = JSON.stringify((after[id] && after[id].iocs) || null);
|
|
263
|
+
if (b !== a) changed.add(id);
|
|
264
|
+
}
|
|
265
|
+
return changed;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function safeParse(s) { try { return s ? JSON.parse(s) : null; } catch { return null; } }
|
|
269
|
+
|
|
270
|
+
// --- Test corpus + coverage probes ------------------------------------------
|
|
271
|
+
|
|
272
|
+
function loadTestCorpus(cwd) {
|
|
273
|
+
const root = path.join(cwd, "tests");
|
|
274
|
+
if (!fs.existsSync(root)) return "";
|
|
275
|
+
const acc = [];
|
|
276
|
+
walk(root, p => {
|
|
277
|
+
const norm = p.replace(/\\/g, "/");
|
|
278
|
+
if (/\.(js|json)$/.test(norm)) {
|
|
279
|
+
try { acc.push(fs.readFileSync(p, "utf8")); } catch { /* ignore unreadable */ }
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
return acc.join("\n\x00\n");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function walk(dir, fn) {
|
|
286
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
287
|
+
const p = path.join(dir, entry.name);
|
|
288
|
+
if (entry.isDirectory()) walk(p, fn);
|
|
289
|
+
else fn(p);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
|
294
|
+
|
|
295
|
+
function coversCliVerb(corpus, verb) {
|
|
296
|
+
const v = escapeRe(verb);
|
|
297
|
+
const quoted = new RegExp("['\"`]" + v + "['\"`]");
|
|
298
|
+
return quoted.test(corpus);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function coversCliFlag(corpus, flag) {
|
|
302
|
+
return corpus.includes(flag);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function coversLibExport(corpus, libRel, ident) {
|
|
306
|
+
const baseName = path.basename(libRel).replace(/\.js$/, "");
|
|
307
|
+
const baseFile = path.basename(libRel); // e.g. "check-sbom-currency.js"
|
|
308
|
+
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
|
+
const requireRe = new RegExp("require\\([^)]*" + escapeRe(baseName) + "[^)]*\\)");
|
|
313
|
+
if (requireRe.test(corpus) && identRe.test(corpus)) return true;
|
|
314
|
+
// v0.12.9: a test that spawns the script under test (e.g.
|
|
315
|
+
// `spawnSync(node, [".../scripts/check-sbom-currency.js", ...])`) is
|
|
316
|
+
// real coverage too. Accept that shape when the corpus references the
|
|
317
|
+
// full filename AND the identifier elsewhere. The `.js` suffix is what
|
|
318
|
+
// distinguishes a real spawn-path from an arbitrary mention of the
|
|
319
|
+
// module base name.
|
|
320
|
+
if (corpus.includes(baseFile) && identRe.test(corpus)) return true;
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function coversPlaybookId(corpus, id) {
|
|
325
|
+
const q = new RegExp("['\"`]" + escapeRe(id) + "['\"`]");
|
|
326
|
+
return q.test(corpus);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function coversCveIoc(corpus, cveId) {
|
|
330
|
+
if (!corpus.includes(cveId)) return false;
|
|
331
|
+
return /\biocs\b/i.test(corpus);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- Main analyzer ----------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
function analyze(opts) {
|
|
337
|
+
const cwd = opts.repo || ROOT;
|
|
338
|
+
// v0.12.8: resolve the diff anchor ONCE and thread it through every
|
|
339
|
+
// per-file call so listChangedFiles + fileDiff + fileBefore all agree on
|
|
340
|
+
// the same SHA. Otherwise origin/main advancing past the merge-base
|
|
341
|
+
// between calls produces false add/remove findings.
|
|
342
|
+
const resolvedBase = resolveBaseRef(opts, cwd);
|
|
343
|
+
const changed = listChangedFiles(opts, cwd, resolvedBase);
|
|
344
|
+
const corpus = loadTestCorpus(cwd);
|
|
345
|
+
|
|
346
|
+
const findings = [];
|
|
347
|
+
const allowlisted = [];
|
|
348
|
+
const manualReview = [];
|
|
349
|
+
|
|
350
|
+
for (const ch of changed) {
|
|
351
|
+
const cat = categorize(ch.file);
|
|
352
|
+
if (cat === "docs" || cat === "test") {
|
|
353
|
+
allowlisted.push({ file: ch.file, reason: cat });
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (cat === "skill") { allowlisted.push({ file: ch.file, reason: "skill-signed" }); continue; }
|
|
357
|
+
if (cat === "workflow") { manualReview.push({ file: ch.file, reason: "workflow" }); continue; }
|
|
358
|
+
if (cat === "other") { allowlisted.push({ file: ch.file, reason: "out-of-scope" }); continue; }
|
|
359
|
+
if (ch.status !== "D" && isWhitespaceOnly(opts, ch.file, cwd, resolvedBase)) {
|
|
360
|
+
allowlisted.push({ file: ch.file, reason: "whitespace-only" });
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const before = fileBefore(opts, ch.file, cwd, resolvedBase);
|
|
365
|
+
const after = ch.status === "D" ? null : fileAfter(opts, ch.file, cwd);
|
|
366
|
+
|
|
367
|
+
if (cat === "cli") {
|
|
368
|
+
const b = extractCliSurface(before);
|
|
369
|
+
const a = extractCliSurface(after);
|
|
370
|
+
const verbsDiff = diffSets(b.verbs, a.verbs);
|
|
371
|
+
const flagsDiff = diffSets(b.flags, a.flags);
|
|
372
|
+
for (const v of verbsDiff.added) if (!coversCliVerb(corpus, v))
|
|
373
|
+
findings.push({ file: ch.file, kind: "cli-verb", surface: v, change: "added" });
|
|
374
|
+
for (const v of verbsDiff.removed) if (coversCliVerb(corpus, v))
|
|
375
|
+
findings.push({ file: ch.file, kind: "cli-verb", surface: v, change: "removed-but-test-remains" });
|
|
376
|
+
for (const f of flagsDiff.added) if (!coversCliFlag(corpus, f))
|
|
377
|
+
findings.push({ file: ch.file, kind: "cli-flag", surface: f, change: "added" });
|
|
378
|
+
for (const f of flagsDiff.removed) if (coversCliFlag(corpus, f))
|
|
379
|
+
findings.push({ file: ch.file, kind: "cli-flag", surface: f, change: "removed-but-test-remains" });
|
|
380
|
+
} else if (cat === "lib") {
|
|
381
|
+
const b = extractLibExports(before);
|
|
382
|
+
const a = extractLibExports(after);
|
|
383
|
+
const d = diffSets(b, a);
|
|
384
|
+
for (const id of d.added) if (!coversLibExport(corpus, ch.file, id))
|
|
385
|
+
findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "added" });
|
|
386
|
+
for (const id of d.removed) if (coversLibExport(corpus, ch.file, id))
|
|
387
|
+
findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "removed-but-test-remains" });
|
|
388
|
+
} else if (cat === "playbook") {
|
|
389
|
+
const b = extractPlaybookIds(before);
|
|
390
|
+
const a = extractPlaybookIds(after);
|
|
391
|
+
const ind = diffSets(b.indicators, a.indicators);
|
|
392
|
+
const arts = diffSets(b.artifacts, a.artifacts);
|
|
393
|
+
for (const id of ind.added) if (!coversPlaybookId(corpus, id))
|
|
394
|
+
findings.push({ file: ch.file, kind: "playbook-indicator", surface: id, change: "added" });
|
|
395
|
+
for (const id of arts.added) if (!coversPlaybookId(corpus, id))
|
|
396
|
+
findings.push({ file: ch.file, kind: "playbook-artifact", surface: id, change: "added" });
|
|
397
|
+
for (const id of ind.removed) if (coversPlaybookId(corpus, id))
|
|
398
|
+
findings.push({ file: ch.file, kind: "playbook-indicator", surface: id, change: "removed-but-test-remains" });
|
|
399
|
+
} else if (cat === "cve-catalog") {
|
|
400
|
+
const ids = extractCveIocChanges(before, after);
|
|
401
|
+
for (const id of ids) if (!coversCveIoc(corpus, id))
|
|
402
|
+
findings.push({ file: ch.file, kind: "cve-ioc", surface: id, change: "iocs-modified" });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return { findings, allowlisted, manualReview, totalChanged: changed.length };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// --- Output -----------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
function emitHuman(report) {
|
|
412
|
+
const out = [];
|
|
413
|
+
out.push("Diff coverage analyzer — " + report.totalChanged + " changed file(s)");
|
|
414
|
+
out.push(" Allowlisted: " + report.allowlisted.length +
|
|
415
|
+
" Manual-review: " + report.manualReview.length +
|
|
416
|
+
" Findings: " + report.findings.length);
|
|
417
|
+
if (report.manualReview.length) {
|
|
418
|
+
out.push("");
|
|
419
|
+
out.push("Manual review required:");
|
|
420
|
+
for (const m of report.manualReview) out.push(" - " + m.file + " [" + m.reason + "]");
|
|
421
|
+
}
|
|
422
|
+
if (report.findings.length) {
|
|
423
|
+
out.push("");
|
|
424
|
+
out.push("Uncovered surface changes:");
|
|
425
|
+
for (const f of report.findings) {
|
|
426
|
+
out.push(" [" + f.kind + "] " + f.file + " '" + f.surface + "' (" + f.change + ")");
|
|
427
|
+
}
|
|
428
|
+
out.push("");
|
|
429
|
+
out.push("Each item above adds, removes, or modifies a surface that has no");
|
|
430
|
+
out.push("matching reference in tests/. Add a regression test before merge,");
|
|
431
|
+
out.push("or move the change into the allowlist if it is genuinely test-exempt.");
|
|
432
|
+
} else {
|
|
433
|
+
out.push("");
|
|
434
|
+
out.push("OK: every changed surface has a matching test reference.");
|
|
435
|
+
}
|
|
436
|
+
process.stdout.write(out.join("\n") + "\n");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function main() {
|
|
440
|
+
let opts;
|
|
441
|
+
try { opts = parseArgs(process.argv.slice(2)); }
|
|
442
|
+
catch (e) { process.stderr.write(e.message + "\n"); process.exitCode = 2; return; }
|
|
443
|
+
if (opts.help) { printHelp(); return; }
|
|
444
|
+
|
|
445
|
+
let report;
|
|
446
|
+
try { report = analyze(opts); }
|
|
447
|
+
catch (e) {
|
|
448
|
+
if (opts.json) process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + "\n");
|
|
449
|
+
else process.stderr.write("check-test-coverage: " + e.message + "\n");
|
|
450
|
+
process.exitCode = 2;
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (opts.json) {
|
|
455
|
+
process.stdout.write(JSON.stringify({
|
|
456
|
+
ok: report.findings.length === 0,
|
|
457
|
+
total_changed: report.totalChanged,
|
|
458
|
+
findings: report.findings,
|
|
459
|
+
allowlisted: report.allowlisted,
|
|
460
|
+
manual_review: report.manualReview,
|
|
461
|
+
}) + "\n");
|
|
462
|
+
} else {
|
|
463
|
+
emitHuman(report);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (report.findings.length > 0 && !opts.warnOnly) process.exitCode = 1;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (require.main === module) main();
|
|
470
|
+
|
|
471
|
+
module.exports = {
|
|
472
|
+
analyze, parseArgs, categorize,
|
|
473
|
+
extractCliSurface, extractLibExports, extractPlaybookIds, extractCveIocChanges,
|
|
474
|
+
coversCliVerb, coversCliFlag, coversLibExport, coversPlaybookId, coversCveIoc,
|
|
475
|
+
DOCS_ALWAYS_GREEN,
|
|
476
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# scripts/hooks/pre-commit.sh
|
|
3
|
+
#
|
|
4
|
+
# Diff-coverage gate as a pre-commit hook. NOT installed by default.
|
|
5
|
+
# To opt in:
|
|
6
|
+
# git config core.hooksPath scripts/hooks
|
|
7
|
+
#
|
|
8
|
+
# To bypass once (acceptable when you genuinely have no covering test
|
|
9
|
+
# yet and intend to add one before push):
|
|
10
|
+
# git commit --no-verify
|
|
11
|
+
#
|
|
12
|
+
# Exits non-zero when staged changes add a CLI verb, CLI flag, lib
|
|
13
|
+
# export, playbook indicator, or CVE iocs field that has no matching
|
|
14
|
+
# reference in tests/. Run `node scripts/check-test-coverage.js --staged`
|
|
15
|
+
# manually for the full report.
|
|
16
|
+
set -e
|
|
17
|
+
|
|
18
|
+
ROOT="$(git rev-parse --show-toplevel)"
|
|
19
|
+
node "$ROOT/scripts/check-test-coverage.js" --staged
|