feat(spec-phase): prohibition probe — surface "must-NOT" constraints (#644) (#1149)

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:
Rezolv
2026-06-14 21:29:11 -04:00
committed by GitHub
parent 9a4a448c27
commit 395fb519e7
29 changed files with 1503 additions and 11 deletions

View File

@@ -32,5 +32,5 @@
"gsd-ui-checker.md": 11081,
"gsd-ui-researcher.md": 19265,
"gsd-user-profiler.md": 8516,
"gsd-verifier.md": 41285
"gsd-verifier.md": 43339
}

View File

@@ -13,6 +13,9 @@
* preserves key-value pairs for simple flat string values
* (d) spliceFrontmatter never throws on any string/object combination
* (e) extractFrontmatter returns {} for content without a leading ---...--- block
* (f) prohibitions bijection (#644): over a generated must_haves.prohibitions block,
* parseMustHavesBlock(spliceFrontmatter(doc, parseFrontmatter(doc)), 'prohibitions')
* deepEquals the original parse — the new parse ↔ splice path is identity-preserving.
*/
const { describe, test } = require('node:test');
@@ -23,6 +26,8 @@ const {
extractFrontmatter,
reconstructFrontmatter,
spliceFrontmatter,
parseFrontmatter,
parseMustHavesBlock,
} = require('../gsd-core/bin/lib/frontmatter.cjs');
// ─── Arbitraries ─────────────────────────────────────────────────────────────
@@ -192,3 +197,69 @@ describe('frontmatter: spliceFrontmatter properties', () => {
);
});
});
// ─── (f) prohibitions bijection (#644) ────────────────────────────────────────
// Locks the new parseMustHavesBlock(…, 'prohibitions') ↔ spliceFrontmatter path that
// the prohibition probe adds. The example-based version lives in
// tests/prohibition-probe.schema.test.cjs; this generalizes it over generated blocks.
// YAML-safe scalar: starts with a letter, no colon/quote/hash/newline (so it parses as a
// plain string and is never coerced to a number by the parser's /^\d+$/ check).
const safeScalar = fc.stringMatching(/^[A-Za-z][A-Za-z0-9 ._-]{0,50}$/);
// One prohibition item with structurally realistic key shape per ADR-550 D7a:
// resolved → carries a verification tier (test|judgment)
// dismissed → carries a non-empty reason (+ a tier)
// unresolved→ neither
const prohibitionItem = fc.oneof(
fc.record({ statement: safeScalar, status: fc.constant('resolved'),
verification: fc.constantFrom('test', 'judgment') }),
fc.record({ statement: safeScalar, status: fc.constant('dismissed'),
verification: fc.constantFrom('test', 'judgment'), reason: safeScalar }),
fc.record({ statement: safeScalar, status: fc.constant('unresolved') })
);
// Emit a frontmatter doc with a must_haves.prohibitions sibling block (keys in a fixed
// order: statement, status, verification?, reason?). Quoted strings carry the values.
function buildDoc(items) {
const lines = ['---', 'phase: 01-x', 'plan: 01', 'must_haves:',
' truths:', ' - "User sees a daily reminder"', ' prohibitions:'];
for (const it of items) {
lines.push(` - statement: "${it.statement}"`);
lines.push(` status: ${it.status}`);
if (it.verification !== undefined) lines.push(` verification: ${it.verification}`);
if (it.reason !== undefined) lines.push(` reason: "${it.reason}"`);
}
lines.push('---', '', 'Body text unchanged.', '');
return lines.join('\n');
}
describe('frontmatter: prohibitions parse ↔ splice bijection (#644)', () => {
test('property: generated prohibitions parse back with their statement and status', () => {
fc.assert(
fc.property(fc.array(prohibitionItem, { minLength: 1, maxLength: 5 }), (items) => {
const doc = buildDoc(items);
const parsed = parseMustHavesBlock(doc, 'prohibitions');
assert.equal(parsed.length, items.length, 'every prohibition item must parse out');
for (let i = 0; i < items.length; i++) {
assert.equal(parsed[i].statement, items[i].statement, `statement[${i}] mismatch`);
assert.equal(parsed[i].status, items[i].status, `status[${i}] mismatch`);
}
})
);
});
test('property: parse -> splice -> re-parse is identity-preserving for prohibitions', () => {
fc.assert(
fc.property(fc.array(prohibitionItem, { minLength: 1, maxLength: 5 }), (items) => {
const doc = buildDoc(items);
const before = parseMustHavesBlock(doc, 'prohibitions');
const parsed = parseFrontmatter(doc);
const spliced = spliceFrontmatter(doc, parsed.frontmatter ?? parsed);
const after = parseMustHavesBlock(spliced, 'prohibitions');
assert.deepEqual(after, before,
'prohibitions must survive a splice/re-parse round-trip unchanged');
})
);
});
});

