Files
msd-core/tests/section-manifest.property.test.cjs
Tom Boucher a987cf2731 chore(#2932): emit a per-invocation section manifest from the init bundle (#2987)
* chore(#2932): emit a per-invocation section manifest from init

Extends the init bundle with a typed per-invocation section manifest so an
invocation loads only the branch guidance it will actually take.

The three flag/state-gated branches in execute-phase.md move into their own
step files; the parent keeps its gsd:section markers wrapping a one-line
on-demand reference, so each section's prose lives in exactly one file and
the parent shrinks 93369 -> 89507 bytes. A new drift-guarded generator
derives the shipped section manifest from those markers, and a new pure
evaluator maps invocation facts to applicable section ids.

The evaluator is a lookup over the frozen WHEN_VOCABULARY, never a parser
(Greenspun's Tenth Rule, ADR-1671:69); a parity test asserts the vocabulary
and the predicate map stay exhaustively in sync.

Closes #2932

* fix(#2932): fail closed on prototype-chain when values

An isolated adversarial review found WHEN_PREDICATES[section.when] was a
bracket lookup on a plain-prototype object, so inherited Object.prototype
members resolved as predicates: "constructor"/"toString"/"valueOf"/
"hasOwnProperty" returned truthy and SILENTLY INCLUDED the section, and
"__proto__" threw an untyped TypeError carrying no .reason. Both violate
the module's documented fail-closed contract, and the manifest is read from
disk at run time so it cannot be assumed trustworthy.

Builds the predicate map on a null prototype and guards the lookup with an
explicit Object.hasOwn check. Adds table-driven coverage for nine
Object.prototype-shaped keys asserting the TYPED reason (asserting only
that it throws would still pass while broken) plus a fast-check property
injecting a hostile value at an arbitrary document position.

* test(#2932): retarget execute-phase step assertions at extracted step files

* fix(#2932): emit typed reasons for generator lib-load and write failures

* fix(#2932): restore launcher preamble in extracted steps and refresh derived fixtures

* chore(#2932): backfill changeset pr number to 2987

---------

Co-authored-by: sim <sim@local>
2026-08-02 12:34:41 -04:00

202 lines
8.1 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 src/section-manifest.cts (compiled to
* gsd-core/bin/lib/section-manifest.cjs) — issue #2932 (epic #1671 Phase 5).
* Covers 50-test-matrix.md rows 25-28.
*
* Document-shaped generators (CONTRIBUTING.md "Fixture provenance #2371",
* mirroring tests/workflow-fragments.property.test.cjs): section lists are
* generated as arbitrary document-order id/when sequences — the SHAPE a
* real `parseWorkflowSections` output would have — never by round-tripping
* through `selectSections`/`WHEN_PREDICATES` itself. `when` values are drawn
* from the module's own frozen `WHEN_VOCABULARY` re-export (imported from
* `workflow-fragments.cjs`, the true source of truth) rather than a
* hardcoded local copy, so the generator can never silently desync from
* production (DEFECT.GENERATIVE-FIX).
*
* Deterministic per CONTRIBUTING.md: seed and numRuns are pinned by
* tests/helpers/fast-check-setup.cjs (seed 42, numRuns 200).
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fc = require('./helpers/fast-check-setup.cjs');
const { selectSections, REASON } = require('../gsd-core/bin/lib/section-manifest.cjs');
const { WHEN_VOCABULARY } = require('../gsd-core/bin/lib/workflow-fragments.cjs');
const WHEN_VALUES = [...WHEN_VOCABULARY];
// Object.prototype-shaped `when` values — added during review — prototype-
// chain fail-open found by isolated adversarial pass. A bracket lookup on a
// plain frozen object resolves these as inherited members instead of failing
// closed; the totality property below must cover them too.
const HOSTILE_WHEN_VALUES = [
'constructor',
'toString',
'valueOf',
'hasOwnProperty',
'__proto__',
'prototype',
'isPrototypeOf',
'propertyIsEnumerable',
'toLocaleString',
];
// ─── Document-shaped generators ────────────────────────────────────────────
const whenArb = fc.constantFrom(...WHEN_VALUES);
// Ids are made unique WITHIN one generated document by suffixing the array
// index at assembly time (below) rather than relying on the raw string
// generator for uniqueness — this keeps the shape arbitrary while still
// letting assertions key on "this exact id".
const idBaseArb = fc.stringMatching(/^[a-z][a-z0-9-]{0,8}$/);
/** A document-order list of `{id, when}` sections, id uniqueness enforced by index-suffixing. */
const sectionsArb = fc
.array(fc.tuple(idBaseArb, whenArb), { minLength: 0, maxLength: 15 })
.map((pairs) => pairs.map(([base, when], idx) => ({ id: `${base}-${idx}`, when })));
const phaseNumberArb = fc.oneof(
fc.constant(null),
fc.constant(''),
fc.stringMatching(/^[0-9]{1,2}$/),
fc.stringMatching(/^[0-9]{1,2}\.[0-9]{1,2}$/),
);
const factsArb = fc.record({
waveFlag: fc.boolean(),
phaseNumber: phaseNumberArb,
hasPriorPhases: fc.boolean(),
});
// ─── Row 25: exact partition ────────────────────────────────────────────────
describe('property: selection is always an exact partition', () => {
test('selectionIsAlwaysAnExactPartitionOfInputSections', () => {
fc.assert(
fc.property(sectionsArb, factsArb, (sections, facts) => {
const { included, excluded } = selectSections(sections, facts);
const allIds = sections.map((s) => s.id);
// Union recovers every id exactly once, intersection is empty.
const unionSorted = [...included, ...excluded].sort();
assert.deepEqual(unionSorted, [...allIds].sort());
const intersection = included.filter((id) => excluded.includes(id));
assert.deepEqual(intersection, []);
}),
);
});
});
// ─── Row 26: `always` sections included under every fact combination ──────
describe('property: always sections are included under every fact combination', () => {
test('alwaysSectionsAreIncludedUnderEveryFactCombination', () => {
fc.assert(
fc.property(sectionsArb, factsArb, (sections, facts) => {
const { included } = selectSections(sections, facts);
const alwaysIds = sections.filter((s) => s.when === 'always').map((s) => s.id);
for (const id of alwaysIds) {
assert.equal(included.includes(id), true, `expected always-section "${id}" to be included`);
}
}),
);
});
});
// ─── Row 27: totality — never throws for vocab-valid when × arbitrary facts ─
describe('property: never throws for vocabulary-valid when and arbitrary facts', () => {
test('neverThrowsForVocabularyValidWhenAndArbitraryFacts', () => {
fc.assert(
fc.property(sectionsArb, factsArb, (sections, facts) => {
assert.doesNotThrow(() => selectSections(sections, facts));
}),
);
});
test('neverThrowsWhenFactsAreMissingKeysEntirely', () => {
// Totality also over PARTIAL facts objects (row 19's property-level
// twin): dropping zero or more of the three fact keys must never throw.
const factKeys = ['waveFlag', 'phaseNumber', 'hasPriorPhases'];
fc.assert(
fc.property(sectionsArb, factsArb, fc.subarray(factKeys), (sections, facts, keysToKeep) => {
const partialFacts = {};
for (const key of keysToKeep) partialFacts[key] = facts[key];
assert.doesNotThrow(() => selectSections(sections, partialFacts));
}),
);
});
});
// ─── Added during review: totality fails closed for prototype-shaped keys ──
// A hostile, Object.prototype-shaped `when` value injected anywhere in an
// otherwise vocab-valid document must always throw REASON.UNKNOWN_WHEN and
// must never appear in `included` — the prototype-chain fail-open the
// isolated adversarial pass found (constructor/toString/etc. resolving as
// truthy inherited members, or `__proto__` throwing an untyped error).
describe('property: prototype-shaped when values always fail closed', () => {
const hostileWhenArb = fc.constantFrom(...HOSTILE_WHEN_VALUES);
const hostileIdArb = fc.stringMatching(/^[a-z][a-z0-9-]{0,8}$/);
test('injectingAHostileWhenAnywhereAlwaysThrowsUnknownWhen', () => {
fc.assert(
fc.property(
sectionsArb,
hostileIdArb,
hostileWhenArb,
fc.nat(),
(sections, hostileIdBase, hostileWhen, rawIndex) => {
const hostileSection = { id: `hostile-${hostileIdBase}`, when: hostileWhen };
const insertAt = sections.length === 0 ? 0 : rawIndex % (sections.length + 1);
const withHostile = [...sections.slice(0, insertAt), hostileSection, ...sections.slice(insertAt)];
let caught;
try {
selectSections(withHostile, {});
} catch (err) {
caught = err;
}
assert.ok(caught instanceof TypeError, 'expected selectSections to throw for a hostile when value');
assert.equal(caught.reason, REASON.UNKNOWN_WHEN);
},
),
);
});
});
// ─── Row 28: order preservation ─────────────────────────────────────────────
describe('property: included ids preserve document order', () => {
test('includedIdsPreserveDocumentOrder', () => {
fc.assert(
fc.property(sectionsArb, factsArb, (sections, facts) => {
const { included, excluded } = selectSections(sections, facts);
const allIds = sections.map((s) => s.id);
// A subsequence check: the positions of `included` ids within
// `allIds`, taken in the order they appear in `included`, must be
// strictly increasing (never reordered relative to the input).
let cursor = -1;
for (const id of included) {
const pos = allIds.indexOf(id, cursor + 1);
assert.ok(pos > cursor, `id "${id}" out of document order in included[]`);
cursor = pos;
}
// Same subsequence guarantee for excluded[].
cursor = -1;
for (const id of excluded) {
const pos = allIds.indexOf(id, cursor + 1);
assert.ok(pos > cursor, `id "${id}" out of document order in excluded[]`);
cursor = pos;
}
}),
);
});
});