* 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>
202 lines
8.1 KiB
JavaScript
202 lines
8.1 KiB
JavaScript
'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;
|
||
}
|
||
}),
|
||
);
|
||
});
|
||
});
|