View File

@@ -0,0 +1,63 @@
// allow-test-rule: runtime-contract-is-the-product (see #644) — the rendered reference doc's worked-example
// vocab surface is the runtime contract; this pins its bijection to the source-of-truth fixtures (docs-parity).
//
// RED-first parity contract: the portable reference doc (gsd-core/references/prohibition-probe.md) keeps
// its worked-example JSON blocks in sync with the source-of-truth fixture files under
// gsd-core/references/prohibition-probe-fixtures/. The fixtures are the canonical data; the doc embeds
// copies. The comparison is PARSED JSON (deepEqual of JSON.parse on both sides), never a raw-text
// substring — a reformat that preserves the data does not fail, and any semantic drift does.
//
// The reference doc does not exist yet (plan 01-02 creates it and embeds the
// ```json prohibition-probe:<dir>/<file>``` blocks) — so this is EXPECTED RED now. NOTE for plan 01-02:
// it MUST embed matching tagged json blocks or this parity test stays red (the doc<->fixture contract).
'use strict';
process.env.GSD_TEST_MODE = '1';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const docPath = path.join(__dirname, '..', 'gsd-core', 'references', 'prohibition-probe.md');
const fixturesRoot = path.join(__dirname, '..', 'gsd-core', 'references', 'prohibition-probe-fixtures');
// Extract fenced blocks tagged ```json prohibition-probe:<dir>/<file> from the doc, keyed by ref.
// The \n? before the closing fence allows blocks whose closing fence has no preceding newline.
function taggedJsonBlocks(md) {
const re = /```json prohibition-probe:([^\n]+)\n([\s\S]*?)\n?```/g;
const out = {};
let m;
while ((m = re.exec(md))) out[m[1].trim()] = m[2];
return out;
}
describe('prohibition-probe doc/fixture sync', () => {
test('reference doc exists', () => {
assert.ok(fs.existsSync(docPath), `${docPath} must exist`);
});
test('doc embeds a tagged fixture block for every expected.json fixture (count-equality)', () => {
const md = fs.readFileSync(docPath, 'utf8');
const blocks = taggedJsonBlocks(md);
const expectedCount = fs.readdirSync(fixturesRoot, { withFileTypes: true })
.filter(e => e.isDirectory())
.filter(dir => fs.existsSync(path.join(fixturesRoot, dir.name, 'expected.json')))
.length;
assert.strictEqual(
Object.keys(blocks).length,
expectedCount,
`prohibition-probe.md must embed exactly ${expectedCount} tagged blocks (one per fixture expected.json)`
);
});
test('every tagged doc block parses and deepEquals its fixture file', () => {
const md = fs.readFileSync(docPath, 'utf8');
const blocks = taggedJsonBlocks(md);
for (const [ref, body] of Object.entries(blocks)) {
const fixtureFile = path.join(fixturesRoot, ref);
const onDisk = fs.readFileSync(fixtureFile, 'utf8');
assert.deepEqual(JSON.parse(body), JSON.parse(onDisk),
`doc block prohibition-probe:${ref} must deepEqual ${fixtureFile}`);
}
});
});

View File

