* chore(#2992): widen the when= grammar and key the section manifest per workflow Epic #1671 Phase 6.1. Two blockers stopped the fragment model reaching any file beyond execute-phase.md: the when= vocabulary was frozen at 4 atoms (3 execute-phase-specific), and the section manifest was single-workflow by construction with 'execute-phase' hardcoded into buildSectionManifestField. - widen WHEN_VOCABULARY 4 -> 14 via a coordinated ADR-1671 amendment; the grammar stays CLOSED (one atom, no operators, negation or nesting) and WHEN_PREDICATES stays a hand-written literal map, never deriving a predicate from its atom string - InvocationFacts gains flags: ReadonlySet<string> plus three computed state booleans; add the missing reverse vocabulary/predicate parity guard - key the manifest artifact per workflow; a stale flat {sections:[...]} artifact now fails shape validation instead of being misattributed - wire the field into six init entry points and parse the flags each needs An atom ships only with both a real consuming section and a fact the init seam actually computes. Six surveyed atoms are withheld because their workflows have no dedicated init entry point; an atom without a computed fact evaluates false forever and silently disables its own section. Fixes a defect found while wiring: parseNamedArgs always materializes a boolean flag key, so folding its false into the absent sentinel is required or every flag reads as present and gating is silently always-on. Also resolves ADR-1671:194 by measurement: --mvp stays unmarkable, because its interleaved sites are always-run flag resolution and a ~340 byte block that already delegates lazily. Refs #2992 * fix(#2992): treat any falsy option value as an absent flag and reject unsafe manifest read paths Findings from two orthogonal reviews (Claude /code-review + an isolated adversarial pass); both independently reproduced the first one. - MAJOR: the flags-builder treated only `undefined` as absent, but parseNamedArgs yields `null` for an absent value-flag and `false` for an absent boolean-flag, so `--granularity` read as present on every plan-phase invocation. Fixed at the root: a flag is present iff its option value is truthy. The six per-handler `|| undefined` folds are now redundant and removed, which also closes the duplicate-translation and missed-onboard-handler findings. - MAJOR: state:needs-codebase-map had zero coverage. Added unit, property and real-CLI integration tests. - MINOR: reject absolute, UNC/drive and `..`-traversing `read` paths in the manifest, degrading the whole load to null like every other shape violation. Verified: `/etc/passwd` previously reached section_manifest.read. - MINOR: corrected a stale "4 to 20" doc comment; the vocabulary is 14. Refs #2992 * test(#2992): update the generator suite for the per-workflow manifest shape The remote matrix went red with 5 unique failures, identical on linux-node22 and linux-node24, all in tests/gen-section-manifest.test.cjs. Re-keying the artifact to {workflows:{...}} left this suite asserting the old flat {sections:[...]} shape; nothing else in the tree still does. - three tests read manifest.sections.length, now undefined; retargeted at workflows.<name> with their original intent preserved (a fenced or loop-host marker still asserts NO section is produced, not merely a changed count) - the stale-manifest test wrote its fixture in the OLD shape, so it tripped shape validation and stopped exercising staleness at all. Its fixture is now valid-but-mismatched so FAIL_STALE is genuinely reached again. - added the coverage that exposed: a pre-6.1 flat artifact must report FAIL_MANIFEST_MALFORMED_SHAPE. That is the real upgrade path for an installed tree and nothing covered it. Refs #2992 * chore(#2992): backfill changeset pr number to 3013 --------- Co-authored-by: sim <sim@local>
235 lines
9.7 KiB
JavaScript
235 lines
9.7 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}$/),
|
||
);
|
||
|
||
// Flag atoms drawn from the module's own frozen WHEN_VOCABULARY re-export
|
||
// (DEFECT.GENERATIVE-FIX — never a hardcoded local copy of flag names).
|
||
const FLAG_TOKENS = WHEN_VOCABULARY.filter((w) => w.startsWith('flag:--')).map((w) => w.slice('flag:'.length));
|
||
|
||
const flagsArb = fc.subarray(FLAG_TOKENS).map((tokens) => new Set(tokens));
|
||
|
||
const factsArb = fc.record({
|
||
flags: flagsArb,
|
||
phaseNumber: phaseNumberArb,
|
||
hasPriorPhases: fc.boolean(),
|
||
// #2992 review finding: the three newer state:* booleans (needsCodebaseMap,
|
||
// phaseMvpMode, worktreesEnabled) must also participate in the partition
|
||
// invariant, not just the original three W/D/P facts.
|
||
needsCodebaseMap: fc.boolean(),
|
||
phaseMvpMode: fc.boolean(),
|
||
worktreesEnabled: 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 six fact keys must never throw.
|
||
const factKeys = ['flags', 'phaseNumber', 'hasPriorPhases', 'needsCodebaseMap', 'phaseMvpMode', 'worktreesEnabled'];
|
||
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);
|
||
},
|
||
),
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── B18: flag monotonicity — more flags never yields fewer included ───────
|
||
|
||
describe('property: adding more flags never yields fewer included sections (#2992 row B18)', () => {
|
||
test('flagSupersetNeverShrinksIncluded', () => {
|
||
fc.assert(
|
||
fc.property(sectionsArb, flagsArb, fc.subarray(FLAG_TOKENS), factsArb, (sections, baseFlags, extraTokens, restFacts) => {
|
||
const supersetFlags = new Set([...baseFlags, ...extraTokens]);
|
||
const baseResult = selectSections(sections, { ...restFacts, flags: baseFlags });
|
||
const supersetResult = selectSections(sections, { ...restFacts, flags: supersetFlags });
|
||
|
||
for (const id of baseResult.included) {
|
||
assert.ok(
|
||
supersetResult.included.includes(id),
|
||
`id "${id}" included under a flags subset must remain included under a flags superset`,
|
||
);
|
||
}
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── 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;
|
||
}
|
||
}),
|
||
);
|
||
});
|
||
});
|