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>
This commit is contained in:
Tom Boucher
2026-08-02 22:36:45 -04:00
committed by GitHub
parent 1259e4b619
commit f1af47766a
17 changed files with 1272 additions and 132 deletions

View File

@@ -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([

View File

@@ -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');
});
});

View File

@@ -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', () => {

View File

@@ -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

View File

@@ -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', () => {