@@ -0,0 +1,96 @@
// allow-test-rule: runtime-contract-is-the-product (see #644) — plan-phase.md's planner prompt is the deployed
// runtime contract under assertion (the workflow PROSE is the product).
//
// RED-first PROSE-PRESENCE contract for the plan-phase lift of confirmed prohibitions. plan-phase.md
// spawns the planner from its own inline <downstream_consumer> block; the load-bearing lift instruction
// must live THERE (not in templates/planner-subagent-prompt.md, which nothing loads at runtime — the
// edge-probe orphan-prompt regression, edge planner test 145-153). Assertions scope to extracted
// sub-blocks to avoid false positives.
//
// ADR-550 Decision 2: resolved prohibitions lift into must_haves.PROHIBITIONS — NOT must_haves.truths.
// EXPECTED RED until Wave 3 adds the plan-phase lift + quality_gate item.
'use strict';
process.env.GSD_TEST_MODE = '1';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const PLAN_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md');
const PROMPT_PATH = path.join(__dirname, '..', 'gsd-core', 'templates', 'planner-subagent-prompt.md');
function readPlanPhase() {
return fs.readFileSync(PLAN_PHASE_PATH, 'utf8');
}
// Extract the planner <downstream_consumer> block from plan-phase.md (the runtime planner surface).
function extractDownstreamConsumerBlock(content) {
const start = content.indexOf('<downstream_consumer>');
if (start === -1) return '';
const end = content.indexOf('</downstream_consumer>', start);
if (end === -1) return '';
return content.slice(start, end + '</downstream_consumer>'.length);
}
// Extract the planner <quality_gate> block (the last one in plan-phase.md, inside the planner prompt).
function extractQualityGateBlock(content) {
const start = content.lastIndexOf('<quality_gate>');
if (start === -1) return '';
const end = content.indexOf('</quality_gate>', start);
if (end === -1) return '';
return content.slice(start, end + '</quality_gate>'.length);
}
// PROB-04 consumer: the lift instruction lives in the RUNTIME planner surface and lifts resolved
// prohibitions into must_haves.prohibitions (ADR-550 D2 — NOT truths).
test('PROB-04 consumer: downstream_consumer lifts SPEC Prohibitions into must_haves.prohibitions', () => {
const block = extractDownstreamConsumerBlock(readPlanPhase());
assert.ok(block.length > 0, 'sanity: plan-phase.md must contain a <downstream_consumer> block to scope this test');
assert.match(
block,
/Prohibitions/,
'plan-phase.md <downstream_consumer> must reference the SPEC Prohibitions section'
);
assert.match(
block,
/must_haves\.prohibitions/,
'plan-phase.md <downstream_consumer> must lift resolved prohibitions into must_haves.prohibitions (ADR-550 D2 — NOT truths)'
);
// ADR-550 D2 GUARD: prohibitions must NOT be lifted into truths.
assert.doesNotMatch(
block,
/prohibition[^.\n]*must_haves\.truths|must_haves\.truths[^.\n]*prohibition/i,
'prohibitions must NOT be lifted into must_haves.truths — truths keeps its positive-observable semantics (ADR-550 D2)'
);
});
// Orphan-prompt regression guard (mirror edge planner test 145-153): the lift instruction must NOT
// be relocated into templates/planner-subagent-prompt.md (which nothing loads at runtime).
test('PROB-04 regression: the prohibitions lift does not live in the orphaned planner-subagent-prompt.md', () => {
const prompt = fs.readFileSync(PROMPT_PATH, 'utf8');
assert.doesNotMatch(
prompt,
/must_haves\.prohibitions/,
'planner-subagent-prompt.md is not loaded at runtime — the prohibitions lift instruction must not live there'
);
});
// PROB-04: quality_gate covers every resolved SPEC Prohibition being represented in must_haves.prohibitions.
test('PROB-04: planner quality_gate requires every resolved SPEC Prohibition represented in must_haves.prohibitions', () => {
const qgBlock = extractQualityGateBlock(readPlanPhase());
assert.ok(qgBlock.length > 0, 'sanity: plan-phase.md must contain a <quality_gate> block to scope this test');
assert.match(
qgBlock,
/prohibition/i,
'planner quality_gate must contain a checklist item covering SPEC prohibitions (PROB-04)'
);
assert.match(
qgBlock,
/must_haves\.prohibitions/,
'planner quality_gate must tie SPEC prohibitions to must_haves.prohibitions with no silent drops (PROB-04)'
);
});

View File

