diff --git a/.github/workflows/plugin-validate.yml b/.github/workflows/plugin-validate.yml index 84f507e..616c70b 100644 --- a/.github/workflows/plugin-validate.yml +++ b/.github/workflows/plugin-validate.yml @@ -23,6 +23,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + # P2b: validate-bp-contract's stable-ID assertions (8/14) diff the + # taxonomy/events id-sets against the merge-base with origin/main and + # FAIL CLOSED on shallow history — full history is load-bearing. + # N-6 (issue): on fork PRs `origin` is the fork, so the baseline is + # fork-controlled; prefer github.base_ref if forks are ever enabled. + fetch-depth: 0 - uses: actions/setup-node@v4 with: @@ -42,3 +49,9 @@ jobs: - name: Run plugin-registry conformance tests (path-contain regression lock) run: node tests/test-plugin-registry.mjs + + - name: Validate bp contracts (assertions 0 + 1-15, P2b) + run: node scripts/validate-bp-contract.mjs --project "$GITHUB_WORKSPACE" + + - name: Run validate-bp-contract self-tests (golden corpus + stable-ID E2E) + run: node tests/test-validate-bp-contract.mjs diff --git a/patterns/bp-001.json b/patterns/bp-001.json new file mode 100644 index 0000000..07da498 --- /dev/null +++ b/patterns/bp-001.json @@ -0,0 +1,15 @@ +{ + "id": "bp-001", + "title": "Standard implementation workflow", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "sha256:7ea41ed82edef968baee6880f040008080afd962fec9120336ee336796013cc4", + "events_version": "sha256:13f01e5a599272b349eabd66694b7898e68438e8aad6497e80a9b780bea34ab0" +} diff --git a/patterns/bp-002.json b/patterns/bp-002.json new file mode 100644 index 0000000..0e2224f --- /dev/null +++ b/patterns/bp-002.json @@ -0,0 +1,15 @@ +{ + "id": "bp-002", + "title": "Proactive milestone storage", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "sha256:7ea41ed82edef968baee6880f040008080afd962fec9120336ee336796013cc4", + "events_version": "sha256:13f01e5a599272b349eabd66694b7898e68438e8aad6497e80a9b780bea34ab0" +} diff --git a/patterns/bp-003.json b/patterns/bp-003.json new file mode 100644 index 0000000..204c9a0 --- /dev/null +++ b/patterns/bp-003.json @@ -0,0 +1,15 @@ +{ + "id": "bp-003", + "title": "Promote project-specific best practices to global memory", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "sha256:7ea41ed82edef968baee6880f040008080afd962fec9120336ee336796013cc4", + "events_version": "sha256:13f01e5a599272b349eabd66694b7898e68438e8aad6497e80a9b780bea34ab0" +} diff --git a/patterns/bp-004.json b/patterns/bp-004.json new file mode 100644 index 0000000..a5d0ef8 --- /dev/null +++ b/patterns/bp-004.json @@ -0,0 +1,15 @@ +{ + "id": "bp-004", + "title": "Machine-readable index for token efficiency", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "sha256:7ea41ed82edef968baee6880f040008080afd962fec9120336ee336796013cc4", + "events_version": "sha256:13f01e5a599272b349eabd66694b7898e68438e8aad6497e80a9b780bea34ab0" +} diff --git a/patterns/bp-005.json b/patterns/bp-005.json new file mode 100644 index 0000000..4076d26 --- /dev/null +++ b/patterns/bp-005.json @@ -0,0 +1,15 @@ +{ + "id": "bp-005", + "title": "Enforcement lives in consuming repos, not rule repos", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "sha256:7ea41ed82edef968baee6880f040008080afd962fec9120336ee336796013cc4", + "events_version": "sha256:13f01e5a599272b349eabd66694b7898e68438e8aad6497e80a9b780bea34ab0" +} diff --git a/patterns/bp-006.json b/patterns/bp-006.json new file mode 100644 index 0000000..ba0923e --- /dev/null +++ b/patterns/bp-006.json @@ -0,0 +1,15 @@ +{ + "id": "bp-006", + "title": "Push only after all verification steps complete", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "sha256:7ea41ed82edef968baee6880f040008080afd962fec9120336ee336796013cc4", + "events_version": "sha256:13f01e5a599272b349eabd66694b7898e68438e8aad6497e80a9b780bea34ab0" +} diff --git a/patterns/bp-008.json b/patterns/bp-008.json new file mode 100644 index 0000000..a98642b --- /dev/null +++ b/patterns/bp-008.json @@ -0,0 +1,15 @@ +{ + "id": "bp-008", + "title": "Redo properly instead of patching retroactively", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "sha256:7ea41ed82edef968baee6880f040008080afd962fec9120336ee336796013cc4", + "events_version": "sha256:13f01e5a599272b349eabd66694b7898e68438e8aad6497e80a9b780bea34ab0" +} diff --git a/patterns/bp-009.json b/patterns/bp-009.json new file mode 100644 index 0000000..571a701 --- /dev/null +++ b/patterns/bp-009.json @@ -0,0 +1,15 @@ +{ + "id": "bp-009", + "title": "Store rule violations as evidence for enforcement", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "sha256:7ea41ed82edef968baee6880f040008080afd962fec9120336ee336796013cc4", + "events_version": "sha256:13f01e5a599272b349eabd66694b7898e68438e8aad6497e80a9b780bea34ab0" +} diff --git a/patterns/bp-010.json b/patterns/bp-010.json new file mode 100644 index 0000000..d9041f2 --- /dev/null +++ b/patterns/bp-010.json @@ -0,0 +1,15 @@ +{ + "id": "bp-010", + "title": "Habits override knowledge — always add mechanical enforcement", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "sha256:7ea41ed82edef968baee6880f040008080afd962fec9120336ee336796013cc4", + "events_version": "sha256:13f01e5a599272b349eabd66694b7898e68438e8aad6497e80a9b780bea34ab0" +} diff --git a/patterns/bp-011.json b/patterns/bp-011.json new file mode 100644 index 0000000..8683f1d --- /dev/null +++ b/patterns/bp-011.json @@ -0,0 +1,15 @@ +{ + "id": "bp-011", + "title": "Local files first, external actions only after confirmation", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "sha256:7ea41ed82edef968baee6880f040008080afd962fec9120336ee336796013cc4", + "events_version": "sha256:13f01e5a599272b349eabd66694b7898e68438e8aad6497e80a9b780bea34ab0" +} diff --git a/patterns/bp-012.json b/patterns/bp-012.json new file mode 100644 index 0000000..b5010e4 --- /dev/null +++ b/patterns/bp-012.json @@ -0,0 +1,15 @@ +{ + "id": "bp-012", + "title": "Complete session wrap-up — episodic memory, changes, handoff", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "sha256:7ea41ed82edef968baee6880f040008080afd962fec9120336ee336796013cc4", + "events_version": "sha256:13f01e5a599272b349eabd66694b7898e68438e8aad6497e80a9b780bea34ab0" +} diff --git a/scripts/scaffold-bp.mjs b/scripts/scaffold-bp.mjs new file mode 100644 index 0000000..6805739 --- /dev/null +++ b/scripts/scaffold-bp.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +/** + * scaffold-bp.mjs — generator for the bp-XXX enforcement-contract DATA files + * (RFC-008 P2b, R2/R3). Derives the contract id-set from the single source of + * truth `patterns/_index.json` (`patterns[].pattern_id`, stripped to the + * `bp-NNN` stem) — the live set is 11 patterns with bp-007 ABSENT (P2 plan + * finding N-1); the scaffold iterates the derived list, never a numeric range. + * + * Content: every NEW contract declares uniform STRONG for the three + * classification gates + the root-level stop gate (the RFC's only normative + * contract-content examples, L927-947, are STRONG; effective enforcement is + * `min(harness_cap, contract_tier, project_config)` per L464, so per-pattern + * relaxation is a later data edit, clamped by P4 project config). + * + * Merge-on-regenerate (plan-review F2): when `patterns/bp-XXX.json` already + * exists, the scaffold PRESERVES the authored `gates`, `stop`, `title` and + * `description` and refreshes ONLY the `taxonomy_version`/`events_version` + * bindings (id/taxonomy_ref re-pinned). Contract data is authored state, not + * derived state — a regeneration after a taxonomy bump must never silently + * revert a hand-relaxed tier to STRONG. + * + * Hashes are computed LIVE from scripts/lib/version-hash.mjs (F8/F37, plan + * F-11) — never hand-typed. + * + * Usage: + * node scripts/scaffold-bp.mjs --project [--json] + * + * All writes bind to the resolved --project root (planner A6) — nothing is + * ever written relative to caller cwd. + * + * Exit: 0 = success, 2 = usage/IO error (a generator has no violation + * concept; there is no exit 1). + */ + +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { execFileSync } from "node:child_process"; +import { taxonomyVersion, eventsVersion } from "./lib/version-hash.mjs"; +import { UsageError } from "./lib/path-contain.mjs"; + +const BP_STEM_RE = /^(bp-[0-9]{3})-/; +const TIER_DEFAULT = "STRONG"; + +/** Resolve the project root. --project explicit -> realpath (git never consulted). */ +function resolveProjectRoot(argProject, cwd) { + if (argProject != null) { + let real; + try { real = fs.realpathSync(argProject); } + catch (e) { throw new UsageError(`--project ${argProject} does not resolve: ${e.message}`); } + if (!fs.statSync(real).isDirectory()) throw new UsageError(`--project ${argProject} is not a directory`); + return real; + } + let top; + try { + top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + } catch { + throw new UsageError("no --project and cwd is not inside a git repository (no silent caller-cwd fallback)"); + } + return fs.realpathSync(top); +} + +function readJson(abs, label) { + let raw; + try { raw = fs.readFileSync(abs, "utf8"); } + catch (e) { throw new UsageError(`${label} unreadable at ${abs}: ${e.message}`); } + try { return JSON.parse(raw); } + catch (e) { throw new UsageError(`${label} is not parseable JSON (${abs}): ${e.message}`); } +} + +/** + * Derive the bp id list from _index.json. A pattern_id that does not match + * the `bp-NNN-` shape is a malformed SoT -> fail closed (exit 2), never + * a silent skip. + */ +export function deriveBpIds(index) { + const patterns = Array.isArray(index && index.patterns) ? index.patterns : null; + if (patterns === null || patterns.length === 0) { + throw new UsageError("patterns/_index.json carries no patterns[] — refusing to scaffold from an empty SoT"); + } + const out = []; + for (const p of patterns) { + const pid = p && p.pattern_id; + const m = typeof pid === "string" ? BP_STEM_RE.exec(pid) : null; + if (!m) throw new UsageError(`pattern_id ${JSON.stringify(pid)} does not match ^bp-NNN- — malformed SoT entry, refusing to scaffold`); + out.push({ id: m[1], name: typeof p.name === "string" ? p.name : null }); + } + const ids = out.map((e) => e.id); + if (new Set(ids).size !== ids.length) throw new UsageError("duplicate bp ids derived from patterns/_index.json"); + return out; +} + +/** + * Build one contract object. `existing` (parsed prior file or null) drives + * merge-on-regenerate: authored gates/stop/title/description survive; only + * the version bindings (and the id/taxonomy_ref pins) are refreshed. + */ +export function buildContract({ id, name }, existing, taxVersion, evVersion) { + const fresh = { + gates: { plan_approval: TIER_DEFAULT, pre_checkpoint: TIER_DEFAULT, post_checkpoint: TIER_DEFAULT }, + stop: { tier: TIER_DEFAULT }, + }; + const src = existing !== null && typeof existing === "object" && !Array.isArray(existing) ? existing : null; + const contract = { id }; + const title = src && typeof src.title === "string" ? src.title : name; + if (title != null) contract.title = title; + if (src && typeof src.description === "string") contract.description = src.description; + contract.gates = src && src.gates !== undefined ? src.gates : fresh.gates; + contract.stop = src && src.stop !== undefined ? src.stop : fresh.stop; + contract.taxonomy_ref = "patterns/taxonomy.json"; + contract.taxonomy_version = taxVersion; + contract.events_version = evVersion; + return contract; +} + +export function scaffoldBp({ projectRoot }) { + const root = resolveProjectRoot(projectRoot, process.cwd()); + const patternsDir = path.join(root, "patterns"); + const index = readJson(path.join(patternsDir, "_index.json"), "patterns/_index.json"); + const taxonomy = readJson(path.join(patternsDir, "taxonomy.json"), "patterns/taxonomy.json"); + const events = readJson(path.join(patternsDir, "events.json"), "patterns/events.json"); + const taxVersion = taxonomyVersion(taxonomy); + const evVersion = eventsVersion(events); + + const derived = deriveBpIds(index); + const created = []; + const updated = []; + const unchanged = []; + for (const entry of derived) { + const abs = path.join(patternsDir, `${entry.id}.json`); + let existing = null; + let existed = false; + if (fs.existsSync(abs)) { + existed = true; + // A corrupt existing contract cannot be merged safely -> fail closed. + existing = readJson(abs, `existing contract ${entry.id}.json`); + } + const contract = buildContract(entry, existing, taxVersion, evVersion); + const text = JSON.stringify(contract, null, 2) + "\n"; + if (existed && fs.readFileSync(abs, "utf8") === text) { + unchanged.push(path.relative(root, abs)); + continue; + } + fs.writeFileSync(abs, text); + (existed ? updated : created).push(path.relative(root, abs)); + } + + return { + status: "ok", + project_root: root, + derived_ids: derived.map((e) => e.id), + taxonomy_version: taxVersion, + events_version: evVersion, + created, + updated, + unchanged, + }; +} + +const HELP = `scaffold-bp.mjs — generate/refresh the bp-XXX enforcement-contract data files (RFC-008 P2b) + +Usage: + node scripts/scaffold-bp.mjs --project [--json] + +Options: + --project explicit project root (realpath'd; git never consulted) + --json full JSON payload on stdout + --help this message + +Derives the id-set from patterns/_index.json; existing contracts keep their +authored gates/stop/title/description (only the version hashes refresh). +Exit: 0 success, 2 usage/IO error.`; + +function parseArgs(argv) { + const args = { project: null, json: false, help: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--project") { args.project = argv[++i]; if (args.project == null) throw new UsageError("--project requires a value"); } + else if (a === "--json") args.json = true; + else if (a === "--help") args.help = true; + else throw new UsageError(`unknown argument ${JSON.stringify(a)}`); + } + return args; +} + +function main() { + let args; + try { args = parseArgs(process.argv.slice(2)); } + catch (e) { process.stderr.write(e.message + "\n"); process.exit(2); } + if (args.help) { process.stdout.write(HELP + "\n"); process.exit(0); } + + let result; + try { + result = scaffoldBp({ projectRoot: args.project }); + } catch (e) { + if (e instanceof UsageError || e.name === "UsageError") { + process.stderr.write(e.message + "\n"); + process.exit(2); + } + process.stdout.write(JSON.stringify({ status: "error", detail: e.message }) + "\n"); + process.exit(2); + } + + if (args.json) { + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); + } else { + process.stdout.write(`OK scaffold-bp: ${result.derived_ids.length} contract(s) — ${result.created.length} created, ${result.updated.length} updated, ${result.unchanged.length} unchanged\n`); + } + process.exit(0); +} + +// pathToFileURL main-guard (P2a step-6 F4 class: a raw-template compare can +// silently skip main() and green the CI step with no output). +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/validate-bp-contract.mjs b/scripts/validate-bp-contract.mjs new file mode 100644 index 0000000..d8e4139 --- /dev/null +++ b/scripts/validate-bp-contract.mjs @@ -0,0 +1,654 @@ +#!/usr/bin/env node +/** + * validate-bp-contract.mjs — the RFC-008 normative §Validation-contract + * assertion checklist (P2b; R2/R3/R4). Implements assertions 0 + 1–8 and + * 10–15 (RFC-008 L446–453 + L478–487). Assertion 9 (golden corpus) is owned + * by tests/test-validate-bp-contract.mjs, which dispatches the fixtures + * through this validator's injection flags. + * + * Checklist map (sub-codes in parentheses are this validator's check ids): + * 0 bp instance validation (0-set / 0-schema / 0-idbind): the discovered + * patterns/bp-*.json set EQUALS the patterns/_index.json-derived id set + * (N-2 bidirectional — phantom AND missing contracts are violations, + * near-miss filenames like bp-07.json are named violations, F5); + * every contract instance-validates against patterns/schema.json + * (attack-target-4: fourth-gate-key / stop-in-gates / extra-root-key); + * interior `id` equals the filename stem (A1 — schema.json:10-13 + * delegates the id requirement here). + * 1 taxonomy.json instance-validates vs patterns/taxonomy.schema.json. + * 2-4 per-label gates: completeness / no extra keys / values in {allow,block}. + * 5 non_overridable equals the derived non-overridable set (both ways). + * 6 label ids unique. + * 7 vocabulary closure, de-vacuified (N-3 — contracts carry hashes, not + * label ids; the bp arm is the assertion-15 hash binding): (7a) every + * enforcement manifest's classifier.emits_labels is a subset of + * taxonomy labels (intentional double-cover with registry M5); + * (7b) the default classifier's _priority() case arms EQUAL the + * taxonomy label set (L473 OQ-2 — equality, not subset: a missing arm + * ranks priority 0, below read_only, a silent downgrade), with the + * exactly-one-definition guard (A3) and the fail-closed arm-line + * grammar (review F3: unrecognized arm spellings are violations, never + * silently skipped). + * 8 taxonomy stable-ID via git (F7/F-4): set-difference vs the merge-base + * of HEAD and origin/main; removal/rename without a major version bump + * is a violation. Fail CLOSED: shallow repo (A2) or unresolvable + * merge-base is exit 2, never a skip. Bootstrap carve-out (N-5): path + * absent from the merge-base tree with the file present now passes. + * 10 events.json instance-validates vs patterns/events.schema.json. + * 11 events vocabulary closure, de-vacuified (N-3 symmetric): every + * manifest's capabilities keys + event_translations keys are subsets of + * events[].id (double-cover with registry M4/M5c). + * 12 action-enum closure: every event x tier action id in the closed + * 10-value enum; all three tier arms present. + * 13 payload_schema resolution: resolves on disk under schemas/events/ + * (path-contain; ENOENT = violation) + lints as a 2020-12 doc (same + * engine as validate-schemas.mjs, D2). + * 14 events stable-ID (mirror of 8, same helper). + * 15 version bindings: every bp contract AND every enforcement manifest + * carries taxonomy_version/events_version equal to the live computed + * hashes (F8/F37; single helper scripts/lib/version-hash.mjs). + * + * Evaluation discipline (review F4): assertions NEVER short-circuit — all of + * them run on every invocation (assertions_run reports which), and semantic + * assertions tolerate schema-invalid shapes by recording violations, never + * throwing. Exit 1 is reachable ONLY via the violation tally; UsageError and + * internal crashes are exit 2 (a crash must never read as "violations found"). + * + * One-effective-source rule (planner A5): after flag resolution there is + * exactly ONE effective taxonomy, ONE effective events, ONE effective bp dir; + * every assertion INCLUDING the assertion-15 hash computations reads those + * same documents. Injection (--taxonomy / --events / --bp-dir) exists only + * for golden-corpus dispatch; the live CI invocation is uninjected. + * + * Usage: + * node scripts/validate-bp-contract.mjs --project [--json] + * node scripts/validate-bp-contract.mjs --project --taxonomy --events --bp-dir + * + * Exit: 0 = pass, 1 = violations, 2 = usage/IO error or internal crash. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { execFileSync } from "node:child_process"; +import { validateInstance, assertAllSchemasModeled } from "./lib/json-instance-validate.mjs"; +import { lintSchema, assertSelfConsistent } from "./lib/mini-jsonschema.mjs"; +import { taxonomyVersion, eventsVersion } from "./lib/version-hash.mjs"; +import { contained, resolveContained, UsageError } from "./lib/path-contain.mjs"; +import { deriveBpIds } from "./scaffold-bp.mjs"; + +const BP_STRICT_RE = /^bp-[0-9]{3}\.json$/; +// Case-INSENSITIVE on purpose (step-6 F-3): on case-insensitive filesystems +// (macOS APFS, Windows) a `BP-099.JSON` is the same dead-data escape the F5 +// guard names; the STRICT regex stays case-sensitive so any case variant is a +// named violation, never silently ignored. +const BP_LOOSE_RE = /^bp-.*\.json$/i; +const GATE_KEYS = ["plan_approval", "pre_checkpoint", "post_checkpoint"]; +const GATE_ACTIONS = ["allow", "block"]; +const ACTION_ENUM = ["block", "warn", "inject", "modify", "observe", "refuse_stop", "inject_context", "inject_static", "write_artifact", "unsupported"]; +const TIER_ARMS = ["STRONG", "MEDIUM", "WEAK"]; +// Step-6 F-1/F-1R2/F-1R3 — the enforcement boundary is a per-OCCURRENCE +// allowlist of _priority token contexts, not a blocklist of definition +// spellings and not a per-line short-circuit. Bash accepts `name()` followed +// by ANY compound command anywhere a command may appear (brace-next-line, +// `case` body, after `;`/`&&` — including on a line that ALSO holds a +// legitimate call site, the F-1R3 P1/P8 false-pass), so: every word-bounded +// _priority occurrence is classified individually — a paren-form opener +// `_priority()` or keyword-form `function _priority` ANYWHERE counts toward +// the exactly-one definition tally; a `$(`-preceded occurrence is a call +// site; a full-line comment is inert; anything else is a "cannot prove +// exactly-one definition" violation. Fail-closed by construction; the one +// accepted fail-open tail is an eval-built definition whose source never +// spells the token (`eval "_priori""ty() …"`) — closing that needs a bash +// parser, documented residual. +const PRIORITY_DEF_RE = /^\s*(?:function\s+_priority\s*(?:\(\s*\))?|_priority\s*\(\s*\))\s*\{/; +const PRIORITY_TOKEN_RE = /\b_priority\b/; +const ARM_PLAIN_RE = /^\s*([a-z_][a-z0-9_]*)\)/; +const ARM_STAR_RE = /^\s*\*\)/; +const ARM_INTRODUCING_RE = /^\s*\S+\)/; + +/** Resolve the project root. --project explicit -> realpath (git never consulted). */ +function resolveProjectRoot(argProject, cwd) { + if (argProject != null) { + let real; + try { real = fs.realpathSync(argProject); } + catch (e) { throw new UsageError(`--project ${argProject} does not resolve: ${e.message}`); } + if (!fs.statSync(real).isDirectory()) throw new UsageError(`--project ${argProject} is not a directory`); + return real; + } + let top; + try { + top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + } catch { + throw new UsageError("no --project and cwd is not inside a git repository (no silent caller-cwd fallback)"); + } + return fs.realpathSync(top); +} + +/** Validation INPUTS (schemas, SoT index, registry) are UsageError on failure (exit 2). */ +function readJsonInput(abs, label) { + let raw; + try { raw = fs.readFileSync(abs, "utf8"); } + catch (e) { throw new UsageError(`${label} unreadable at ${abs}: ${e.message}`); } + try { return JSON.parse(raw); } + catch (e) { throw new UsageError(`${label} is not parseable JSON (${abs}): ${e.message}`); } +} + +function git(root, argv) { + return execFileSync("git", argv, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +} + +function majorOf(version) { + const m = typeof version === "string" ? /^(\d+)\./.exec(version) : null; + return m ? Number(m[1]) : null; +} + +/** + * Stable-ID assertion core (8/14). Fail-closed environment handling lives + * here: shallow repo (A2) or unresolvable merge-base throws UsageError + * (exit 2 — "cannot verify" is an infra failure, never a data violation and + * never a silent skip). Bootstrap (N-5): path absent in the merge-base tree + * while present now passes (introduction; no removal possible). + */ +function stableIdCheck({ root, relPath, currentDoc, idsOf, label, violation }) { + let shallow; + try { shallow = git(root, ["rev-parse", "--is-shallow-repository"]).trim(); } + catch (e) { throw new UsageError(`stable-ID (${label}): git unavailable in ${root}: ${e.message}`); } + if (shallow === "true") { + throw new UsageError(`stable-ID (${label}): repository is shallow — a grafted history can false-pass the merge-base diff; fetch full history (fetch-depth: 0)`); + } + let mergeBase; + try { mergeBase = git(root, ["merge-base", "HEAD", "origin/main"]).trim(); } + catch (e) { + throw new UsageError(`stable-ID (${label}): cannot resolve merge-base of HEAD and origin/main — fetch full history (fetch-depth: 0): ${e.message}`); + } + const treePath = relPath.split(path.sep).join("/"); // git tree paths are slash-separated on every platform + let lsTree; + try { lsTree = git(root, ["ls-tree", mergeBase, "--", treePath]).trim(); } + catch (e) { throw new UsageError(`stable-ID (${label}): git ls-tree failed: ${e.message}`); } + if (lsTree === "") return { baseline: null, bootstrap: true }; // N-5: introduced since baseline + let oldDoc; + try { oldDoc = JSON.parse(git(root, ["show", `${mergeBase}:${treePath}`])); } + catch (e) { throw new UsageError(`stable-ID (${label}): cannot read/parse baseline ${treePath} at ${mergeBase.slice(0, 12)}: ${e.message}`); } + + const oldIds = idsOf(oldDoc); + const newIds = idsOf(currentDoc); + const removed = [...oldIds].filter((id) => !newIds.has(id)).sort(); + if (removed.length > 0) { + const oldMajor = majorOf(oldDoc && oldDoc.version); + const newMajor = majorOf(currentDoc && currentDoc.version); + if (oldMajor === null || newMajor === null) { + violation(`${label} version field unparseable (old=${JSON.stringify(oldDoc && oldDoc.version)}, new=${JSON.stringify(currentDoc && currentDoc.version)}) — cannot certify the removal of ${removed.join(", ")}`); + } else if (newMajor === oldMajor) { + violation(`${label} id(s) removed/renamed without a major version bump: ${removed.join(", ")} — add the new id + mark the old deprecated, or bump major (set-difference invariant, F7)`); + } + } + return { baseline: mergeBase, bootstrap: false }; +} + +/** + * Assertion 7b arm extraction. Fail-closed grammar (review F3): inside the + * case block, any line INTRODUCING a `)`-terminated pattern must be a plain + * `name)` arm or the `*)` fallback — alternation/quoted/glob spellings are + * violations, never silently skipped. Returns null when a violation already + * explains why arms are unusable. + */ +export function extractPriorityArms(text, relPath, violation) { + // Comment-aware logical-line assembly (step-6 F-1R4/F-1R5): bash strips + // `\`-newline during lexing — so `_prio\` + newline + `rity() {` is a live + // redefinition the per-line token scan would never see — but ONLY outside + // comments: a trailing `\` on a comment line is just a comment character, + // and an unconditional join would absorb the next-line definition INTO the + // comment skip (the R4→R5 regression). Honor the continuation only when the + // logical line does not start with `#`. Known divergences both err + // fail-closed: a continuation inside a single-quoted string joins here but + // stays literal in bash (DUP/unproven FP, never a miss); same for mid-line + // comments. The one accepted fail-OPEN tail remains eval/quoted-string- + // concat definitions whose source never spells the token (needs a bash + // lexer). + const phys = text.split(/\r?\n/); + const lines = []; + for (let i = 0; i < phys.length; i++) { + let cur = phys[i]; + if (!/^\s*#/.test(cur)) { + while (cur.endsWith("\\") && i + 1 < phys.length) { cur = cur.slice(0, -1) + phys[++i]; } + } + lines.push(cur); + } + const defIdx = []; + const unproven = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!PRIORITY_TOKEN_RE.test(line)) continue; + if (line.trim().startsWith("#")) continue; // full-line comment: inert + const tokenRe = /\b_priority\b/g; + let m; + while ((m = tokenRe.exec(line)) !== null) { + const before = line.slice(0, m.index); + const after = line.slice(m.index + "_priority".length); + // Definition opener, ANY position on the line (F-1R3): paren form + // `_priority()` (bash cannot "call" with literal empty parens) or + // keyword form `function _priority`. + if (/^\s*\(\s*\)/.test(after) || /\bfunction\s+$/.test(before)) { defIdx.push(i); continue; } + if (/\$\(\s*$/.test(before)) continue; // $( call site (this OCCURRENCE only — the rest of the line is still scanned) + unproven.push(i + 1); + } + } + if (unproven.length > 0) { + violation(`${relPath}: cannot prove exactly-one _priority definition — unrecognized _priority token occurrence(s) at line(s) ${unproven.join(", ")}; allowlisted contexts are the definition opener, $(-call sites, and full-line comments — any other spelling could be a bash redefinition (fail-closed, F-1R2)`); + return null; + } + if (defIdx.length === 0) { violation(`${relPath}: no _priority() definition found — cannot verify priority-arm closure`); return null; } + if (defIdx.length > 1) { violation(`${relPath}: ${defIdx.length} _priority() definitions found (expected exactly one) — bash last-wins would diverge from a first-match parse (A3)`); return null; } + if (!PRIORITY_DEF_RE.test(lines[defIdx[0]])) { + violation(`${relPath}: the single _priority definition is not in canonical \`_priority() {\` form (line ${defIdx[0] + 1}) — cannot parse arms (fail-closed)`); + return null; + } + + let caseIdx = -1; + for (let i = defIdx[0] + 1; i < lines.length; i++) { + if (/^\s*case\s+"\$1"\s+in\b/.test(lines[i])) { caseIdx = i; break; } + if (/^\s*\}\s*$/.test(lines[i])) break; // function closed before any case block (indent-tolerant, F-1 class) + } + if (caseIdx === -1) { violation(`${relPath}: _priority() carries no \`case "$1" in\` block — cannot verify priority-arm closure`); return null; } + + const arms = []; + let sawEsac = false; + for (let i = caseIdx + 1; i < lines.length; i++) { + const line = lines[i]; + if (/^\s*esac\b/.test(line)) { sawEsac = true; break; } + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) continue; + const plain = ARM_PLAIN_RE.exec(line); + if (plain) { arms.push(plain[1]); continue; } + if (ARM_STAR_RE.test(line)) continue; + if (ARM_INTRODUCING_RE.test(line)) { + violation(`${relPath}: unrecognized _priority case-arm spelling ${JSON.stringify(trimmed.slice(0, 40))} — only plain \`name)\` arms and \`*)\` are parseable; alternation/quoted/glob arms would be silently skipped past the closure check (F3)`); + return null; + } + // anything else is an arm-body continuation line; ignore + } + if (!sawEsac) { violation(`${relPath}: _priority() case block has no esac — unparseable (fail-closed)`); return null; } + return arms; +} + +export function validateBpContract({ projectRoot, taxonomyPath = null, eventsPath = null, bpDirPath = null } = {}) { + const root = resolveProjectRoot(projectRoot, process.cwd()); + const violations = []; + let checks = 0; + const assertionsRun = new Set(); + const violation = (check, detail) => violations.push({ check, severity: "error", detail }); + + // --- effective sources (A5: one taxonomy, one events, one bp dir for ALL assertions) --- + const taxonomyAbs = taxonomyPath != null ? resolveContained(root, taxonomyPath, "taxonomy") : path.join(root, "patterns", "taxonomy.json"); + const eventsAbs = eventsPath != null ? resolveContained(root, eventsPath, "events") : path.join(root, "patterns", "events.json"); + const bpDirAbs = bpDirPath != null ? resolveContained(root, bpDirPath, "bp-dir") : path.join(root, "patterns"); + + const schemas = { + bp: readJsonInput(path.join(root, "patterns", "schema.json"), "patterns/schema.json"), + taxonomy: readJsonInput(path.join(root, "patterns", "taxonomy.schema.json"), "patterns/taxonomy.schema.json"), + events: readJsonInput(path.join(root, "patterns", "events.schema.json"), "patterns/events.schema.json"), + }; + assertAllSchemasModeled(schemas); // throws SchemaModelingError -> exit 2 at the boundary + assertSelfConsistent(); // lint engine self-check, fail-closed (assertion 13 uses it) + + const taxonomy = readJsonInput(taxonomyAbs, "taxonomy"); + const events = readJsonInput(eventsAbs, "events"); + const patternsIndex = readJsonInput(path.join(root, "patterns", "_index.json"), "patterns/_index.json"); + const pluginsIndex = readJsonInput(path.join(root, "plugins", "_index.json"), "plugins/_index.json"); + + const labels = Array.isArray(taxonomy.labels) ? taxonomy.labels : []; + const labelIds = new Set(labels.map((l) => l && l.id).filter((id) => typeof id === "string")); + const eventList = Array.isArray(events.events) ? events.events : []; + const eventIds = new Set(eventList.map((e) => e && e.id).filter((id) => typeof id === "string")); + const liveTaxVersion = taxonomyVersion({ labels }); + const liveEvVersion = eventsVersion({ events: eventList }); + + // --- enforcement manifests (shared input for 7a / 11 / 15) --- + const enforcementEntries = (Array.isArray(pluginsIndex.plugins) ? pluginsIndex.plugins : []).filter((p) => p && p.type === "enforcement"); + const manifests = []; + for (const entry of enforcementEntries) { + // An unloadable manifest skips arms in THREE assertion groups — attribute + // the skip in each so no consumer reads a green 11/15 as "checked" + // (step-6 NIT: attribution-only, exit was already 1). + const skipAll = (why) => { for (const check of ["7", "11", "15"]) violation(check, why); }; + if (typeof entry.manifest !== "string") { skipAll(`_index entry ${JSON.stringify(entry.id)} has no manifest path — closure arms cannot run for it`); continue; } + const abs = path.join(root, entry.manifest); + try { manifests.push({ entry, manifest: JSON.parse(fs.readFileSync(abs, "utf8")) }); } + catch (e) { skipAll(`manifest ${entry.manifest} unreadable/unparseable (${e.message}) — closure arms cannot run for it`); } + } + + // --------------------------------------------------------------------------- + // Assertion 0 — bp contract set + instance + id binding (sub-coded). + // --------------------------------------------------------------------------- + assertionsRun.add(0); + let derived; + try { derived = deriveBpIds(patternsIndex); } + catch (e) { throw new UsageError(`patterns/_index.json SoT derivation failed: ${e.message}`); } + const derivedIds = new Set(derived.map((d) => d.id)); + + let bpDirents; + try { bpDirents = fs.readdirSync(bpDirAbs, { withFileTypes: true }); } + catch (e) { throw new UsageError(`bp dir unreadable at ${bpDirAbs}: ${e.message}`); } + const discovered = new Map(); // id -> abs path + for (const ent of bpDirents) { + if (!BP_LOOSE_RE.test(ent.name)) continue; + checks++; + if (!BP_STRICT_RE.test(ent.name)) { + // F5: near-miss filenames are dead data a lenient consumer could load. + violation("0-set", `${ent.name}: malformed contract filename (must match bp-NNN.json) — would sit unvalidated and invisible to the set-equality guard`); + continue; + } + if (!ent.isFile()) { + violation("0-set", `${ent.name}: contract is not a regular file (symlink/directory/other) — contracts must be regular files`); + continue; + } + discovered.set(ent.name.replace(/\.json$/, ""), path.join(bpDirAbs, ent.name)); + } + checks++; + for (const id of derivedIds) if (!discovered.has(id)) violation("0-set", `missing contract ${id}.json (pattern enumerated in patterns/_index.json has no contract) — run scripts/scaffold-bp.mjs`); + for (const id of discovered.keys()) if (!derivedIds.has(id)) violation("0-set", `phantom contract ${id}.json (no matching pattern_id in patterns/_index.json)`); + + const bpDocs = new Map(); // id -> parsed doc (only parseable ones) + for (const [id, abs] of [...discovered.entries()].sort()) { + checks++; + let doc; + try { doc = JSON.parse(fs.readFileSync(abs, "utf8")); } + catch (e) { violation("0-schema", `${id}.json: not parseable JSON — ${e.message}`); continue; } + bpDocs.set(id, doc); + const res = validateInstance(doc, schemas.bp); + if (!res.valid) for (const err of res.errors) violation("0-schema", `${id}.json ${err.path}: ${err.keyword} — ${err.detail}`); + checks++; + if (doc === null || typeof doc !== "object" || doc.id !== id) { + violation("0-idbind", `${id}.json: interior id ${JSON.stringify(doc && doc.id)} must equal the filename stem ${JSON.stringify(id)} (A1 — schema.json leaves id optional and delegates the binding here)`); + } + } + + // --------------------------------------------------------------------------- + // Assertion 1 — taxonomy meta-schema instance validation. + // --------------------------------------------------------------------------- + assertionsRun.add(1); + checks++; + { + const res = validateInstance(taxonomy, schemas.taxonomy); + if (!res.valid) for (const err of res.errors) violation("1", `taxonomy ${err.path}: ${err.keyword} — ${err.detail}`); + } + + // --------------------------------------------------------------------------- + // Assertions 2/3/4 — per-label gate completeness / closure / action enum. + // Defensive (F4): tolerate schema-invalid shapes; assertion 1 already + // recorded them, these record their own view without throwing. + // --------------------------------------------------------------------------- + assertionsRun.add(2); assertionsRun.add(3); assertionsRun.add(4); + for (const l of labels) { + const id = l && typeof l.id === "string" ? l.id : ""; + const gates = l && l.gates !== null && typeof l.gates === "object" && !Array.isArray(l.gates) ? l.gates : null; + checks++; + if (gates === null) { violation("2", `label ${id}: gates object missing/mistyped`); continue; } + for (const g of GATE_KEYS) if (!(g in gates)) violation("2", `label ${id}: gate ${g} missing (gate-completeness, F1a)`); + for (const k of Object.keys(gates)) if (!GATE_KEYS.includes(k)) violation("3", `label ${id}: extra gate key ${JSON.stringify(k)} (closed gate set, F1e)`); + for (const [k, v] of Object.entries(gates)) { + if (GATE_KEYS.includes(k) && !GATE_ACTIONS.includes(v)) violation("4", `label ${id}: gate ${k} value ${JSON.stringify(v)} not in {allow, block} (F1b)`); + } + } + + // --------------------------------------------------------------------------- + // Assertion 5 — overridability equality (stored ≡ derived, both directions). + // --------------------------------------------------------------------------- + assertionsRun.add(5); + checks++; + { + const derivedNo = labels.filter((l) => l && l.overridable === false).map((l) => l.id).filter((id) => typeof id === "string").sort(); + const stored = Array.isArray(taxonomy.non_overridable) ? [...taxonomy.non_overridable].sort() : null; + if (stored === null) violation("5", "non_overridable missing/mistyped (must be an array)"); + else if (JSON.stringify(stored) !== JSON.stringify(derivedNo)) { + violation("5", `non_overridable ${JSON.stringify(stored)} != derived non-overridable set ${JSON.stringify(derivedNo)} (F1c/F9 — per-label overridable is canonical)`); + } + } + + // --------------------------------------------------------------------------- + // Assertion 6 — duplicate label ids. + // --------------------------------------------------------------------------- + assertionsRun.add(6); + checks++; + { + const ids = labels.map((l) => l && l.id).filter((id) => typeof id === "string"); + if (new Set(ids).size !== ids.length) { + const dup = ids.filter((id, i) => ids.indexOf(id) !== i); + violation("6", `duplicate label id(s): ${[...new Set(dup)].join(", ")} (F1d)`); + } + } + + // --------------------------------------------------------------------------- + // Assertion 7 — vocabulary closure (7a manifests, 7b default classifier). + // --------------------------------------------------------------------------- + assertionsRun.add(7); + checks++; + if (enforcementEntries.length === 0) violation("7", "zero enforcement plugins in plugins/_index.json — closure arms 7a/11/15 would be vacuous (fail-closed)"); + for (const { entry, manifest } of manifests) { + checks++; + const emits = manifest.classifier && Array.isArray(manifest.classifier.emits_labels) ? manifest.classifier.emits_labels : []; + for (const lbl of emits) if (!labelIds.has(lbl)) violation("7", `manifest ${entry.id}: dangling emits_label ${JSON.stringify(lbl)} (not a taxonomy label, F6)`); + } + let classifiersParsed = 0; + for (const { entry, manifest } of manifests) { + if (!manifest.classifier || manifest.classifier.mode !== "default") continue; + checks++; + const dir = typeof entry.directory === "string" ? entry.directory : `plugins/${entry.harness}`; + const clsLex = path.join(root, dir, "hooks", "lib", "command-classifier.sh"); + const rel = path.relative(root, clsLex); + let clsReal; + try { clsReal = fs.realpathSync(clsLex); } + catch { violation("7", `${rel}: default classifier script missing — R4 requires the default classifier; arm closure cannot be verified (fail-closed)`); continue; } + if (!contained(clsReal, root)) { violation("7", `${rel}: classifier resolves outside the project root — refusing to read`); continue; } + const arms = extractPriorityArms(fs.readFileSync(clsReal, "utf8"), rel, (d) => violation("7", d)); + if (arms === null) continue; + classifiersParsed++; + const armSet = new Set(arms); + if (new Set(arms).size !== arms.length) violation("7", `${rel}: duplicate _priority case arm(s)`); + for (const a of armSet) if (!labelIds.has(a)) violation("7", `${rel}: _priority arm ${JSON.stringify(a)} is not a taxonomy label (extra arm)`); + for (const id of labelIds) if (!armSet.has(id)) violation("7", `${rel}: taxonomy label ${JSON.stringify(id)} has NO _priority arm — it would rank priority 0, below read_only, a silent downgrade in most-restrictive-wins reduction (L473)`); + } + checks++; + if (manifests.some((m) => m.manifest.classifier && m.manifest.classifier.mode === "default") && classifiersParsed === 0) { + violation("7", "no default classifier was successfully parsed — assertion 7b ran zero times (vacuity, fail-closed)"); + } + + // --------------------------------------------------------------------------- + // Assertions 8 / 14 — stable-ID integrity (taxonomy, events). + // --------------------------------------------------------------------------- + assertionsRun.add(8); + checks++; + stableIdCheck({ + root, + relPath: path.relative(root, taxonomyAbs), + currentDoc: taxonomy, + idsOf: (doc) => new Set((Array.isArray(doc && doc.labels) ? doc.labels : []).map((l) => l && l.id).filter((id) => typeof id === "string")), + label: "taxonomy", + violation: (d) => violation("8", d), + }); + assertionsRun.add(14); + checks++; + stableIdCheck({ + root, + relPath: path.relative(root, eventsAbs), + currentDoc: events, + idsOf: (doc) => new Set((Array.isArray(doc && doc.events) ? doc.events : []).map((e) => e && e.id).filter((id) => typeof id === "string")), + label: "events", + violation: (d) => violation("14", d), + }); + + // --------------------------------------------------------------------------- + // Assertion 10 — events meta-schema instance validation. + // --------------------------------------------------------------------------- + assertionsRun.add(10); + checks++; + { + const res = validateInstance(events, schemas.events); + if (!res.valid) for (const err of res.errors) violation("10", `events ${err.path}: ${err.keyword} — ${err.detail}`); + } + + // --------------------------------------------------------------------------- + // Assertion 11 — events vocabulary closure over manifests (de-vacuified). + // --------------------------------------------------------------------------- + assertionsRun.add(11); + for (const { entry, manifest } of manifests) { + checks++; + for (const k of Object.keys(manifest.capabilities || {})) { + if (!eventIds.has(k)) violation("11", `manifest ${entry.id}: capability key ${JSON.stringify(k)} is not an events.json event id`); + } + for (const k of Object.keys(manifest.event_translations || {})) { + if (!eventIds.has(k)) violation("11", `manifest ${entry.id}: event_translations key ${JSON.stringify(k)} is not an events.json event id`); + } + } + + // --------------------------------------------------------------------------- + // Assertion 12 — action-enum closure per event x tier, plus event-id + // uniqueness (step-6 F-2 — the events mirror of assertion 6; without it a + // duplicate event id silently dedups into the eventIds Set. RFC L448-453 + // carries no explicit dup-id assertion — asymmetry flagged for the P2c + // doc sweep). + // --------------------------------------------------------------------------- + assertionsRun.add(12); + checks++; + { + const ids = eventList.map((e) => e && e.id).filter((id) => typeof id === "string"); + if (new Set(ids).size !== ids.length) { + const dup = ids.filter((id, i) => ids.indexOf(id) !== i); + violation("12", `duplicate event id(s): ${[...new Set(dup)].join(", ")} (events mirror of F1d)`); + } + } + for (const ev of eventList) { + const id = ev && typeof ev.id === "string" ? ev.id : ""; + const actions = ev && ev.actions !== null && typeof ev.actions === "object" && !Array.isArray(ev.actions) ? ev.actions : null; + checks++; + if (actions === null) { violation("12", `event ${id}: actions object missing/mistyped`); continue; } + for (const tier of TIER_ARMS) { + const a = actions[tier]; + if (a === undefined) { violation("12", `event ${id}: tier arm ${tier} missing (all three arms required)`); continue; } + const actionId = a && typeof a === "object" ? a.id : undefined; + if (!ACTION_ENUM.includes(actionId)) violation("12", `event ${id}: ${tier} action id ${JSON.stringify(actionId)} not in the closed enum {${ACTION_ENUM.join(", ")}} (F37)`); + } + } + + // --------------------------------------------------------------------------- + // Assertion 13 — payload_schema resolution + containment + doc lint. + // Path values come from DATA, so escapes are violations (exit 1), not usage + // errors; ENOENT = fail (payload schemas MUST exist). + // --------------------------------------------------------------------------- + assertionsRun.add(13); + const eventsSchemaDirAbs = path.join(root, "schemas", "events"); + for (const ev of eventList) { + const id = ev && typeof ev.id === "string" ? ev.id : ""; + checks++; + const p = ev && ev.payload_schema; + if (typeof p !== "string" || p.length === 0) { violation("13", `event ${id}: payload_schema missing/mistyped`); continue; } + let real; + try { real = resolveContained(root, p, "payload-schema"); } + catch (e) { violation("13", `event ${id}: payload_schema ${JSON.stringify(p)} escapes the project root — ${e.message}`); continue; } + if (!contained(real, eventsSchemaDirAbs)) { violation("13", `event ${id}: payload_schema ${JSON.stringify(p)} resolves outside schemas/events/ (containment, F-7 axis 6)`); continue; } + let doc; + try { doc = JSON.parse(fs.readFileSync(real, "utf8")); } + catch (e) { violation("13", `event ${id}: payload_schema ${JSON.stringify(p)} unreadable/unparseable (ENOENT = fail, F-7 axis 5): ${e.message}`); continue; } + const { valid, errors } = lintSchema(doc); + if (!valid) violation("13", `event ${id}: payload_schema ${JSON.stringify(p)} is not a valid 2020-12 schema doc: ${errors.slice(0, 3).join(" | ")}`); + } + + // --------------------------------------------------------------------------- + // Assertion 15 — version bindings (bp contracts + manifests, both hashes). + // Hashes computed from the EFFECTIVE taxonomy/events (A5). + // --------------------------------------------------------------------------- + assertionsRun.add(15); + const bindingTargets = [ + ...[...bpDocs.entries()].map(([id, doc]) => [`${id}.json`, doc]), + ...manifests.map(({ entry, manifest }) => [`manifest ${entry.id}`, manifest]), + ]; + for (const [name, doc] of bindingTargets) { + checks++; + const pairs = [["taxonomy_version", liveTaxVersion], ["events_version", liveEvVersion]]; + for (const [field, expected] of pairs) { + const actual = doc && doc[field]; + if (actual !== expected) violation("15", `${name}: stale ${field} (have ${JSON.stringify(actual)}, live ${expected}) — regenerate via scripts/scaffold-bp.mjs (F8/F37)`); + } + } + + return { + status: violations.length === 0 ? "ok" : "violations", + project_root: root, + bp_files_checked: bpDocs.size, + derived_ids: [...derivedIds].sort(), + manifests_checked: manifests.length, + classifiers_parsed: classifiersParsed, + assertions_run: [...assertionsRun].sort((a, b) => a - b), + taxonomy_version: liveTaxVersion, + events_version: liveEvVersion, + checks, + violations, + exit: violations.length === 0 ? 0 : 1, + }; +} + +const HELP = `validate-bp-contract.mjs — RFC-008 normative bp-contract assertion checklist 0 + 1-15 (P2b) + +Usage: + node scripts/validate-bp-contract.mjs --project [--json] + +Options: + --project explicit project root (realpath'd; git never consulted) + --taxonomy inject a taxonomy fixture (golden-corpus dispatch only) + --events inject an events fixture (golden-corpus dispatch only) + --bp-dir inject a staged bp-contract dir (golden-corpus dispatch only) + --json full JSON payload on stdout + --help this message + +Exit: 0 pass, 1 violations, 2 usage/IO error or internal crash (incl. +unverifiable stable-ID: shallow repo or no origin/main merge-base).`; + +function parseArgs(argv) { + const args = { project: null, taxonomy: null, events: null, bpDir: null, json: false, help: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--project") { args.project = argv[++i]; if (args.project == null) throw new UsageError("--project requires a value"); } + else if (a === "--taxonomy") { args.taxonomy = argv[++i]; if (args.taxonomy == null) throw new UsageError("--taxonomy requires a value"); } + else if (a === "--events") { args.events = argv[++i]; if (args.events == null) throw new UsageError("--events requires a value"); } + else if (a === "--bp-dir") { args.bpDir = argv[++i]; if (args.bpDir == null) throw new UsageError("--bp-dir requires a value"); } + else if (a === "--json") args.json = true; + else if (a === "--help") args.help = true; + else throw new UsageError(`unknown argument ${JSON.stringify(a)}`); + } + return args; +} + +function main() { + let args; + try { args = parseArgs(process.argv.slice(2)); } + catch (e) { process.stderr.write(e.message + "\n"); process.exit(2); } + if (args.help) { process.stdout.write(HELP + "\n"); process.exit(0); } + + let result; + try { + result = validateBpContract({ projectRoot: args.project, taxonomyPath: args.taxonomy, eventsPath: args.events, bpDirPath: args.bpDir }); + } catch (e) { + if (e instanceof UsageError || e.name === "UsageError") { + process.stderr.write(e.message + "\n"); + process.exit(2); + } + // Internal crash — fail closed as usage/IO (exit 2), NEVER exit 1. + process.stdout.write(JSON.stringify({ status: "error", project_root: null, checks: 0, violations: [{ check: "internal", severity: "error", detail: e.message }] }) + "\n"); + process.exit(2); + } + + const { exit, ...payload } = result; + if (args.json) { + process.stdout.write(JSON.stringify(payload, null, 2) + "\n"); + } else if (payload.status === "ok") { + process.stdout.write(`OK validate-bp-contract: ${payload.bp_files_checked} contract(s), ${payload.manifests_checked} manifest(s), ${payload.assertions_run.length} assertion group(s), ${payload.checks} check(s) for ${payload.project_root}\n`); + } else { + process.stderr.write(`FAIL validate-bp-contract (${payload.status}): ${payload.violations.length} violation(s)\n`); + for (const v of payload.violations) process.stderr.write(` ✗ [${v.check}] ${v.detail}\n`); + } + process.exit(exit); +} + +// pathToFileURL main-guard (P2a step-6 F4 class). +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/validate-plugin-registry.mjs b/scripts/validate-plugin-registry.mjs index 98bd030..3adf3b5 100644 --- a/scripts/validate-plugin-registry.mjs +++ b/scripts/validate-plugin-registry.mjs @@ -39,7 +39,7 @@ import { contained, resolveContained, UsageError } from "./lib/path-contain.mjs" // --- closed vocabularies (re-asserted even where a schema already closes them) --- export const MAX_SUPPORTED = "1.0.0"; // byte-equal'd to _corpus-index.current_schema_version (a test asserts equality) const HARNESS_IDS = ["claude-code", "opencode", "codex", "pi-agent", "cursor", "windsurf"]; -const EVENT_IDS = ["pre_tool_use", "tool_result", "stop", "session_start", "session_end"]; +export const EVENT_IDS = ["pre_tool_use", "tool_result", "stop", "session_start", "session_end"]; // exported for the Rule-14 binding check in tests/test-validate-bp-contract.mjs (EVENT_IDS ≡ live events[].id) const TIERS = ["STRONG", "MEDIUM", "WEAK", "TBD"]; const TIER_RANK = { TBD: 0, WEAK: 1, MEDIUM: 2, STRONG: 3 }; const MIN_RUNBOOK_FULL_BYTES = 1024; diff --git a/tests/fixtures/bp-contract/_corpus-index.json b/tests/fixtures/bp-contract/_corpus-index.json new file mode 100644 index 0000000..7a945ec --- /dev/null +++ b/tests/fixtures/bp-contract/_corpus-index.json @@ -0,0 +1,24 @@ +{ + "_note": "RFC-008 P2b golden corpus for scripts/validate-bp-contract.mjs (normative assertion 9, RFC L487). Dispatch: tests/test-validate-bp-contract.mjs stages each fixture into a git-initialized sandbox copy of the repo (review F1 — a bare fixture file can never satisfy the 0-set equality guard, so bp fixtures OVERLAY a canonical contract in the staged set; taxonomy/events fixtures are injected via --taxonomy/--events). The runner asserts each fail-fixture fails AT its attributed check (membership in violations[]), never merely exit 1.", + "_token_note": "bp fixtures carry __LIVE_TAXONOMY_VERSION__/__LIVE_EVENTS_VERSION__ tokens replaced at staging time with hashes computed from the sandbox's effective documents (scripts/lib/version-hash.mjs) — committed digests would be a hand-typed-rot time bomb. The two stale-version fixtures keep all-zero digests on the field under test (schema-valid by pattern, never equal to a live hash).", + "min_fixtures": 17, + "fixtures": { + "bad-bp-fourth-gate-key.json": { "expect": "fail", "attributed_check": "0-schema", "inject": "bp-overlay", "asserts": ["attack-target-4: fourth classification gate (additionalProperties:false in gates)"] }, + "bad-bp-stop-in-gates.json": { "expect": "fail", "attributed_check": "0-schema", "inject": "bp-overlay", "asserts": ["attack-target-4: stop demoted per-label into gates (F2/F10)"] }, + "bad-bp-extra-root-key.json": { "expect": "fail", "attributed_check": "0-schema", "inject": "bp-overlay", "asserts": ["attack-target-4: unknown root key (additionalProperties:false at root)"] }, + "bad-bp-missing-gate.json": { "expect": "fail", "attributed_check": "0-schema", "inject": "bp-overlay", "asserts": ["gates.required closure (post_checkpoint missing)"] }, + "bad-bp-bad-tier-value.json": { "expect": "fail", "attributed_check": "0-schema", "inject": "bp-overlay", "asserts": ["tier enum closure (ULTRA)"] }, + "bad-bp-id-mismatch.json": { "expect": "fail", "attributed_check": "0-idbind", "inject": "bp-overlay", "asserts": ["A1: interior id must equal filename stem"] }, + "bad-bp-stale-taxonomy-version.json": { "expect": "fail", "attributed_check": "15", "inject": "bp-overlay", "asserts": ["F8: taxonomy_version equality vs live computed hash"] }, + "bad-bp-stale-events-version.json": { "expect": "fail", "attributed_check": "15", "inject": "bp-overlay", "asserts": ["F37: events_version equality vs live computed hash"] }, + "bad-taxonomy-missing-gate.json": { "expect": "fail", "attributed_check": "2", "inject": "taxonomy", "asserts": ["F1a gate-completeness"] }, + "bad-taxonomy-extra-gate-key.json": { "expect": "fail", "attributed_check": "3", "inject": "taxonomy", "asserts": ["F1e no extra gate keys"] }, + "bad-taxonomy-bad-action-value.json": { "expect": "fail", "attributed_check": "4", "inject": "taxonomy", "asserts": ["F1b action-enum closure {allow, block}"] }, + "bad-taxonomy-overridability-mismatch.json": { "expect": "fail", "attributed_check": "5", "inject": "taxonomy", "asserts": ["F1c/F9 stored non_overridable equals derived set"] }, + "bad-taxonomy-duplicate-id.json": { "expect": "fail", "attributed_check": "6", "inject": "taxonomy", "asserts": ["F1d unique label ids"] }, + "bad-events-bad-action-id.json": { "expect": "fail", "attributed_check": "12", "inject": "events", "asserts": ["F37 assertion-12 closed action enum"] }, + "bad-events-duplicate-id.json": { "expect": "fail", "attributed_check": "12", "inject": "events", "asserts": ["step-6 F-2: event-id uniqueness (events mirror of F1d)"] }, + "bad-events-dangling-payload-schema.json": { "expect": "fail", "attributed_check": "13", "inject": "events", "asserts": ["F37 assertion-13 payload_schema resolution (ENOENT = fail)"] }, + "bad-events-missing-tier-arm.json": { "expect": "fail", "attributed_check": "12", "inject": "events", "asserts": ["F37 assertion-12 all three tier arms present"] } + } +} diff --git a/tests/fixtures/bp-contract/bad-bp-bad-tier-value.json b/tests/fixtures/bp-contract/bad-bp-bad-tier-value.json new file mode 100644 index 0000000..f8ba74f --- /dev/null +++ b/tests/fixtures/bp-contract/bad-bp-bad-tier-value.json @@ -0,0 +1,15 @@ +{ + "id": "bp-001", + "title": "tier value outside the closed enum", + "gates": { + "plan_approval": "ULTRA", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "__LIVE_TAXONOMY_VERSION__", + "events_version": "__LIVE_EVENTS_VERSION__" +} diff --git a/tests/fixtures/bp-contract/bad-bp-extra-root-key.json b/tests/fixtures/bp-contract/bad-bp-extra-root-key.json new file mode 100644 index 0000000..a65f6fc --- /dev/null +++ b/tests/fixtures/bp-contract/bad-bp-extra-root-key.json @@ -0,0 +1,16 @@ +{ + "id": "bp-001", + "title": "unknown root-level key", + "enforcement_mode": "hard", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "__LIVE_TAXONOMY_VERSION__", + "events_version": "__LIVE_EVENTS_VERSION__" +} diff --git a/tests/fixtures/bp-contract/bad-bp-fourth-gate-key.json b/tests/fixtures/bp-contract/bad-bp-fourth-gate-key.json new file mode 100644 index 0000000..d6a1673 --- /dev/null +++ b/tests/fixtures/bp-contract/bad-bp-fourth-gate-key.json @@ -0,0 +1,16 @@ +{ + "id": "bp-001", + "title": "fourth classification gate smuggled into gates", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG", + "deploy_approval": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "__LIVE_TAXONOMY_VERSION__", + "events_version": "__LIVE_EVENTS_VERSION__" +} diff --git a/tests/fixtures/bp-contract/bad-bp-id-mismatch.json b/tests/fixtures/bp-contract/bad-bp-id-mismatch.json new file mode 100644 index 0000000..86450b0 --- /dev/null +++ b/tests/fixtures/bp-contract/bad-bp-id-mismatch.json @@ -0,0 +1,15 @@ +{ + "id": "bp-099", + "title": "interior id disagrees with the filename stem (A1 binding)", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "__LIVE_TAXONOMY_VERSION__", + "events_version": "__LIVE_EVENTS_VERSION__" +} diff --git a/tests/fixtures/bp-contract/bad-bp-missing-gate.json b/tests/fixtures/bp-contract/bad-bp-missing-gate.json new file mode 100644 index 0000000..2cd9ab4 --- /dev/null +++ b/tests/fixtures/bp-contract/bad-bp-missing-gate.json @@ -0,0 +1,14 @@ +{ + "id": "bp-001", + "title": "post_checkpoint gate missing", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "__LIVE_TAXONOMY_VERSION__", + "events_version": "__LIVE_EVENTS_VERSION__" +} diff --git a/tests/fixtures/bp-contract/bad-bp-stale-events-version.json b/tests/fixtures/bp-contract/bad-bp-stale-events-version.json new file mode 100644 index 0000000..a05ed9d --- /dev/null +++ b/tests/fixtures/bp-contract/bad-bp-stale-events-version.json @@ -0,0 +1,15 @@ +{ + "id": "bp-001", + "title": "stale events_version binding (F37)", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "__LIVE_TAXONOMY_VERSION__", + "events_version": "sha256:0000000000000000000000000000000000000000000000000000000000000000" +} diff --git a/tests/fixtures/bp-contract/bad-bp-stale-taxonomy-version.json b/tests/fixtures/bp-contract/bad-bp-stale-taxonomy-version.json new file mode 100644 index 0000000..8c9f1bf --- /dev/null +++ b/tests/fixtures/bp-contract/bad-bp-stale-taxonomy-version.json @@ -0,0 +1,15 @@ +{ + "id": "bp-001", + "title": "stale taxonomy_version binding (F8)", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "events_version": "__LIVE_EVENTS_VERSION__" +} diff --git a/tests/fixtures/bp-contract/bad-bp-stop-in-gates.json b/tests/fixtures/bp-contract/bad-bp-stop-in-gates.json new file mode 100644 index 0000000..7042af0 --- /dev/null +++ b/tests/fixtures/bp-contract/bad-bp-stop-in-gates.json @@ -0,0 +1,16 @@ +{ + "id": "bp-001", + "title": "stop demoted into the per-pattern gates object (F2/F10 violation)", + "gates": { + "plan_approval": "STRONG", + "pre_checkpoint": "STRONG", + "post_checkpoint": "STRONG", + "stop": "STRONG" + }, + "stop": { + "tier": "STRONG" + }, + "taxonomy_ref": "patterns/taxonomy.json", + "taxonomy_version": "__LIVE_TAXONOMY_VERSION__", + "events_version": "__LIVE_EVENTS_VERSION__" +} diff --git a/tests/fixtures/bp-contract/bad-events-bad-action-id.json b/tests/fixtures/bp-contract/bad-events-bad-action-id.json new file mode 100644 index 0000000..fc82fc5 --- /dev/null +++ b/tests/fixtures/bp-contract/bad-events-bad-action-id.json @@ -0,0 +1,25 @@ +{ + "version": "1.0.0", + "events": [ + { + "id": "pre_tool_use", + "fires_on": "fixture: STRONG action id outside the closed enum", + "payload_schema": "schemas/events/event-pre-tool-use.schema.json", + "actions": { + "STRONG": { "id": "explode", "semantics": "fixture" }, + "MEDIUM": { "id": "warn", "semantics": "fixture" }, + "WEAK": { "id": "inject", "semantics": "fixture" } + } + }, + { + "id": "stop", + "fires_on": "fixture", + "payload_schema": "schemas/events/event-stop.schema.json", + "actions": { + "STRONG": { "id": "refuse_stop", "semantics": "fixture" }, + "MEDIUM": { "id": "warn", "semantics": "fixture" }, + "WEAK": { "id": "unsupported", "semantics": "fixture" } + } + } + ] +} diff --git a/tests/fixtures/bp-contract/bad-events-dangling-payload-schema.json b/tests/fixtures/bp-contract/bad-events-dangling-payload-schema.json new file mode 100644 index 0000000..4b02bcd --- /dev/null +++ b/tests/fixtures/bp-contract/bad-events-dangling-payload-schema.json @@ -0,0 +1,25 @@ +{ + "version": "1.0.0", + "events": [ + { + "id": "pre_tool_use", + "fires_on": "fixture: payload_schema points at a nonexistent file (ENOENT = fail, F-7 axis 5)", + "payload_schema": "schemas/events/event-nonexistent.schema.json", + "actions": { + "STRONG": { "id": "block", "semantics": "fixture" }, + "MEDIUM": { "id": "warn", "semantics": "fixture" }, + "WEAK": { "id": "inject", "semantics": "fixture" } + } + }, + { + "id": "stop", + "fires_on": "fixture", + "payload_schema": "schemas/events/event-stop.schema.json", + "actions": { + "STRONG": { "id": "refuse_stop", "semantics": "fixture" }, + "MEDIUM": { "id": "warn", "semantics": "fixture" }, + "WEAK": { "id": "unsupported", "semantics": "fixture" } + } + } + ] +} diff --git a/tests/fixtures/bp-contract/bad-events-duplicate-id.json b/tests/fixtures/bp-contract/bad-events-duplicate-id.json new file mode 100644 index 0000000..b9f9d62 --- /dev/null +++ b/tests/fixtures/bp-contract/bad-events-duplicate-id.json @@ -0,0 +1,25 @@ +{ + "version": "1.0.0", + "events": [ + { + "id": "pre_tool_use", + "fires_on": "fixture: DUPLICATE event id (events mirror of F1d, step-6 F-2)", + "payload_schema": "schemas/events/event-pre-tool-use.schema.json", + "actions": { + "STRONG": { "id": "block", "semantics": "fixture" }, + "MEDIUM": { "id": "warn", "semantics": "fixture" }, + "WEAK": { "id": "inject", "semantics": "fixture" } + } + }, + { + "id": "pre_tool_use", + "fires_on": "fixture: second entry under the same id", + "payload_schema": "schemas/events/event-pre-tool-use.schema.json", + "actions": { + "STRONG": { "id": "block", "semantics": "fixture" }, + "MEDIUM": { "id": "warn", "semantics": "fixture" }, + "WEAK": { "id": "inject", "semantics": "fixture" } + } + } + ] +} diff --git a/tests/fixtures/bp-contract/bad-events-missing-tier-arm.json b/tests/fixtures/bp-contract/bad-events-missing-tier-arm.json new file mode 100644 index 0000000..c66e81c --- /dev/null +++ b/tests/fixtures/bp-contract/bad-events-missing-tier-arm.json @@ -0,0 +1,24 @@ +{ + "version": "1.0.0", + "events": [ + { + "id": "pre_tool_use", + "fires_on": "fixture: WEAK tier arm MISSING (all three arms required)", + "payload_schema": "schemas/events/event-pre-tool-use.schema.json", + "actions": { + "STRONG": { "id": "block", "semantics": "fixture" }, + "MEDIUM": { "id": "warn", "semantics": "fixture" } + } + }, + { + "id": "stop", + "fires_on": "fixture", + "payload_schema": "schemas/events/event-stop.schema.json", + "actions": { + "STRONG": { "id": "refuse_stop", "semantics": "fixture" }, + "MEDIUM": { "id": "warn", "semantics": "fixture" }, + "WEAK": { "id": "unsupported", "semantics": "fixture" } + } + } + ] +} diff --git a/tests/fixtures/bp-contract/bad-taxonomy-bad-action-value.json b/tests/fixtures/bp-contract/bad-taxonomy-bad-action-value.json new file mode 100644 index 0000000..45b3e17 --- /dev/null +++ b/tests/fixtures/bp-contract/bad-taxonomy-bad-action-value.json @@ -0,0 +1,13 @@ +{ + "version": "1.0.0", + "labels": [ + { "id": "read_only", "meaning": "fixture: gate value outside {allow, block}", "overridable": true, "gates": { "plan_approval": "maybe", "pre_checkpoint": "allow", "post_checkpoint": "allow" } }, + { "id": "nonsrc_write", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "allow", "post_checkpoint": "allow" } }, + { "id": "shared_write", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "block", "post_checkpoint": "allow" } }, + { "id": "push_or_pr_create", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "allow", "post_checkpoint": "block" } }, + { "id": "marker_write", "meaning": "fixture", "overridable": false, "gates": { "plan_approval": "allow", "pre_checkpoint": "allow", "post_checkpoint": "allow" } }, + { "id": "unsafe_complex", "meaning": "fixture", "overridable": false, "gates": { "plan_approval": "block", "pre_checkpoint": "block", "post_checkpoint": "block" } }, + { "id": "unknown", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "block", "post_checkpoint": "block" } } + ], + "non_overridable": ["marker_write", "unsafe_complex"] +} diff --git a/tests/fixtures/bp-contract/bad-taxonomy-duplicate-id.json b/tests/fixtures/bp-contract/bad-taxonomy-duplicate-id.json new file mode 100644 index 0000000..a6d32cf --- /dev/null +++ b/tests/fixtures/bp-contract/bad-taxonomy-duplicate-id.json @@ -0,0 +1,14 @@ +{ + "version": "1.0.0", + "labels": [ + { "id": "read_only", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "allow", "pre_checkpoint": "allow", "post_checkpoint": "allow" } }, + { "id": "read_only", "meaning": "fixture: DUPLICATE id", "overridable": true, "gates": { "plan_approval": "allow", "pre_checkpoint": "allow", "post_checkpoint": "allow" } }, + { "id": "nonsrc_write", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "allow", "post_checkpoint": "allow" } }, + { "id": "shared_write", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "block", "post_checkpoint": "allow" } }, + { "id": "push_or_pr_create", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "allow", "post_checkpoint": "block" } }, + { "id": "marker_write", "meaning": "fixture", "overridable": false, "gates": { "plan_approval": "allow", "pre_checkpoint": "allow", "post_checkpoint": "allow" } }, + { "id": "unsafe_complex", "meaning": "fixture", "overridable": false, "gates": { "plan_approval": "block", "pre_checkpoint": "block", "post_checkpoint": "block" } }, + { "id": "unknown", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "block", "post_checkpoint": "block" } } + ], + "non_overridable": ["marker_write", "unsafe_complex"] +} diff --git a/tests/fixtures/bp-contract/bad-taxonomy-extra-gate-key.json b/tests/fixtures/bp-contract/bad-taxonomy-extra-gate-key.json new file mode 100644 index 0000000..c9f9410 --- /dev/null +++ b/tests/fixtures/bp-contract/bad-taxonomy-extra-gate-key.json @@ -0,0 +1,13 @@ +{ + "version": "1.0.0", + "labels": [ + { "id": "read_only", "meaning": "fixture: EXTRA gate key deploy", "overridable": true, "gates": { "plan_approval": "allow", "pre_checkpoint": "allow", "post_checkpoint": "allow", "deploy": "allow" } }, + { "id": "nonsrc_write", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "allow", "post_checkpoint": "allow" } }, + { "id": "shared_write", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "block", "post_checkpoint": "allow" } }, + { "id": "push_or_pr_create", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "allow", "post_checkpoint": "block" } }, + { "id": "marker_write", "meaning": "fixture", "overridable": false, "gates": { "plan_approval": "allow", "pre_checkpoint": "allow", "post_checkpoint": "allow" } }, + { "id": "unsafe_complex", "meaning": "fixture", "overridable": false, "gates": { "plan_approval": "block", "pre_checkpoint": "block", "post_checkpoint": "block" } }, + { "id": "unknown", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "block", "post_checkpoint": "block" } } + ], + "non_overridable": ["marker_write", "unsafe_complex"] +} diff --git a/tests/fixtures/bp-contract/bad-taxonomy-missing-gate.json b/tests/fixtures/bp-contract/bad-taxonomy-missing-gate.json new file mode 100644 index 0000000..9ee3452 --- /dev/null +++ b/tests/fixtures/bp-contract/bad-taxonomy-missing-gate.json @@ -0,0 +1,13 @@ +{ + "version": "1.0.0", + "labels": [ + { "id": "read_only", "meaning": "fixture: post_checkpoint gate MISSING", "overridable": true, "gates": { "plan_approval": "allow", "pre_checkpoint": "allow" } }, + { "id": "nonsrc_write", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "allow", "post_checkpoint": "allow" } }, + { "id": "shared_write", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "block", "post_checkpoint": "allow" } }, + { "id": "push_or_pr_create", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "allow", "post_checkpoint": "block" } }, + { "id": "marker_write", "meaning": "fixture", "overridable": false, "gates": { "plan_approval": "allow", "pre_checkpoint": "allow", "post_checkpoint": "allow" } }, + { "id": "unsafe_complex", "meaning": "fixture", "overridable": false, "gates": { "plan_approval": "block", "pre_checkpoint": "block", "post_checkpoint": "block" } }, + { "id": "unknown", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "block", "post_checkpoint": "block" } } + ], + "non_overridable": ["marker_write", "unsafe_complex"] +} diff --git a/tests/fixtures/bp-contract/bad-taxonomy-overridability-mismatch.json b/tests/fixtures/bp-contract/bad-taxonomy-overridability-mismatch.json new file mode 100644 index 0000000..29f4a91 --- /dev/null +++ b/tests/fixtures/bp-contract/bad-taxonomy-overridability-mismatch.json @@ -0,0 +1,13 @@ +{ + "version": "1.0.0", + "labels": [ + { "id": "read_only", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "allow", "pre_checkpoint": "allow", "post_checkpoint": "allow" } }, + { "id": "nonsrc_write", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "allow", "post_checkpoint": "allow" } }, + { "id": "shared_write", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "block", "post_checkpoint": "allow" } }, + { "id": "push_or_pr_create", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "allow", "post_checkpoint": "block" } }, + { "id": "marker_write", "meaning": "fixture: overridable false but ABSENT from non_overridable", "overridable": false, "gates": { "plan_approval": "allow", "pre_checkpoint": "allow", "post_checkpoint": "allow" } }, + { "id": "unsafe_complex", "meaning": "fixture", "overridable": false, "gates": { "plan_approval": "block", "pre_checkpoint": "block", "post_checkpoint": "block" } }, + { "id": "unknown", "meaning": "fixture", "overridable": true, "gates": { "plan_approval": "block", "pre_checkpoint": "block", "post_checkpoint": "block" } } + ], + "non_overridable": ["unsafe_complex"] +} diff --git a/tests/test-validate-bp-contract.mjs b/tests/test-validate-bp-contract.mjs new file mode 100644 index 0000000..22c6d0b --- /dev/null +++ b/tests/test-validate-bp-contract.mjs @@ -0,0 +1,515 @@ +// test-validate-bp-contract.mjs — RFC-008 P2b: scripts/validate-bp-contract.mjs +// (assertions 0 + 1-15) is fail-CLOSED, and scripts/scaffold-bp.mjs is SoT-bound. +// +// Dispatch architecture (plan review F1): the golden corpus cannot run as bare +// fixture files — assertion 0's set-equality guard requires the full 11-contract +// set. So every case runs in a SANDBOX: a copy of patterns/ plugins/ schemas/ +// (verbatimSymlinks, P2a F-A) turned into a real git repo with an origin/main +// ref (assertions 8/14 are git-backed; `git update-ref refs/remotes/origin/main` +// reproduces CI's fetch-depth:0 baseline). bp fixtures OVERLAY a canonical +// contract (bp-001.json) in the staged set; taxonomy/events fixtures inject via +// --taxonomy/--events; classifier negatives mutate the sandbox classifier +// (synthetic-root mechanism, review F3b). +// +// Hash tokens: bp fixtures carry __LIVE_TAXONOMY_VERSION__/__LIVE_EVENTS_VERSION__ +// replaced at staging time from the sandbox's effective documents — committed +// digests would be hand-typed rot (lesson 20260610-000157-…-f9b5 class). +// +// Run: node tests/test-validate-bp-contract.mjs (exit 0 = pass, non-zero = fail) + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { spawnSync, execFileSync } from "node:child_process"; +import { taxonomyVersion, eventsVersion } from "../scripts/lib/version-hash.mjs"; +import { EVENT_IDS } from "../scripts/validate-plugin-registry.mjs"; + +const REPO_ROOT = fs.realpathSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..")); +const VALIDATOR = path.join(REPO_ROOT, "scripts", "validate-bp-contract.mjs"); +const SCAFFOLD = path.join(REPO_ROOT, "scripts", "scaffold-bp.mjs"); +const FIXTURE_DIR = path.join(REPO_ROOT, "tests", "fixtures", "bp-contract"); +const COPY_ROOTS = ["patterns", "plugins", "schemas"]; +const CLASSIFIER_REL = path.join("plugins", "claude-code", "hooks", "lib", "command-classifier.sh"); +const EXPECTED_ASSERTIONS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15]; + +let pass = 0; +let fail = 0; +let skipped = 0; +const failures = []; + +function ok() { pass++; } +function bad(name, detail) { fail++; failures.push(`${name}${detail ? " — " + detail : ""}`); } +function assert(cond, name, detail) { if (cond) ok(name); else bad(name, detail); } + +// Both runners forward the isolated git env (step-6 NIT): the validator's own +// git subprocesses must not pick up developer-global config (hooks/signing). +function run(args, opts = {}) { + const r = spawnSync(process.execPath, [VALIDATOR, ...args], { encoding: "utf8", env: GIT_ENV, ...opts }); + let payload = null; + try { payload = JSON.parse(r.stdout); } catch { /* non-JSON (human/usage) output */ } + return { exit: r.status, stdout: r.stdout, stderr: r.stderr, payload }; +} + +function runScaffold(args, opts = {}) { + const r = spawnSync(process.execPath, [SCAFFOLD, ...args], { encoding: "utf8", env: GIT_ENV, ...opts }); + let payload = null; + try { payload = JSON.parse(r.stdout); } catch { /* ignore */ } + return { exit: r.status, stdout: r.stdout, stderr: r.stderr, payload }; +} + +function hasViolation(payload, check, substr) { + return !!payload && Array.isArray(payload.violations) && + payload.violations.some((v) => v.check === check && (!substr || v.detail.includes(substr))); +} + +// --- sandbox machinery ------------------------------------------------------ +const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "validate-bp-"))); +let symlinksOk = true; +// Sandbox git runs under an ISOLATED config (no user hooks/signing/templates). +const GIT_ENV = { ...process.env, GIT_CONFIG_GLOBAL: os.devNull, GIT_CONFIG_SYSTEM: os.devNull }; + +function gitIn(root, argv) { + return execFileSync("git", argv, { cwd: root, encoding: "utf8", env: GIT_ENV, stdio: ["ignore", "pipe", "pipe"] }); +} + +function copyTree(src, dest) { + try { + fs.cpSync(src, dest, { recursive: true, verbatimSymlinks: true }); + } catch (e) { + if (e.code === "EPERM" || e.code === "EACCES") { + symlinksOk = false; + fs.cpSync(src, dest, { recursive: true, filter: (s) => !fs.lstatSync(s).isSymbolicLink() }); + } else { + throw e; + } + } +} + +/** Sandbox = tree copy + git repo + origin/main ref at the baseline commit. */ +function makeRepo(name, { withOriginRef = true, excludeAtBaseline = [] } = {}) { + const root = path.join(TMP, name); + for (const rel of COPY_ROOTS) copyTree(path.join(REPO_ROOT, rel), path.join(root, rel)); + gitIn(root, ["init", "-q", "-b", "main"]); + const aside = []; + for (const rel of excludeAtBaseline) { + const abs = path.join(root, rel); + const tmpAbs = abs + ".aside"; + fs.renameSync(abs, tmpAbs); + aside.push([abs, tmpAbs]); + } + gitIn(root, ["add", "-A"]); + gitIn(root, ["-c", "user.email=test@example.invalid", "-c", "user.name=test", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "baseline"]); + if (withOriginRef) gitIn(root, ["update-ref", "refs/remotes/origin/main", "HEAD"]); + for (const [abs, tmpAbs] of aside) fs.renameSync(tmpAbs, abs); + if (aside.length > 0) { + gitIn(root, ["add", "-A"]); + gitIn(root, ["-c", "user.email=test@example.invalid", "-c", "user.name=test", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "introduce excluded files"]); + } + return root; +} + +function readJsonAt(root, rel) { return JSON.parse(fs.readFileSync(path.join(root, rel), "utf8")); } +function writeJsonAt(root, rel, doc) { fs.writeFileSync(path.join(root, rel), JSON.stringify(doc, null, 2) + "\n"); } + +function liveHashesOf(root) { + return { + tax: taxonomyVersion(readJsonAt(root, path.join("patterns", "taxonomy.json"))), + ev: eventsVersion(readJsonAt(root, path.join("patterns", "events.json"))), + }; +} + +function stageBpFixture(root, fixtureName) { + const { tax, ev } = liveHashesOf(root); + const text = fs.readFileSync(path.join(FIXTURE_DIR, fixtureName), "utf8") + .replaceAll("__LIVE_TAXONOMY_VERSION__", tax) + .replaceAll("__LIVE_EVENTS_VERSION__", ev); + fs.writeFileSync(path.join(root, "patterns", "bp-001.json"), text); +} + +// --------------------------------------------------------------------------- +// 1. POSITIVE — the real repo passes; assertions all run; EVENT_IDS binding. +// --------------------------------------------------------------------------- +{ + const r = run(["--project", REPO_ROOT, "--json"]); + assert(r.exit === 0, "real repo: exit 0", `exit=${r.exit} stderr=${r.stderr.slice(0, 300)}`); + assert(r.payload && r.payload.status === "ok", "real repo: status ok"); + assert(r.payload && r.payload.bp_files_checked === 11, "real repo: 11 contracts checked", `got ${r.payload && r.payload.bp_files_checked}`); + // Rule-14 cross-check: validator's derived set equals an independent derivation. + const indexIds = readJsonAt(REPO_ROOT, path.join("patterns", "_index.json")).patterns + .map((p) => /^(bp-[0-9]{3})-/.exec(p.pattern_id)[1]).sort(); + assert(JSON.stringify(r.payload && r.payload.derived_ids) === JSON.stringify(indexIds), "real repo: derived_ids equals _index.json derivation (bp-007 absent)", `got ${JSON.stringify(r.payload && r.payload.derived_ids)}`); + assert(!indexIds.includes("bp-007"), "real repo: bp-007 stays absent (N-1)"); + assert(JSON.stringify(r.payload && r.payload.assertions_run) === JSON.stringify(EXPECTED_ASSERTIONS), "real repo: all 15 assertion groups ran (zero-run vacuity guard)", `got ${JSON.stringify(r.payload && r.payload.assertions_run)}`); + assert(r.payload && r.payload.checks > 0, "real repo: non-zero check count"); + assert(r.payload && r.payload.classifiers_parsed >= 1, "real repo: >= 1 default classifier parsed (7b non-vacuous)"); + // EVENT_IDS <-> events.json binding (review FU, Rule 14): the registry's + // hardcoded cross-file constant must equal the live data SoT. + const liveEventIds = readJsonAt(REPO_ROOT, path.join("patterns", "events.json")).events.map((e) => e.id).sort(); + assert(JSON.stringify([...EVENT_IDS].sort()) === JSON.stringify(liveEventIds), "EVENT_IDS constant equals live events[].id set (Rule-14 binding)", `constant=${JSON.stringify(EVENT_IDS)} live=${JSON.stringify(liveEventIds)}`); +} + +// --------------------------------------------------------------------------- +// 2. Sandbox baseline — a faithful copy with merge-base == HEAD passes. +// --------------------------------------------------------------------------- +const SANDBOX = makeRepo("corpus"); +{ + const r = run(["--project", SANDBOX, "--json"]); + assert(r.exit === 0, "sandbox baseline: exit 0", `exit=${r.exit} stderr=${r.stderr.slice(0, 300)}`); +} +const ORIG_BP001 = fs.readFileSync(path.join(SANDBOX, "patterns", "bp-001.json"), "utf8"); +const ORIG_CLASSIFIER = fs.readFileSync(path.join(SANDBOX, CLASSIFIER_REL), "utf8"); + +// --------------------------------------------------------------------------- +// 3. Golden corpus loop (assertion 9) — each fail-fixture fails AT its +// attributed check, never merely exit 1. +// --------------------------------------------------------------------------- +const corpusIndex = JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, "_corpus-index.json"), "utf8")); +{ + const names = Object.keys(corpusIndex.fixtures); + assert(names.length >= corpusIndex.min_fixtures, `corpus: >= ${corpusIndex.min_fixtures} fixtures (non-vacuity floor)`, `got ${names.length}`); + const onDisk = fs.readdirSync(FIXTURE_DIR).filter((n) => n.endsWith(".json") && n !== "_corpus-index.json").sort(); + assert(JSON.stringify(onDisk) === JSON.stringify([...names].sort()), "corpus: index covers exactly the on-disk fixture files (no orphans, no ghosts)", `disk=${onDisk.length} index=${names.length}`); +} +for (const [name, meta] of Object.entries(corpusIndex.fixtures)) { + let r; + if (meta.inject === "bp-overlay") { + stageBpFixture(SANDBOX, name); + r = run(["--project", SANDBOX, "--json"]); + fs.writeFileSync(path.join(SANDBOX, "patterns", "bp-001.json"), ORIG_BP001); + } else if (meta.inject === "taxonomy" || meta.inject === "events") { + const injectedAbs = path.join(SANDBOX, `injected-${name}`); + fs.copyFileSync(path.join(FIXTURE_DIR, name), injectedAbs); + r = run(["--project", SANDBOX, `--${meta.inject}`, injectedAbs, "--json"]); + fs.rmSync(injectedAbs); + } else { + bad(`corpus ${name}: unknown inject mode ${meta.inject}`); + continue; + } + assert(r.exit === 1, `corpus ${name}: exit 1`, `exit=${r.exit} stderr=${r.stderr.slice(0, 200)}`); + assert(hasViolation(r.payload, meta.attributed_check), `corpus ${name}: fails AT attributed check ${meta.attributed_check}`, `violations=${JSON.stringify((r.payload && r.payload.violations || []).map((v) => v.check))}`); +} + +// --------------------------------------------------------------------------- +// 4. Assertion 0-set — set properties (not file properties): missing, phantom, +// near-miss filename (F5). +// --------------------------------------------------------------------------- +{ + const abs = path.join(SANDBOX, "patterns", "bp-003.json"); + const orig = fs.readFileSync(abs, "utf8"); + fs.rmSync(abs); + const r = run(["--project", SANDBOX, "--json"]); + assert(r.exit === 1 && hasViolation(r.payload, "0-set", "missing contract bp-003"), "0-set: missing contract is a named violation", `exit=${r.exit}`); + fs.writeFileSync(abs, orig); +} +{ + const { tax, ev } = liveHashesOf(SANDBOX); + const phantom = { id: "bp-013", title: "phantom", gates: { plan_approval: "STRONG", pre_checkpoint: "STRONG", post_checkpoint: "STRONG" }, stop: { tier: "STRONG" }, taxonomy_ref: "patterns/taxonomy.json", taxonomy_version: tax, events_version: ev }; + writeJsonAt(SANDBOX, path.join("patterns", "bp-013.json"), phantom); + const r = run(["--project", SANDBOX, "--json"]); + assert(r.exit === 1 && hasViolation(r.payload, "0-set", "phantom contract bp-013"), "0-set: phantom contract is a named violation", `exit=${r.exit}`); + fs.rmSync(path.join(SANDBOX, "patterns", "bp-013.json")); +} +{ + fs.writeFileSync(path.join(SANDBOX, "patterns", "bp-07.json"), "{}"); + const r = run(["--project", SANDBOX, "--json"]); + assert(r.exit === 1 && hasViolation(r.payload, "0-set", "malformed contract filename"), "0-set: near-miss filename bp-07.json is a named violation (F5)", `exit=${r.exit}`); + fs.rmSync(path.join(SANDBOX, "patterns", "bp-07.json")); +} +{ + // Step-6 F-3: case variants are the same dead-data escape on + // case-insensitive filesystems — the loose filter is case-insensitive, the + // strict regex is not, so the variant is a NAMED violation. + fs.writeFileSync(path.join(SANDBOX, "patterns", "BP-099.JSON"), "{}"); + const r = run(["--project", SANDBOX, "--json"]); + assert(r.exit === 1 && hasViolation(r.payload, "0-set", "BP-099.JSON"), "0-set: case-variant filename BP-099.JSON is a named violation (F-3)", `exit=${r.exit}`); + fs.rmSync(path.join(SANDBOX, "patterns", "BP-099.JSON")); +} + +// --------------------------------------------------------------------------- +// 5. Assertion 7b classifier negatives (synthetic-root mechanism, F3b). +// --------------------------------------------------------------------------- +const CLS_ABS = path.join(SANDBOX, CLASSIFIER_REL); +function classifierCase(name, mutate, expectSubstr) { + mutate(); + const r = run(["--project", SANDBOX, "--json"]); + assert(r.exit === 1 && hasViolation(r.payload, "7", expectSubstr), `7b: ${name}`, `exit=${r.exit} violations=${JSON.stringify((r.payload && r.payload.violations || []).filter((v) => v.check === "7").map((v) => v.detail.slice(0, 80)))}`); + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER); +} +classifierCase("missing classifier script is fail-closed", () => fs.rmSync(CLS_ABS), "default classifier script missing"); +classifierCase("duplicate _priority() definition (A3)", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + '\n_priority() {\n case "$1" in\n read_only) printf \'1\' ;;\n esac\n}\n'); +}, "_priority() definitions"); +classifierCase("alternation arm spelling is fail-closed (F3)", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER.replace(/^(\s*)read_only\)/m, "$1read_only|sneaky_alias)")); +}, "unrecognized _priority case-arm spelling"); +classifierCase("missing arm = silent-downgrade violation (L473)", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER.split("\n").filter((l) => !/^\s*shared_write\)/.test(l)).join("\n")); +}, 'label "shared_write" has NO _priority arm'); +classifierCase("extra arm not in taxonomy", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER.replace(/^(\s*)read_only\)(.*)$/m, "$1bogus_label) printf '9' ;;\n$1read_only)$2")); +}, '"bogus_label" is not a taxonomy label'); +// Step-6 F-1 class: the definition recognizer must cover bash's full spelling +// class — `function` keyword, space-before-parens, and indented duplicates are +// last-wins at runtime and must trip the A3 exactly-one guard, not slip past a +// single-spelling regex (captured false-pass repros N1/N2). +const PLANTED_BODY = '\n case "$1" in\n read_only) printf \'1\' ;;\n esac\n}\n'; +classifierCase("duplicate via `function _priority {` spelling (F-1)", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + "\nfunction _priority {" + PLANTED_BODY); +}, "_priority() definitions"); +classifierCase("duplicate via `_priority () {` spelling (F-1)", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + "\n_priority () {" + PLANTED_BODY); +}, "_priority() definitions"); +classifierCase("duplicate via INDENTED `_priority() {` spelling (F-1)", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + "\n _priority() {" + PLANTED_BODY); +}, "_priority() definitions"); +// Step-6 F-1R2/F-1R3: per-OCCURRENCE token-context allowlist — every +// definition opener anywhere on any line counts toward the exactly-one +// tally; occurrences outside the allowlist are unproven violations. +const UNPROVEN = "cannot prove exactly-one _priority definition"; +const DUP_DEFS = "_priority() definitions"; +classifierCase("N11: brace-on-next-line definition (F-1R2)", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + "\n_priority()\n{" + PLANTED_BODY); +}, DUP_DEFS); +classifierCase("N12: `function _priority` brace-on-next-line (F-1R2)", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + "\nfunction _priority\n{" + PLANTED_BODY); +}, DUP_DEFS); +classifierCase("N13: non-brace compound body definition (F-1R2)", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + "\n_priority() case \"$1\" in read_only) printf '1' ;; esac\n"); +}, DUP_DEFS); +classifierCase("N14: definition after a same-line command (F-1R2)", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + "\n: ; _priority() {" + PLANTED_BODY); +}, DUP_DEFS); +// F-1R3 P-members: a call site and a redefinition share one line — the call +// context must mask only its own OCCURRENCE, never the rest of the line. +classifierCase("P1: call site + same-line redefinition (F-1R3)", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + "\nout=$(_priority read_only); _priority() { printf '9' ; }\n"); +}, DUP_DEFS); +classifierCase("P8: call inside a string + same-line redefinition (F-1R3)", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + "\nmsg=\"see $(_priority x)\"; _priority() { printf '9' ; }\n"); +}, DUP_DEFS); +classifierCase("P-live: redefinition appended to an EXISTING allowlisted call-site line (F-1R3)", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER.replace('local lp=$(_priority "$lbl")', 'local lp=$(_priority "$lbl"); _priority() { printf \'9\' ; }')); +}, DUP_DEFS); +classifierCase("unproven branch: direct call without $() is fail-closed", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + '\n_priority "read_only"\n'); +}, UNPROVEN); +// F-1R4: a backslash-newline continuation splits the token across physical +// lines — bash joins during lexing, so the scan must normalize first. +classifierCase("F-1R4: token split by backslash-newline continuation", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + "\n_prio\\\nrity() { printf '9' ; }\n"); +}, DUP_DEFS); +// F-1R4 FP control: a legitimate continuation that does NOT touch the token +// stays green (the join must not manufacture spurious occurrences). +{ + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + '\nprobe_continuation() {\n printf \'%s\' \\\n "harmless"\n}\n'); + const r = run(["--project", SANDBOX, "--json"]); + assert(r.exit === 0, "7b FP control: harmless backslash-newline continuation stays green (F-1R4)", `exit=${r.exit} violations=${JSON.stringify((r.payload && r.payload.violations || []).filter((v) => v.check === "7").map((v) => v.detail.slice(0, 80)))}`); + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER); +} +// F-1R5: bash does NOT join a backslash-newline inside a comment — a trailing +// `\` on a comment line must not absorb a next-line definition into the +// comment skip (the R4 unconditional join regressed exactly this). +classifierCase("F-1R5: definition after a trailing-backslash COMMENT line", () => { + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + "\n# note \\\n_priority() { printf '9' ; }\n"); +}, DUP_DEFS); +// F-1R5 FP control: comment-with-trailing-backslash followed by a harmless +// line stays green (the comment-aware join must not flag inert text). +{ + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + '\n# trailing backslash here \\\nprobe_harmless() { printf \'ok\' ; }\n'); + const r = run(["--project", SANDBOX, "--json"]); + assert(r.exit === 0, "7b FP control: comment trailing-backslash + harmless next line stays green (F-1R5)", `exit=${r.exit} violations=${JSON.stringify((r.payload && r.payload.violations || []).filter((v) => v.check === "7").map((v) => v.detail.slice(0, 80)))}`); + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER); +} +// FP controls (F-1R2): allowlisted contexts stay green — an extra $(-call +// site and a full-line comment mention are NOT violations. +{ + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER + '\n# _priority is documented here\nextra_probe() {\n local x=$(_priority "read_only")\n printf \'%s\' "$x"\n}\n'); + const r = run(["--project", SANDBOX, "--json"]); + assert(r.exit === 0, "7b FP control: call-site + comment _priority mentions stay green (F-1R2)", `exit=${r.exit} violations=${JSON.stringify((r.payload && r.payload.violations || []).filter((v) => v.check === "7").map((v) => v.detail.slice(0, 80)))}`); + fs.writeFileSync(CLS_ABS, ORIG_CLASSIFIER); +} + +// --------------------------------------------------------------------------- +// 6. Stable-ID E2E (assertions 8/14) — all branches incl. A2 + N-5. +// --------------------------------------------------------------------------- +// (a) rename without major bump -> assertion 8 violation +{ + const root = makeRepo("rename-label"); + const tax = readJsonAt(root, path.join("patterns", "taxonomy.json")); + tax.labels.find((l) => l.id === "read_only").id = "read_onlyx"; + writeJsonAt(root, path.join("patterns", "taxonomy.json"), tax); + const r = run(["--project", root, "--json"]); + assert(r.exit === 1 && hasViolation(r.payload, "8", "read_only"), "stable-ID (a): label rename w/o major bump fails at assertion 8", `exit=${r.exit}`); +} +// (b) removal without major bump -> assertion 8 violation +{ + const root = makeRepo("remove-label"); + const tax = readJsonAt(root, path.join("patterns", "taxonomy.json")); + tax.labels = tax.labels.filter((l) => l.id !== "unknown"); + writeJsonAt(root, path.join("patterns", "taxonomy.json"), tax); + const r = run(["--project", root, "--json"]); + assert(r.exit === 1 && hasViolation(r.payload, "8", "unknown"), "stable-ID (b): label removal w/o major bump fails at assertion 8", `exit=${r.exit}`); +} +// (d) removal WITH major bump -> NO assertion-8 violation (other checks may fire) +{ + const root = makeRepo("remove-label-bump"); + const tax = readJsonAt(root, path.join("patterns", "taxonomy.json")); + tax.labels = tax.labels.filter((l) => l.id !== "unknown"); + tax.version = "2.0.0"; + writeJsonAt(root, path.join("patterns", "taxonomy.json"), tax); + const r = run(["--project", root, "--json"]); + assert(!hasViolation(r.payload, "8"), "stable-ID (d): removal WITH major bump passes assertion 8", `violations=${JSON.stringify((r.payload && r.payload.violations || []).filter((v) => v.check === "8"))}`); +} +// (c) pure add + scaffold-refresh + classifier arm -> FULL green (FP control) +{ + const root = makeRepo("add-label"); + const tax = readJsonAt(root, path.join("patterns", "taxonomy.json")); + tax.labels.push({ id: "new_label", meaning: "pure-add FP control", overridable: true, gates: { plan_approval: "allow", pre_checkpoint: "allow", post_checkpoint: "allow" } }); + writeJsonAt(root, path.join("patterns", "taxonomy.json"), tax); + const s = runScaffold(["--project", root, "--json"]); + assert(s.exit === 0 && s.payload && s.payload.updated.length === 11, "stable-ID (c): scaffold refreshes all 11 contracts after taxonomy add", `exit=${s.exit} updated=${s.payload && s.payload.updated.length}`); + const clsAbs = path.join(root, CLASSIFIER_REL); + fs.writeFileSync(clsAbs, fs.readFileSync(clsAbs, "utf8").replace(/^(\s*)read_only\)(\s*)printf '1' ;;$/m, "$1new_label)$2printf '1' ;;\n$1read_only)$2printf '1' ;;")); + // A taxonomy change also stales the plugin manifest's hash binding (assertion + // 15 covers manifests; scaffold refreshes contracts only) — refresh it as the + // manifest author would. + const manRel = path.join("plugins", "claude-code", "manifest.json"); + const man = readJsonAt(root, manRel); + man.taxonomy_version = taxonomyVersion(readJsonAt(root, path.join("patterns", "taxonomy.json"))); + writeJsonAt(root, manRel, man); + const r = run(["--project", root, "--json"]); + assert(r.exit === 0, "stable-ID (c): pure add + scaffold refresh + classifier arm + manifest hash refresh = exit 0 (FP control)", `exit=${r.exit} violations=${JSON.stringify((r.payload && r.payload.violations || []).map((v) => v.check + ":" + v.detail.slice(0, 60)))}`); +} +// (e) bootstrap carve-out (N-5): taxonomy absent at merge-base, present at HEAD +{ + const root = makeRepo("bootstrap", { excludeAtBaseline: [path.join("patterns", "taxonomy.json")] }); + const r = run(["--project", root, "--json"]); + assert(r.exit === 0, "stable-ID (e): N-5 bootstrap (file absent at merge-base) passes", `exit=${r.exit} stderr=${r.stderr.slice(0, 300)}`); +} +// (f) no origin/main ref -> exit 2 fail-closed, never exit 1 +{ + const root = makeRepo("no-origin", { withOriginRef: false }); + const r = run(["--project", root, "--json"]); + assert(r.exit === 2, "stable-ID (f): unresolvable merge-base is exit 2 (fail-closed)", `exit=${r.exit}`); + assert(r.stderr.includes("fetch full history"), "stable-ID (f): stderr names the fetch-depth remedy", r.stderr.slice(0, 200)); +} +// (g) shallow repo WITH an origin/main ref present -> exit 2 via the A2 guard +{ + const base = makeRepo("shallow-base"); + const shallow = path.join(TMP, "shallow-clone"); + execFileSync("git", ["clone", "-q", "--depth", "1", pathToFileURL(base).href, shallow], { encoding: "utf8", env: GIT_ENV }); + try { gitIn(shallow, ["update-ref", "refs/remotes/origin/main", "HEAD"]); } catch { /* ref may already exist from the clone */ } + const r = run(["--project", shallow, "--json"]); + assert(r.exit === 2, "stable-ID (g): shallow repo with origin/main ref is exit 2 (A2 guard)", `exit=${r.exit}`); + assert(r.stderr.includes("shallow"), "stable-ID (g): stderr names the shallow condition", r.stderr.slice(0, 200)); +} +// (14) events mirror: rename an event id without major bump -> assertion 14 +{ + const root = makeRepo("rename-event"); + const ev = readJsonAt(root, path.join("patterns", "events.json")); + ev.events.find((e) => e.id === "stop").id = "halt"; + writeJsonAt(root, path.join("patterns", "events.json"), ev); + const r = run(["--project", root, "--json"]); + assert(r.exit === 1 && hasViolation(r.payload, "14", "stop"), "stable-ID events mirror: event rename w/o major bump fails at assertion 14", `exit=${r.exit}`); +} + +// --------------------------------------------------------------------------- +// 7. Scaffold — SoT binding, round-trip, idempotency, merge-on-regenerate (F2), +// malformed SoT, A6 cwd-negative. +// --------------------------------------------------------------------------- +{ + const root = makeRepo("scaffold-roundtrip"); + for (const f of fs.readdirSync(path.join(root, "patterns"))) { + if (/^bp-[0-9]{3}\.json$/.test(f)) fs.rmSync(path.join(root, "patterns", f)); + } + const s = runScaffold(["--project", root, "--json"]); + assert(s.exit === 0 && s.payload && s.payload.created.length === 11, "scaffold: creates exactly 11 contracts from a clean slate", `exit=${s.exit} created=${s.payload && s.payload.created.length}`); + assert(s.payload && !s.payload.derived_ids.includes("bp-007"), "scaffold: bp-007 NOT generated (N-1)"); + const r = run(["--project", root, "--json"]); + assert(r.exit === 0, "scaffold round-trip: scaffolded repo passes the validator end-to-end", `exit=${r.exit} stderr=${r.stderr.slice(0, 300)}`); + + // idempotency: second run is byte-stable + const before = fs.readFileSync(path.join(root, "patterns", "bp-001.json"), "utf8"); + const s2 = runScaffold(["--project", root, "--json"]); + assert(s2.exit === 0 && s2.payload && s2.payload.unchanged.length === 11 && s2.payload.created.length === 0 && s2.payload.updated.length === 0, "scaffold: re-run is byte-stable (11 unchanged)", `payload=${JSON.stringify(s2.payload && { c: s2.payload.created.length, u: s2.payload.updated.length, n: s2.payload.unchanged.length })}`); + assert(fs.readFileSync(path.join(root, "patterns", "bp-001.json"), "utf8") === before, "scaffold: bp-001.json byte-identical after re-run"); + + // merge-on-regenerate (F2): hand-relaxed tier survives a hash refresh + const bp2 = readJsonAt(root, path.join("patterns", "bp-002.json")); + bp2.gates.plan_approval = "WEAK"; + writeJsonAt(root, path.join("patterns", "bp-002.json"), bp2); + const tax = readJsonAt(root, path.join("patterns", "taxonomy.json")); + tax.labels[0].meaning = tax.labels[0].meaning + " (editorial change -> new hash)"; + writeJsonAt(root, path.join("patterns", "taxonomy.json"), tax); + const newTax = taxonomyVersion(tax); + const s3 = runScaffold(["--project", root, "--json"]); + const bp2After = readJsonAt(root, path.join("patterns", "bp-002.json")); + assert(s3.exit === 0 && bp2After.gates.plan_approval === "WEAK", "scaffold F2: hand-relaxed tier survives regeneration", `tier=${bp2After.gates.plan_approval}`); + assert(bp2After.taxonomy_version === newTax, "scaffold F2: taxonomy_version refreshed to the new live hash", `have=${bp2After.taxonomy_version}`); +} +{ + const root = makeRepo("scaffold-badsot"); + const idx = readJsonAt(root, path.join("patterns", "_index.json")); + idx.patterns[0].pattern_id = "bp-7-bad"; + writeJsonAt(root, path.join("patterns", "_index.json"), idx); + const s = runScaffold(["--project", root, "--json"]); + assert(s.exit === 2, "scaffold: malformed pattern_id in the SoT is exit 2 (fail-closed)", `exit=${s.exit}`); + assert(s.stderr.includes("bp-7-bad"), "scaffold: stderr names the malformed pattern_id", s.stderr.slice(0, 200)); +} +{ + // A6: nothing lands under the CALLER cwd — writes bind to --project only. + const root = makeRepo("scaffold-cwd"); + const cwdDir = path.join(TMP, "elsewhere-cwd"); + fs.mkdirSync(cwdDir, { recursive: true }); + const s = runScaffold(["--project", root, "--json"], { cwd: cwdDir }); + assert(s.exit === 0, "scaffold A6: runs from a foreign cwd", `exit=${s.exit}`); + const strays = fs.readdirSync(cwdDir).filter((n) => /^bp-.*\.json$/.test(n)); + assert(strays.length === 0, "scaffold A6: no bp-*.json written under the caller cwd", `strays=${strays.join(", ")}`); +} + +// --------------------------------------------------------------------------- +// 8. Injection containment + usage errors — exit 2, never conflated with 1. +// --------------------------------------------------------------------------- +{ + const outside = path.join(TMP, "outside-taxonomy.json"); + fs.copyFileSync(path.join(REPO_ROOT, "patterns", "taxonomy.json"), outside); + const r = run(["--project", SANDBOX, "--taxonomy", outside, "--json"]); + assert(r.exit === 2, "injection containment: --taxonomy outside --project root is exit 2", `exit=${r.exit}`); +} +{ + const r = run(["--project", SANDBOX, "--bp-dir", path.join("..", ".."), "--json"]); + assert(r.exit === 2, "injection containment: --bp-dir ../ escape is exit 2", `exit=${r.exit}`); +} +{ + const r = run(["--project", path.join(TMP, "does-not-exist")]); + assert(r.exit === 2, "nonexistent --project: exit 2", `exit=${r.exit}`); +} +{ + const r = run(["--bogus"]); + assert(r.exit === 2 && r.stderr.includes("--bogus"), "unknown argument: exit 2, named in stderr", `exit=${r.exit}`); +} +{ + // corrupt effective taxonomy is an INPUT failure -> exit 2 (never exit 1) + const broken = path.join(SANDBOX, "injected-broken.json"); + fs.writeFileSync(broken, "{ not json"); + const r = run(["--project", SANDBOX, "--taxonomy", broken, "--json"]); + assert(r.exit === 2, "corrupt injected taxonomy: exit 2 (input failure, not a violation)", `exit=${r.exit}`); + fs.rmSync(broken); +} + +// --------------------------------------------------------------------------- +// Summary. +// --------------------------------------------------------------------------- +fs.rmSync(TMP, { recursive: true, force: true }); +console.log(`\ntest-validate-bp-contract: ${pass} passed, ${fail} failed, ${skipped} skipped`); +if (fail > 0) { + console.error("\nFAILURES:"); + for (const f of failures) console.error(` ✗ ${f}`); + process.exit(1); +} +if (pass === 0) { + console.error("✗ zero checks executed (vacuous run)"); + process.exit(1); +} +console.log("✓ all validate-bp-contract checks passed");