Files
msd-core/tests/section-manifest.property.test.cjs
Tom Boucher f1af47766a chore(#1671): widen the when= grammar and key the section manifest per workflow — Phase 6.1 (#3013)
* 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>
2026-08-02 22:36:45 -04:00

235 lines
9.7 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}$/),
);
// 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;
}
}),
);
});
});