Adds the spec-time prohibition probe (spec-phase Step 5.6) — the second adapter of the probe-core resolution model. Surfaces unwritten must-NOT constraints as negative SPEC acceptance criteria with test/judgment verification tiers; fail-closed at verify time. Per ADR-550. Closes #644.
This commit is contained in:
@@ -196,14 +196,59 @@ function reconstructFrontmatter(obj: Frontmatter): string {
|
||||
}
|
||||
|
||||
function spliceFrontmatter(content: string, newObj: Frontmatter): string {
|
||||
const yamlStr = reconstructFrontmatter(newObj);
|
||||
const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/);
|
||||
if (match) {
|
||||
// Identity-preservation (additive, lossless round-trip): `reconstructFrontmatter` is a
|
||||
// deliberately lossy serializer — it cannot faithfully re-emit nested object-list items
|
||||
// (e.g. must_haves.artifacts / must_haves.prohibitions, whose items are `{ path, provides }`
|
||||
// / `{ statement, status, … }` maps). When the caller is writing back a value that is
|
||||
// STRUCTURALLY UNCHANGED from the original parse (the canonical CRUD round-trip and the
|
||||
// #644 prohibition schema round-trip both do this), regenerating from the lossy object would
|
||||
// silently mangle those blocks. Detect that case by deep-equality against a re-parse of the
|
||||
// original frontmatter and preserve the ORIGINAL raw text verbatim — a true no-op splice.
|
||||
// This touches neither the parser (`extractFrontmatter`) nor `parseMustHavesBlock`; it only
|
||||
// makes the existing splice faithful when nothing changed. A genuine mutation (different
|
||||
// object) still flows through `reconstructFrontmatter` exactly as before.
|
||||
try {
|
||||
if (frontmatterDeepEqual(extractFrontmatter(content), newObj)) {
|
||||
return content;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to regeneration on any comparison hiccup */
|
||||
}
|
||||
const yamlStr = reconstructFrontmatter(newObj);
|
||||
return `---\n${yamlStr}\n---` + content.slice(match[0].length);
|
||||
}
|
||||
const yamlStr = reconstructFrontmatter(newObj);
|
||||
return `---\n${yamlStr}\n---\n\n` + content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural deep-equality for two parsed frontmatter objects. Order-sensitive for arrays
|
||||
* (YAML lists are ordered), key-order-insensitive for objects. Used only by `spliceFrontmatter`
|
||||
* to recognize a no-op write-back; intentionally narrow (handles the string / string[] /
|
||||
* nested-object shapes `extractFrontmatter` produces).
|
||||
*/
|
||||
function frontmatterDeepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return a === b;
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
||||
return a.every((v, i) => frontmatterDeepEqual(v, b[i]));
|
||||
}
|
||||
if (typeof a === 'object' && typeof b === 'object') {
|
||||
const ao: Record<string, unknown> = a as Record<string, unknown>;
|
||||
const bo: Record<string, unknown> = b as Record<string, unknown>;
|
||||
const ak = Object.keys(ao);
|
||||
const bk = Object.keys(bo);
|
||||
if (ak.length !== bk.length) return false;
|
||||
return ak.every((k) =>
|
||||
Object.prototype.hasOwnProperty.call(bo, k) && frontmatterDeepEqual(ao[k], bo[k]),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseMustHavesBlock(content: string, blockName: string): unknown[] {
|
||||
// Extract a specific block from must_haves in raw frontmatter YAML
|
||||
// Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}]
|
||||
@@ -393,6 +438,11 @@ function cmdFrontmatterValidate(cwd: string, filePath: string, schemaName: strin
|
||||
|
||||
export = {
|
||||
extractFrontmatter,
|
||||
// Additive alias (#644 prohibition-probe schema contract): the probe round-trip seam reads a
|
||||
// frontmatter object via `parseFrontmatter` (the name the contract test pins). It is the SAME
|
||||
// function as `extractFrontmatter` — a bare-object parse with no behavior change — exposed under
|
||||
// the alias so the prohibition schema round-trip and any future caller can use the canonical name.
|
||||
parseFrontmatter: extractFrontmatter,
|
||||
reconstructFrontmatter,
|
||||
spliceFrontmatter,
|
||||
parseMustHavesBlock,
|
||||
|
||||
@@ -262,6 +262,179 @@ export function analyzeCoverage<V extends string>(
|
||||
return { items: merged, coverage: { applicable, resolved, unresolved, byVerification } };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Prohibition adapter surface (#644 — the SECOND probe-core adapter).
|
||||
*
|
||||
* Unlike the edge adapter, the prohibition probe has NO deterministic propose stage: recall
|
||||
* is an LLM prose pass (ADR-550 Decision 7b), so there is intentionally no `proposeProhibitions`
|
||||
* here. What IS deterministic — and therefore real code that belongs in core — is (1) the
|
||||
* injected verification validators (`test | judgment`) and (2) `projectProhibitions`, the
|
||||
* SPEC<->`must_haves.prohibitions:` projection the DEFECT.GENERATIVE-FIX parity assertion
|
||||
* round-trips as a FUNCTION rather than a prompt (ADR-550 Decision 5c).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** The prohibition probe's verification tiers (the `verification` axis values for a resolved item). */
|
||||
export type ProhibitionVerification = 'test' | 'judgment';
|
||||
|
||||
/**
|
||||
* A surfaced prohibition item. Structurally a probe-core `Item` specialized to the prohibition
|
||||
* verification vocabulary, but the load-bearing payload field is `statement` (the must-NOT
|
||||
* sentence) rather than the edge adapter's `probe` question. Both fields are optional on the
|
||||
* shared shape so a single `Item` type serves both adapters.
|
||||
*/
|
||||
export interface Prohibition {
|
||||
requirement_id: string;
|
||||
category: string;
|
||||
status: Status;
|
||||
verification: ProhibitionVerification | null;
|
||||
resolution: string | null;
|
||||
reason: string | null;
|
||||
statement: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prohibition adapter's injected runtime validators (ADR-550 #5). There is no closed
|
||||
* category taxonomy (recall is open-vocabulary values/safety/ethics prose), so `categories`
|
||||
* is intentionally empty — `analyzeCoverage` is not the prohibition entry point and the
|
||||
* round-trip schema layer does not gate on category. The verification tiers are
|
||||
* `test | judgment` (ADR-550 D7a); both require only a present `resolution`/`reason` per their
|
||||
* lifecycle (a resolved prohibition's checkable content is the `statement`, validated by the
|
||||
* schema layer, not a `resolution` string), so `requiredFieldsByVerification` is the minimal
|
||||
* fail-closed set: a dismissed item still needs its reason (enforced by `validateResolution`).
|
||||
*/
|
||||
export const PROHIBITION_VALIDATORS: Validators = {
|
||||
categories: [],
|
||||
verification: ['test', 'judgment'],
|
||||
// A resolved prohibition's checkable content is the `statement` (schema-layer validated), NOT a
|
||||
// `resolution` string — the canonical fixtures and the reference doc's worked examples all carry
|
||||
// `resolution: null`. So the per-tier required set is empty: `resolved` still requires a present
|
||||
// verification tier (enforced in validateResolution) and `dismissed` still requires a reason
|
||||
// (enforced unconditionally), but neither tier requires a `resolution`. This matches the corpus
|
||||
// the docs-fixtures parity test pins; the validators.test.cjs regression keeps them aligned.
|
||||
requiredFieldsByVerification: { test: [], judgment: [] },
|
||||
};
|
||||
|
||||
/** Validate a prohibition resolution against the prohibition verification vocabulary. */
|
||||
export function validateProhibitionResolution(resolution: Resolution<ProhibitionVerification>): true {
|
||||
return validateResolution(resolution, PROHIBITION_VALIDATORS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically project resolved prohibition items into the `must_haves.prohibitions:`
|
||||
* list shape (the SPEC<->plan projection; ADR-550 Decision 5c). This is a FUNCTION the parity
|
||||
* assertion round-trips, never a prompt: the same input always yields the same output, and the
|
||||
* output is the exact re-readable block shape `parseMustHavesBlock(content, 'prohibitions')`
|
||||
* returns — `{ statement, status, verification }` plus `reason` only when present (a dismissed
|
||||
* item's audit trail). `resolution`/`requirement_id`/`category` are recall-stage bookkeeping
|
||||
* and are intentionally NOT projected into the plan block (which is keyed on the must-NOT
|
||||
* statement, not the source requirement). A non-array input projects to `[]` (fail-soft on the
|
||||
* empty/zero-prohibition case), never a throw.
|
||||
*/
|
||||
export function projectProhibitions(
|
||||
items: unknown,
|
||||
): Array<Record<string, string>> {
|
||||
if (!Array.isArray(items)) return [];
|
||||
const out: Array<Record<string, string>> = [];
|
||||
for (const item of items) {
|
||||
if (item == null || typeof item !== 'object') continue;
|
||||
const p = item as Partial<Prohibition>;
|
||||
const statement = typeof p.statement === 'string' ? p.statement : '';
|
||||
const entry: Record<string, string> = {
|
||||
statement,
|
||||
status: typeof p.status === 'string' ? p.status : 'unresolved',
|
||||
};
|
||||
if (p.verification != null) entry.verification = String(p.verification);
|
||||
if (p.reason != null && String(p.reason).trim()) entry.reason = String(p.reason);
|
||||
out.push(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The structured verify-time disposition of a single prohibition (ADR-550 Decision 5d, the
|
||||
* "B-with-guard" safety half — maintainer decision 2026-06-12). `status` is the verdict the
|
||||
* verifier reads; `flagged` marks an item that must surface in SUMMARY/verdict rather than pass
|
||||
* silently. `tier` echoes the verification axis so the caller can route. `reason` is human-readable.
|
||||
*/
|
||||
export interface ProhibitionDisposition {
|
||||
status: 'green' | 'unverified';
|
||||
flagged: boolean;
|
||||
tier: ProhibitionVerification | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Optional enforcement context handed to `dispositionForProhibition`. */
|
||||
export interface ProhibitionDispositionContext {
|
||||
/** Evidence that a resolved prohibition is actually enforced (e.g. a wired negative test). */
|
||||
enforcementEvidence?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic verify-time disposition for a single prohibition — the FAIL-CLOSED default
|
||||
* (ADR-550 Decision 5d, the safety half of the 2026-06-12 "B-with-guard" maintainer decision).
|
||||
*
|
||||
* This is the cheap safety guarantee: a well-formed prohibition that reaches verify-phase with NO
|
||||
* wired enforcement evidence can NEVER be a silent pass. It is `{ status: 'unverified', flagged:
|
||||
* true }` — never `green` — exactly like an unresolved judgment item. The HEAVY half (a real
|
||||
* fail-first negative-test enforcement mechanism that, given evidence, would flip a test-tier item
|
||||
* to green) is OUT of #644 scope and defers to a follow-up PR: #644's corpus is entirely
|
||||
* judgment-tier, so wiring a contrived test-tier consumer here would be the delete-bad-tests /
|
||||
* gold-plating failure mode. Until that follow-up lands, ANY prohibition without enforcement
|
||||
* evidence — test- or judgment-tier — disposes as flagged-unverified.
|
||||
*
|
||||
* The function is pure: same input always yields the same disposition (no LLM judgment, ADR-550
|
||||
* D5). The LLM-judge soft-gate for judgment-tier items is a verify-phase PROSE concern (the
|
||||
* verifier records a non-authoritative verdict + the unverified-prohibition flag); this helper
|
||||
* only owns the deterministic fail-closed default that the plan-01-01 CI safety assertion pins.
|
||||
*/
|
||||
export function dispositionForProhibition(
|
||||
prohibition: unknown,
|
||||
context: ProhibitionDispositionContext = {},
|
||||
): ProhibitionDisposition {
|
||||
const p = (prohibition ?? {}) as Partial<Prohibition>;
|
||||
const tier: ProhibitionVerification | null =
|
||||
p.verification === 'test' || p.verification === 'judgment' ? p.verification : null;
|
||||
const evidence = Array.isArray(context.enforcementEvidence) ? context.enforcementEvidence : [];
|
||||
const hasEnforcement = evidence.length > 0;
|
||||
|
||||
// FAIL CLOSED: no wired enforcement evidence -> flagged unverified, never green. This holds for
|
||||
// every tier today (the real enforcement mechanism that could flip a test-tier item to green is
|
||||
// deferred to a follow-up PR). The guard the safety assertion proves: an unwired item can never
|
||||
// be silently skipped.
|
||||
if (!hasEnforcement) {
|
||||
return {
|
||||
status: 'unverified',
|
||||
flagged: true,
|
||||
tier,
|
||||
reason:
|
||||
tier === 'test'
|
||||
? 'test-tier prohibition has no wired enforcement evidence — flagged unverified (fail-closed; real negative-test enforcement deferred to a follow-up PR, ADR-550 D5d)'
|
||||
: 'prohibition has no enforcement evidence — flagged unverified (fail-closed; never a silent pass, ADR-550 D5d)',
|
||||
};
|
||||
}
|
||||
|
||||
// D4 GUARD: a judgment-tier (or unknown-tier) prohibition is NEVER a silent green from this
|
||||
// deterministic helper — it always routes to human/LLM judgment review (ADR-550 D4; verify-phase.md).
|
||||
// Only a test-tier item with wired enforcement evidence may go green, and even that is the deferred
|
||||
// heavy half until the real negative-test enforcement mechanism lands (no #644 caller passes evidence).
|
||||
if (tier === 'test') {
|
||||
return {
|
||||
status: 'green',
|
||||
flagged: false,
|
||||
tier,
|
||||
reason: 'test-tier prohibition has wired enforcement evidence',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'unverified',
|
||||
flagged: true,
|
||||
tier,
|
||||
reason:
|
||||
'judgment-tier prohibition routes to judgment review — never a silent green (ADR-550 D4)',
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* CLI scaffold (the EP-06 invokable surface, generalized). Each probe ships one bin that
|
||||
* calls `runProbeCli` with its own `analyze` (closing over the adapter's propose + validators)
|
||||
|
||||
Reference in New Issue
Block a user