Files
msd-core/tests/probe-core.property.test.cjs
Dave c09c13f295 enhance(verify-phase): node-test causation control — prove the RED is content-caused (#1346)
The #1279 node-test machine-proof confirmed a known-bad subject drives the
negative test RED, but could not distinguish a genuine content-violation from a
deceptive test that reds merely because GSD_PROHIB_SUBJECT is set. Add an
optional fifth flat scalar `check_clean_fixture` (-> CheckDescriptor.cleanFixture)
threading a KNOWN-CLEAN control subject through projectProhibitions +
descriptorFromProjection. When present, the prover also runs the check against
the clean subject and requires GREEN, so fail-first is proven only when the check
is RED on the violation AND GREEN on the clean subject (content-dependent).

Opt-in and additive: absent a clean fixture the prover behaves exactly as
post-#1314 (no control, documented residual), preserving the zero-authoring
compose path; the lint-rule kind needs no analog (its subject IS the linted
file, no env indirection). Coverage: RED-first deceptive case, positive,
missing-clean fail-closed, round-trip read-back/emit, fast-check property
extended to the 5th scalar, and an end-to-end COMPOSE capstone (honest vs
deceptive). Docs: ADR-550 dated addendum, prohibition-probe reference,
spec-phase + verify-phase workflows.

Closes #1346

Claude-Session: https://claude.ai/code/session_01GsPRb8zvpcT7Eat6vZw8PX
2026-06-21 10:44:48 -04:00

281 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* Property-based tests for probe-core.cjs (ADR-550 Decision 7).
*
* Module: gsd-core/bin/lib/probe-core.cjs (generated from src/probe-core.cts)
* Exercised: analyzeCoverage(items, resolutions?, validators) — the generic
* merge/rollup/orphan-reject engine shared by the edge probe and the #644
* prohibition probe.
*
* trek-e re-review #7 N2 (RULESET.TESTS.property-based-testing): analyzeCoverage is a
* transformation/rollup module (items × resolutions → CoverageReport) — exactly the
* class the predicate covers. The example-based suite (tests/probe-core.test.cjs)
* pins specific scenarios; these properties pin the algebraic invariants that must
* hold for EVERY valid scenario.
*
* Properties tested:
* (a) closed-set identity: applicable === resolved + unresolved (and === items.length)
* (b) byVerification sums ≤ resolved (dismissed is closed but unverified)
* (c) per-tier byVerification ≤ resolved, and only `resolved`-status items are counted
* (d) determinism: same input → identical CoverageReport (stable rollup)
* (e) orphan rejection is stable: a resolution matching no proposed item always throws
*/
const { describe, test } = require('node:test');
const path = require('node:path');
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.
const VALIDATORS = {
categories: ['adjacency', 'empty', 'ordering'],
verification: ['explicit', 'backstop'],
requiredFieldsByVerification: { explicit: ['resolution'], backstop: ['resolution'] },
};
function bareItem(requirement_id, category) {
return {
requirement_id,
category,
status: 'unresolved',
verification: null,
resolution: null,
reason: null,
probe: `probe-for-${category}`,
};
}
const catArb = fc.constantFrom(...VALIDATORS.categories);
const idArb = fc.constantFrom('R1', 'R2', 'R3', 'R4', 'R5');
const keyArb = fc.record({ requirement_id: idArb, category: catArb });
// Unique (requirement_id, category) keys — the merge keys analyzeCoverage maps on.
const keyOf = (k) => `${k.requirement_id}::${k.category}`;
const uniqueKeysArb = fc.uniqueArray(keyArb, { selector: keyOf, minLength: 0, maxLength: 12 });
// Each unique item key gets one resolution disposition. Resolution text/reason use fixed
// non-empty literals — the counting invariants are independent of their content, and this
// keeps the generator off the validateResolution rejection paths (which the example suite
// already covers exhaustively).
const DISPOSITIONS = ['none', 'resolved-explicit', 'resolved-backstop', 'dismissed', 'unresolved'];
function resolutionFor(k, disposition) {
const base = { requirement_id: k.requirement_id, category: k.category };
switch (disposition) {
case 'resolved-explicit':
return { ...base, status: 'resolved', verification: 'explicit', resolution: 'AC#1' };
case 'resolved-backstop':
return { ...base, status: 'resolved', verification: 'backstop', resolution: 'held-out PBT suite' };
case 'dismissed':
return { ...base, status: 'dismissed', reason: 'bounded enum — not applicable' };
case 'unresolved':
return { ...base, status: 'unresolved' };
default: // 'none' — author left no resolution; item rolls up verbatim (bare unresolved)
return null;
}
}
// A fully valid scenario: unique items (all bare-unresolved) + a per-item resolution choice.
const scenarioArb = uniqueKeysArb.chain((keys) =>
fc.tuple(...keys.map(() => fc.constantFrom(...DISPOSITIONS))).map((choices) => {
const items = keys.map((k) => bareItem(k.requirement_id, k.category));
const resolutions = [];
keys.forEach((k, i) => {
const r = resolutionFor(k, choices[i]);
if (r) resolutions.push(r);
});
return { items, resolutions };
}),
);
describe('probe-core property: analyzeCoverage algebraic invariants', () => {
test('(a) closed-set identity: applicable === resolved + unresolved === items.length', () => {
fc.assert(
fc.property(scenarioArb, ({ items, resolutions }) => {
const { coverage } = pc.analyzeCoverage(items, resolutions, VALIDATORS);
return (
coverage.applicable === coverage.resolved + coverage.unresolved &&
coverage.applicable === items.length
);
}),
);
});
test('(b) sum(byVerification) ≤ resolved — dismissed counts closed but unverified', () => {
fc.assert(
fc.property(scenarioArb, ({ items, resolutions }) => {
const { coverage } = pc.analyzeCoverage(items, resolutions, VALIDATORS);
const verifiedTotal = Object.values(coverage.byVerification).reduce((a, b) => a + b, 0);
return verifiedTotal <= coverage.resolved && verifiedTotal >= 0;
}),
);
});
test('(c) byVerification only counts resolved-status items, and matches a direct recount', () => {
fc.assert(
fc.property(scenarioArb, ({ items, resolutions }) => {
const rep = pc.analyzeCoverage(items, resolutions, VALIDATORS);
for (const tier of VALIDATORS.verification) {
const recount = rep.items.filter((i) => i.status === 'resolved' && i.verification === tier).length;
if (rep.coverage.byVerification[tier] !== recount) return false;
if (rep.coverage.byVerification[tier] > rep.coverage.resolved) return false;
}
return true;
}),
);
});
test('(d) determinism: identical inputs produce an identical CoverageReport', () => {
fc.assert(
fc.property(scenarioArb, ({ items, resolutions }) => {
const a = pc.analyzeCoverage(items, resolutions, VALIDATORS);
const b = pc.analyzeCoverage(items, resolutions, VALIDATORS);
return JSON.stringify(a) === JSON.stringify(b);
}),
);
});
});
describe('probe-core property: orphan rejection is stable', () => {
// An orphan resolution carries an id ('Z9') that no generated item ever uses, so its
// (requirement_id, category) key never matches a proposed item. The resolution is itself
// structurally VALID (a bare unresolved), so it clears validateResolution and reaches the
// orphan-reject guard — isolating that guard from input-validation throws.
const orphanScenarioArb = fc.record({
keys: uniqueKeysArb,
orphanCategory: catArb,
});
test('(e) a resolution matching no proposed item always throws', () => {
fc.assert(
fc.property(orphanScenarioArb, ({ keys, orphanCategory }) => {
const items = keys.map((k) => bareItem(k.requirement_id, k.category));
const orphan = { requirement_id: 'Z9', category: orphanCategory, status: 'unresolved' };
let threwForOrphan = false;
try {
pc.analyzeCoverage(items, [orphan], VALIDATORS);
} catch (e) {
threwForOrphan = /unknown resolution|no matching proposed item/i.test(e.message);
}
return threwForOrphan;
}),
);
});
});
// ─── #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}`);
if (e.check_violation_fixture !== undefined) lines.push(` check_violation_fixture: ${e.check_violation_fixture}`);
if (e.check_clean_fixture !== undefined) lines.push(` check_clean_fixture: ${e.check_clean_fixture}`);
}
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. The violation
// fixture and the clean control fixture (#1346) both ride BOTH kinds and exercise numeric coercion too.
const wellFormedArb = KIND_ARB.chain((kind) =>
fc.record({ target: targetArb, rule: pathScalarArb, fixture: targetArb, clean: targetArb })
.map(({ target, rule, fixture, clean }) => {
const item = { ...BASE_TIER, check_kind: kind, check_target: target,
check_violation_fixture: fixture, check_clean_fixture: clean };
if (kind === 'lint-rule') item.check_rule = rule;
return { item, kind, target, rule: kind === 'lint-rule' ? rule : undefined, fixture, clean };
}),
);
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, fixture, clean }) => {
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;
// violationFixture (#1346) survives the round-trip as a string (numeric-coercion normalized).
if (typeof d.violationFixture !== 'string' || d.violationFixture !== fixture) return false;
// cleanFixture (#1346) survives the round-trip as a string too (numeric-coercion normalized).
if (typeof d.cleanFixture !== 'string' || d.cleanFixture !== clean) 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;
}),
);
});
});