* 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>
This commit is contained in:
@@ -138,10 +138,11 @@ describe('gen-section-manifest.cjs --check / --write (matrix D)', () => {
|
||||
markerSource('handle-x', 'always', 'handle-x.md'),
|
||||
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
|
||||
);
|
||||
// Stale: valid shape, but "when" no longer matches the source's marker.
|
||||
// Stale: valid {workflows:{...}} shape, but "when" no longer matches the
|
||||
// source's marker (must reach FAIL_STALE, not trip the shape check).
|
||||
fs.writeFileSync(
|
||||
manifestPath,
|
||||
JSON.stringify({ sections: [{ id: 'handle-x', when: 'flag:--wave', read: 'gsd-core/workflows/sample/steps/handle-x.md' }] }, null, 2) + '\n',
|
||||
JSON.stringify({ workflows: { sample: [{ id: 'handle-x', when: 'flag:--wave', read: 'gsd-core/workflows/sample/steps/handle-x.md' }] } }, null, 2) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
@@ -248,6 +249,36 @@ describe('gen-section-manifest.cjs --check / --write (matrix D)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('rejectsPre6_1FlatSectionsShapeAsMalformed (upgrade-path supplemental: an installed tree still carrying the old flat {sections:[...]} artifact)', (t) => {
|
||||
const tmpRoot = createTempDir('gen-section-manifest-');
|
||||
t.after(() => cleanup(tmpRoot));
|
||||
|
||||
const { workflowsDir, manifestPath } = buildFixture(
|
||||
tmpRoot,
|
||||
'sample',
|
||||
markerSource('handle-x', 'always', 'handle-x.md'),
|
||||
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
|
||||
);
|
||||
|
||||
// Pre-6.1 committed artifact shape: a flat `{sections:[...]}` array with
|
||||
// no `workflows` key at all. Must be rejected as malformed, never
|
||||
// silently attributed to whichever workflow asks first (design row C4).
|
||||
fs.writeFileSync(
|
||||
manifestPath,
|
||||
JSON.stringify({ sections: [{ id: 'handle-x', when: 'always', read: 'gsd-core/workflows/sample/steps/handle-x.md' }] }, null, 2) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const r = runGenSectionManifest([
|
||||
'--check', '--json', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
|
||||
]);
|
||||
assert.equal(r.code, 1);
|
||||
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'a pre-6.1 flat manifest must not crash the generator');
|
||||
const report = parseJsonReport(r.stdout);
|
||||
assert.equal(report.ok, false);
|
||||
assert.equal(report.reason, REASON.FAIL_MANIFEST_MALFORMED_SHAPE, 'pre-6.1 flat {sections:[...]} shape must be rejected, not mistaken for up-to-date or stale');
|
||||
});
|
||||
|
||||
test('checkFailsWhenMarkerReferencesMissingStepFile (row 35)', (t) => {
|
||||
const tmpRoot = createTempDir('gen-section-manifest-');
|
||||
t.after(() => cleanup(tmpRoot));
|
||||
@@ -324,7 +355,7 @@ describe('gen-section-manifest.cjs --check / --write (matrix D)', () => {
|
||||
);
|
||||
|
||||
const fresh = buildFreshManifest(workflowsDir, tmpRoot);
|
||||
assert.equal(fresh.sections.length, 1);
|
||||
assert.equal(fresh.workflows.sample.length, 1);
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
|
||||
|
||||
const r = runGenSectionManifest([
|
||||
@@ -381,8 +412,8 @@ describe('gen-section-manifest.cjs --check / --write (matrix D)', () => {
|
||||
});
|
||||
|
||||
const fresh = buildFreshManifest(workflowsDir, tmpRoot);
|
||||
assert.equal(fresh.sections.length, 1, 'the fenced marker-shaped lines must not produce a section');
|
||||
assert.equal(fresh.sections[0].id, 'handle-x');
|
||||
assert.equal(fresh.workflows.sample.length, 1, 'the fenced marker-shaped lines must not produce a section');
|
||||
assert.equal(fresh.workflows.sample[0].id, 'handle-x');
|
||||
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
|
||||
const r = runGenSectionManifest([
|
||||
@@ -411,8 +442,8 @@ describe('gen-section-manifest.cjs --check / --write (matrix D)', () => {
|
||||
});
|
||||
|
||||
const fresh = buildFreshManifest(workflowsDir, tmpRoot);
|
||||
assert.equal(fresh.sections.length, 1, 'gsd:loop-host must never be treated as a gsd:section marker');
|
||||
assert.equal(fresh.sections[0].id, 'handle-x');
|
||||
assert.equal(fresh.workflows.sample.length, 1, 'gsd:loop-host must never be treated as a gsd:section marker');
|
||||
assert.equal(fresh.workflows.sample[0].id, 'handle-x');
|
||||
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
|
||||
const r = runGenSectionManifest([
|
||||
|
||||
@@ -3275,20 +3275,366 @@ describe('init section manifest', () => {
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null);
|
||||
});
|
||||
|
||||
// ── C4 (#2992): stale FLAT pre-6.1 artifact must degrade to null and
|
||||
// never be mis-attributed to any workflow — the upgrade-path row.
|
||||
|
||||
test('staleFlatPreWideningArtifactDegradesToNull (row C4)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-c4-');
|
||||
const flatPath = path.join(dir, 'flat-section-manifest.json');
|
||||
// The pre-#2932-Phase-6.1 shape: a single top-level `sections` array,
|
||||
// no `workflows` key at all. It even NAMES an execute-phase-shaped
|
||||
// section, so a mis-attribution bug would silently "work".
|
||||
fs.writeFileSync(
|
||||
flatPath,
|
||||
JSON.stringify({
|
||||
sections: [{ id: 'partial-wave', when: 'flag:--wave', read: 'gsd-core/workflows/execute-phase/steps/partial-wave.md' }],
|
||||
}),
|
||||
);
|
||||
const result = runExecutePhase(['1', '--wave', '1'], dir, { GSD_SECTION_MANIFEST: flatPath });
|
||||
assert.equal(result.status, 0, `expected exit 0 despite stale flat artifact, got ${result.status} (stderr: ${result.stderr})`);
|
||||
assertNoStackTrace(result.stderr, 'stale-flat-artifact');
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null, 'a pre-6.1 flat artifact must never be mis-parsed as some workflow\'s sections');
|
||||
});
|
||||
|
||||
// ── C8 (#2992): valid JSON that is not an object — one row each.
|
||||
|
||||
test('nonObjectJsonArtifactDegradesToNull (row C8)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-c8-');
|
||||
for (const hostileJson of ['0', '"str"', '[]', 'null', 'true']) {
|
||||
const hostilePath = path.join(dir, `hostile-${Buffer.from(hostileJson).toString('hex')}-section-manifest.json`);
|
||||
fs.writeFileSync(hostilePath, hostileJson);
|
||||
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: hostilePath });
|
||||
assert.equal(result.status, 0, `hostile JSON ${hostileJson}: expected exit 0 (stderr: ${result.stderr})`);
|
||||
assertNoStackTrace(result.stderr, `hostile-json:${hostileJson}`);
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null, `hostile JSON ${hostileJson} must degrade to null`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── C9 (#2992): file present but 0 bytes.
|
||||
|
||||
test('emptyZeroByteArtifactFileDegradesToNull (row C9)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-c9-');
|
||||
const emptyPath = path.join(dir, 'empty-section-manifest.json');
|
||||
fs.writeFileSync(emptyPath, '');
|
||||
assert.equal(fs.statSync(emptyPath).size, 0, 'sanity: fixture file must be 0 bytes');
|
||||
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: emptyPath });
|
||||
assert.equal(result.status, 0, `expected exit 0 despite empty artifact file, got ${result.status} (stderr: ${result.stderr})`);
|
||||
assertNoStackTrace(result.stderr, 'empty-artifact-file');
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null);
|
||||
});
|
||||
|
||||
// ── #2992 review finding: an unsafe `read` path degrades the WHOLE
|
||||
// load to null, exactly like every other shape violation above — the
|
||||
// field is documented as a POSIX-normalized, repo-root-RELATIVE path,
|
||||
// so an absolute path, a Windows drive/UNC prefix, or a `..` traversal
|
||||
// segment must never be trusted through to a later `fs.readFileSync`.
|
||||
|
||||
test('degradesToNullWhenReadPathIsAbsolute', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-unsafe-abs-');
|
||||
const manifestPath = path.join(dir, 'unsafe-abs-section-manifest.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({
|
||||
workflows: { 'execute-phase': [{ id: 'x', when: 'always', read: '/etc/passwd' }] },
|
||||
}));
|
||||
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath });
|
||||
assert.equal(result.status, 0, `expected exit 0 despite an absolute read path, got ${result.status} (stderr: ${result.stderr})`);
|
||||
assertNoStackTrace(result.stderr, 'unsafe-read-absolute');
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null, 'an absolute `read` path must degrade the whole load to null');
|
||||
});
|
||||
|
||||
test('degradesToNullWhenReadPathContainsDotDotTraversal', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-unsafe-dotdot-');
|
||||
const manifestPath = path.join(dir, 'unsafe-dotdot-section-manifest.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({
|
||||
workflows: { 'execute-phase': [{ id: 'x', when: 'always', read: '../../etc/passwd' }] },
|
||||
}));
|
||||
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath });
|
||||
assert.equal(result.status, 0, `expected exit 0 despite a ../ traversal read path, got ${result.status} (stderr: ${result.stderr})`);
|
||||
assertNoStackTrace(result.stderr, 'unsafe-read-dotdot');
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null, 'a `..` traversal segment in `read` must degrade the whole load to null');
|
||||
});
|
||||
|
||||
test('degradesToNullWhenReadPathIsWindowsDriveAbsolute', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-unsafe-windrive-');
|
||||
const manifestPath = path.join(dir, 'unsafe-windrive-section-manifest.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({
|
||||
workflows: { 'execute-phase': [{ id: 'x', when: 'always', read: 'C:\\Windows\\System32\\config' }] },
|
||||
}));
|
||||
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath });
|
||||
assert.equal(result.status, 0, `expected exit 0 despite a Windows drive-absolute read path, got ${result.status} (stderr: ${result.stderr})`);
|
||||
assertNoStackTrace(result.stderr, 'unsafe-read-windrive');
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null, 'a Windows drive-absolute `read` path must degrade the whole load to null');
|
||||
});
|
||||
});
|
||||
|
||||
// ── C1/C12/C16/C17 (#2992): shape guarantees on the field itself ────────
|
||||
|
||||
describe('init execute-phase: section_manifest field-shape guarantees (#2992)', () => {
|
||||
test('emptySectionsArrayIsComputedNotDegraded (row C12 — present + empty is not null)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-c12-');
|
||||
const emptyWorkflowPath = path.join(dir, 'empty-workflow-section-manifest.json');
|
||||
fs.writeFileSync(emptyWorkflowPath, JSON.stringify({ workflows: { 'execute-phase': [] } }));
|
||||
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: emptyWorkflowPath });
|
||||
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.deepStrictEqual(
|
||||
body.section_manifest,
|
||||
{ workflow: 'execute-phase', included: [], excluded: [], read: [] },
|
||||
'a workflow key present with sections:[] must compute to an empty-but-present selection, never null',
|
||||
);
|
||||
});
|
||||
|
||||
test('workflowAbsentFromArtifactDegradesToNullNotEmptySections (row C16 — absence is not empty)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-c16-');
|
||||
const otherWorkflowPath = path.join(dir, 'other-workflow-section-manifest.json');
|
||||
fs.writeFileSync(otherWorkflowPath, JSON.stringify({ workflows: { 'plan-phase': [{ id: 'x', when: 'always', read: 'x.md' }] } }));
|
||||
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: otherWorkflowPath });
|
||||
assert.equal(result.status, 0);
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.section_manifest, null, 'execute-phase has no key in this artifact — must degrade to null, never {included:[]}');
|
||||
});
|
||||
|
||||
test('executePhaseSectionManifestFieldIsByteIdenticalToThePreChangeShape (row C1 — Hyrum gate)', (t) => {
|
||||
// Locks the EXACT shape execute-phase's real, shipped manifest produces
|
||||
// for a plain (no --wave, no gap-closure, no prior phases) invocation —
|
||||
// #2992 widened the vocabulary and generalized the artifact to
|
||||
// per-workflow keying, but execute-phase's own 3 real sections (all
|
||||
// pre-existing atoms) must select IDENTICALLY to before this change.
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-c1-');
|
||||
const body = parseOkJson(runExecutePhase(['1'], dir), 'c1-baseline');
|
||||
assert.deepStrictEqual(body.section_manifest, {
|
||||
workflow: 'execute-phase',
|
||||
included: [],
|
||||
excluded: ['partial-wave', 'gap-closure-artifacts', 'regression-gate'],
|
||||
read: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('restOfTheInitBundleIsUnaffectedByEverySectionManifestDegradedCondition (row C17 — Hyrum gate)', (t) => {
|
||||
// The `section_manifest` field is additive; every OTHER field of the
|
||||
// init-bundle (22 direct dependents per the design doc's blast-radius
|
||||
// table) must be byte-identical regardless of whether the manifest
|
||||
// artifact resolves, is missing, or is malformed.
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-c17-');
|
||||
|
||||
const real = parseOkJson(runExecutePhase(['1'], dir), 'c17-real');
|
||||
const missingPath = path.join(dir, 'does-not-exist-c17.json');
|
||||
const missing = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: missingPath }), 'c17-missing');
|
||||
const badPath = path.join(dir, 'bad-c17.json');
|
||||
fs.writeFileSync(badPath, '{ not valid json');
|
||||
const malformed = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: badPath }), 'c17-malformed');
|
||||
|
||||
delete real.section_manifest;
|
||||
delete missing.section_manifest;
|
||||
delete malformed.section_manifest;
|
||||
assert.deepStrictEqual(missing, real, 'every other init-bundle field must be unaffected by a missing manifest artifact');
|
||||
assert.deepStrictEqual(malformed, real, 'every other init-bundle field must be unaffected by a malformed manifest artifact');
|
||||
});
|
||||
});
|
||||
|
||||
// ── D4 (#2992, prod-shape): an absent CLI flag is absent from `flags` ────
|
||||
//
|
||||
// #2992 review finding (fixed in this change, src/init-command-router.cts):
|
||||
// `parseNamedArgs`'s booleanFlags ALWAYS populate the option key (`true`
|
||||
// when the token was seen, `false` otherwise — never `undefined`).
|
||||
// `buildSectionManifestField`'s flags-Set builder (src/init.cts) treats
|
||||
// ANY non-`undefined` option value as present (matrix D2/D3, for VALUE
|
||||
// flags whose absence is `null`). Passed through unmodified, a
|
||||
// booleanFlag's own `false` ("--wave" never typed) would still have been
|
||||
// added to `flags`, making `flag:--wave` permanently true regardless of
|
||||
// the actual invocation — verified live pre-fix: `partial-wave` was
|
||||
// INCLUDED with no `--wave` on the command line at all, silently
|
||||
// defeating the gating feature this whole seam exists for. The router now
|
||||
// folds a booleanFlag's own `false` into `undefined` before it reaches
|
||||
// `buildSectionManifestField`, so this test is the regression lock for
|
||||
// that fix — it duplicates the assertion `emitsSectionManifestWithoutWaveFlag`
|
||||
// already makes, under an explicit D4 name for matrix traceability.
|
||||
//
|
||||
// D2 ("option present, value false" as a generically-present option) and
|
||||
// D3 ("option present, string value") and D6 (a hostile literal option
|
||||
// key) are NOT independently prod-shape-testable through the real CLI as
|
||||
// currently wired: `execute-phase`/`plan-phase` are the only two handlers
|
||||
// that feed router-derived options into `buildSectionManifestField`, their
|
||||
// option-key lists are FIXED literals (`validate`/`tdd`/`wave`/
|
||||
// `granularity`) never derived from user input (so a hostile key name can
|
||||
// never reach it for real), none of the 14 shipped vocabulary atoms is
|
||||
// backed by a value-taking CLI flag (so a real string-valued option can
|
||||
// never reach a `flag:` atom yet), and this fix means a booleanFlag's own
|
||||
// `false` never reaches the builder at all anymore. Synthesizing an
|
||||
// options object to force these paths would violate the matrix's own
|
||||
// "(prod-shape) rows must drive the options path" constraint, so they are
|
||||
// left unaddressed here rather than faked — surfaced for the orchestrator.
|
||||
describe('init execute-phase: undefined CLI flag is absent from flags (#2992 row D4)', () => {
|
||||
test('waveOptionAbsentWithoutTheFlagNeverActivatesPartialWave', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-d4-');
|
||||
const body = parseOkJson(runExecutePhase(['1'], dir), 'd4-no-wave');
|
||||
assert.ok(!body.section_manifest.included.includes('partial-wave'), 'an unset --wave must never leak into flags as "present"');
|
||||
assert.deepStrictEqual(body.section_manifest.excluded, ['partial-wave', 'gap-closure-artifacts', 'regression-gate']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── D9/D10/D11 (#2992, prod-shape): state:* detector degradation ─────────
|
||||
// Drives the REAL cmdInitExecutePhase -> buildSectionManifestField ->
|
||||
// readConfigJsonBoolean/detectPhaseMvpMode seam through the real CLI with a
|
||||
// fixture manifest naming the two config-backed atoms, never a hand-built
|
||||
// InvocationFacts (matrix note: "(prod-shape)" rows must drive the options
|
||||
// path). `seedSinglePhaseProject` writes no `.planning/config.json` and no
|
||||
// `.planning/ROADMAP.md` at all, so D9 (absent config) and D11 (absent
|
||||
// ROADMAP) are the fixture's natural, un-monkeypatched state.
|
||||
|
||||
describe('init execute-phase: state:* detector degradation (#2992 rows D9-D11)', () => {
|
||||
function writeDetectorManifest(dir) {
|
||||
const manifestPath = path.join(dir, 'detector-section-manifest.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({
|
||||
workflows: {
|
||||
'execute-phase': [
|
||||
{ id: 'worktrees-section', when: 'state:worktrees-enabled', read: 'x.md' },
|
||||
{ id: 'mvp-section', when: 'state:phase-mvp-mode', read: 'y.md' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
test('absentConfigAndAbsentRoadmapDegradeBothStateAtomsToFalse (rows D9/D11)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-d9-');
|
||||
assert.equal(fs.existsSync(path.join(dir, '.planning', 'config.json')), false, 'sanity: no config.json');
|
||||
assert.equal(fs.existsSync(path.join(dir, '.planning', 'ROADMAP.md')), false, 'sanity: no ROADMAP.md');
|
||||
const manifestPath = writeDetectorManifest(dir);
|
||||
const body = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath }), 'd9-d11');
|
||||
assert.deepStrictEqual(body.section_manifest.excluded, ['worktrees-section', 'mvp-section']);
|
||||
assert.deepStrictEqual(body.section_manifest.included, []);
|
||||
});
|
||||
|
||||
test('nonBooleanConfigValueDegradesToFalse (row D10 — strict boolean, string "true" is not true)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-d10-');
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: { use_worktrees: 'true' } }));
|
||||
const manifestPath = writeDetectorManifest(dir);
|
||||
const body = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath }), 'd10');
|
||||
assert.ok(
|
||||
body.section_manifest.excluded.includes('worktrees-section'),
|
||||
'a string "true" config value must never coerce to boolean true',
|
||||
);
|
||||
});
|
||||
|
||||
test('realBooleanTrueConfigValueIncludesTheSection (independence: the strict check still accepts a real boolean)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-d10b-');
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: { use_worktrees: true } }));
|
||||
const manifestPath = writeDetectorManifest(dir);
|
||||
const body = parseOkJson(runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: manifestPath }), 'd10b');
|
||||
assert.ok(body.section_manifest.included.includes('worktrees-section'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── #2992 review finding: state:needs-codebase-map wiring (real CLI) ─────
|
||||
//
|
||||
// No test anywhere drove `state:needs-codebase-map` (src/section-manifest.cts)
|
||||
// or its `cmdInitNewProject` override wiring (src/init.cts, `overrides.needsCodebaseMap`)
|
||||
// through the real CLI. Drives `init new-project` with a `mkdtempSync`
|
||||
// fixture manifest (via `GSD_SECTION_MANIFEST`) naming a single section
|
||||
// gated on the atom, proving inclusion/exclusion tracks the SAME
|
||||
// isBrownfield/hasCodebaseMap computation `needs_codebase_map` itself uses
|
||||
// — never mutating the shipped gsd-core/workflows/section-manifest.json.
|
||||
|
||||
describe('init new-project: state:needs-codebase-map wiring (#2992 review finding)', () => {
|
||||
function writeNeedsCodebaseMapManifest(dir) {
|
||||
const manifestPath = path.join(dir, 'needs-map-section-manifest.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify({
|
||||
workflows: {
|
||||
'new-project': [
|
||||
{ id: 'needs-map-section', when: 'state:needs-codebase-map', read: 'z.md' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
test('brownfieldWithoutCodebaseMapIncludesTheGatedSection', () => {
|
||||
const dir = createTempProject('gsd-needsmap-true-');
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"test"}');
|
||||
const manifestPath = writeNeedsCodebaseMapManifest(dir);
|
||||
const result = runGsdTools('init new-project', dir, { GSD_SECTION_MANIFEST: manifestPath });
|
||||
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.needs_codebase_map, true, 'sanity: fixture must be brownfield without a codebase map');
|
||||
assert.deepStrictEqual(output.section_manifest.included, ['needs-map-section']);
|
||||
assert.deepStrictEqual(output.section_manifest.excluded, []);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('greenfieldExcludesTheGatedSection', () => {
|
||||
const dir = createTempProject('gsd-needsmap-false-');
|
||||
try {
|
||||
const manifestPath = writeNeedsCodebaseMapManifest(dir);
|
||||
const result = runGsdTools('init new-project', dir, { GSD_SECTION_MANIFEST: manifestPath });
|
||||
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.needs_codebase_map, false, 'sanity: fixture must be greenfield');
|
||||
assert.deepStrictEqual(output.section_manifest.included, []);
|
||||
assert.deepStrictEqual(output.section_manifest.excluded, ['needs-map-section']);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('brownfieldWithExistingCodebaseMapExcludesTheGatedSection', () => {
|
||||
const dir = createTempProject('gsd-needsmap-hascomap-');
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"test"}');
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true });
|
||||
for (const name of ['STACK', 'ARCHITECTURE', 'STRUCTURE', 'CONVENTIONS', 'TESTING', 'INTEGRATIONS', 'CONCERNS']) {
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'codebase', `${name}.md`), `# ${name}\n`);
|
||||
}
|
||||
const manifestPath = writeNeedsCodebaseMapManifest(dir);
|
||||
const result = runGsdTools('init new-project', dir, { GSD_SECTION_MANIFEST: manifestPath });
|
||||
assert.ok(result.success, `init new-project failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.needs_codebase_map, false, 'sanity: fixture must already have a complete codebase map');
|
||||
assert.deepStrictEqual(output.section_manifest.included, []);
|
||||
assert.deepStrictEqual(output.section_manifest.excluded, ['needs-map-section']);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── E59: independence — other init subcommands unaffected ───────────────
|
||||
//
|
||||
// #2992 (epic #1671 Phase 6.1) generalized the manifest seam from
|
||||
// execute-phase-only to per-workflow (`buildSectionManifestField` now wires
|
||||
// into `cmdInitPlanPhase`/`cmdInitNewProject`/`cmdInitNewMilestone`/
|
||||
// `cmdInitQuick`/`cmdInitProgress` too — src/init.cts:740/787/839/885/1810).
|
||||
// The OLD assertion here ("section_manifest must be execute-phase-only")
|
||||
// is stale: plan-phase's field is now PRESENT, but `null` — the shipped
|
||||
// gsd-core/workflows/section-manifest.json still keys only `execute-phase`
|
||||
// (design row C3: a workflow absent from the artifact's `workflows` map
|
||||
// degrades to `null`, never an absent field). `resume` has no dedicated
|
||||
// `cmdInit*` manifest wiring at all (design's withheld-atom survey), so its
|
||||
// field truly remains absent — that half of the guard is unchanged.
|
||||
|
||||
describe('init dispatch: other subcommands unaffected (#2932 row 59, CRITICAL radius guard)', () => {
|
||||
test('leavesOtherInitSubcommandsUnchanged', (t) => {
|
||||
describe('init dispatch: other subcommands unaffected (#2932/#2992 row 59, CRITICAL radius guard)', () => {
|
||||
test('planPhaseEmitsANullManifestNotAnAbsentField (row C3/C16 — absence is not empty)', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-e59-');
|
||||
|
||||
const planPhase = parseOkJson(runSectionManifestCli(['init.plan-phase', '1'], dir), 'plan-phase');
|
||||
assert.equal(planPhase.phase_found, true);
|
||||
assert.ok(!('section_manifest' in planPhase), 'section_manifest must be execute-phase-only, never leak into plan-phase');
|
||||
assert.ok('section_manifest' in planPhase, 'section_manifest field must be present (computed, degraded to null) for plan-phase');
|
||||
assert.equal(planPhase.section_manifest, null, 'plan-phase has no key in the shipped artifact, so it must degrade to null, never {included:[]}');
|
||||
});
|
||||
|
||||
test('resumeNeverEmitsASectionManifestField', (t) => {
|
||||
const dir = seedSinglePhaseProject(t, 'gsd-e59-resume-');
|
||||
const resume = parseOkJson(runSectionManifestCli(['init.resume'], dir), 'resume');
|
||||
assert.ok(!('section_manifest' in resume), 'section_manifest must never leak into resume');
|
||||
assert.ok(!('section_manifest' in resume), 'section_manifest must never leak into resume — resume has no cmdInit* manifest wiring at all');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -66,10 +66,22 @@ const phaseNumberArb = fc.oneof(
|
||||
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({
|
||||
waveFlag: fc.boolean(),
|
||||
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 ────────────────────────────────────────────────
|
||||
@@ -120,8 +132,8 @@ describe('property: never throws for vocabulary-valid when and arbitrary 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'];
|
||||
// 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 = {};
|
||||
@@ -169,6 +181,27 @@ describe('property: prototype-shaped when values always fail closed', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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', () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ const BRANCH_SECTIONS = Object.freeze([
|
||||
]);
|
||||
|
||||
function facts(overrides) {
|
||||
return { waveFlag: false, phaseNumber: null, hasPriorPhases: false, ...overrides };
|
||||
return { flags: new Set(), phaseNumber: null, hasPriorPhases: false, ...overrides };
|
||||
}
|
||||
|
||||
// ─── Rows 1-8: happy path + combinations over W/D/P ─────────────────────────
|
||||
@@ -44,7 +44,7 @@ describe('W/D/P combination matrix (design doc behavior table rows 1-8)', () =>
|
||||
});
|
||||
|
||||
test('includesPartialWaveWhenWaveFlagPresent', () => {
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ waveFlag: true }));
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ flags: new Set(['--wave']) }));
|
||||
assert.deepEqual(result.included, ['preamble', 'partial-wave']);
|
||||
assert.deepEqual(result.excluded, ['gap-closure-artifacts', 'regression-gate']);
|
||||
});
|
||||
@@ -62,13 +62,13 @@ describe('W/D/P combination matrix (design doc behavior table rows 1-8)', () =>
|
||||
});
|
||||
|
||||
test('includesBothWaveAndGapClosureWhenBothHold', () => {
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ waveFlag: true, phaseNumber: '3.1' }));
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ flags: new Set(['--wave']), phaseNumber: '3.1' }));
|
||||
assert.deepEqual(result.included, ['preamble', 'partial-wave', 'gap-closure-artifacts']);
|
||||
assert.deepEqual(result.excluded, ['regression-gate']);
|
||||
});
|
||||
|
||||
test('includesBothWaveAndRegressionWhenBothHold', () => {
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ waveFlag: true, hasPriorPhases: true }));
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ flags: new Set(['--wave']), hasPriorPhases: true }));
|
||||
assert.deepEqual(result.included, ['preamble', 'partial-wave', 'regression-gate']);
|
||||
assert.deepEqual(result.excluded, ['gap-closure-artifacts']);
|
||||
});
|
||||
@@ -80,7 +80,7 @@ describe('W/D/P combination matrix (design doc behavior table rows 1-8)', () =>
|
||||
});
|
||||
|
||||
test('includesEveryBranchSectionWhenAllFactsHold', () => {
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ waveFlag: true, phaseNumber: '3.1', hasPriorPhases: true }));
|
||||
const result = selectSections(BRANCH_SECTIONS, facts({ flags: new Set(['--wave']), phaseNumber: '3.1', hasPriorPhases: true }));
|
||||
assert.deepEqual(result.included, ['preamble', 'partial-wave', 'gap-closure-artifacts', 'regression-gate']);
|
||||
assert.deepEqual(result.excluded, []);
|
||||
});
|
||||
@@ -146,7 +146,7 @@ describe('boundary section-list sizes (limit-1 / limit / limit+1)', () => {
|
||||
{ id: 's5', when: 'always' },
|
||||
{ id: 's6', when: 'state:has-prior-phases' },
|
||||
];
|
||||
const result = selectSections(sections, facts({ waveFlag: true }));
|
||||
const result = selectSections(sections, facts({ flags: new Set(['--wave']) }));
|
||||
assert.deepEqual(result.included, ['s0', 's1', 's2', 's3', 's5']);
|
||||
assert.deepEqual(result.excluded, ['s4', 's6']);
|
||||
});
|
||||
@@ -183,7 +183,7 @@ describe('determinism and input non-mutation', () => {
|
||||
{ id: 'b', when: 'flag:--wave' },
|
||||
];
|
||||
const snapshotBefore = sections.map((s) => ({ ...s }));
|
||||
const f = facts({ waveFlag: true });
|
||||
const f = facts({ flags: new Set(['--wave']) });
|
||||
|
||||
const first = selectSections(sections, f);
|
||||
const second = selectSections(sections, f);
|
||||
@@ -221,6 +221,63 @@ describe('WHEN_PREDICATES and WHEN_VOCABULARY parity (DEFECT.GENERATIVE-FIX)', (
|
||||
});
|
||||
});
|
||||
|
||||
// ─── B11: atom↔flag-string desync (#2992 — "the key new test") ─────────────
|
||||
// For EVERY 'flag:--X' atom in the frozen WHEN_VOCABULARY, the predicate must
|
||||
// be true iff `flags={--X}` and false for `flags={}`. Derived FROM the
|
||||
// vocabulary export (never a hand-copied local list of flag names), so a
|
||||
// typo in WHEN_PREDICATES' hand-written literal map (e.g. matching the wrong
|
||||
// token) is caught behaviorally instead of only by eyeballing the diff.
|
||||
|
||||
describe('atom<->flag-string desync guard (#2992 row B11)', () => {
|
||||
const flagAtoms = WHEN_VOCABULARY.filter((w) => w.startsWith('flag:--'));
|
||||
|
||||
test('everyFlagAtomHasAtLeastOneEntryToGuard', () => {
|
||||
// Sanity: this guard is vacuous if the vocabulary somehow shipped zero
|
||||
// flag atoms — fail loudly rather than silently passing on an empty loop.
|
||||
assert.ok(flagAtoms.length > 0, 'expected at least one flag: atom in WHEN_VOCABULARY');
|
||||
});
|
||||
|
||||
for (const atom of flagAtoms) {
|
||||
// The atom's own token, derived ONLY for use as the flags-Set member in
|
||||
// this TEST (never fed back into production, which forbids exactly this
|
||||
// derivation in WHEN_PREDICATES itself — see the module doc comment).
|
||||
const token = atom.slice('flag:'.length);
|
||||
|
||||
test(`predicateForAtomMatchesItsOwnToken_${atom}`, () => {
|
||||
const included = selectSections([{ id: 'x', when: atom }], facts({ flags: new Set([token]) }));
|
||||
assert.deepEqual(included, { included: ['x'], excluded: [] }, `expected "${atom}" included when flags={${token}}`);
|
||||
|
||||
const excluded = selectSections([{ id: 'x', when: atom }], facts({ flags: new Set() }));
|
||||
assert.deepEqual(excluded, { included: [], excluded: ['x'] }, `expected "${atom}" excluded when flags={}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── B14: flags set cardinality boundary (0 / 1 / many) ────────────────────
|
||||
|
||||
describe('flags set cardinality (#2992 row B14)', () => {
|
||||
const sections = Object.freeze([
|
||||
{ id: 'a', when: 'flag:--auto' },
|
||||
{ id: 'b', when: 'flag:--discuss' },
|
||||
{ id: 'c', when: 'flag:--full' },
|
||||
]);
|
||||
|
||||
test('zeroFlagsExcludesEveryFlagSection', () => {
|
||||
const result = selectSections(sections, facts({ flags: new Set() }));
|
||||
assert.deepEqual(result, { included: [], excluded: ['a', 'b', 'c'] });
|
||||
});
|
||||
|
||||
test('oneFlagIncludesOnlyItsOwnSection', () => {
|
||||
const result = selectSections(sections, facts({ flags: new Set(['--discuss']) }));
|
||||
assert.deepEqual(result, { included: ['b'], excluded: ['a', 'c'] });
|
||||
});
|
||||
|
||||
test('manyFlagsIncludeEveryMatchingSection', () => {
|
||||
const result = selectSections(sections, facts({ flags: new Set(['--auto', '--discuss', '--full', '--irrelevant']) }));
|
||||
assert.deepEqual(result, { included: ['a', 'b', 'c'], excluded: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 24: REASON enum shape is locked ────────────────────────────────────
|
||||
|
||||
describe('REASON enum is frozen and its shape is locked', () => {
|
||||
@@ -230,6 +287,80 @@ describe('REASON enum is frozen and its shape is locked', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2992 review finding: state:needs-codebase-map / state:phase-mvp-mode /
|
||||
// state:worktrees-enabled predicate coverage ─────────────────────────────
|
||||
//
|
||||
// These three atoms were shipped (src/section-manifest.cts) with zero
|
||||
// direct predicate-level test coverage — `state:phase-mvp-mode` and
|
||||
// `state:worktrees-enabled` DO have real prod-shape integration coverage
|
||||
// (tests/init.test.cjs "init execute-phase: state:* detector degradation
|
||||
// (#2992 rows D9-D11)"), but `state:needs-codebase-map` had none anywhere.
|
||||
// Locking all three here at the evaluator level too, matching every other
|
||||
// shipped predicate's dedicated matrix test.
|
||||
|
||||
describe('state:needs-codebase-map / state:phase-mvp-mode / state:worktrees-enabled predicates', () => {
|
||||
test('needsCodebaseMapTrueWhenFactIsTrue', () => {
|
||||
assert.equal(WHEN_PREDICATES['state:needs-codebase-map'](facts({ needsCodebaseMap: true })), true);
|
||||
});
|
||||
|
||||
test('needsCodebaseMapFalseWhenFactIsFalse', () => {
|
||||
assert.equal(WHEN_PREDICATES['state:needs-codebase-map'](facts({ needsCodebaseMap: false })), false);
|
||||
});
|
||||
|
||||
test('needsCodebaseMapFalseWhenFactIsAbsent', () => {
|
||||
assert.doesNotThrow(() => WHEN_PREDICATES['state:needs-codebase-map'](facts({})));
|
||||
assert.equal(WHEN_PREDICATES['state:needs-codebase-map'](facts({})), false);
|
||||
});
|
||||
|
||||
test('needsCodebaseMapFalseWhenFactIsUndefined', () => {
|
||||
assert.doesNotThrow(() => WHEN_PREDICATES['state:needs-codebase-map'](facts({ needsCodebaseMap: undefined })));
|
||||
assert.equal(WHEN_PREDICATES['state:needs-codebase-map'](facts({ needsCodebaseMap: undefined })), false);
|
||||
});
|
||||
|
||||
test('phaseMvpModeTrueWhenFactIsTrue', () => {
|
||||
assert.equal(WHEN_PREDICATES['state:phase-mvp-mode'](facts({ phaseMvpMode: true })), true);
|
||||
});
|
||||
|
||||
test('phaseMvpModeFalseWhenFactIsFalse', () => {
|
||||
assert.equal(WHEN_PREDICATES['state:phase-mvp-mode'](facts({ phaseMvpMode: false })), false);
|
||||
});
|
||||
|
||||
test('phaseMvpModeFalseWhenFactIsAbsent', () => {
|
||||
assert.doesNotThrow(() => WHEN_PREDICATES['state:phase-mvp-mode'](facts({})));
|
||||
assert.equal(WHEN_PREDICATES['state:phase-mvp-mode'](facts({})), false);
|
||||
});
|
||||
|
||||
test('phaseMvpModeFalseWhenFactIsUndefined', () => {
|
||||
assert.doesNotThrow(() => WHEN_PREDICATES['state:phase-mvp-mode'](facts({ phaseMvpMode: undefined })));
|
||||
assert.equal(WHEN_PREDICATES['state:phase-mvp-mode'](facts({ phaseMvpMode: undefined })), false);
|
||||
});
|
||||
|
||||
test('worktreesEnabledTrueWhenFactIsTrue', () => {
|
||||
assert.equal(WHEN_PREDICATES['state:worktrees-enabled'](facts({ worktreesEnabled: true })), true);
|
||||
});
|
||||
|
||||
test('worktreesEnabledFalseWhenFactIsFalse', () => {
|
||||
assert.equal(WHEN_PREDICATES['state:worktrees-enabled'](facts({ worktreesEnabled: false })), false);
|
||||
});
|
||||
|
||||
test('worktreesEnabledFalseWhenFactIsAbsent', () => {
|
||||
assert.doesNotThrow(() => WHEN_PREDICATES['state:worktrees-enabled'](facts({})));
|
||||
assert.equal(WHEN_PREDICATES['state:worktrees-enabled'](facts({})), false);
|
||||
});
|
||||
|
||||
test('worktreesEnabledFalseWhenFactIsUndefined', () => {
|
||||
assert.doesNotThrow(() => WHEN_PREDICATES['state:worktrees-enabled'](facts({ worktreesEnabled: undefined })));
|
||||
assert.equal(WHEN_PREDICATES['state:worktrees-enabled'](facts({ worktreesEnabled: undefined })), false);
|
||||
});
|
||||
|
||||
test('selectSectionsIncludesNeedsCodebaseMapSectionOnlyWhenFactIsTrue', () => {
|
||||
const sections = [{ id: 'needs-map', when: 'state:needs-codebase-map' }];
|
||||
assert.deepEqual(selectSections(sections, facts({ needsCodebaseMap: true })), { included: ['needs-map'], excluded: [] });
|
||||
assert.deepEqual(selectSections(sections, facts({ needsCodebaseMap: false })), { included: [], excluded: ['needs-map'] });
|
||||
assert.deepEqual(selectSections(sections, facts({})), { included: [], excluded: ['needs-map'] });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rows 25-33: Object.prototype-shaped when= values fail closed ──────────
|
||||
// Added during review — prototype-chain fail-open found by isolated
|
||||
// adversarial pass. A bracket lookup on a plain frozen object resolves
|
||||
|
||||
@@ -486,11 +486,136 @@ describe('frozen when= vocabulary', () => {
|
||||
assert.equal(Object.isFrozen(WHEN_VOCABULARY), true);
|
||||
assert.deepEqual(
|
||||
[...WHEN_VOCABULARY].sort(),
|
||||
['always', 'flag:--wave', 'state:gap-closure-phase', 'state:has-prior-phases'],
|
||||
[
|
||||
'always',
|
||||
'flag:--auto',
|
||||
'flag:--discuss',
|
||||
'flag:--forensic',
|
||||
'flag:--full',
|
||||
'flag:--research',
|
||||
'flag:--reset-phase-numbers',
|
||||
'flag:--validate',
|
||||
'flag:--wave',
|
||||
'state:gap-closure-phase',
|
||||
'state:has-prior-phases',
|
||||
'state:needs-codebase-map',
|
||||
'state:phase-mvp-mode',
|
||||
'state:worktrees-enabled',
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2992 (epic #1671 Phase 6.1): widened when= vocabulary (14 atoms) ─────
|
||||
// 50-test-matrix.md rows A2/A5/A6/A14/A19.
|
||||
|
||||
describe('widened when= vocabulary (#2992)', () => {
|
||||
// The 10 net-new atoms shipped by #2992, independent of the pre-existing 4
|
||||
// (Object.keys-style derivation of the diff would be tokenization of the
|
||||
// vocabulary itself, so this list is a deliberate hand-written literal,
|
||||
// mirroring the discipline WHEN_PREDICATES already applies).
|
||||
const NET_NEW_ATOMS = Object.freeze([
|
||||
'flag:--auto',
|
||||
'flag:--discuss',
|
||||
'flag:--forensic',
|
||||
'flag:--full',
|
||||
'flag:--research',
|
||||
'flag:--reset-phase-numbers',
|
||||
'flag:--validate',
|
||||
'state:needs-codebase-map',
|
||||
'state:phase-mvp-mode',
|
||||
'state:worktrees-enabled',
|
||||
]);
|
||||
|
||||
test('everyNetNewAtomIsInWhenVocabulary', () => {
|
||||
// Sanity that the hand-written NET_NEW_ATOMS list above has not drifted
|
||||
// from the module's own frozen export.
|
||||
for (const atom of NET_NEW_ATOMS) {
|
||||
assert.ok(WHEN_VOCABULARY.includes(atom), `expected "${atom}" in WHEN_VOCABULARY`);
|
||||
}
|
||||
});
|
||||
|
||||
test('acceptsEveryWidenedAtom (row A2)', () => {
|
||||
for (const when of NET_NEW_ATOMS) {
|
||||
const source = `<!-- gsd:section id="x" when="${when}" -->\nbody\n<!-- /gsd:section -->`;
|
||||
const sections = parseWorkflowSections(source);
|
||||
const explicitSections = sections.filter((s) => s.explicit);
|
||||
assert.equal(explicitSections.length, 1, `expected acceptance for when="${when}"`);
|
||||
assert.equal(explicitSections[0].when, when);
|
||||
assert.equal(composeWorkflow(source), 'body\n');
|
||||
}
|
||||
});
|
||||
|
||||
test('sameAtomOnTwoDifferentSectionsIsLegal (row A19)', () => {
|
||||
// Atom reuse is legal; only `id` must be unique.
|
||||
const source = doc(
|
||||
'<!-- gsd:section id="first" when="flag:--auto" -->',
|
||||
'bodyA',
|
||||
'<!-- /gsd:section -->',
|
||||
'<!-- gsd:section id="second" when="flag:--auto" -->',
|
||||
'bodyB',
|
||||
'<!-- /gsd:section -->',
|
||||
);
|
||||
const sections = parseWorkflowSections(source);
|
||||
const explicitSections = sections.filter((s) => s.explicit);
|
||||
assert.deepEqual(
|
||||
explicitSections.map((s) => ({ id: s.id, when: s.when })),
|
||||
[
|
||||
{ id: 'first', when: 'flag:--auto' },
|
||||
{ id: 'second', when: 'flag:--auto' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('atomMatchIsCaseSensitive (row A5)', () => {
|
||||
// A case variant of a real net-new atom must still throw — exact `===`,
|
||||
// no case folding.
|
||||
for (const when of ['Flag:--auto', 'flag:--Auto', 'FLAG:--AUTO', 'flag:--RESEARCH']) {
|
||||
const source = `<!-- gsd:section id="x" when="${when}" -->\nbody\n<!-- /gsd:section -->`;
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.reason === REASON.UNKNOWN_WHEN,
|
||||
`expected throw for when="${when}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('atomValueIsNotTrimmed (row A6)', () => {
|
||||
// A padded value must still throw — the value is not trimmed before the
|
||||
// vocabulary membership check.
|
||||
for (const when of [' flag:--auto', 'flag:--auto ', ' state:worktrees-enabled ']) {
|
||||
const source = `<!-- gsd:section id="x" when="${when}" -->\nbody\n<!-- /gsd:section -->`;
|
||||
assert.throws(
|
||||
() => parseWorkflowSections(source, 'workflow.md'),
|
||||
(err) => err instanceof TypeError && err.reason === REASON.UNKNOWN_WHEN,
|
||||
`expected throw for when="${when}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('crlfMarkerLineCarryingAWidenedAtomRoundTripsExactly (row A14)', () => {
|
||||
const source = [
|
||||
'prose one',
|
||||
'<!-- gsd:section id="x" when="flag:--forensic" -->',
|
||||
'crlf body',
|
||||
'<!-- /gsd:section -->',
|
||||
'prose two',
|
||||
].join('\r\n');
|
||||
const sections = parseWorkflowSections(source);
|
||||
const explicitSections = sections.filter((s) => s.explicit);
|
||||
assert.equal(explicitSections.length, 1);
|
||||
assert.equal(explicitSections[0].when, 'flag:--forensic');
|
||||
assert.equal(explicitSections[0].body, 'crlf body\r\n');
|
||||
|
||||
const rendered = composeWorkflow(source);
|
||||
const expected = source
|
||||
.split('\r\n')
|
||||
.filter((line) => !/^<!--\s*\/?gsd:section.*-->\s*$/.test(line))
|
||||
.join('\r\n');
|
||||
assert.equal(rendered, expected);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rows 20-22: boundary documents ─────────────────────────────────────────
|
||||
|
||||
describe('boundary documents', () => {
|
||||
|
||||
Reference in New Issue
Block a user