From 8f428bdd414ef79210435872d610f5e9bc5dfc5e Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 15 Jun 2026 12:44:29 -0400 Subject: [PATCH] feat(1259-01): deterministic prohibition-enforcement producer + check route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Author src/prohibition-enforcement.cts: the test-tier prohibition PRODUCER/gate (ADR-550 D5d heavy half) locate wired check (node-test|lint-rule) -> confirm fail-first -> run -> build enforcementEvidence -> dispositionForProhibition pure/deterministic with injectable runCheck; missing/failing/non-fail-first -> hard-gate (both modes); passing -> green - Route check prohibition-enforcement in src/check-command-router.cts (same family as ui-plan-gate / tdd-review-checkpoint) - Add tests/prohibition-enforcement.test.cjs (behavioral, typed-field, injected runner) — new module within <=2 budget - Register the built bin/lib surface: docs/INVENTORY.md row + regenerated docs/INVENTORY-MANIFEST.json - .gitignore: add the emitted gsd-core/bin/lib/prohibition-enforcement.cjs (build artifact, ADR-457) - No src/probe-core.cts edit — the green/fail-closed policy seam already exists --- .gitignore | 1 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 1 + src/check-command-router.cts | 11 +- src/prohibition-enforcement.cts | 233 +++++++++++++++++++++++++ tests/prohibition-enforcement.test.cjs | 182 +++++++++++++++++++ 6 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 src/prohibition-enforcement.cts create mode 100644 tests/prohibition-enforcement.test.cjs diff --git a/.gitignore b/.gitignore index fed1bfbaf..6467ba791 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ build/ /gsd-core/bin/lib/plan-drift-guard.cjs /gsd-core/bin/lib/edge-probe.cjs /gsd-core/bin/lib/probe-core.cjs +/gsd-core/bin/lib/prohibition-enforcement.cjs /gsd-core/bin/lib/config-types.cjs /gsd-core/bin/lib/cli-exit.cjs /gsd-core/bin/lib/code-review-flags.cjs diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 2b593e958..c5f8d67bb 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -344,6 +344,7 @@ "profile-output.cjs", "profile-pipeline-command-router.cjs", "profile-pipeline.cjs", + "prohibition-enforcement.cjs", "project-root.cjs", "prompt-budget.cjs", "research-provider.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index eb7dfba87..e60245bf4 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -459,6 +459,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `research-provider.cjs` | Research provider waterfall, confidence tiers, and planResearch (cache-hits + fetch plan) | | `research-store.cjs` | Content-addressed research cache: sha256 keys, per-source TTL staleness, two-tier (user ~/.gsd / project .planning) store | | `probe-core.cjs` | Generic spec-phase probe resolution model (compiled from `src/probe-core.cts`, gitignored; ADR-550 Decision 7) — the status×verification re-cut (`status: resolved/dismissed/unresolved` × per-probe `verification`), `validateResolution`/`validateRequirement`, `analyzeCoverage(items, resolutions?, validators)` merge/rollup/orphan-reject, the `byVerification` rollup, and the `runProbeCli` I/O scaffold; the shared seam consumed by `edge-probe` (and the prohibition probe #644); exports `VALID_STATUS`, `validateResolution`, `validateRequirement`, `analyzeCoverage`, `runProbeCli` (#550) | +| `prohibition-enforcement.cjs` | Deterministic test-tier prohibition PRODUCER/gate (compiled from `src/prohibition-enforcement.cts`, gitignored; #1259, ADR-550 D5d "heavy half") — locates the wired mechanical check (`node-test` or `lint-rule`), confirms it is fail-first, runs it via an injectable runner, builds typed `enforcementEvidence`, and emits the `dispositionForProhibition` verdict; a passing wired check disposes green, a missing/failing/non-fail-first check hard-gates (flagged, non-green) in both interactive and autonomous modes; exports `runProhibitionEnforcement`, `routeProhibitionEnforcement`; CLI surface `gsd_run check prohibition-enforcement ` | | `review-reviewer-selection.cjs` | Reviewer selection/normalization helpers for `/gsd-review` default reviewer policy and precedence | | `roadmap-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools roadmap` | | `roadmap-parser.cjs` | ROADMAP.md parsing — milestone slicing, current-milestone extraction, phase/milestone lookups, milestone-phase filter (extracted from `core.cjs`, ADR-857) | diff --git a/src/check-command-router.cts b/src/check-command-router.cts index fbcc07806..b93c59b8a 100644 --- a/src/check-command-router.cts +++ b/src/check-command-router.cts @@ -24,6 +24,7 @@ const { getRoadmapPhaseWithFallback } = roadmapModule; // eslint-disable-next-line @typescript-eslint/no-require-imports import gapCheckerModule = require('./gap-checker.cjs'); const { runGapAnalysis } = gapCheckerModule; +import { routeProhibitionEnforcement } from './prohibition-enforcement.cjs'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -887,7 +888,15 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void { cmdVerifyCodebaseDrift(cwd, raw); return; } - error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND); + if (subcommand === 'prohibition-enforcement') { + // The deterministic test-tier prohibition PRODUCER/gate (#1259, ADR-550 D5d). Locates the + // wired mechanical check (node-test or lint-rule), confirms fail-first, runs it, builds + // enforcementEvidence, and emits the dispositionForProhibition verdict. Invocable as + // `gsd_run check prohibition-enforcement `. + routeProhibitionEnforcement(args, raw); + return; + } + error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND); } export = { diff --git a/src/prohibition-enforcement.cts b/src/prohibition-enforcement.cts new file mode 100644 index 000000000..1b7197343 --- /dev/null +++ b/src/prohibition-enforcement.cts @@ -0,0 +1,233 @@ +/** + * prohibition-enforcement — the deterministic PRODUCER for test-tier prohibition verification + * (#1259, ADR-550 Decision 5d "heavy half"; the D1 seam — a NEW deterministic gsd-tools + * sub-command, NOT free-form workflow prose). + * + * Today `dispositionForProhibition()` (src/probe-core.cts) already carries the POLICY seam: with + * non-empty `enforcementEvidence` AND `tier === 'test'` it returns `{ status: 'green' }` (the + * branch at probe-core 420-427); with empty evidence it fails closed to flagged-unverified. But + * NOTHING in the live pipeline ever produced `enforcementEvidence`, so the green branch was + * unreachable. This module is the missing producer: it LOCATES the wired mechanical check from a + * check descriptor, CONFIRMS it is fail-first (regression-must-fail-first), RUNS it, builds a typed + * `enforcementEvidence` array on PASS, and emits the `dispositionForProhibition` verdict as JSON. + * The green/fail-closed policy itself is untouched (no src/probe-core.cts edit). + * + * Accepts BOTH wired-check kinds (ADR-550 D2): a `node --test` negative test OR an existing + * lint/AST rule (e.g. the in-tree `no-source-grep` rule — the D4 dogfood anchor). A missing OR + * failing OR non-fail-first check hard-gates (flagged, non-green) in BOTH interactive and + * autonomous modes (ADR-550 D4 / D3) — never a silent green. + * + * Authored as strict TypeScript (`src/prohibition-enforcement.cts`) and compiled by + * `tsc -p tsconfig.build.json` (`npm run build:lib`) to the gitignored runtime artifact + * `gsd-core/bin/lib/prohibition-enforcement.cjs`. Do NOT hand-write the `.cjs`; it is emitted. + * + * The function is PURE/deterministic (same input -> same output, no LLM, mutation-survivable): the + * actual check execution is delegated to an injectable `runCheck` (defaults to a real runner) so + * the contract is unit-testable without spawning a process — mirroring the injectable I/O pattern + * in `runProbeCli` / `ProbeCliOptions`. + */ + +import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import core = require('./core.cjs'); +const { output, error, ERROR_REASON } = core; +import { dispositionForProhibition } from './probe-core.cjs'; +import type { ProhibitionDisposition } from './probe-core.cjs'; + +/** The two accepted wired-check kinds (ADR-550 D2). */ +export type CheckKind = 'node-test' | 'lint-rule'; + +/** + * A descriptor of the wired mechanical check that asserts the must-NOT. `kind` selects the + * runner family; `target` is the negative-test file path (node-test) or the rule id (lint-rule); + * `failFirst` records whether the check is a genuine `regression-must-fail-first` proof. + */ +export interface CheckDescriptor { + kind: CheckKind; + target: string; + failFirst?: boolean; +} + +/** The result a check-runner returns: whether the check is fail-first and whether it passed. */ +export interface CheckRunResult { + failFirst: boolean; + passed: boolean; +} + +/** A single typed enforcement-evidence record (the array `dispositionForProhibition` reads). */ +export interface EnforcementEvidence { + kind: CheckKind; + target: string; + failFirst: boolean; + passed: boolean; +} + +/** Injectable options for `runProhibitionEnforcement` (defaults wire to the real runner). */ +export interface EnforcementOptions { + /** Runs the located check; injected in tests so no real subprocess is spawned. */ + runCheck?: (check: CheckDescriptor) => CheckRunResult; + /** Verify mode — recorded for transparency; the hard-gate applies in BOTH modes (ADR-550 D4). */ + mode?: string; + /** Project root for the default real runner (defaults to process.cwd()). */ + cwd?: string; +} + +/** The producer's verdict: the disposition PLUS the located/kind/evidence provenance. */ +export interface EnforcementResult extends ProhibitionDisposition { + located: boolean; + kind: CheckKind | null; + evidence: EnforcementEvidence[]; + mode?: string; +} + +/** + * The default REAL check runner (used when no `runCheck` is injected). Deterministic per + * environment and guarded so a missing tool yields a non-passing result, NEVER an uncaught throw + * (the no-throw contract). A real run is fail-first by construction here — the descriptor's + * `failFirst` marker is the authoritative regression-must-fail-first signal the producer confirms. + * - node-test: runs `node --test `; exit 0 = passed. + * - lint-rule: runs `eslint --rule ': error' ` (or the repo's lint), exit 0 = passed. + */ +function defaultRunCheck(check: CheckDescriptor, cwd: string): CheckRunResult { + const failFirst = check.failFirst === true; + try { + if (check.kind === 'node-test') { + execFileSync('node', ['--test', check.target], { + cwd, + encoding: 'utf-8', + stdio: 'ignore', + windowsHide: true, + }); + return { failFirst, passed: true }; + } + // lint-rule: run the rule via eslint. A clean exit (0) means no violation -> the must-NOT holds. + execFileSync('npx', ['eslint', '--rule', `${check.target}: error`, check.target], { + cwd, + encoding: 'utf-8', + stdio: 'ignore', + windowsHide: true, + }); + return { failFirst, passed: true }; + } catch { + // Non-zero exit (violation surfaced) OR missing tool -> not passing. Never throw. + return { failFirst, passed: false }; + } +} + +/** + * LOCATE -> CONFIRM fail-first -> RUN -> build enforcementEvidence -> dispositionForProhibition. + * + * (1) LOCATE: if no check descriptor is locatable -> fail-closed (`dispositionForProhibition` with + * empty evidence) plus `{ located: false, kind: null, evidence: [] }`. + * (2) CONFIRM + RUN: confirm the descriptor is fail-first and run it via `runCheck`. A check that + * is not fail-first, that the runner reports not fail-first, or that FAILS -> fail-closed + * disposition with `located: true` (a real located miss, non-green, flagged) in BOTH modes. + * (3) PASS: build a typed `enforcementEvidence` array and call `dispositionForProhibition` — the + * non-empty array flips a test-tier item to green (the previously-unreachable branch). + * + * Pure/deterministic: same (prohibition, check, runCheck) -> same result. + */ +export function runProhibitionEnforcement( + prohibition: unknown, + check: CheckDescriptor | null | undefined, + options: EnforcementOptions = {}, +): EnforcementResult { + const mode = options.mode; + + // (1) LOCATE — no locatable wired check -> fail-closed, located: false. + if (!check || typeof check !== 'object' || typeof check.kind !== 'string' || typeof check.target !== 'string') { + const disposition = dispositionForProhibition(prohibition, { enforcementEvidence: [] }); + return { ...disposition, located: false, kind: null, evidence: [], ...(mode ? { mode } : {}) }; + } + + const runCheck = options.runCheck ?? ((c: CheckDescriptor) => defaultRunCheck(c, options.cwd ?? process.cwd())); + + // (2) CONFIRM fail-first + RUN. Descriptor must declare fail-first AND the runner must agree. + const descriptorFailFirst = check.failFirst === true; + const run = runCheck(check); + const failFirstConfirmed = descriptorFailFirst && run.failFirst === true; + const passed = failFirstConfirmed && run.passed === true; + + if (!passed) { + // FAIL / not-fail-first -> fail-closed, located: true (an actual located miss/fail). Hard-gate + // applies in BOTH modes; the disposition stays non-green / flagged. + const disposition = dispositionForProhibition(prohibition, { enforcementEvidence: [] }); + return { + ...disposition, + located: true, + kind: check.kind, + evidence: [], + ...(mode ? { mode } : {}), + }; + } + + // (3) PASS -> build typed enforcementEvidence and let the policy flip a test-tier item green. + const evidence: EnforcementEvidence[] = [{ + kind: check.kind, + target: check.target, + failFirst: true, + passed: true, + }]; + const disposition = dispositionForProhibition(prohibition, { enforcementEvidence: evidence }); + return { + ...disposition, + located: true, + kind: check.kind, + evidence, + ...(mode ? { mode } : {}), + }; +} + +/** + * Parse a `{ prohibition, check, mode }` request from a JSON file path or inline `--json` string. + * Returns null on any parse failure (the caller surfaces a structured error, never a throw). + */ +function parseRequest(args: string[]): { prohibition: unknown; check: CheckDescriptor | null; mode?: string } | null { + // args[0] = 'check', args[1] = 'prohibition-enforcement', args[2] = + const jsonFlagIdx = args.indexOf('--json'); + let payload = ''; + if (jsonFlagIdx !== -1 && typeof args[jsonFlagIdx + 1] === 'string') { + payload = args[jsonFlagIdx + 1]; + } else if (typeof args[2] === 'string' && args[2]) { + try { + payload = fs.readFileSync(args[2], 'utf-8'); + } catch { + return null; + } + } else { + return null; + } + try { + const parsed = JSON.parse(payload) as Record; + const checkRaw = parsed['check']; + const check: CheckDescriptor | null = (checkRaw && typeof checkRaw === 'object') + ? (checkRaw as CheckDescriptor) + : null; + const modeRaw = parsed['mode']; + const mode = typeof modeRaw === 'string' ? modeRaw : undefined; + return { prohibition: parsed['prohibition'] ?? null, check, ...(mode ? { mode } : {}) }; + } catch { + return null; + } +} + +/** + * CLI surface: `gsd_run check prohibition-enforcement ` (or `--json ''`). + * Parses the request, runs the producer, and emits the result as JSON. Honors the no-throw + * contract: malformed input -> structured `error(...)`, never an uncaught throw. + */ +export function routeProhibitionEnforcement(args: string[], raw: boolean): void { + const req = parseRequest(args); + if (!req) { + error( + 'prohibition-enforcement requires a JSON request: check prohibition-enforcement | --json \'{"prohibition":{...},"check":{...}}\'', + ERROR_REASON.SDK_MISSING_ARG, + ); + return; + } + const result = runProhibitionEnforcement(req.prohibition, req.check, req.mode ? { mode: req.mode } : {}); + output(result, raw, undefined); +} + +export {}; diff --git a/tests/prohibition-enforcement.test.cjs b/tests/prohibition-enforcement.test.cjs new file mode 100644 index 000000000..4353eb417 --- /dev/null +++ b/tests/prohibition-enforcement.test.cjs @@ -0,0 +1,182 @@ +// allow-test-rule: runtime-contract-is-the-product (#1259) — the test-tier enforcement PRODUCER is +// the deployed verify-time gate; these assertions pin its deterministic locate/fail-first/run/ +// evidence-construction contract to the code (ADR-550 D5d). +// +// Behavioral tests for the deterministic prohibition-enforcement producer (#1259, ADR-550 D5d +// "heavy half"). Requires the BUILT gsd-core/bin/lib/prohibition-enforcement.cjs — authored as +// src/prohibition-enforcement.cts and compiled by `npm run build:lib` (mirrors how the verify-tier +// suite requires the built probe-core.cjs). Typed-field assertions only; the check-runner is +// injected so no real subprocess is spawned. No source-grep. +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const ENFORCEMENT_LIB = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'prohibition-enforcement.cjs'); + +const TEST_TIER = Object.freeze({ + requirement_id: 'R1', + category: 'safety', + status: 'resolved', + verification: 'test', + resolution: null, + reason: null, + statement: 'MUST NOT read source files and text-search them in tests', +}); + +describe('prohibition-enforcement: deterministic test-tier producer (#1259 / ADR-550 D5d)', () => { + test('exports the producer + route functions', () => { + const enforce = require(ENFORCEMENT_LIB); + assert.equal(typeof enforce.runProhibitionEnforcement, 'function', + 'must export runProhibitionEnforcement (the deterministic producer)'); + assert.equal(typeof enforce.routeProhibitionEnforcement, 'function', + 'must export routeProhibitionEnforcement (the CLI surface)'); + }); + + test('locate-miss (no check descriptor) -> fail-closed, located:false, no evidence', () => { + const enforce = require(ENFORCEMENT_LIB); + const result = enforce.runProhibitionEnforcement(TEST_TIER, null, { + runCheck: () => ({ failFirst: true, passed: true }), + }); + assert.equal(result.located, false, 'no locatable check'); + assert.notEqual(result.status, 'green', 'locate-miss must never be green'); + assert.equal(result.flagged, true, 'locate-miss must be flagged'); + assert.equal(result.kind, null, 'no kind when nothing located'); + assert.ok(Array.isArray(result.evidence) && result.evidence.length === 0, 'no evidence on locate-miss'); + }); + + test('malformed check descriptor (missing target) -> treated as locate-miss', () => { + const enforce = require(ENFORCEMENT_LIB); + const result = enforce.runProhibitionEnforcement(TEST_TIER, { kind: 'node-test' }, { + runCheck: () => ({ failFirst: true, passed: true }), + }); + assert.equal(result.located, false, 'a descriptor without a target is not locatable'); + assert.notEqual(result.status, 'green'); + assert.equal(result.flagged, true); + }); + + test('node-test check that passes -> green + non-empty typed evidence', () => { + const enforce = require(ENFORCEMENT_LIB); + const result = enforce.runProhibitionEnforcement( + TEST_TIER, + { kind: 'node-test', target: 'tests/neg.test.cjs', failFirst: true }, + { runCheck: () => ({ failFirst: true, passed: true }) }, + ); + assert.equal(result.status, 'green'); + assert.equal(result.flagged, false); + assert.equal(result.tier, 'test'); + assert.equal(result.located, true); + assert.equal(result.kind, 'node-test'); + assert.equal(result.evidence.length, 1, 'one evidence record built'); + const ev = result.evidence[0]; + assert.equal(ev.kind, 'node-test'); + assert.equal(ev.target, 'tests/neg.test.cjs'); + assert.equal(ev.failFirst, true); + assert.equal(ev.passed, true); + }); + + test('lint-rule (no-source-grep) check that passes -> green', () => { + const enforce = require(ENFORCEMENT_LIB); + const result = enforce.runProhibitionEnforcement( + TEST_TIER, + { kind: 'lint-rule', target: 'no-source-grep', failFirst: true }, + { runCheck: () => ({ failFirst: true, passed: true }) }, + ); + assert.equal(result.status, 'green'); + assert.equal(result.flagged, false); + assert.equal(result.kind, 'lint-rule'); + assert.equal(result.evidence[0].kind, 'lint-rule'); + }); + + test('check that FAILS -> hard-gate (non-green, flagged), located:true, no evidence', () => { + const enforce = require(ENFORCEMENT_LIB); + const result = enforce.runProhibitionEnforcement( + TEST_TIER, + { kind: 'node-test', target: 'tests/neg.test.cjs', failFirst: true }, + { runCheck: () => ({ failFirst: true, passed: false }) }, + ); + assert.notEqual(result.status, 'green'); + assert.equal(result.flagged, true); + assert.equal(result.located, true, 'the check was located even though it failed'); + assert.equal(result.evidence.length, 0, 'a failing check builds no evidence'); + }); + + test('fail-first NOT satisfied (descriptor or runner) -> hard-gate, never green', () => { + const enforce = require(ENFORCEMENT_LIB); + // descriptor declares failFirst:false + const a = enforce.runProhibitionEnforcement( + TEST_TIER, + { kind: 'node-test', target: 'tests/neg.test.cjs', failFirst: false }, + { runCheck: () => ({ failFirst: false, passed: true }) }, + ); + assert.notEqual(a.status, 'green', 'not-fail-first is not a valid regression proof'); + assert.equal(a.flagged, true); + // descriptor says failFirst:true but runner reports failFirst:false -> still hard-gate + const b = enforce.runProhibitionEnforcement( + TEST_TIER, + { kind: 'node-test', target: 'tests/neg.test.cjs', failFirst: true }, + { runCheck: () => ({ failFirst: false, passed: true }) }, + ); + assert.notEqual(b.status, 'green', 'runner-reported not-fail-first must also hard-gate'); + assert.equal(b.flagged, true); + }); + + test('hard-gates in BOTH modes on a failing check (ADR-550 D4)', () => { + const enforce = require(ENFORCEMENT_LIB); + for (const mode of ['interactive', 'autonomous']) { + const result = enforce.runProhibitionEnforcement( + TEST_TIER, + { kind: 'node-test', target: 'tests/neg.test.cjs', failFirst: true }, + { runCheck: () => ({ failFirst: true, passed: false }), mode }, + ); + assert.notEqual(result.status, 'green', `non-green in ${mode}`); + assert.equal(result.flagged, true, `flagged in ${mode}`); + assert.equal(result.mode, mode, 'mode echoed for transparency'); + } + }); + + test('passing run echoes the requested mode without changing the green verdict', () => { + const enforce = require(ENFORCEMENT_LIB); + const result = enforce.runProhibitionEnforcement( + TEST_TIER, + { kind: 'node-test', target: 'tests/neg.test.cjs', failFirst: true }, + { runCheck: () => ({ failFirst: true, passed: true }), mode: 'autonomous' }, + ); + assert.equal(result.status, 'green', 'a passing wired check is green in autonomous mode too'); + assert.equal(result.mode, 'autonomous'); + }); + + test('routeProhibitionEnforcement parses a JSON request file and emits a structured result', (t) => { + const fs = require('node:fs'); + const { execFileSync } = require('node:child_process'); + // Write a request file; the route reads it and runs the node-test descriptor's default runner + // (its target does not exist, so it fail-closes deterministically — we assert the JSON SHAPE, + // not a green verdict). We invoke the built CLI surface in a child process so output() + // (writeAllSync to fd 1) is captured on stdout — no source-grep (we parse our own emitted JSON). + const dir = createTempDir('prohib-enf-'); + const reqPath = path.join(dir, 'req.json'); + const runnerPath = path.join(dir, 'runner.cjs'); + fs.writeFileSync(reqPath, JSON.stringify({ + prohibition: TEST_TIER, + check: { kind: 'node-test', target: 'tests/neg.test.cjs', failFirst: true }, + mode: 'autonomous', + })); + // A tiny runner that requires the BUILT module and invokes the route — output() writes to fd 1. + fs.writeFileSync(runnerPath, + "require(" + JSON.stringify(ENFORCEMENT_LIB) + ")" + + ".routeProhibitionEnforcement(['check','prohibition-enforcement'," + JSON.stringify(reqPath) + "], false);\n"); + t.after(() => cleanup(dir)); + + const captured = execFileSync('node', [runnerPath], { encoding: 'utf-8' }); + const parsed = JSON.parse(captured); + assert.equal(typeof parsed, 'object', 'route emits a JSON object'); + assert.equal(parsed.tier, 'test', 'tier is preserved through the CLI surface'); + assert.equal(parsed.located, true, 'the check descriptor was located'); + assert.equal(parsed.mode, 'autonomous', 'mode flows through the CLI surface'); + assert.equal(typeof parsed.flagged, 'boolean', 'flagged is a typed boolean'); + assert.ok(Array.isArray(parsed.evidence), 'evidence is an array'); + }); +});