@blamejs/exceptd-skills 0.12.7 → 0.12.8
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 +14 -0
- package/CHANGELOG.md +67 -0
- package/bin/exceptd.js +189 -52
- package/data/_indexes/_meta.json +37 -37
- package/data/_indexes/activity-feed.json +26 -26
- package/data/_indexes/catalog-summaries.json +8 -8
- package/data/_indexes/chains.json +238 -0
- package/data/_indexes/frequency.json +63 -5
- package/data/_indexes/jurisdiction-map.json +13 -3
- package/data/_indexes/section-offsets.json +881 -845
- package/data/_indexes/summary-cards.json +2 -2
- package/data/_indexes/token-budget.json +145 -125
- package/data/atlas-ttps.json +189 -1
- 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/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/refresh-external.js +25 -5
- package/lib/schemas/skill-frontmatter.schema.json +2 -2
- package/manifest-snapshot.json +1 -1
- package/manifest.json +67 -67
- 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 +455 -0
- package/scripts/hooks/pre-commit.sh +19 -0
- package/scripts/predeploy.js +16 -30
- package/skills/age-gates-child-safety/skill.md +3 -0
- package/skills/ai-attack-surface/skill.md +4 -1
- package/skills/ai-c2-detection/skill.md +6 -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/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/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 +4 -1
- package/skills/mlops-security/skill.md +3 -0
- package/skills/ot-ics-security/skill.md +3 -0
- package/skills/rag-pipeline-security/skill.md +3 -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 +19 -1
- package/skills/skill-update-loop/skill.md +32 -0
- package/skills/supply-chain-integrity/skill.md +3 -0
- package/skills/threat-modeling-methodology/skill.md +3 -0
- package/skills/webapp-security/skill.md +3 -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,455 @@
|
|
|
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
|
+
const m = content.match(/module\.exports\s*=\s*\{([^}]+)\}/);
|
|
216
|
+
if (m) {
|
|
217
|
+
for (const tok of m[1].split(",")) {
|
|
218
|
+
const id = tok.split(":")[0].trim();
|
|
219
|
+
if (/^[a-zA-Z_$][\w$]*$/.test(id)) out.add(id);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const re = /module\.exports\.([a-zA-Z_$][\w$]*)\s*=/g;
|
|
223
|
+
let mm;
|
|
224
|
+
while ((mm = re.exec(content)) !== null) out.add(mm[1]);
|
|
225
|
+
const re2 = /^exports\.([a-zA-Z_$][\w$]*)\s*=/gm;
|
|
226
|
+
while ((mm = re2.exec(content)) !== null) out.add(mm[1]);
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function extractPlaybookIds(content) {
|
|
231
|
+
if (!content) return { indicators: new Set(), artifacts: new Set() };
|
|
232
|
+
let obj;
|
|
233
|
+
try { obj = JSON.parse(content); }
|
|
234
|
+
catch { return { indicators: new Set(), artifacts: new Set() }; }
|
|
235
|
+
const ind = new Set();
|
|
236
|
+
const arts = new Set();
|
|
237
|
+
const detect = obj && obj.phases && obj.phases.detect && obj.phases.detect.indicators;
|
|
238
|
+
if (Array.isArray(detect)) for (const i of detect) if (i && i.id) ind.add(i.id);
|
|
239
|
+
const look = obj && obj.phases && obj.phases.look && obj.phases.look.artifacts;
|
|
240
|
+
if (Array.isArray(look)) for (const a of look) if (a && a.id) arts.add(a.id);
|
|
241
|
+
return { indicators: ind, artifacts: arts };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function extractCveIocChanges(beforeStr, afterStr) {
|
|
245
|
+
const before = safeParse(beforeStr) || {};
|
|
246
|
+
const after = safeParse(afterStr) || {};
|
|
247
|
+
const changed = new Set();
|
|
248
|
+
const ids = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
249
|
+
for (const id of ids) {
|
|
250
|
+
if (!/^CVE-\d{4}-\d+/.test(id)) continue;
|
|
251
|
+
const b = JSON.stringify((before[id] && before[id].iocs) || null);
|
|
252
|
+
const a = JSON.stringify((after[id] && after[id].iocs) || null);
|
|
253
|
+
if (b !== a) changed.add(id);
|
|
254
|
+
}
|
|
255
|
+
return changed;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function safeParse(s) { try { return s ? JSON.parse(s) : null; } catch { return null; } }
|
|
259
|
+
|
|
260
|
+
// --- Test corpus + coverage probes ------------------------------------------
|
|
261
|
+
|
|
262
|
+
function loadTestCorpus(cwd) {
|
|
263
|
+
const root = path.join(cwd, "tests");
|
|
264
|
+
if (!fs.existsSync(root)) return "";
|
|
265
|
+
const acc = [];
|
|
266
|
+
walk(root, p => {
|
|
267
|
+
const norm = p.replace(/\\/g, "/");
|
|
268
|
+
if (/\.(js|json)$/.test(norm)) {
|
|
269
|
+
try { acc.push(fs.readFileSync(p, "utf8")); } catch { /* ignore unreadable */ }
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
return acc.join("\n\x00\n");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function walk(dir, fn) {
|
|
276
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
277
|
+
const p = path.join(dir, entry.name);
|
|
278
|
+
if (entry.isDirectory()) walk(p, fn);
|
|
279
|
+
else fn(p);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
|
284
|
+
|
|
285
|
+
function coversCliVerb(corpus, verb) {
|
|
286
|
+
const v = escapeRe(verb);
|
|
287
|
+
const quoted = new RegExp("['\"`]" + v + "['\"`]");
|
|
288
|
+
return quoted.test(corpus);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function coversCliFlag(corpus, flag) {
|
|
292
|
+
return corpus.includes(flag);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function coversLibExport(corpus, libRel, ident) {
|
|
296
|
+
const baseName = path.basename(libRel).replace(/\.js$/, "");
|
|
297
|
+
const requireRe = new RegExp("require\\([^)]*" + escapeRe(baseName) + "[^)]*\\)");
|
|
298
|
+
if (!requireRe.test(corpus)) return false;
|
|
299
|
+
const identRe = new RegExp("\\b" + escapeRe(ident) + "\\b");
|
|
300
|
+
return identRe.test(corpus);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function coversPlaybookId(corpus, id) {
|
|
304
|
+
const q = new RegExp("['\"`]" + escapeRe(id) + "['\"`]");
|
|
305
|
+
return q.test(corpus);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function coversCveIoc(corpus, cveId) {
|
|
309
|
+
if (!corpus.includes(cveId)) return false;
|
|
310
|
+
return /\biocs\b/i.test(corpus);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// --- Main analyzer ----------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
function analyze(opts) {
|
|
316
|
+
const cwd = opts.repo || ROOT;
|
|
317
|
+
// v0.12.8: resolve the diff anchor ONCE and thread it through every
|
|
318
|
+
// per-file call so listChangedFiles + fileDiff + fileBefore all agree on
|
|
319
|
+
// the same SHA. Otherwise origin/main advancing past the merge-base
|
|
320
|
+
// between calls produces false add/remove findings.
|
|
321
|
+
const resolvedBase = resolveBaseRef(opts, cwd);
|
|
322
|
+
const changed = listChangedFiles(opts, cwd, resolvedBase);
|
|
323
|
+
const corpus = loadTestCorpus(cwd);
|
|
324
|
+
|
|
325
|
+
const findings = [];
|
|
326
|
+
const allowlisted = [];
|
|
327
|
+
const manualReview = [];
|
|
328
|
+
|
|
329
|
+
for (const ch of changed) {
|
|
330
|
+
const cat = categorize(ch.file);
|
|
331
|
+
if (cat === "docs" || cat === "test") {
|
|
332
|
+
allowlisted.push({ file: ch.file, reason: cat });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (cat === "skill") { allowlisted.push({ file: ch.file, reason: "skill-signed" }); continue; }
|
|
336
|
+
if (cat === "workflow") { manualReview.push({ file: ch.file, reason: "workflow" }); continue; }
|
|
337
|
+
if (cat === "other") { allowlisted.push({ file: ch.file, reason: "out-of-scope" }); continue; }
|
|
338
|
+
if (ch.status !== "D" && isWhitespaceOnly(opts, ch.file, cwd, resolvedBase)) {
|
|
339
|
+
allowlisted.push({ file: ch.file, reason: "whitespace-only" });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const before = fileBefore(opts, ch.file, cwd, resolvedBase);
|
|
344
|
+
const after = ch.status === "D" ? null : fileAfter(opts, ch.file, cwd);
|
|
345
|
+
|
|
346
|
+
if (cat === "cli") {
|
|
347
|
+
const b = extractCliSurface(before);
|
|
348
|
+
const a = extractCliSurface(after);
|
|
349
|
+
const verbsDiff = diffSets(b.verbs, a.verbs);
|
|
350
|
+
const flagsDiff = diffSets(b.flags, a.flags);
|
|
351
|
+
for (const v of verbsDiff.added) if (!coversCliVerb(corpus, v))
|
|
352
|
+
findings.push({ file: ch.file, kind: "cli-verb", surface: v, change: "added" });
|
|
353
|
+
for (const v of verbsDiff.removed) if (coversCliVerb(corpus, v))
|
|
354
|
+
findings.push({ file: ch.file, kind: "cli-verb", surface: v, change: "removed-but-test-remains" });
|
|
355
|
+
for (const f of flagsDiff.added) if (!coversCliFlag(corpus, f))
|
|
356
|
+
findings.push({ file: ch.file, kind: "cli-flag", surface: f, change: "added" });
|
|
357
|
+
for (const f of flagsDiff.removed) if (coversCliFlag(corpus, f))
|
|
358
|
+
findings.push({ file: ch.file, kind: "cli-flag", surface: f, change: "removed-but-test-remains" });
|
|
359
|
+
} else if (cat === "lib") {
|
|
360
|
+
const b = extractLibExports(before);
|
|
361
|
+
const a = extractLibExports(after);
|
|
362
|
+
const d = diffSets(b, a);
|
|
363
|
+
for (const id of d.added) if (!coversLibExport(corpus, ch.file, id))
|
|
364
|
+
findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "added" });
|
|
365
|
+
for (const id of d.removed) if (coversLibExport(corpus, ch.file, id))
|
|
366
|
+
findings.push({ file: ch.file, kind: "lib-export", surface: id, change: "removed-but-test-remains" });
|
|
367
|
+
} else if (cat === "playbook") {
|
|
368
|
+
const b = extractPlaybookIds(before);
|
|
369
|
+
const a = extractPlaybookIds(after);
|
|
370
|
+
const ind = diffSets(b.indicators, a.indicators);
|
|
371
|
+
const arts = diffSets(b.artifacts, a.artifacts);
|
|
372
|
+
for (const id of ind.added) if (!coversPlaybookId(corpus, id))
|
|
373
|
+
findings.push({ file: ch.file, kind: "playbook-indicator", surface: id, change: "added" });
|
|
374
|
+
for (const id of arts.added) if (!coversPlaybookId(corpus, id))
|
|
375
|
+
findings.push({ file: ch.file, kind: "playbook-artifact", surface: id, change: "added" });
|
|
376
|
+
for (const id of ind.removed) if (coversPlaybookId(corpus, id))
|
|
377
|
+
findings.push({ file: ch.file, kind: "playbook-indicator", surface: id, change: "removed-but-test-remains" });
|
|
378
|
+
} else if (cat === "cve-catalog") {
|
|
379
|
+
const ids = extractCveIocChanges(before, after);
|
|
380
|
+
for (const id of ids) if (!coversCveIoc(corpus, id))
|
|
381
|
+
findings.push({ file: ch.file, kind: "cve-ioc", surface: id, change: "iocs-modified" });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return { findings, allowlisted, manualReview, totalChanged: changed.length };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// --- Output -----------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
function emitHuman(report) {
|
|
391
|
+
const out = [];
|
|
392
|
+
out.push("Diff coverage analyzer — " + report.totalChanged + " changed file(s)");
|
|
393
|
+
out.push(" Allowlisted: " + report.allowlisted.length +
|
|
394
|
+
" Manual-review: " + report.manualReview.length +
|
|
395
|
+
" Findings: " + report.findings.length);
|
|
396
|
+
if (report.manualReview.length) {
|
|
397
|
+
out.push("");
|
|
398
|
+
out.push("Manual review required:");
|
|
399
|
+
for (const m of report.manualReview) out.push(" - " + m.file + " [" + m.reason + "]");
|
|
400
|
+
}
|
|
401
|
+
if (report.findings.length) {
|
|
402
|
+
out.push("");
|
|
403
|
+
out.push("Uncovered surface changes:");
|
|
404
|
+
for (const f of report.findings) {
|
|
405
|
+
out.push(" [" + f.kind + "] " + f.file + " '" + f.surface + "' (" + f.change + ")");
|
|
406
|
+
}
|
|
407
|
+
out.push("");
|
|
408
|
+
out.push("Each item above adds, removes, or modifies a surface that has no");
|
|
409
|
+
out.push("matching reference in tests/. Add a regression test before merge,");
|
|
410
|
+
out.push("or move the change into the allowlist if it is genuinely test-exempt.");
|
|
411
|
+
} else {
|
|
412
|
+
out.push("");
|
|
413
|
+
out.push("OK: every changed surface has a matching test reference.");
|
|
414
|
+
}
|
|
415
|
+
process.stdout.write(out.join("\n") + "\n");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function main() {
|
|
419
|
+
let opts;
|
|
420
|
+
try { opts = parseArgs(process.argv.slice(2)); }
|
|
421
|
+
catch (e) { process.stderr.write(e.message + "\n"); process.exitCode = 2; return; }
|
|
422
|
+
if (opts.help) { printHelp(); return; }
|
|
423
|
+
|
|
424
|
+
let report;
|
|
425
|
+
try { report = analyze(opts); }
|
|
426
|
+
catch (e) {
|
|
427
|
+
if (opts.json) process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + "\n");
|
|
428
|
+
else process.stderr.write("check-test-coverage: " + e.message + "\n");
|
|
429
|
+
process.exitCode = 2;
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (opts.json) {
|
|
434
|
+
process.stdout.write(JSON.stringify({
|
|
435
|
+
ok: report.findings.length === 0,
|
|
436
|
+
total_changed: report.totalChanged,
|
|
437
|
+
findings: report.findings,
|
|
438
|
+
allowlisted: report.allowlisted,
|
|
439
|
+
manual_review: report.manualReview,
|
|
440
|
+
}) + "\n");
|
|
441
|
+
} else {
|
|
442
|
+
emitHuman(report);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (report.findings.length > 0 && !opts.warnOnly) process.exitCode = 1;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (require.main === module) main();
|
|
449
|
+
|
|
450
|
+
module.exports = {
|
|
451
|
+
analyze, parseArgs, categorize,
|
|
452
|
+
extractCliSurface, extractLibExports, extractPlaybookIds, extractCveIocChanges,
|
|
453
|
+
coversCliVerb, coversCliFlag, coversLibExport, coversPlaybookId, coversCveIoc,
|
|
454
|
+
DOCS_ALWAYS_GREEN,
|
|
455
|
+
};
|
|
@@ -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
|
package/scripts/predeploy.js
CHANGED
|
@@ -118,7 +118,7 @@ const GATES = [
|
|
|
118
118
|
{
|
|
119
119
|
name: "SBOM currency check (sbom.cdx.json vs. live surface)",
|
|
120
120
|
command: process.execPath,
|
|
121
|
-
args: ["
|
|
121
|
+
args: [path.join(ROOT, "scripts", "check-sbom-currency.js")],
|
|
122
122
|
ciJobName: "Data integrity (catalog + manifest snapshot)",
|
|
123
123
|
},
|
|
124
124
|
{
|
|
@@ -151,37 +151,23 @@ const GATES = [
|
|
|
151
151
|
ciJobName: "Data integrity (catalog + manifest snapshot)",
|
|
152
152
|
requiresKeys: true,
|
|
153
153
|
},
|
|
154
|
+
{
|
|
155
|
+
// v0.12.8 — AGENTS.md hard rule #15 (e2e no-MVP). Every diff that
|
|
156
|
+
// touches a CLI verb, CLI flag, lib/orchestrator/scripts export,
|
|
157
|
+
// playbook indicator, or CVE iocs field must land with a covering
|
|
158
|
+
// test reference in the same PR. The analyzer parses git diff against
|
|
159
|
+
// origin/main, classifies each change shape, and fails if a covered
|
|
160
|
+
// surface lacks a test literal anywhere under tests/. Run with
|
|
161
|
+
// --warn-only during the v0.12.8 → v0.12.9 rollout window so the gate
|
|
162
|
+
// surfaces gaps without blocking; flip to blocking after one release
|
|
163
|
+
// cycle.
|
|
164
|
+
name: "Diff coverage (feature changes require test coverage)",
|
|
165
|
+
command: process.execPath,
|
|
166
|
+
args: [path.join(ROOT, "scripts", "check-test-coverage.js"), "--warn-only"],
|
|
167
|
+
ciJobName: "Diff coverage",
|
|
168
|
+
},
|
|
154
169
|
];
|
|
155
170
|
|
|
156
|
-
/* Inline checker, run as `node -e`, so the predeploy gate stays one
|
|
157
|
-
* file and the SBOM regen logic stays in scripts/refresh-sbom.js
|
|
158
|
-
* (single source of truth). Compares the persisted sbom.cdx.json
|
|
159
|
-
* against the live skill_count + catalog_count derived from
|
|
160
|
-
* manifest.json + data/. Exits nonzero on drift, with a hint to run
|
|
161
|
-
* `npm run refresh-sbom`. */
|
|
162
|
-
function sbomCurrencyChecker() {
|
|
163
|
-
return [
|
|
164
|
-
"const fs=require('fs');const path=require('path');",
|
|
165
|
-
"const root=" + JSON.stringify(ROOT) + ";",
|
|
166
|
-
"const sbomPath=path.join(root,'sbom.cdx.json');",
|
|
167
|
-
"if(!fs.existsSync(sbomPath)){console.error('sbom.cdx.json not found — run `npm run refresh-sbom`.');process.exit(1);}",
|
|
168
|
-
"const sbom=JSON.parse(fs.readFileSync(sbomPath,'utf8'));",
|
|
169
|
-
"const manifest=JSON.parse(fs.readFileSync(path.join(root,'manifest.json'),'utf8'));",
|
|
170
|
-
"const dataDir=path.join(root,'data');",
|
|
171
|
-
"const liveCatalogs=fs.readdirSync(dataDir).filter(f=>f.endsWith('.json')).length;",
|
|
172
|
-
"const liveSkills=Array.isArray(manifest.skills)?manifest.skills.length:0;",
|
|
173
|
-
"const props=Object.fromEntries((sbom.metadata&&sbom.metadata.properties||[]).map(p=>[p.name,p.value]));",
|
|
174
|
-
"const sbomCatalogs=Number(props['exceptd:catalog:count']);",
|
|
175
|
-
"const sbomSkills=Number(props['exceptd:skill:count']);",
|
|
176
|
-
"let drift=false;",
|
|
177
|
-
"if(sbomCatalogs!==liveCatalogs){console.error(`SBOM catalog count ${sbomCatalogs} != live ${liveCatalogs}`);drift=true;}",
|
|
178
|
-
"if(sbomSkills!==liveSkills){console.error(`SBOM skill count ${sbomSkills} != live ${liveSkills}`);drift=true;}",
|
|
179
|
-
"if(sbom.bomFormat!=='CycloneDX'||sbom.specVersion!=='1.6'){console.error('SBOM is not CycloneDX 1.6');drift=true;}",
|
|
180
|
-
"if(drift){console.error('Run `npm run refresh-sbom` to regenerate sbom.cdx.json.');process.exit(1);}",
|
|
181
|
-
"console.log(`SBOM current — ${sbomSkills} skills, ${sbomCatalogs} catalogs.`);",
|
|
182
|
-
].join("");
|
|
183
|
-
}
|
|
184
|
-
|
|
185
171
|
function runGate(gate) {
|
|
186
172
|
if (gate.requiresKeys) {
|
|
187
173
|
const pubKey = path.join(ROOT, "keys", "public.pem");
|