@@ -0,0 +1,181 @@
// allow-test-rule: runtime-contract-is-the-product (see #644) — the must_haves.prohibitions: block is the
// runtime plan-contract surface; this pins its parse/round-trip/projection bijection to the code.
//
// RED-first schema contract for the `must_haves.prohibitions:` SIBLING block (ADR-550 Decision 3 —
// NOT a `polarity` field on `truths`; Decision 2 leaves `truths` untouched). Mirrors the round-trip
// discipline of tests/probe-core.test.cjs and the frontmatter callers. The parser under assertion is
// the block-name-generic parseMustHavesBlock @ src/frontmatter.cts:207 (built to gsd-core/bin/lib/
// frontmatter.cjs by `npm run build:lib`) and spliceFrontmatter @ src/frontmatter.cts:198.
//
// EXPECTED RED until plan 01-02 builds the schema callers + projectProhibitions and plan 01-04 adds
// the test-tier fail-closed disposition. No `polarity` key appears anywhere. No LLM judgment is
// asserted (ADR-550 Decision 5) — only parse / round-trip / projection determinism.
'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 FRONTMATTER_LIB = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'frontmatter.cjs');
const PROBE_CORE_LIB = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'probe-core.cjs');
// A must_haves block carrying a prohibitions: sibling list. ADR-550 D7a axes:
// status ∈ {resolved, dismissed, unresolved}; verification ∈ {test, judgment} (NOT the retired
// covered/backstop enum). Dismissed items carry a non-empty reason.
const CONTENT_WITH_PROHIBITIONS = `---
phase: 01-x
plan: 01
must_haves:
truths:
- "User sees a daily reminder"
artifacts:
- path: "src/reminders.ts"
provides: "scheduleReminders"
prohibitions:
- statement: "MUST NOT use shaming/guilt/negative-streak framing"
status: resolved
verification: judgment
- statement: "MUST NOT store raw SSN in the audit log"
status: dismissed
verification: test
reason: "Out of scope for this phase; tracked in PRIV-02"
key_links:
- from: "src/reminders.ts"
to: "src/notify.ts"
via: "import"
---
Body text unchanged.
`;
// A must_haves block with NO prohibitions: sibling — the backward-compat case.
const CONTENT_NO_PROHIBITIONS = `---
phase: 01-x
plan: 01
must_haves:
truths:
- "User sees a daily reminder"
artifacts:
- path: "src/reminders.ts"
provides: "scheduleReminders"
key_links:
- from: "src/reminders.ts"
to: "src/notify.ts"
via: "import"
---
Body text unchanged.
`;
describe('prohibition-probe schema: must_haves.prohibitions round-trip (PROB-07)', () => {
const fm = require(FRONTMATTER_LIB);
test('a prohibitions: list survives parse -> splice -> re-parse unchanged', () => {
assert.equal(typeof fm.parseMustHavesBlock, 'function', 'parseMustHavesBlock must be exported from the built lib');
assert.equal(typeof fm.spliceFrontmatter, 'function', 'spliceFrontmatter must be exported from the built lib');
assert.equal(typeof fm.parseFrontmatter, 'function', 'parseFrontmatter must be exported from the built lib');
const prohibitions = fm.parseMustHavesBlock(CONTENT_WITH_PROHIBITIONS, 'prohibitions');
assert.equal(prohibitions.length, 2, 'two prohibition items must parse out of the must_haves block');
const resolved = prohibitions[0];
assert.equal(resolved.statement, 'MUST NOT use shaming/guilt/negative-streak framing');
assert.equal(resolved.status, 'resolved');
assert.equal(resolved.verification, 'judgment');
const dismissed = prohibitions[1];
assert.equal(dismissed.status, 'dismissed');
assert.equal(dismissed.verification, 'test');
assert.ok(typeof dismissed.reason === 'string' && dismissed.reason.trim().length > 0,
'a dismissed prohibition must carry a non-empty reason (ADR-550 Decision 2/3)');
// Round-trip: parse full frontmatter, splice it back, re-parse — prohibitions are stable.
const parsed = fm.parseFrontmatter(CONTENT_WITH_PROHIBITIONS);
const spliced = fm.spliceFrontmatter(CONTENT_WITH_PROHIBITIONS, parsed.frontmatter ?? parsed);
const reparsed = fm.parseMustHavesBlock(spliced, 'prohibitions');
assert.deepEqual(reparsed, prohibitions, 'prohibitions must survive a splice/re-parse round-trip unchanged');
});
test('no polarity key is present on any prohibition item (ADR-550 Decision 2/3)', () => {
const prohibitions = fm.parseMustHavesBlock(CONTENT_WITH_PROHIBITIONS, 'prohibitions');
for (const item of prohibitions) {
assert.ok(!Object.prototype.hasOwnProperty.call(item, 'polarity'),
'prohibition items must NOT carry a polarity key — the prohibitions: sibling block replaces it');
}
});
});
describe('prohibition-probe schema: backward-compat byte-stability (PROB-08)', () => {
const fm = require(FRONTMATTER_LIB);
test('a must_haves with no prohibitions: is byte-unchanged through the round-trip', () => {
const parsed = fm.parseFrontmatter(CONTENT_NO_PROHIBITIONS);
const spliced = fm.spliceFrontmatter(CONTENT_NO_PROHIBITIONS, parsed.frontmatter ?? parsed);
assert.equal(spliced, CONTENT_NO_PROHIBITIONS,
'a prohibitions-less must_haves must round-trip byte-for-byte (backward compatibility)');
});
test('parseMustHavesBlock(content, "prohibitions") returns [] when absent', () => {
const prohibitions = fm.parseMustHavesBlock(CONTENT_NO_PROHIBITIONS, 'prohibitions');
assert.deepEqual(prohibitions, [], 'absent prohibitions: block must parse to an empty list, not throw');
});
});
describe('prohibition-probe schema: deterministic projectProhibitions round-trip (PROB-14)', () => {
// ADR-550 Decision 5(c): the DEFECT.GENERATIVE-FIX parity assertion across template <-> parser <->
// planner is grounded on a deterministic projectProhibitions() in probe-core rather than a prompt.
// The function does not exist yet (plan 01-02 adds it) — assert its expected signature so this is RED.
test('probe-core exports a deterministic projectProhibitions(items) function', () => {
const pc = require(PROBE_CORE_LIB);
assert.equal(typeof pc.projectProhibitions, 'function',
'probe-core must export projectProhibitions() — the deterministic SPEC<->must_haves projection (ADR-550 D5c)');
const items = [
{ requirement_id: 'R1', category: 'values', status: 'resolved', verification: 'judgment', resolution: null, reason: null, statement: 'MUST NOT shame the user' },
];
const once = pc.projectProhibitions(items);
const twice = pc.projectProhibitions(items);
assert.deepEqual(once, twice, 'projectProhibitions must be deterministic (same input -> identical output)');
assert.ok(Array.isArray(once), 'projectProhibitions must return an array of prohibition entries');
});
// Render projected entries into a must_haves.prohibitions: block exactly as the planner/template
// would, so the parser reads back what the projector wrote (keys: statement, status, optional
// verification, optional reason — the projectProhibitions output shape).
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}"`);
}
lines.push('---', '', 'Body.', '');
return lines.join('\n');
}
// ADR-550 D5c (DEFECT.GENERATIVE-FIX): close the parity loop by round-tripping the PROJECTOR's
// output through the parser — proving the write-shape and read-shape cannot drift, not merely that
// each is independently correct.
test('projectProhibitions output round-trips through parseMustHavesBlock (PROB-14 parity)', () => {
const pc = require(PROBE_CORE_LIB);
const fm = require(FRONTMATTER_LIB);
const items = [
{ requirement_id: 'R1', category: 'values', status: 'resolved', verification: 'judgment', resolution: null, reason: null, statement: 'MUST NOT shame the user' },
{ requirement_id: 'R1', category: 'privacy', status: 'dismissed', verification: 'test', resolution: null, reason: 'out of scope this phase', statement: 'MUST NOT store raw SSN' },
{ 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);
const reparsed = fm.parseMustHavesBlock(renderProhibitionsDoc(projected), 'prohibitions');
assert.deepEqual(reparsed, projected,
'parseMustHavesBlock(serialize(projectProhibitions(items))) must equal projectProhibitions(items) — writer<->reader bijection');
});
test('projectProhibitions returns [] for empty / null / undefined input (fail-soft boundary)', () => {
const pc = require(PROBE_CORE_LIB);
assert.deepEqual(pc.projectProhibitions([]), [], 'empty array -> []');
assert.deepEqual(pc.projectProhibitions(null), [], 'null -> [] (documented fail-soft)');
assert.deepEqual(pc.projectProhibitions(undefined), [], 'undefined -> [] (documented fail-soft)');
});
});

