diff --git a/.changeset/1278-prohibition-check-descriptor.md b/.changeset/1278-prohibition-check-descriptor.md new file mode 100644 index 000000000..d380cb9b7 --- /dev/null +++ b/.changeset/1278-prohibition-check-descriptor.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 1301 +--- +**The test-tier prohibition gate now has a deterministic SOURCE for its wired check** — a resolved `test`-tier `must_haves.prohibitions` item MAY carry an optional `check` descriptor authored at spec-phase: the flat-scalar keys `check_kind` (`node-test` | `lint-rule`), `check_target`, and `check_rule` (lint-rule only). `projectProhibitions` projects these scalars deterministically and verify-phase reads them back (via `descriptorFromProjection`) to locate the check handed to `check prohibition-enforcement` — so a wired, passing test closes the gap with **zero manual descriptor authoring** (previously the verify-phase LLM had to invent `{kind, target, rule}` each run, #1259). This extends the ADR-550 Decision 3 prohibition-item shape (ratified in a dated 2026-06-15 ADR-550 addendum). The descriptor is **optional and fully backward-compatible** — a prohibition with no descriptor parses and disposes byte-identically to today — and **fail-closed**: a partial, invalid, or absent descriptor falls through to the producer's existing fail-closed locate, never a silent green. The descriptor is represented as flat scalars (not a nested `check:{}` object) to keep the shared `parseMustHavesBlock` round-trip regression-free. Out of scope: machine-proven fail-first (#1279) and the `dispositionForProhibition` policy stay unchanged. (#1278) diff --git a/CONTEXT.md b/CONTEXT.md index e055a4474..83e371250 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -256,7 +256,7 @@ The orthogonal `verification` dimension a resolved probe item carries alongside The ownership seam between the prohibition probe and security/compliance tooling (ADR-550 D6). The probe owns **bespoke** product/values prohibitions — the unwritten must-NOTs specific to this feature's intent (e.g. "the streak reminder must not manipulate the user into returning"). When precision classifies an item as a **canon** security/compliance concern (OWASP / GDPR / fairness / prototype-pollution / path-traversal — the codified, cross-project rule sets), the probe does **not** mint a SPEC prohibition: it emits a one-line breadcrumb (*"possible canon-security concern X — owned by `/gsd:secure-phase` / eslint"*) and stops. Canon checks are **referred, not duplicated** — keeping the surfaced list short (#644's ~2–3-item precision goal) and the secure-phase boundary explicit. See Prohibition Probe Module. ### Prohibition Probe Module -Second adapter of the Probe Core Module (ADR-550 Decision 7): the spec-phase prohibition-completeness probe wired into spec-phase Step 5.6, surfacing the unwritten *must-NOT* constraints (values/safety/ethics) the spec never forbids. Unlike the Edge Probe, recall is **prose-orchestrated, not a compiled engine** (ADR-550 D7b) — a two-stage pass per requirement: Stage 1 an adversarial recall question, Stage 2 a one-pass precision classifier (drop routine engineering, keep genuine prohibitions). The code surface is schema/projection only: `projectProhibitions()` (deterministic SPEC↔`must_haves.prohibitions` projection backing the `DEFECT.GENERATIVE-FIX` parity assertion), the `{test, judgment}` `PROHIBITION_VALIDATORS`, `validateProhibitionResolution`, and `dispositionForProhibition()` (the fail-closed default — an unwired `test`-tier item resolves to `unverified`/flagged, never green). No `proposeProhibitions()` — recall is LLM prose. `plan-phase` lifts every resolved prohibition from the SPEC `## Prohibitions (must-NOT)` section into the `must_haves.prohibitions` sibling block (never `truths`). Exports (locked surface): `projectProhibitions`, `PROHIBITION_VALIDATORS` (the `{test, judgment}` validators bundle injected into `probe-core`'s generic engine), `validateProhibitionResolution`, and `dispositionForProhibition` (the fail-closed disposition) — the prohibition adapter surface, shipped from `probe-core` alongside the generic engine. Source of truth: `gsd-core/bin/lib/probe-core.cjs` (the prohibition exports live in `src/probe-core.cts`, gitignored per ADR-457) + `gsd-core/references/prohibition-probe.md`. Tests: `tests/prohibition-probe.*.test.cjs`. See ADR-550, Probe Core Module, Edge Probe Module, Verification Tier, Bespoke vs Canon Prohibition. +Second adapter of the Probe Core Module (ADR-550 Decision 7): the spec-phase prohibition-completeness probe wired into spec-phase Step 5.6, surfacing the unwritten *must-NOT* constraints (values/safety/ethics) the spec never forbids. Unlike the Edge Probe, recall is **prose-orchestrated, not a compiled engine** (ADR-550 D7b) — a two-stage pass per requirement: Stage 1 an adversarial recall question, Stage 2 a one-pass precision classifier (drop routine engineering, keep genuine prohibitions). The code surface is schema/projection only: `projectProhibitions()` (deterministic SPEC↔`must_haves.prohibitions` projection backing the `DEFECT.GENERATIVE-FIX` parity assertion), the `{test, judgment}` `PROHIBITION_VALIDATORS`, `validateProhibitionResolution`, and `dispositionForProhibition()` (the fail-closed default — an unwired `test`-tier item resolves to `unverified`/flagged, never green). **Deterministic test-tier locate (#1278, ADR-550 D3 addendum):** a resolved `test`-tier prohibition MAY carry an optional flat-scalar `check` descriptor — `check_kind` (`node-test` | `lint-rule`), `check_target`, and `check_rule` (lint-rule only) — that `projectProhibitions` emits into `must_haves.prohibitions` when well-formed, and `descriptorFromProjection()` (the read-back seam in the #1259 enforcement producer, `src/prohibition-enforcement.cts`) reconstructs into a `{kind, target, rule?}` `CheckDescriptor`, so verify-phase locates the wired check with zero LLM/author authoring. **Flat scalars, never a nested `check:{}` object** — so the round-trip rides the *unchanged* shared `parseMustHavesBlock` (the #644 no-parser-rewrite precedent); an absent/partial descriptor falls through to the producer's existing fail-closed locate, and `failFirst` stays caller-attested (machine-proof is #1279). No `proposeProhibitions()` — recall is LLM prose. `plan-phase` lifts every resolved prohibition from the SPEC `## Prohibitions (must-NOT)` section into the `must_haves.prohibitions` sibling block (never `truths`). Exports (locked surface): `projectProhibitions`, `PROHIBITION_VALIDATORS` (the `{test, judgment}` validators bundle injected into `probe-core`'s generic engine), `validateProhibitionResolution`, and `dispositionForProhibition` (the fail-closed disposition) — the prohibition adapter surface, shipped from `probe-core` alongside the generic engine. Source of truth: `gsd-core/bin/lib/probe-core.cjs` (the prohibition exports live in `src/probe-core.cts`, gitignored per ADR-457) + `gsd-core/references/prohibition-probe.md`. Tests: `tests/prohibition-probe.*.test.cjs`. See ADR-550, Probe Core Module, Edge Probe Module, Verification Tier, Bespoke vs Canon Prohibition. ### MVP Mode Phase-level planning mode that frames work as a vertical slice (UI → API → DB) of one user-visible capability instead of horizontal layers. Resolved at workflow init via the precedence chain: `--mvp` CLI flag → ROADMAP.md `**Mode:** mvp` field → `workflow.mvp_mode` config → false. All-or-nothing per phase (PRD #2826 Q1). Surfaced as `MVP_MODE=true|false` to the planner, executor, verifier, and discovery surfaces (progress, stats, graphify). Canonical parser: `roadmap.cjs` `**Mode:**` field; canonical resolution chain documented in `workflows/plan-phase.md`. Concept index: `references/mvp-concepts.md`. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 3d2ad7a5a..cdf2bf822 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -3173,6 +3173,8 @@ Each resolved prohibition carries a `verification` tier — `test` (a negative t The load-bearing wire is the `plan-phase` lift into `must_haves.prohibitions`, so the section is not merely documentation. +**Deterministic prohibition-check descriptor source (#1278).** A resolved `test`-tier prohibition MAY carry an optional **`check` descriptor** — the flat-scalar keys `check_kind` (`node-test` | `lint-rule`), `check_target`, and `check_rule` (lint-rule only) — authored at spec-phase. `projectProhibitions` projects these scalars deterministically and verify-phase reads them back to locate the check handed to `check prohibition-enforcement`, so a wired, passing test closes the gap with **zero manual descriptor authoring** (previously the verify-phase LLM had to invent `{kind, target, rule}` each run, #1259). The descriptor is **optional and backward-compatible** — a descriptor-less prohibition parses and disposes byte-identically to today — and **fail-closed**: a partial, invalid, or absent descriptor falls through to the producer's existing fail-closed locate, never a silent green. `failFirst` stays a verify-time caller attestation (machine-proven fail-first is tracked in #1279). + **Requirements:** - REQ-PROHIB-01: The prohibition pass MUST run after the edge probe and emit a `## Prohibitions (must-NOT)` SPEC section. - REQ-PROHIB-02: Stage 1 MUST ask the adversarial recall question; Stage 2 MUST drop routine-engineering items and keep values/safety/ethics prohibitions. diff --git a/docs/adr/550-spec-phase-probe-contract.md b/docs/adr/550-spec-phase-probe-contract.md index b76ebf637..1433af18c 100644 --- a/docs/adr/550-spec-phase-probe-contract.md +++ b/docs/adr/550-spec-phase-probe-contract.md @@ -96,3 +96,19 @@ Decision 4 describes the `test`-tier as a "**Hard gate in both interactive and a Net effect on D4: the *guarantee* ("a `test`-tier prohibition is never a silent pass") was preserved through the fail-closed-now half and is now joined by the genuine-execution half — a test-tier prohibition with a passing, non-vacuous wired check can reach `green`/`passed`, and a missing/failing one hard-gates. The previously-unreachable green branch in `dispositionForProhibition()` is reachable from the live pipeline, and the fail-closed default backs every miss/fail. The one remaining gap to D4's literal intent — *machine-proven* fail-first — is documented above as a tracked follow-up. The decision also lives in `src/probe-core.cts` comments, `src/prohibition-enforcement.cts`, `verify-phase.md`, and the #644 / #1259 changesets. This enforcement seam is the concrete instance of **ADR-857 open-question §147** — the deferred "deterministic CI conformance test for the verifier↔predicate contract." Per D6 it lands on the **core verify rail** (non-toggleable substrate), never in `capabilities/`: the verifier consuming a contract-shaped, deterministic predicate is core, not an opt-in capability. + +## Addendum (2026-06-15): optional `check` descriptor on the prohibition item — D3 shape extension (#1278) + +This ratifies the **deterministic SOURCE** for the test-tier `CheckDescriptor` that #1259 (PR #1273) left caller/verifier-supplied. #1259 shipped the PRODUCER (`check prohibition-enforcement`) that *runs* a wired check given a `{kind, target, rule?}` descriptor, but the descriptor itself was invented by the verify-phase LLM each run (the "locate" half). #1278 makes that locate half **deterministic**: an optional `check` descriptor is authored at spec-phase on the resolved `test`-tier prohibition, projected by `projectProhibitions`, and read back by verify-phase — so a wired, passing test closes the gap with **zero manual authoring**. This extends the **Decision 3 prohibition-item shape** (it adds optional keys to that item), so it is ratified here rather than rewriting D3 in place. + +1. **The D3 item shape gains OPTIONAL flat-scalar keys.** Alongside `statement`, `status`+`verification`, and the dismissed-only `reason`, a resolved `test`-tier prohibition MAY carry `check_kind` (`node-test` | `lint-rule`), `check_target`, and `check_rule` (lint-rule only). All three are **optional**; a descriptor-less prohibition parses and disposes byte-identically to today (backward compatibility, CHK-07). + +2. **Flat scalars — NOT a nested `check:{}` object (load-bearing).** The representation is three flat scalar keys, never a nested object. The shared `parseMustHavesBlock` (`src/frontmatter.cts:252`) is a **flat parser**: continuation lines under a list item are handled only as nested ARRAYS or scalar `key: value` pairs, and the `reconstructFrontmatter` serializer is deliberately lossy for nested object-lists. A nested `check:{}` would flatten its keys into the parent item and mangle the round-trip. We follow the #644 precedent — **no parser rewrite, no `parseMustHavesBlock` change** — so the `truths`/`artifacts`/`key_links` shared-parser readers stay regression-free (the untouched frontmatter suite is the proof). + +3. **Deterministic projection + read-back.** `projectProhibitions` (`src/probe-core.cts`) emits the scalar keys ONLY for a well-formed descriptor (valid `check_kind` + non-empty `check_target`; `check_rule` only on the lint-rule path). `descriptorFromProjection` (`src/prohibition-enforcement.cts`) reads them back into a `{ kind: check_kind, target: check_target, rule?: check_rule }` `CheckDescriptor` to build the producer request. The `CheckDescriptor` type itself is unchanged; `failFirst` is **NOT** sourced from the projection — it stays a verify-time caller attestation. verify-phase locates from the projection, so a wired passing test needs no hand-authored descriptor. + +4. **Fail-closed on partial / invalid / absent descriptor.** A `lint-rule` descriptor missing `rule`, an unknown `kind`, or an absent descriptor on a test-tier prohibition MUST fall through to the producer's existing fail-closed locate ("no well-formed check descriptor is locatable → fail-closed") — never a silent green. The producer's locate semantics from #1259 are unchanged. + +5. **Out of scope (unchanged boundaries).** Machine-proven fail-first (a violation-fixture / RuleTester-invalid proof replacing the `failFirst` caller attestation) stays tracked as **#1279**. The `dispositionForProhibition` green/fail-closed **policy** is untouched. No new check kinds are added. + +Net effect on D3: the prohibition-item shape is extended with three optional, backward-compatible flat-scalar keys that give the test-tier locate a deterministic spec-phase source; the contract's CI-testable surface (D5) gains the projection round-trip parity (CHK-03), the fail-closed guard (CHK-06), and the byte-stable backward-compat fixture (CHK-07). The decision also lives in `src/probe-core.cts` / `src/prohibition-enforcement.cts` comments, the `verify-phase.md` / `spec-phase.md` prose, and the #1278 changeset. diff --git a/gsd-core/references/prohibition-probe.md b/gsd-core/references/prohibition-probe.md index 2cebfa403..e30ac4dc8 100644 --- a/gsd-core/references/prohibition-probe.md +++ b/gsd-core/references/prohibition-probe.md @@ -128,16 +128,51 @@ Splitting these axes keeps the lifecycle enum free of a verification fact and le prohibition adapter declare `test | judgment` without forking the shared lifecycle enum that the edge-probe's `explicit | backstop` also uses. +## Optional wired-check descriptor (deterministic locate, #1278) + +A `resolved`/`test`-tier prohibition MAY carry an **optional `check` descriptor** that names +the wired mechanical check, so verify-phase locates it deterministically instead of inventing +`{kind, target, rule}` each run. The descriptor is captured at spec-phase (soft / optional — +the author wires it when the negative test or lint rule already exists) and is represented as +**three flat scalar keys** on the `must_haves.prohibitions` item — never a nested `check: {}` +object: + +- `check_kind` — `node-test` | `lint-rule` (which producer mechanism runs the check). +- `check_target` — the test file (`node-test`) or the file the rule runs against (`lint-rule`). +- `check_rule` — the `ruleId` to filter on, **lint-rule only** (absent for `node-test`). + +The flat-scalar shape is load-bearing: the shared `parseMustHavesBlock` is a flat parser and a +nested object would flatten/mangle the round-trip (ADR-550 2026-06-15 addendum; #644 "no parser +rewrite" precedent). `projectProhibitions` emits these keys **only for a well-formed descriptor** +(valid `check_kind` + non-empty `check_target`; `check_rule` only on the lint-rule path), and +verify-phase reads them back via `descriptorFromProjection` into the `CheckDescriptor` handed to +`check prohibition-enforcement`. A wired, passing test then closes the gap with **zero manual +descriptor authoring**. + +**Fail-closed + backward-compat.** A partial descriptor (`lint-rule` missing `check_rule`), an +unknown `check_kind`, or an **absent** descriptor on a test-tier prohibition falls through to the +producer's existing fail-closed locate — never a silent green. A prohibition with no descriptor +parses and disposes byte-identically to today. `failFirst` is **not** sourced from the +descriptor — it stays a verify-time caller attestation (machine-proven fail-first is tracked in +#1279; the `dispositionForProhibition` policy is unchanged). + ## Output schema The probe emits, per kept prohibition, an item of the form: ``` -{ requirement_id, category, status, verification, resolution, reason, statement } +{ requirement_id, category, status, verification, resolution, reason, statement, + check_kind?, check_target?, check_rule? } ``` where `statement` is the must-NOT sentence and `category` is the values/safety/ethics class -(`values`, `fairness`, `privacy`, `transparency`, `safety`, …), plus a coverage summary: +(`values`, `fairness`, `privacy`, `transparency`, `safety`, …). The optional **flat-scalar +`check_*` descriptor** (#1278) is present only on a resolved `test`-tier prohibition carrying a +wired check: `check_kind` (`node-test` | `lint-rule`), `check_target`, and `check_rule` (lint-rule +only). `projectProhibitions` emits these into `must_haves.prohibitions` and `descriptorFromProjection` +reads them back into a `{ kind, target, rule? }` `CheckDescriptor`; they are flat scalars (never a +nested `check:{}` object) so they round-trip through the unchanged `parseMustHavesBlock`. Plus a +coverage summary: ``` coverage: { applicable, resolved, unresolved, byVerification: { test, judgment } } diff --git a/gsd-core/workflows/spec-phase.md b/gsd-core/workflows/spec-phase.md index 9a93c6758..7a6d49bde 100644 --- a/gsd-core/workflows/spec-phase.md +++ b/gsd-core/workflows/spec-phase.md @@ -356,6 +356,21 @@ For each Requirement gathered so far, run the two-stage recall→precision pass: Criteria AND mark the prohibition `resolved` with a verification tier: `test` (a mechanical negative test/lint/assertion exists) or `judgment` (real but not mechanically checkable — routes to judgment review). + - **Capture the wired-check descriptor on `test`-tier (#1278, SOFT).** When a prohibition is + resolved `verification: test`, ALSO capture the descriptor of the wired check so + `verify-phase` can LOCATE it deterministically (no verifier invention at verify time). + Capture the flat scalars — persisted into SPEC and projected onto the + `must_haves.prohibitions` item by `projectProhibitions`: + - `check_kind` — `node-test` | `lint-rule`. + - `check_target` — the negative-test file path (for `node-test`), or the path to lint + (for `lint-rule`). + - `check_rule` — the eslint rule id (e.g. `local/no-source-grep`); `lint-rule` only. + This is a **SOFT capture (CHK-04): a `test`-tier prohibition WITHOUT a descriptor is still + allowed** — if the author cannot yet name the wired check, leave the descriptor empty and + proceed. It is NOT a hard authoring block; the item simply stays fail-closed/flagged + downstream (an absent/partial descriptor → `descriptorFromProjection` null/under-specified + → producer fail-closed locate, never green). Do NOT capture `failFirst` here — it is a + verify-time caller attestation, not a spec-authored field (#1279). - **Dismiss (reason)** → mark `dismissed` with a REQUIRED non-empty reason (PROB-05). The reason string is the audit trail; silence is not a valid dismissal. - **Defer** → leave `unresolved`. @@ -374,7 +389,11 @@ For each Requirement gathered so far, run the two-stage recall→precision pass: **`--auto` mode:** auto-`resolved` where a defensible negative acceptance criterion can be written (test or judgment tier); otherwise leave `unresolved`. **`--auto` NEVER auto-dismisses a prohibition** — a wrong dismissal is the exact silent failure this probe eliminates (PROB-06, -the load-bearing safety property). Log: `[auto] prohibitions: R resolved, U unresolved`. +the load-bearing safety property). On a `test`-tier auto-resolution, capture the `check_kind` / +`check_target` / `check_rule` descriptor **only when a wired check is unambiguous**; otherwise +leave it empty — `--auto` NEVER fabricates a check path (a wrong locate is re-validated and +fails closed at the producer, but a fabricated path is still noise to avoid). Log: +`[auto] prohibitions: R resolved, U unresolved`. **Text mode (PROB-09):** per Step 5's text-mode rule, replace the AskUserQuestion menus above with plain-text numbered lists — there is NO hard AskUserQuestion dependency, so the probe @@ -382,7 +401,11 @@ runs identically for non-Claude / text-mode hosts. Populate the `## Prohibitions` section of SPEC.md from the resolved prohibitions (each `resolved`/`test` row is a checkable negative acceptance criterion; `resolved`/`judgment` -rows route to judgment review; `⚠ UNRESOLVED` rows are flagged as assumptions). +rows route to judgment review; `⚠ UNRESOLVED` rows are flagged as assumptions). A +`resolved`/`test` row ALSO carries its captured `check_kind` / `check_target` / `check_rule` +descriptor when present (so the projection feeds `verify-phase`'s deterministic locate, #1278); +a `test` row with no captured descriptor is still valid — it stays fail-closed/flagged +downstream rather than blocking authoring. ## Step 6: Generate SPEC.md diff --git a/gsd-core/workflows/verify-phase.md b/gsd-core/workflows/verify-phase.md index fa556c2a8..0fbdc4ff2 100644 --- a/gsd-core/workflows/verify-phase.md +++ b/gsd-core/workflows/verify-phase.md @@ -70,17 +70,17 @@ Aggregate all must_haves across plans for phase-level verification. **Prohibitions (`must_haves.prohibitions`, ADR-550 D3 — the must-NOT sibling block):** When a plan carries `must_haves.prohibitions`, extract each `{ statement, status, verification }` item and route it by `verification` tier in verdict assembly (ADR-550 D4, "B-with-guard", 2026-06-12 maintainer decision). These are NEGATIVE checks (the must-NOT must NOT have happened), distinct from positive `truths`: - **judgment-tier → mode-dependent soft-gate.** Interactive verify defers each item to the end-of-phase human checkpoint (`human_verify_mode: end-of-phase`). Autonomous verify records a NON-AUTHORITATIVE LLM-judge verdict + a prominent `unverified-prohibition — human review recommended` flag (autonomous completion reads "complete with N flagged prohibitions"). NEVER a silent pass; NEVER a hard halt of an AFK run. -- **test-tier → ENFORCED via `check prohibition-enforcement` (green on pass, hard-gate on miss/fail).** Accept the `verification: test` value (the SPEC↔must_haves.prohibitions projection contract holds — no forced schema change later). For each test-tier item, the verifier invokes the deterministic producer: +- **test-tier → ENFORCED via `check prohibition-enforcement` (green on pass, hard-gate on miss/fail).** Accept the `verification: test` value (the SPEC↔must_haves.prohibitions projection contract holds — no forced schema change later). For each test-tier item, the verifier builds `request.check` **DETERMINISTICALLY from the projected descriptor** — it does NOT invent `{ kind, target, rule }`. Read the flat scalar keys `check_kind` / `check_target` / `check_rule` off the `must_haves.prohibitions` item and reconstruct the `CheckDescriptor` via the `descriptorFromProjection` adapter in `prohibition-enforcement` (`descriptorFromProjection(projectedItem)` → `{ kind: check_kind, target: check_target, rule?: check_rule }`). Then attest `failFirst: true` in the request — `failFirst` is the ONE field NOT sourced from the projection; it stays a verify-time caller attestation (#1279 machine-proves it against a violation fixture). Invoke the producer (CLI surface unchanged): ```bash gsd_run check prohibition-enforcement ``` - where `` carries `{ prohibition, check, mode }` — `check` being the wired mechanical-check descriptor `{ kind: 'node-test' | 'lint-rule', target, rule?, failFirst: true }`. For `node-test`, `target` is the negative-test file path; for `lint-rule`, `target` is the PATH to lint and `rule` is the eslint rule id (e.g. `local/no-source-grep`) — both required (a lint-rule without `rule` is not a valid wired check). The producer LOCATES the wired check, requires the caller-attested `failFirst` marker, RUNS it for a genuine non-vacuous pass, builds `enforcementEvidence`, and emits the `dispositionForProhibition()` verdict (#1259, ADR-550 D5d). (`failFirst` is caller-attested, not yet machine-proven against a violation fixture — a tracked follow-up.) Route the result by its typed fields: + where `` carries `{ prohibition, check, mode }` — `check` being the wired mechanical-check descriptor `{ kind: 'node-test' | 'lint-rule', target, rule?, failFirst: true }`, with `kind`/`target`/`rule` now sourced from the projected `check_*` scalars (not author/verifier invention) and `failFirst` caller-attested. For `node-test`, `target` (from `check_target`) is the negative-test file path; for `lint-rule`, `target` is the PATH to lint and `rule` (from `check_rule`) is the eslint rule id (e.g. `local/no-source-grep`) — both required (a lint-rule without `rule` is not a valid wired check). The producer LOCATES the wired check, requires the caller-attested `failFirst` marker, RUNS it for a genuine non-vacuous pass, builds `enforcementEvidence`, and emits the `dispositionForProhibition()` verdict (#1259, ADR-550 D5d). Route the result by its typed fields: - **`status: 'green'`, `flagged: false`** (a genuinely-passing wired negative test / lint rule, `located: true`, non-empty `evidence`) → the item is satisfiable → it can reach **passed**. - **missing, non-attested, or genuinely-non-passing check** (`located: false` OR `status: 'unverified'`, `flagged: true`) → **hard-gate**: disposes flagged-unverified, NEVER green, routing to `gaps_found` in BOTH interactive and autonomous modes (a failing mechanical check blocks even AFK; ADR-550 D4 / D3). The deterministic fail-closed default backing every miss/fail is `dispositionForProhibition()` in probe-core (`status: 'unverified'`, `flagged: true` on empty `enforcementEvidence`). - > **Descriptor authoring — current scope (#1259).** The `check` descriptor is **supplied by the phase author / verifier**; there is no projection field yet that deterministically derives `{ kind, target, rule, failFirst }` from a prohibition in `must_haves.prohibitions` (which carries only `{ statement, status, verification }`). So #1259 lands the **deterministic run+verdict half** (locate→run→evidence→disposition, all CI-testable) while the **locate→descriptor half is author-provided** for now. Deterministic auto-locate — so a wired passing test closes the gap with zero manual descriptor authoring — is a **tracked follow-up: #1278**. Until then, the green path requires the author to wire the descriptor explicitly. + > **Descriptor source — deterministic locate (#1278, DELIVERED).** The `check` descriptor's `{ kind, target, rule }` is now sourced **deterministically from the projected `check_kind` / `check_target` / `check_rule` scalars** on the `must_haves.prohibitions` item (authored at `/gsd:spec-phase`, projected by `projectProhibitions`, read back via the `descriptorFromProjection` adapter). So a wired passing test closes the gap with **zero manual descriptor authoring** — the verifier no longer invents the locate (removing the spoofable invent-at-verify-time surface; ADR-857 §147 exogenous grading). **Fail-closed is preserved:** an item with NO projected descriptor — or a PARTIAL one (e.g. a `lint-rule` missing `check_rule`) — makes `descriptorFromProjection` return `null` / an under-specified descriptor, which falls through to the producer's existing fail-closed LOCATE (`located: false`) → flagged-unverified, NEVER green, in BOTH modes. The only field still attested at verify time (not projected) is `failFirst`; machine-proving it against a violation fixture is the remaining **tracked follow-up: #1279**. **Option B: Use Success Criteria from ROADMAP.md** diff --git a/src/probe-core.cts b/src/probe-core.cts index 85a39f395..7a924fe94 100644 --- a/src/probe-core.cts +++ b/src/probe-core.cts @@ -290,6 +290,15 @@ export interface Prohibition { resolution: string | null; reason: string | null; statement: string; + // Optional flat-scalar wired-check descriptor (#1278). A resolved test-tier prohibition may carry + // these before projection; `projectProhibitions` emits them as the LOCKED flat scalar keys + // `check_kind`/`check_target`/`check_rule` that round-trip the EXISTING flat `parseMustHavesBlock` + // (a nested `check:{}` object is rejected per IMPL-SCOPING §3 — it flattens through the shared + // parser). These mirror `CheckDescriptor.kind/target/rule` (prohibition-enforcement.cts:62) MINUS + // the caller-attested `failFirst`, which is deliberately NOT a Prohibition field (#1279). + check_kind?: 'node-test' | 'lint-rule'; + check_target?: string; + check_rule?: string; } /** @@ -329,6 +338,15 @@ export function validateProhibitionResolution(resolution: Resolution `null`. + * - `check_kind` ABSENT -> `null` (no descriptor -> producer locates nothing -> fail-closed). + * - `check_kind` present -> `{ kind: check_kind, target: check_target }`, adding `rule: check_rule` + * ONLY when `check_rule` is a non-empty string. + * - `failFirst` is NEVER sourced from the projection — it stays a verify-time caller attestation + * (#1279 machine-proves it; out of scope here). The returned descriptor carries no `failFirst`. + * - The adapter does NOT strictly validate kind/target/rule: it faithfully reconstructs whatever + * scalars are present (e.g. `{check_kind:'lint-rule', check_target:'src/'}` with no `check_rule` + * reconstructs to `{kind:'lint-rule', target:'src/'}`), letting the existing LOCATE guard reject + * an under-specified descriptor (located:false, never green). It does NOT re-implement that guard. + * - Pure, deterministic, no-throw. + */ +export function descriptorFromProjection( + projected: Record | null | undefined, +): CheckDescriptor | null { + if (!projected || typeof projected !== 'object') return null; + if (!('check_kind' in projected)) return null; + // The shared `parseMustHavesBlock` (src/frontmatter.cts) coerces /^\d+$/ scalar values to NUMBERS on + // round-trip, so a numeric-looking check_kind/check_target/check_rule arrives here as a number. Normalize + // ONLY string|number scalars back to string — a non-scalar (object/array/bool) or absent value yields '' + // (never an `[object Object]` stringification, no `as string` lie over a number). This keeps the + // descriptor honestly typed and the round-trip lossless across the full string domain; an under-specified + // '' target/kind is rejected by the producer's locate guard (fail-closed; never green). + const scalar = (v: unknown): string => + typeof v === 'string' ? v : typeof v === 'number' ? String(v) : ''; + const kind = scalar(projected.check_kind) as CheckKind; + const target = scalar(projected.check_target); + const descriptor: CheckDescriptor = { kind, target }; + // `rule` belongs only to the lint-rule kind; a stray check_rule on a node-test descriptor is dropped + // (the projector never emits one there — defense in depth). failFirst is NOT sourced here (#1279). + if (kind === 'lint-rule') { + const rule = scalar(projected.check_rule); + if (rule.trim().length > 0) descriptor.rule = rule; + } + return descriptor; +} + /** * The result a check-runner returns: whether the check genuinely, non-vacuously PASSED. The runner * reports only what it can OBSERVE (a real pass) — it does NOT determine fail-first. Whether the diff --git a/tests/probe-core.property.test.cjs b/tests/probe-core.property.test.cjs index a7a7de064..003ea4858 100644 --- a/tests/probe-core.property.test.cjs +++ b/tests/probe-core.property.test.cjs @@ -28,6 +28,11 @@ const fc = require('./helpers/fast-check-setup.cjs'); const BUILT_SCRIPT = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'probe-core.cjs'); const pc = require(BUILT_SCRIPT); +// #1278: the check-descriptor deterministic-locate round-trip crosses three modules — probe-core's +// projector, the shared flat parser, and the enforcement read-back adapter. Require all three here so +// the property exercises the real end-to-end chain (not a stubbed seam). +const fm = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'frontmatter.cjs')); +const enforce = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'prohibition-enforcement.cjs')); // The same representative validators bundle the edge adapter injects (see // tests/probe-core.test.cjs) — exercises the generic engine independent of any one probe. @@ -165,3 +170,102 @@ describe('probe-core property: orphan rejection is stable', () => { ); }); }); + +// ─── #1278: the check-descriptor deterministic-locate round-trip (property-based) ──────────────── +// trek-e re-review (RULESET.TESTS.property-based-testing): the projectProhibitions -> render -> +// parseMustHavesBlock -> descriptorFromProjection chain is a bijective/transformation contract. The +// example suite (tests/prohibition-probe.schema.test.cjs CHK-03 A/B/C) pins three hand-picked rows; +// these properties pin the invariant across the FULL input domain — including the parseMustHavesBlock +// numeric-coercion case (a /^\d+$/ scalar parses back as a number; descriptorFromProjection +// String()-normalizes it) and the under-specified fail-closed cases. The "stable" contract is +// expressed at the descriptorFromProjection reconstruction layer, because the raw parse step is +// intentionally lossy for numeric scalars (the shared parser coerces; #1278 does not change it). + +// Mirror of the schema test's renderProhibitionsDoc: flat scalar continuation KVs, emitted only when +// present (src/frontmatter.cts:344 reads them back as scalar `key: value` lines). +function renderProhibitionsDoc(entries) { + const lines = ['---', 'phase: 01-x', 'plan: 01', 'must_haves:', ' prohibitions:']; + for (const e of entries) { + lines.push(` - statement: "${e.statement}"`); + lines.push(` status: ${e.status}`); + if (e.verification !== undefined) lines.push(` verification: ${e.verification}`); + if (e.reason !== undefined) lines.push(` reason: "${e.reason}"`); + if (e.check_kind !== undefined) lines.push(` check_kind: ${e.check_kind}`); + if (e.check_target !== undefined) lines.push(` check_target: ${e.check_target}`); + if (e.check_rule !== undefined) lines.push(` check_rule: ${e.check_rule}`); + } + lines.push('---', '', 'Body.', ''); + return lines.join('\n'); +} + +const BASE_TIER = Object.freeze({ + requirement_id: 'R1', category: 'safety', status: 'resolved', verification: 'test', + resolution: null, reason: null, statement: 'MUST NOT do the forbidden thing', +}); +const KIND_ARB = fc.constantFrom('node-test', 'lint-rule'); +// Path-like scalar that is NEVER pure-digit (so the flat parser does not numeric-coerce it) — models +// realistic targets / rule-ids. The renderer is unquoted, so the charset excludes whitespace, quotes +// and colons that the flat continuation-KV regex would not round-trip. +const PATH_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-'.split(''); +const pathScalarArb = fc.array(fc.constantFrom(...PATH_CHARS), { minLength: 1, maxLength: 24 }) + .map((chars) => chars.join('')) + .filter((s) => /\D/.test(s)); // ≥1 non-digit → stays a string through parseMustHavesBlock +// Canonical integer string — exercises the numeric-coercion path (render `key: 12345` -> parse coerces +// to NUMBER -> descriptorFromProjection String()-normalizes back). Capped well under MAX_SAFE_INTEGER, +// no leading zeros, so the integer round-trips exactly. +const numericScalarArb = fc.nat({ max: 9999999 }).map(String); +const targetArb = fc.oneof(pathScalarArb, numericScalarArb); + +// A fully well-formed descriptor item (resolved test-tier); node-test carries no rule. +const wellFormedArb = KIND_ARB.chain((kind) => + fc.record({ target: targetArb, rule: pathScalarArb }).map(({ target, rule }) => { + const item = { ...BASE_TIER, check_kind: kind, check_target: target }; + if (kind === 'lint-rule') item.check_rule = rule; + return { item, kind, target, rule: kind === 'lint-rule' ? rule : undefined }; + }), +); + +describe('probe-core property: #1278 check-descriptor round-trip is deterministic across the full string domain', () => { + test('a well-formed descriptor survives project -> render -> parse -> descriptorFromProjection (incl. numeric coercion); target/rule reconstruct as strings', () => { + fc.assert( + fc.property(wellFormedArb, ({ item, kind, target, rule }) => { + const projected = pc.projectProhibitions([item]); + if (projected[0].check_kind !== kind) return false; // projector emits the descriptor + const reparsed = fm.parseMustHavesBlock(renderProhibitionsDoc(projected), 'prohibitions'); + const d = enforce.descriptorFromProjection(reparsed[0]); + if (!d || d.kind !== kind) return false; + // target is string-normalized even when parseMustHavesBlock numerically coerced it. + if (typeof d.target !== 'string' || d.target !== target) return false; + if (kind === 'lint-rule') { + return typeof d.rule === 'string' && d.rule === rule; + } + return !('rule' in d); // a node-test descriptor never carries a rule + }), + ); + }); +}); + +// Under-specified / invalid projected descriptors: the deterministic-locate contract is fail-CLOSED — +// the adapter + the producer's existing locate guard must NEVER green and ALWAYS flag, even when the +// (injected) runner would report a pass. +const malformedArb = fc.oneof( + fc.constant({ ...BASE_TIER }), // absent descriptor (no check_*) + KIND_ARB.map((kind) => ({ ...BASE_TIER, check_kind: kind })), // valid kind, NO target + pathScalarArb.map((t) => ({ ...BASE_TIER, check_kind: 'lint-rule', check_target: t })), // lint-rule, NO rule + fc.record({ k: fc.constantFrom('shell-script', 'bash', 'python', 'exec', ''), t: targetArb }) + .map(({ k, t }) => ({ ...BASE_TIER, check_kind: k, check_target: t })), // unknown kind +); + +describe('probe-core property: #1278 under-specified descriptor is always fail-closed (never green)', () => { + test('an absent / target-less / rule-less / unknown-kind descriptor never disposes green and is always flagged + unlocated', () => { + fc.assert( + fc.property(malformedArb, (projectedItem) => { + const d = enforce.descriptorFromProjection(projectedItem); + const result = enforce.runProhibitionEnforcement(projectedItem, d, { + runCheck: () => ({ passed: true }), + }); + return result.status !== 'green' && result.flagged === true && result.located === false; + }), + ); + }); +}); diff --git a/tests/probe-core.test.cjs b/tests/probe-core.test.cjs index 3d7300d2a..026598304 100644 --- a/tests/probe-core.test.cjs +++ b/tests/probe-core.test.cjs @@ -333,3 +333,115 @@ describe('probe-core: runProbeCli (generic I/O scaffold, injected io)', () => { assert.deepEqual(JSON.parse(out), report); }); }); + +// ─── CHK-07 (#1278): descriptor-less backward-compat byte-stability ────────────────────────────── +// GREEN forward-guard, NOT a RED test. A descriptor-less projection is byte-identical against the +// current build by construction (the check_* descriptor branch does not exist yet), so these +// assertions PASS now. Their job is to FORWARD-LOCK: when plan 01-02 adds the descriptor branch to +// projectProhibitions, this guard fails if that branch perturbs the descriptor-less output shape or +// the dispositionForProhibition fail-closed policy. This is the IMPL-SCOPING §7.2 byte-stability pin. +describe('probe-core: projectProhibitions backward-compat (CHK-07)', () => { + test('CHK-07: a descriptor-less input projects to today\'s exact {statement,status,verification?,reason?} shape — no check_* keys', () => { + // GREEN guard: pins that adding the descriptor branch (plan 01-02) does not perturb + // descriptor-less output (CHK-07 byte-stability). Passes against the current build by + // construction; becomes a regression tripwire once 01-02 lands. + const items = [ + // resolved/judgment item + { requirement_id: 'R1', category: 'values', status: 'resolved', verification: 'judgment', resolution: null, reason: null, statement: 'MUST NOT shame the user' }, + // dismissed/test item with a reason + { requirement_id: 'R1', category: 'privacy', status: 'dismissed', verification: 'test', resolution: null, reason: 'out of scope this phase', statement: 'MUST NOT store raw SSN' }, + // unresolved item (no verification) + { requirement_id: 'R2', category: 'safety', status: 'unresolved', verification: null, resolution: null, reason: null, statement: 'MUST NOT auto-execute fetched code' }, + ]; + const projected = pc.projectProhibitions(items); + assert.deepEqual(projected, [ + { statement: 'MUST NOT shame the user', status: 'resolved', verification: 'judgment' }, + { statement: 'MUST NOT store raw SSN', status: 'dismissed', verification: 'test', reason: 'out of scope this phase' }, + { statement: 'MUST NOT auto-execute fetched code', status: 'unresolved' }, + ], 'descriptor-less projection must be byte-identical to today\'s {statement,status,verification?,reason?} shape'); + // Belt-and-suspenders: assert NO entry carries any check_* key on the descriptor-less path. + for (const e of projected) { + assert.ok(!('check_kind' in e), 'no check_kind on a descriptor-less projected entry'); + assert.ok(!('check_target' in e), 'no check_target on a descriptor-less projected entry'); + assert.ok(!('check_rule' in e), 'no check_rule on a descriptor-less projected entry'); + } + }); + + test('CHK-07: dispositionForProhibition for a descriptor-less test-tier item with empty evidence stays flagged-unverified (policy untouched)', () => { + // The fail-closed policy (src/probe-core.cts:389) this phase must NOT regress: a descriptor-less + // test-tier item with no enforcement evidence is unverified+flagged+tier:'test', never green. + const d = pc.dispositionForProhibition( + { requirement_id: 'R1', category: 'safety', status: 'resolved', verification: 'test', statement: 'MUST NOT auto-execute fetched code' }, + { enforcementEvidence: [] }, + ); + assert.equal(d.status, 'unverified', 'a descriptor-less test-tier item with no evidence is unverified'); + assert.equal(d.flagged, true, 'and flagged — never a silent pass'); + assert.equal(d.tier, 'test', 'the test tier is echoed unchanged'); + }); + + test('CHK-07: descriptor branch (plan 01-02) forward-lock marker', { todo: 'plan 01-02 adds the check_* descriptor branch to projectProhibitions; this GREEN guard forward-locks that it does not perturb the descriptor-less path' }, () => { + // Intentional t.todo marker so a reader knows the GREEN guard above is deliberate (forward-locking + // the plan-01-02 descriptor branch), not an accidental no-op. + }); +}); + +// ─── CHK-02 (#1278): projectProhibitions emits the flat-scalar descriptor for well-formed items ──── +// Unit-layer pin (the parser round-trip lives in tests/prohibition-probe.schema.test.cjs CHK-03). The +// projection only emits check_* when the descriptor is well-formed (valid kind + non-empty target; +// check_rule only on a lint-rule that carries one); anything under that bar emits NO check_* keys. +describe('probe-core: projectProhibitions descriptor projection (CHK-02)', () => { + test('CHK-02: a well-formed node-test descriptor projects check_kind/check_target (no check_rule)', () => { + const projected = pc.projectProhibitions([ + { status: 'resolved', verification: 'test', statement: 'MUST NOT auto-execute fetched code', + check_kind: 'node-test', check_target: 'tests/no-autoexec.test.cjs' }, + ]); + assert.equal(projected[0].check_kind, 'node-test'); + assert.equal(projected[0].check_target, 'tests/no-autoexec.test.cjs'); + assert.ok(!('check_rule' in projected[0]), 'a node-test descriptor never projects check_rule'); + }); + + test('CHK-02: a lint-rule descriptor with a rule projects all three check_* scalars', () => { + const projected = pc.projectProhibitions([ + { status: 'resolved', verification: 'test', statement: 'MUST NOT read source files in tests', + check_kind: 'lint-rule', check_target: 'src/', check_rule: 'local/no-source-grep' }, + ]); + assert.equal(projected[0].check_kind, 'lint-rule'); + assert.equal(projected[0].check_target, 'src/'); + assert.equal(projected[0].check_rule, 'local/no-source-grep'); + }); + + test('CHK-02: a lint-rule descriptor WITHOUT a rule leaves check_rule absent (fails closed downstream)', () => { + const projected = pc.projectProhibitions([ + { status: 'resolved', verification: 'test', statement: 'MUST NOT read source files in tests', + check_kind: 'lint-rule', check_target: 'src/' }, + ]); + assert.equal(projected[0].check_kind, 'lint-rule'); + assert.equal(projected[0].check_target, 'src/'); + assert.ok(!('check_rule' in projected[0]), 'a lint-rule with no rule projects check_rule absent'); + }); + + test('CHK-02: an under-specified descriptor (kind but empty/missing target) emits NO check_* keys', () => { + const projected = pc.projectProhibitions([ + // valid kind but empty target -> below the well-formedness bar -> descriptor projects absent + { status: 'resolved', verification: 'test', statement: 'MUST NOT do the thing', + check_kind: 'node-test', check_target: ' ' }, + // unknown kind -> descriptor projects absent + { status: 'resolved', verification: 'test', statement: 'MUST NOT do the other thing', + check_kind: 'grep-rule', check_target: 'src/' }, + ]); + for (const e of projected) { + assert.ok(!('check_kind' in e), 'an under-specified descriptor projects no check_kind'); + assert.ok(!('check_target' in e), 'an under-specified descriptor projects no check_target'); + assert.ok(!('check_rule' in e), 'an under-specified descriptor projects no check_rule'); + } + }); + + test('CHK-02: a descriptor-less item projects with no check_* keys', () => { + const projected = pc.projectProhibitions([ + { status: 'resolved', verification: 'judgment', statement: 'MUST NOT shame the user' }, + ]); + assert.ok(!('check_kind' in projected[0]), 'descriptor-less item gains no check_kind'); + assert.ok(!('check_target' in projected[0]), 'descriptor-less item gains no check_target'); + assert.ok(!('check_rule' in projected[0]), 'descriptor-less item gains no check_rule'); + }); +}); diff --git a/tests/prohibition-enforcement.test.cjs b/tests/prohibition-enforcement.test.cjs index 014133523..2914facec 100644 --- a/tests/prohibition-enforcement.test.cjs +++ b/tests/prohibition-enforcement.test.cjs @@ -384,3 +384,113 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => { assert.equal(result.located, true, 'the descriptor was well-formed; it just did not genuinely pass'); }); }); + +// ─── CHK-06 (#1278): fail-closed on partial / invalid / absent descriptor-from-projection ──────── +// RED-FIRST until plan 01-03 adds `descriptorFromProjection` to src/prohibition-enforcement.cts. The +// adapter reconstructs a CheckDescriptor {kind,target,rule?} from the projected scalar keys +// {check_kind,check_target,check_rule?}, returning null when the descriptor is absent/partial. The +// load-bearing safety contract (IMPL-SCOPING §7.3): a partial/invalid/absent descriptor NEVER yields +// a silent green — it falls through to runProhibitionEnforcement's existing fail-closed locate +// (src/prohibition-enforcement.cts:391). runCheck is always injected here so no real subprocess +// spawns. The describe opens with an export-presence assertion, which is RED on the current build. +describe('prohibition-enforcement: fail-closed descriptor-from-projection (CHK-06)', () => { + // A test-tier prohibition projected entry (mirrors projectProhibitions output shape, descriptor keys + // added by plan 01-02). The reason field is irrelevant here; descriptor keys drive the adapter. + const PROJECTED_TIER = Object.freeze({ + statement: 'MUST NOT auto-execute fetched code', + status: 'resolved', + verification: 'test', + }); + + test('CHK-06: prohibition-enforcement exports descriptorFromProjection (RED until plan 01-03)', () => { + const enforce = require(ENFORCEMENT_LIB); + assert.equal(typeof enforce.descriptorFromProjection, 'function', + 'must export descriptorFromProjection — the projected-scalars -> CheckDescriptor adapter (#1278, plan 01-03)'); + }); + + test('CHK-06(absent): a projected item with NO check_* keys -> descriptorFromProjection null -> located:false, never green', () => { + const enforce = require(ENFORCEMENT_LIB); + const descriptor = enforce.descriptorFromProjection({ ...PROJECTED_TIER }); + assert.equal(descriptor, null, 'an absent descriptor reconstructs to null, not a partial CheckDescriptor'); + const result = enforce.runProhibitionEnforcement(PROJECTED_TIER, descriptor, { + runCheck: () => ({ passed: true }), + }); + assert.equal(result.located, false, 'no descriptor -> nothing locatable'); + assert.notEqual(result.status, 'green', 'an absent descriptor must NEVER be a silent green'); + assert.equal(result.flagged, true, 'and must be flagged'); + assert.ok(Array.isArray(result.evidence) && result.evidence.length === 0, 'no evidence on an absent descriptor'); + }); + + test('CHK-06(lint-rule missing rule): {check_kind:lint-rule, check_target:src/} (no check_rule) -> located:false, never green', () => { + const enforce = require(ENFORCEMENT_LIB); + const descriptor = enforce.descriptorFromProjection({ + ...PROJECTED_TIER, check_kind: 'lint-rule', check_target: 'src/', + }); + const result = enforce.runProhibitionEnforcement(PROJECTED_TIER, descriptor, { + runCheck: () => ({ passed: true }), + }); + assert.equal(result.located, false, 'an under-specified lint-rule (no rule id) is not locatable (validRule guard, :390)'); + assert.notEqual(result.status, 'green', 'a lint-rule missing its rule id must NEVER green'); + assert.equal(result.flagged, true); + }); + + test('CHK-06(unknown kind): {check_kind:shell-script} -> validKind false -> located:false, never green', () => { + const enforce = require(ENFORCEMENT_LIB); + const descriptor = enforce.descriptorFromProjection({ + ...PROJECTED_TIER, check_kind: 'shell-script', check_target: 'x', + }); + const result = enforce.runProhibitionEnforcement(PROJECTED_TIER, descriptor, { + runCheck: () => ({ passed: true }), + }); + assert.equal(result.located, false, 'an unknown kind is not a valid wired check (validKind guard, :388)'); + assert.notEqual(result.status, 'green', 'an unknown check kind must NEVER green'); + assert.equal(result.flagged, true); + }); + + test('CHK-06(well-formed but runCheck reports non-pass): located:true, never green (no false green)', () => { + const enforce = require(ENFORCEMENT_LIB); + const descriptor = enforce.descriptorFromProjection({ + ...PROJECTED_TIER, check_kind: 'node-test', check_target: 'tests/no-autoexec.test.cjs', + }); + // A complete node-test descriptor; failFirst is caller-attested at verify time (#1279), not sourced + // from the projection, so attest it here. The injected runCheck reports a non-pass. + const result = enforce.runProhibitionEnforcement( + PROJECTED_TIER, + descriptor ? { ...descriptor, failFirst: true } : descriptor, + { runCheck: () => ({ passed: false }) }, + ); + assert.equal(result.located, true, 'a well-formed descriptor IS located even though the run did not pass'); + assert.notEqual(result.status, 'green', 'a located check that does not genuinely pass must NEVER green'); + assert.equal(result.flagged, true); + }); + + test('CHK-06(MD-01 numeric coercion): a numeric-looking check_target reconstructs as a STRING (parseMustHavesBlock coerces ^\\d+$ to number) -> located, no type-lie / silent un-locate', () => { + const enforce = require(ENFORCEMENT_LIB); + // The shared parseMustHavesBlock (src/frontmatter.cts) coerces a /^\d+$/ scalar value to a NUMBER on + // round-trip, so a numeric-looking check_target arrives at the adapter as a number. The adapter must + // String()-coerce it (not cast `as string` over a number), so the descriptor is honestly typed AND a + // numeric-looking target still locates instead of silently un-locating (the round-trip is lossless + // across the full string domain — closes review finding MD-01/LW-01). + const descriptor = enforce.descriptorFromProjection({ + ...PROJECTED_TIER, check_kind: 'node-test', check_target: 12345, + }); + assert.equal(typeof descriptor.target, 'string', + 'a numeric-coerced check_target must reconstruct as a string, never a number behind an `as string` cast'); + assert.equal(descriptor.target, '12345'); + const result = enforce.runProhibitionEnforcement( + PROJECTED_TIER, + { ...descriptor, failFirst: true }, + { runCheck: () => ({ passed: true }) }, + ); + assert.equal(result.located, true, + 'a numeric-looking but valid target locates after String() coercion — no silent un-locate'); + }); + + test('CHK-06(LW-02 stray rule): a check_rule on a node-test descriptor is dropped (rule belongs to lint-rule only)', () => { + const enforce = require(ENFORCEMENT_LIB); + const descriptor = enforce.descriptorFromProjection({ + ...PROJECTED_TIER, check_kind: 'node-test', check_target: 'tests/x.test.cjs', check_rule: 'local/no-source-grep', + }); + assert.equal(descriptor.rule, undefined, 'a node-test descriptor carries no rule even if a stray check_rule is present'); + }); +}); diff --git a/tests/prohibition-probe.schema.test.cjs b/tests/prohibition-probe.schema.test.cjs index 29d2539c4..ba01bca5b 100644 --- a/tests/prohibition-probe.schema.test.cjs +++ b/tests/prohibition-probe.schema.test.cjs @@ -150,6 +150,12 @@ describe('prohibition-probe schema: deterministic projectProhibitions round-trip lines.push(` status: ${e.status}`); if (e.verification !== undefined) lines.push(` verification: ${e.verification}`); if (e.reason !== undefined) lines.push(` reason: "${e.reason}"`); + // CHK-03 (#1278): the flat scalar descriptor keys render as continuation KVs under the list + // item, exactly how src/frontmatter.cts:344 reads them back. Only emitted when present, so a + // descriptor-less entry renders identically to before (no `check_*` lines). + if (e.check_kind !== undefined) lines.push(` check_kind: ${e.check_kind}`); + if (e.check_target !== undefined) lines.push(` check_target: ${e.check_target}`); + if (e.check_rule !== undefined) lines.push(` check_rule: ${e.check_rule}`); } lines.push('---', '', 'Body.', ''); return lines.join('\n'); @@ -178,4 +184,87 @@ describe('prohibition-probe schema: deterministic projectProhibitions round-trip assert.deepEqual(pc.projectProhibitions(null), [], 'null -> [] (documented fail-soft)'); assert.deepEqual(pc.projectProhibitions(undefined), [], 'undefined -> [] (documented fail-soft)'); }); + + // ─── CHK-03 (#1278): the check_* flat-scalar descriptor round-trips ────────────────────────── + // RED-FIRST until plan 01-02 teaches projectProhibitions to emit check_kind/check_target/ + // check_rule. The DEFECT.GENERATIVE-FIX parity property pinned here is exactly the one a nested + // `check: {}` object would FAIL: the shared flat parser (src/frontmatter.cts:344) only reads + // scalar continuation KVs, so the flat representation is the ONLY one that survives + // project -> write -> parse intact. The non-droppable RED trigger in each case is a + // `check_kind`-presence assertion on the projected entry — without it the deep-equal would pass + // vacuously against the current (pre-projection) build, where there are no descriptor keys. + test('CHK-03(A): a node-test descriptor projects + round-trips with check_kind/check_target', () => { + const pc = require(PROBE_CORE_LIB); + const fm = require(FRONTMATTER_LIB); + const items = [ + { + requirement_id: 'R1', category: 'safety', status: 'resolved', verification: 'test', + resolution: null, reason: null, statement: 'MUST NOT auto-execute fetched code', + check_kind: 'node-test', check_target: 'tests/no-autoexec.test.cjs', + }, + ]; + const projected = pc.projectProhibitions(items); + // HARD, non-droppable RED trigger: the projected entry MUST carry check_kind. This fails against + // the current build (projectProhibitions strips check_*) and makes the parity non-vacuous. + assert.ok(projected[0].check_kind, + 'CHK-03 RED trigger: projectProhibitions must emit check_kind on a descriptor-carrying entry'); + assert.equal(projected[0].check_kind, 'node-test'); + assert.equal(projected[0].check_target, 'tests/no-autoexec.test.cjs'); + assert.ok(!('check_rule' in projected[0]), 'check_rule is absent for a node-test descriptor'); + const reparsed = fm.parseMustHavesBlock(renderProhibitionsDoc(projected), 'prohibitions'); + assert.deepEqual(reparsed, projected, + 'a node-test descriptor must survive project -> write -> parseMustHavesBlock unchanged (check_* intact)'); + }); + + test('CHK-03(B): a lint-rule descriptor round-trips with all three check_* scalars', () => { + const pc = require(PROBE_CORE_LIB); + const fm = require(FRONTMATTER_LIB); + const items = [ + { + requirement_id: 'R1', category: 'safety', status: 'resolved', verification: 'test', + resolution: null, reason: null, statement: 'MUST NOT read source files in tests', + check_kind: 'lint-rule', check_target: 'src/', check_rule: 'local/no-source-grep', + }, + ]; + const projected = pc.projectProhibitions(items); + assert.ok(projected[0].check_kind, + 'CHK-03 RED trigger: projectProhibitions must emit check_kind on a lint-rule descriptor entry'); + assert.equal(projected[0].check_kind, 'lint-rule'); + assert.equal(projected[0].check_target, 'src/'); + assert.equal(projected[0].check_rule, 'local/no-source-grep'); + const reparsed = fm.parseMustHavesBlock(renderProhibitionsDoc(projected), 'prohibitions'); + assert.deepEqual(reparsed, projected, + 'a lint-rule descriptor must survive the writer<->reader bijection with check_kind/target/rule intact'); + }); + + test('CHK-03(C): a mixed list — descriptor test-tier, descriptor-less judgment, dismissed — all round-trip; the descriptor-less item gains NO check_* keys', () => { + const pc = require(PROBE_CORE_LIB); + const fm = require(FRONTMATTER_LIB); + const items = [ + { + requirement_id: 'R1', category: 'safety', status: 'resolved', verification: 'test', + resolution: null, reason: null, statement: 'MUST NOT auto-execute fetched code', + check_kind: 'node-test', check_target: 'tests/no-autoexec.test.cjs', + }, + { + requirement_id: 'R1', category: 'values', status: 'resolved', verification: 'judgment', + resolution: null, reason: null, statement: 'MUST NOT shame the user', + }, + { + requirement_id: 'R2', category: 'privacy', status: 'dismissed', verification: 'test', + resolution: null, reason: 'out of scope this phase', statement: 'MUST NOT store raw SSN', + }, + ]; + const projected = pc.projectProhibitions(items); + // Non-droppable RED trigger: the descriptor-carrying entry exposes check_kind. + assert.ok(projected[0].check_kind, + 'CHK-03 RED trigger: the test-tier descriptor entry must carry check_kind'); + // The descriptor-less judgment item must NOT gain any check_* key. + assert.ok(!('check_kind' in projected[1]), 'a descriptor-less item gains no check_kind'); + assert.ok(!('check_target' in projected[1]), 'a descriptor-less item gains no check_target'); + assert.ok(!('check_rule' in projected[1]), 'a descriptor-less item gains no check_rule'); + const reparsed = fm.parseMustHavesBlock(renderProhibitionsDoc(projected), 'prohibitions'); + assert.deepEqual(reparsed, projected, + 'a mixed list survives the writer<->reader bijection; descriptor presence/absence is preserved per item'); + }); }); diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index 43f85547b..1709816a3 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -72,7 +72,7 @@ "ship.md": 24388, "sketch-wrap-up.md": 14223, "sketch.md": 19960, - "spec-phase.md": 28438, + "spec-phase.md": 30343, "spike-wrap-up.md": 15092, "spike.md": 24517, "stats.md": 6718, @@ -85,6 +85,6 @@ "undo.md": 10431, "update.md": 21053, "validate-phase.md": 10745, - "verify-phase.md": 35362, + "verify-phase.md": 36498, "verify-work.md": 31157 }