* test(1278): RED-first descriptor parity + fail-closed guards + CHK-07 byte-stability (wave 1) - CHK-03 (RED): extend PROB-14 parity in prohibition-probe.schema.test.cjs to carry the flat check_kind/check_target/check_rule scalars through project->write->parseMustHavesBlock; the non-droppable check_kind-presence assertion is the load-bearing RED trigger (fails because projectProhibitions strips check_* on the current build). - CHK-07 (GREEN forward-guard): probe-core.test.cjs pins descriptor-less byte-stability + dispositionForProhibition fail-closed policy, with a t.todo marker forward-locking plan 01-02. - CHK-06 (RED): prohibition-enforcement.test.cjs asserts descriptorFromProjection export + fail-closed on absent/partial/unknown descriptors via the projection adapter (RED until 01-03). - No src/*.cts or .cjs edits; no new test files; lint-test-file-count clean. * feat(1278): add optional flat-scalar check descriptor fields to Prohibition interface (wave 2) - check_kind?/check_target?/check_rule? mirror CheckDescriptor.kind/target/rule (minus caller-attested failFirst, #1279) - optional so existing Prohibition consumers compile unchanged * feat(1278): project check descriptor as flat scalars in projectProhibitions (wave 2) - emit check_kind/check_target (+ check_rule only for lint-rule with a rule) when descriptor well-formed - under-specified/descriptor-less items project byte-identically (CHK-07); flat scalars ride existing parseMustHavesBlock continuation-KV path (no parser rewrite) - add CHK-02 probe-core unit cases pinning the projection - turns CHK-03 parity test GREEN; dispositionForProhibition untouched * feat(1278): descriptorFromProjection read-back adapter feeds fail-closed locate (wave 3) - Add descriptorFromProjection(projected) -> CheckDescriptor | null to src/prohibition-enforcement.cts: renames the projected flat scalars check_kind/check_target/check_rule -> {kind,target,rule?}, or null when the descriptor is absent/non-object (no check_kind key). - failFirst is NEVER sourced from the projection (stays caller-attested; #1279). - rule is set only when check_rule is a non-empty string; the adapter does NOT re-validate kind/target/rule — an under-specified descriptor reconstructs to one the EXISTING runProhibitionEnforcement LOCATE guard rejects (located:false, never green). The merged #1259 guard stays the single source of fail-closed truth. - Turns the RED CHK-06 fail-closed tests (plan 01-01) GREEN end-to-end; CHK-03 / CHK-07 stay green. CheckDescriptor type, locate guard, dispositionForProhibition, and parseMustHavesBlock are unchanged (additive +36/-0). * feat(1278): verify-phase locates prohibition check from projected descriptor (wave 3) - request.check kind/target/rule sourced from projected check_kind/check_target/check_rule via descriptorFromProjection, not verifier invention (CHK-05) - replaces the #1278 author-supplied / tracked-follow-up note with the delivered deterministic-locate behavior - preserves fail-closed routing: absent/partial descriptor -> never green, hard-gate in both modes - failFirst stays a verify-time caller attestation; #1279 bounds the remaining fail-first proof * feat(1278): spec-phase captures wired-check descriptor on test-tier resolution (wave 3) - Step 5.6 'Keep it' / verification: test path captures check_kind/check_target/check_rule, projected onto must_haves.prohibitions for verify-phase deterministic locate (CHK-04) - SOFT capture: a test-tier prohibition without a descriptor is still allowed (no hard authoring block); stays fail-closed/flagged downstream - --auto captures only an unambiguous descriptor, never fabricates a check path - failFirst NOT captured at spec-phase (verify-time attestation; #1279) - PROB-06 soft-gate + text-mode (PROB-09) behavior unchanged * chore(1278): re-baseline workflow size for grown verify-phase + spec-phase prose (wave 3) - spec-phase.md 28438 -> 30343 (+1905), verify-phase.md 35362 -> 36498 (+1136) - regenerated via npm run size:baseline (no hand-picked numbers); growth is the #1278 deterministic-locate + descriptor-capture prose - workflow-size-budget guard green (122/122) * docs(1278): ratify optional check descriptor in dated ADR-550 addendum + type:Changed changeset - Append dated 2026-06-15 ADR-550 addendum ratifying the D3 prohibition-item shape extension (optional flat-scalar check_kind/check_target/check_rule) - Document flat-scalar rationale, deterministic projection/read-back, fail-closed on partial/invalid/absent, #1279/policy out-of-scope - Add .changeset/1278-prohibition-check-descriptor.md (type: Changed) * docs(1278): document optional check descriptor in prohibition-probe reference + FEATURES - Add 'Optional wired-check descriptor (deterministic locate, #1278)' section to the prohibition-probe reference (flat-scalar keys, projection/read-back, fail-closed + backward-compat, failFirst stays attested) - Add deterministic prohibition-check descriptor source entry to FEATURES.md - No CONTEXT.md glossary change: descriptor reuses existing wired-check / verification:test vocabulary, no new glossary term introduced * fix(1278): pass packaging gates — changeset pr field + retired slash-form fix - Add required pr: 1278 to changeset (lint:changeset MISSING_PR hard requirement; plan's 'omit if unknown' was inaccurate — issue number per #1259 convention, updated to real PR number when opened) [Rule 3 - blocking] - Fix retired /gsd-spec-phase -> /gsd:spec-phase at verify-phase.md:83 (wave-3 prose; caught by slash-namespace invariant #3443/bug-2543, blocked CHK-09 full-suite-green) [Rule 1 - bug] - size:baseline + INVENTORY manifest verified in-sync post-build (no diff) * docs(1278): add check descriptor + descriptorFromProjection to CONTEXT.md prohibition glossary * fix(1278): harden descriptorFromProjection round-trip (numeric-coercion + stray-rule) per review - MD-01/LW-01: narrow projected scalars to primitives + String()-coerce, so a numeric-looking check_target (parseMustHavesBlock coerces ^\d+$ to number) reconstructs as a string and locates instead of silently un-locating; no as-string type-lie, satisfies no-base-to-string. - LW-02: attach rule only for the lint-rule kind (drop a stray node-test rule). - LW-03: document the optional check_* keys in the reference Output schema. RED->GREEN tests added in prohibition-enforcement.test.cjs. * chore(1278): set changeset pr to 1301 * test(1278): add fast-check property for the check-descriptor round-trip + fail-closed (trek-e review) RULESET.TESTS.property-based-testing: the projectProhibitions -> render -> parseMustHavesBlock -> descriptorFromProjection chain is a bijective/transformation contract. Adds 2 fc properties to tests/probe-core.property.test.cjs (no new file; ratchet stays at 2 for probe-core): - well-formed descriptors survive the round-trip across the full string domain incl. the numeric-coercion case (target/rule reconstruct as strings); - under-specified/invalid descriptors (absent / target-less / rule-less / unknown-kind) are always fail-closed (never green, flagged, unlocated). Stability is asserted at the descriptorFromProjection layer (the raw parse step is intentionally lossy for numeric scalars; the shared parser is unchanged). --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
272 lines
14 KiB
JavaScript
272 lines
14 KiB
JavaScript
'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}`);
|
||
}
|
||
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;
|
||
}),
|
||
);
|
||
});
|
||
});
|