View File

@@ -0,0 +1,145 @@
// allow-test-rule: runtime-contract-is-the-product (see #644) — spec-phase.md Step 5.6 is the deployed workflow
// runtime contract under assertion (the workflow PROSE is the product; ADR-550 D5 forbids a JS engine here).
//
// RED-first PROSE-PRESENCE contract for the prohibition probe's Step 5.6 (ADR-550 D1 DIVERGENCE,
// RESEARCH Pitfall 2 / PATTERNS D1): the prohibition probe is LLM-propose (ADR-550 D7b) — there is NO
// compiled engine. DO NOT assert a node-engine / prohibition-probe.cjs invocation; a prohibition-probe.cjs
// path here is a DEFECT. Assert the protocol PROSE is present in the Step 5.6 slice instead. Assertions
// scope to the extracted Step 5.6 block to avoid false positives from mentions elsewhere in the file.
//
// EXPECTED RED until Wave 2/3 add Step 5.6 to spec-phase.md.
'use strict';
process.env.GSD_TEST_MODE = '1';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SPEC_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'spec-phase.md');
function readSpecPhase() {
return fs.readFileSync(SPEC_PHASE_PATH, 'utf8');
}
// Slice the Step 5.6 block: from the "Step 5.6" heading to the next "## " or "Step N" heading.
// This scopes assertions to Step 5.6 only, preventing false positives from mentions elsewhere.
function extractStep56Block(content) {
const startIdx = content.indexOf('## Step 5.6');
if (startIdx === -1) {
const altIdx = content.indexOf('Step 5.6');
if (altIdx === -1) return '';
const rest = content.slice(altIdx + 'Step 5.6'.length);
const nextHeading = rest.search(/\n## |\nStep \d/);
if (nextHeading === -1) return content.slice(altIdx);
return content.slice(altIdx, altIdx + 'Step 5.6'.length + nextHeading);
}
const rest = content.slice(startIdx + '## Step 5.6'.length);
const nextHeading = rest.search(/\n## /);
if (nextHeading === -1) return content.slice(startIdx);
return content.slice(startIdx, startIdx + '## Step 5.6'.length + nextHeading);
}
// D1 DIVERGENCE GUARD: there is no compiled prohibition engine. Assert the Step 5.6 block does NOT
// reference a prohibition-probe.cjs node invocation (RESEARCH Pitfall 2: a copied edge-probe.cjs
// wire is the defect — prohibition is LLM-propose, ADR-550 D7b).
test('D1: Step 5.6 does NOT invoke a prohibition-probe.cjs engine (LLM-propose, not compiled)', () => {
const block = extractStep56Block(readSpecPhase());
assert.ok(block.length > 0, 'Step 5.6 block must be extractable from spec-phase.md');
assert.doesNotMatch(
block,
/prohibition-probe\.cjs/,
'Step 5.6 must NOT reference a prohibition-probe.cjs engine — the prohibition probe is LLM-propose (ADR-550 D7b); a node-engine wire here is a defect'
);
});
// PROB-01: adversarial recall question is present (the model-robust recall stage).
test('PROB-01: Step 5.6 poses the adversarial recall question', () => {
const block = extractStep56Block(readSpecPhase());
assert.ok(block.length > 0, 'Step 5.6 block must be extractable from spec-phase.md');
assert.match(
block,
/silently become|would NOT want|must.?not|adversarial/i,
'Step 5.6 must pose the adversarial recall question (what could this feature silently become that the author would NOT want)'
);
});
// PROB-02: precision classifier drops routine-engineering items.
test('PROB-02: Step 5.6 precision stage drops routine engineering items', () => {
const block = extractStep56Block(readSpecPhase());
assert.match(
block,
/routine engineering|drop[a-z]*\b[^.]*engineering|precision[^.]*classif/i,
'Step 5.6 must describe a precision classifier that drops routine engineering items (PROB-02)'
);
});
// PROB-06: soft-gate write-anyway-with-flags.
test('PROB-06: Step 5.6 is a soft gate (write-anyway-with-flags)', () => {
const block = extractStep56Block(readSpecPhase());
assert.match(
block,
/write.?anyway|soft.?gate|with.?flags/i,
'Step 5.6 must be a soft gate (write-anyway-with-flags), not a hard halt (PROB-06)'
);
});
// PROB-05: dismissals require a non-empty reason.
test('PROB-05: Step 5.6 requires a non-empty reason to dismiss', () => {
const block = extractStep56Block(readSpecPhase());
assert.match(
block,
/dismiss[a-z]*\b[^.]*reason|reason[^.]*dismiss/i,
'Step 5.6 must require a non-empty reason when dismissing a prohibition (PROB-05)'
);
});
// PROB-06: --auto never auto-dismisses.
test('PROB-06: Step 5.6 --auto never auto-dismisses prohibitions', () => {
const block = extractStep56Block(readSpecPhase());
assert.match(
block,
/--auto[^.]*(never|not)[^.]*dismiss|never auto.?dismiss/i,
'Step 5.6 must specify that --auto never auto-dismisses prohibitions (PROB-06)'
);
});
// PROB-09: text-mode has no hard AskUserQuestion dependency.
test('PROB-09: Step 5.6 text-mode handling has no hard AskUserQuestion dependency', () => {
const block = extractStep56Block(readSpecPhase());
assert.match(
block,
/text.?mode|non-?Claude|AskUserQuestion/i,
'Step 5.6 must handle text-mode (non-Claude, no hard AskUserQuestion) (PROB-09)'
);
});
// SPEC population: confirmed prohibitions populate a SPEC Prohibitions section.
test('Step 5.6 populates a SPEC Prohibitions section', () => {
const block = extractStep56Block(readSpecPhase());
assert.match(
block,
/Prohibitions/,
'Step 5.6 must populate a SPEC Prohibitions section with confirmed prohibitions'
);
});
// PROB-13: ADR-550 D6 canon-referral breadcrumb to /gsd:secure-phase.
test('PROB-13: Step 5.6 emits the canon-referral breadcrumb to /gsd:secure-phase', () => {
const block = extractStep56Block(readSpecPhase());
assert.match(
block,
/secure-phase|canon[^.]*security|OWASP|GDPR/i,
'Step 5.6 must emit a canon-referral breadcrumb (e.g. owned by /gsd:secure-phase) for canon-security items (ADR-550 D6, PROB-13)'
);
});
// @-include reference to references/prohibition-probe.md.
test('Step 5.6 @-includes the references/prohibition-probe.md core', () => {
const block = extractStep56Block(readSpecPhase());
assert.match(
block,
/references\/prohibition-probe\.md/,
'Step 5.6 must @-include references/prohibition-probe.md (the portable probe core)'
);
});

View File

@@ -0,0 +1,82 @@
// allow-test-rule: runtime-contract-is-the-product (see #644) — the prohibition validators and the verify-time
// disposition are the deployed safety contract; this pins them against the CANONICAL fixture corpus
// and the ADR-550 D4 "judgment is never silently green" invariant so the code can never drift from
// its own documented intent again.
//
// Regression coverage for the two defects the RED-first suite did not exercise (code review #644):
// CR-01 — PROHIBITION_VALIDATORS must ACCEPT every resolved prohibition in the canonical corpus
// (resolution: null; the checkable content is `statement`). The prior config required a
// non-empty `resolution` and threw on 100% of the fixtures.
// WR-01 — dispositionForProhibition must NEVER return a silent green for a judgment-tier item,
// regardless of enforcement evidence (ADR-550 D4 / verify-phase.md).
//
// Fixture-driven on purpose: the validators are exercised against the SAME expected.json files the
// docs-fixtures parity test pins, so the validator and the corpus can never silently diverge.
'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 fs = require('node:fs');
const PROBE_CORE_LIB = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'probe-core.cjs');
const FIXTURES_DIR = path.join(__dirname, '..', 'gsd-core', 'references', 'prohibition-probe-fixtures');
function loadFixtureItems() {
const out = [];
for (const name of fs.readdirSync(FIXTURES_DIR).sort()) {
const expected = path.join(FIXTURES_DIR, name, 'expected.json');
if (!fs.existsSync(expected)) continue;
const json = JSON.parse(fs.readFileSync(expected, 'utf8'));
assert.ok(json.items === undefined || Array.isArray(json.items),
`fixture ${name}: expected.json "items" must be an array when present (got ${typeof json.items})`);
for (const item of json.items ?? []) out.push({ fixture: name, item });
}
return out;
}
describe('prohibition-probe validators: canonical corpus is accepted (CR-01 regression)', () => {
test('validateProhibitionResolution accepts every resolved item in the reference fixtures', () => {
const pc = require(PROBE_CORE_LIB);
assert.equal(typeof pc.validateProhibitionResolution, 'function',
'probe-core must export validateProhibitionResolution()');
const fixtureItems = loadFixtureItems();
assert.ok(fixtureItems.length >= 4,
'the corpus must carry the resolved prohibitions the validator is pinned against');
for (const { fixture, item } of fixtureItems) {
// A resolved prohibition's checkable content is `statement`, NOT `resolution` (resolution: null
// in every fixture). The validator must not throw on its own canonical corpus.
assert.doesNotThrow(
() => pc.validateProhibitionResolution(item),
`validateProhibitionResolution must accept fixture ${fixture} item ${item.requirement_id}::${item.category} (resolution: ${JSON.stringify(item.resolution)})`,
);
}
});
});
describe('prohibition-probe disposition: judgment is never silently green (WR-01 / ADR-550 D4)', () => {
const evidence = { enforcementEvidence: ['a wired negative test reference'] };
test('a judgment-tier item with enforcement evidence is NEVER a silent green', () => {
const pc = require(PROBE_CORE_LIB);
const judgment = { status: 'resolved', verification: 'judgment', statement: 'MUST NOT shame the user' };
const disposition = pc.dispositionForProhibition(judgment, evidence);
assert.notEqual(disposition.status, 'green',
'a judgment-tier prohibition can never be green from the deterministic helper — it routes to judgment review (ADR-550 D4)');
assert.equal(disposition.flagged, true,
'a judgment-tier prohibition with evidence must still be flagged for judgment review');
});
test('no-evidence items stay fail-closed for both tiers', () => {
const pc = require(PROBE_CORE_LIB);
for (const tier of ['test', 'judgment']) {
const d = pc.dispositionForProhibition({ status: 'resolved', verification: tier, statement: 'x' }, { enforcementEvidence: [] });
assert.notEqual(d.status, 'green', `${tier}-tier with no evidence must be fail-closed (never green)`);
assert.equal(d.flagged, true, `${tier}-tier with no evidence must be flagged`);
}
});
});

View File

@@ -0,0 +1,57 @@
// allow-test-rule: runtime-contract-is-the-product (see #644) — the verify-time disposition of a test-tier
// prohibition is the deployed safety contract; this pins its fail-closed default to the code.
//
// RED-first SAFETY HALF of ADR-550 Decision 5(d) [maintainer decision 2026-06-12 "B-with-guard"].
// A WELL-FORMED test-tier prohibition (statement + status: resolved + verification: test) with NO
// wired enforcement evidence MUST yield a NON-GREEN / flagged-unverified disposition — proving an
// unwired test-tier item can NEVER be silently skipped (fail-closed default).
//
// This lives in its OWN test file (not the schema file) because it asserts a verify-disposition
// behavior — a different production module than frontmatter. The deterministic disposition helper
// does not exist yet (plan 01-04 implements the fail-closed default; the seam mirrors
// projectProhibitions in probe-core) — assert its expected contract so this is RED now.
//
// This is the cheap safety-guarantee half; the heavy "real negative-test enforcement mechanism" half
// is OUT of #644 scope (follow-up PR). No `polarity` key; no LLM judgment asserted (ADR-550 D5).
'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 PROBE_CORE_LIB = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'probe-core.cjs');
describe('prohibition-probe verify-tier: test-tier fail-closed safety (PROB-12 / ADR-550 D5d)', () => {
test('probe-core exports a deterministic prohibition-disposition helper', () => {
const pc = require(PROBE_CORE_LIB);
assert.equal(typeof pc.dispositionForProhibition, 'function',
'probe-core must export dispositionForProhibition() — the deterministic verify-time disposition (ADR-550 D5d)');
});
test('a well-formed test-tier item with NO enforcement evidence is flagged non-green (fail-closed)', () => {
const pc = require(PROBE_CORE_LIB);
// Synthetic, well-formed test-tier prohibition with NO wired enforcement evidence.
const unwiredTestTier = {
requirement_id: 'R1',
category: 'safety',
status: 'resolved',
verification: 'test',
resolution: null,
reason: null,
statement: 'MUST NOT store raw SSN in plaintext',
// deliberately: no enforcement evidence wired (no test reference / no proof)
};
const disposition = pc.dispositionForProhibition(unwiredTestTier, { enforcementEvidence: [] });
// The disposition must NOT be a silent pass. Accept any explicit non-green signal the helper
// chooses, but it must be unambiguously NOT 'green'/'pass' and must carry a flag.
assert.ok(disposition && typeof disposition === 'object', 'disposition must be a structured object');
assert.notEqual(disposition.status, 'green', 'an unwired test-tier item must NEVER be green (fail-closed)');
assert.notEqual(disposition.status, 'pass', 'an unwired test-tier item must NEVER pass silently (fail-closed)');
assert.equal(disposition.flagged, true,
'an unwired test-tier item must be flagged unverified — it can never be silently skipped');
});
});

View File

@@ -51,7 +51,7 @@
"note.md": 6563,
"pause-work.md": 14397,
"plan-milestone-gaps.md": 11765,
"plan-phase.md": 92120,
"plan-phase.md": 92759,
"plan-review-convergence.md": 23468,
"plant-seed.md": 11741,
"pr-branch.md": 9561,
@@ -72,7 +72,7 @@
"ship.md": 24388,
"sketch-wrap-up.md": 14223,
"sketch.md": 19960,
"spec-phase.md": 23094,
"spec-phase.md": 27589,
"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": 28430,
"verify-phase.md": 30875,
"verify-work.md": 31157
}