diff --git a/.changeset/witty-ravens-fly.md b/.changeset/witty-ravens-fly.md new file mode 100644 index 000000000..cbf1e2ea3 --- /dev/null +++ b/.changeset/witty-ravens-fly.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4486 +--- +**W002 no longer fires on quoted commands in STATE.md** — the health check read GSD's own command names (like ``/gsd-execute-phase 5`` in a ledger row) and anything inside backticks as phase references, so healthy multi-workstream projects reported degraded with false warnings; under an active workstream the warning now also says its declared-phase list is workstream-scoped (`... are declared in workstream `) instead of making an unqualified project-wide claim. (#4257) diff --git a/src/health-diagnostic-rules/state-consistency.cts b/src/health-diagnostic-rules/state-consistency.cts index c7ac8630c..d54f19d53 100644 --- a/src/health-diagnostic-rules/state-consistency.cts +++ b/src/health-diagnostic-rules/state-consistency.cts @@ -175,6 +175,17 @@ const RULE_W002: Rule = { ); const diagnostics: Diagnostic[] = []; + // #4257: the valid set above is WORKSTREAM-scoped by construction (every + // source field is read from `planningPaths(cwd)`'s base, which resolves + // under `.planning/workstreams//`), and per-workstream phase + // numbering is deliberate — so under a workstream the message must NAME + // that scope rather than make an unqualified project-wide claim (a + // reference to a phase declared only in a SIBLING workstream reads as + // "undeclared" against this list; that is the scope speaking, not drift). + // Root scope (`workstream === null`, flat or root-planning projects) + // keeps the byte-identical message — no clause is appended when none + // applies. + const scopeClause = snapshot.workstream ? ` in workstream ${snapshot.workstream}` : ''; for (const ref of snapshot.statePhaseTokens.value) { const dotIdx = ref.indexOf('.'); const head = dotIdx === -1 ? ref : ref.slice(0, dotIdx); @@ -184,7 +195,7 @@ const RULE_W002: Rule = { diagnostics.push({ code: 'W002', severity: SEVERITY.WARNING, - message: `STATE.md references phase ${ref}, but only phases ${sortedValid.join(', ')} are declared`, + message: `STATE.md references phase ${ref}, but only phases ${sortedValid.join(', ')} are declared${scopeClause}`, remedy: adviseRemedy( 'Review STATE.md manually before changing it; /gsd-health --repair will not overwrite an existing STATE.md for phase mismatches', ), diff --git a/src/planning-snapshot.cts b/src/planning-snapshot.cts index 167bd8f33..0cb753e4d 100644 --- a/src/planning-snapshot.cts +++ b/src/planning-snapshot.cts @@ -38,7 +38,15 @@ import planningWorkspace = require('./planning-workspace.cjs'); // `phase_id_convention` reader, from the same §7 owner module `planningPaths` // comes from. Resolved once in `buildPlanningSnapshot` — see the // `phaseIdConvention` field's comment for why one resolution point matters. -const { planningPaths, planningRoot, resolvePhaseIdConvention } = planningWorkspace; +// #4257: `resolveEnvWorkstream` is that same module's ONE owner of the env +// workstream discriminator `planningDir` applies — the name W002's scope +// clause prints comes from the same resolution point that scoped the reads. +const { planningPaths, planningRoot, resolvePhaseIdConvention, resolveEnvWorkstream } = planningWorkspace; +// #4257: canonical CommonMark code strippers (markdown-sectionizer is the +// repo's T0 structural seam, adopted per the #2365 composition order — fenced +// blocks first, then inline spans) so the `statePhaseTokens` harvest sees +// PROSE, not quoted literals. +import { stripFencedCode, stripInlineCode } from './markdown-sectionizer.cjs'; import { platformReadSync, execGit } from './shell-command-projection.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import frontmatterMod = require('./frontmatter.cjs'); @@ -278,6 +286,18 @@ interface PlanningSnapshot { // for it at all, so a check that can fail a repo runs only for the convention // that repo opted into. roadmapBracketIncoherences: { value: BracketIncoherence[]; scope: Scope }; + // ─── #4257 addition ──────────────────────────────────────────────────────── + // The name of the workstream every workstream-aware read in THIS snapshot + // was scoped to, `null` on a flat/root-scope project. Derived from + // `resolveEnvWorkstream()` — the ONE env discriminator `planningDir` + // itself applies when `planningPaths(cwd)` resolves the base — so the + // field cannot disagree with the base the fields were actually read from + // (one resolution point; the #612 PR-2 two-readers-two-bases lesson). + // Additive-only in the `archivedPhaseTokens` (#3652) shape; backs W002's + // scope clause (`... are declared in workstream `), which names the + // scope because per-workstream phase numbering is deliberate and a + // cross-workstream "declared" union would silence genuine drift. + workstream: string | null; } /** @@ -346,9 +366,11 @@ interface StateFields { * a whole-body fallback, together. * - `statePhaseTokens` scans the WHOLE document (`verify.cts`'s exact * `PHASE_NUMBER_TOKEN_SOURCE` regex, relocated verbatim from - * `verify.cts:1731-1735`), not just the Current Position section, so it is - * NOT degraded to `TRUNCATED` by a missing section header — it stays - * `COMPLETE` whenever the file itself was read successfully. + * `verify.cts:1731-1735`; #4257 adds the left word boundary and the + * fenced-block/inline-span strip — see the harvest site's comment), not + * just the Current Position section, so it is NOT degraded to `TRUNCATED` + * by a missing section header — it stays `COMPLETE` whenever the file + * itself was read successfully. */ function buildStateFields(statePath: string): StateFields { let content: string | null; @@ -411,9 +433,32 @@ function buildStateFields(statePath: string): StateFields { scope: currentPositionScope, }); const statePhaseTokens = { - value: [...content.matchAll(new RegExp(`[Pp]hase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'g'))].map( - (m) => m[1], - ), + // #4257: harvest PROSE phase references, not every literal token match. + // Two precisions over the pre-#4257 verbatim relocation of verify.cts's + // scan (which was `[Pp]hase\s+(TOKEN)`, unanchored, over the raw file): + // + // 1. Strip fenced code blocks, then inline code spans (the #2365 + // composition order, via the canonical markdown-sectionizer seam) — + // a token inside backticks is a QUOTED LITERAL (a ledger row quoting + // `` `/gsd-execute-phase 5` `` or `` `- [ ] **Phase 40:` `` from a + // sibling roadmap), not a reference. Pinned tradeoff: a GENUINE + // reference written in backticks stops counting too — a quoted + // literal and a reference are indistinguishable inside a code span. + // 2. Left word boundary `(? m[1]), scope: SCOPE.COMPLETE, }; @@ -1207,6 +1252,11 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot { // See the `phaseIdConvention` field's comment for why one resolution point is // load-bearing rather than a micro-optimisation. const phaseIdConvention = resolvePhaseIdConvention(cwd) ?? null; + // #4257: the workstream `planningPaths(cwd)` just scoped every read to + // (its `planningDir` call applies this exact discriminator when handed no + // `ws`), resolved through the same owner so W002's scope clause names the + // scope the valid set was ACTUALLY built from. + const workstream = resolveEnvWorkstream(); const milestone = getMilestoneInfo(cwd); // #612: deliberately LEFT to `listMilestonePhaseDirs`'s own lazy resolve — // this call is byte-identical to upstream's. @@ -1263,6 +1313,7 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot { phaseIdConvention, roadmapSentinelPhaseTokens: roadmapDeclared.sentinelTokens, roadmapBracketIncoherences: buildRoadmapBracketIncoherencesField(paths.roadmap, phaseIdConvention), + workstream, }; } diff --git a/src/planning-workspace.cts b/src/planning-workspace.cts index 037a4e325..e87cb2168 100644 --- a/src/planning-workspace.cts +++ b/src/planning-workspace.cts @@ -125,9 +125,26 @@ const PLANNING_LOCK_RETRY_ERRNOS = new Set([ // compatible with the structural type the store expects. type WorkstreamAdapterOpts = Record; +/** + * #4257: the ONE owner of the env workstream discriminator `planningDir` + * itself applies when handed no `ws` argument. `planningPaths(cwd)` — and + * therefore every workstream-scoped `PlanningSnapshot` read — resolves its + * base through exactly this read, and the CLI bootstrap has already folded + * the stored active-workstream pointer into the env by the time any + * diagnostic runs (`resolveActiveWorkstream` → `applyResolvedWorkstreamEnv`, + * `active-workstream-store.cjs`). Exposed so a consumer that needs to NAME + * the scope those reads used (W002's warning message, via the snapshot's + * `workstream` field) derives it from the same resolution point instead of + * growing a second env read site that can drift (the #612 PR-2 + * two-readers-two-bases lesson). + */ +function resolveEnvWorkstream(): string | null { + return process.env['GSD_WORKSTREAM'] ?? null; +} + function planningDir(cwd: string, ws?: string | null, project?: string | null): string { if (project === undefined) project = process.env['GSD_PROJECT'] ?? null; - if (ws === undefined) ws = process.env['GSD_WORKSTREAM'] ?? null; + if (ws === undefined) ws = resolveEnvWorkstream(); // Reject path separators and traversal components in project/workstream names const BAD_SEGMENT = /[/\\]|\.\./; @@ -667,6 +684,7 @@ export = { createMemoryPointerAdapter, planningDir, planningRoot, + resolveEnvWorkstream, resolvePhaseIdConvention, listAvailableWorkstreams, planningPaths, diff --git a/tests/health-diagnostic-rules/state-consistency.test.cjs b/tests/health-diagnostic-rules/state-consistency.test.cjs index b7fec633b..69add3525 100644 --- a/tests/health-diagnostic-rules/state-consistency.test.cjs +++ b/tests/health-diagnostic-rules/state-consistency.test.cjs @@ -152,7 +152,9 @@ describe('W002 — STATE.md references a phase not declared on disk or ROADMAP', assert.equal(diagnostics.length, 1); assert.equal(diagnostics[0].code, 'W002'); assert.equal(diagnostics[0].severity, SEVERITY.WARNING); - assert.match(diagnostics[0].message, /STATE\.md references phase 9, but only phases .* are declared/); + // #4257: root scope (no active workstream) keeps the BYTE-IDENTICAL + // message grammar — no scope clause is appended when none applies. + assert.match(diagnostics[0].message, /STATE\.md references phase 9, but only phases .* are declared$/); assert.deepEqual(diagnostics[0].remedy, { action: REMEDY_ACTION.ADVISE, risk: REMEDY_RISK.NONE, @@ -240,6 +242,137 @@ describe('W002 — STATE.md references a phase not declared on disk or ROADMAP', const diagnostics = ruleFor('W002').check(snapshot); assert.deepEqual(diagnostics, []); }); + + // ─── #4257: command mentions are not phase references; the warning names ── + // ─── its workstream scope ───────────────────────────────────────────────── + // + // Fixture shape: the issue's own repro — active workstream `alpha` declares + // phases 1-2, sibling `beta` declares phase 5, and alpha's STATE.md carries + // a Queue/Ledger row mentioning the phase via a GSD command name or a quoted + // roadmap line. GSD_WORKSTREAM is set directly (save/restore per + // tests/health-diagnostic.test.cjs:598-602) — the same discriminator + // planningDir applies and the CLI bootstrap folds the stored pointer into. + function withWorkstreamEnv(t, name) { + const prev = process.env['GSD_WORKSTREAM']; + if (name === null) delete process.env['GSD_WORKSTREAM']; + else process.env['GSD_WORKSTREAM'] = name; + t.after(() => { + if (prev === undefined) delete process.env['GSD_WORKSTREAM']; + else process.env['GSD_WORKSTREAM'] = prev; + }); + } + + function makeTwoWorkstreamFixture(cwd, stateQueueLine) { + writeFile(cwd, '.planning/config.json', '{}'); + writeFile( + cwd, + '.planning/workstreams/alpha/ROADMAP.md', + '## v1.0 Current 🚧\n\n### Phase 1: One\n\n### Phase 2: Two\n', + ); + writeFile( + cwd, + '.planning/workstreams/beta/ROADMAP.md', + '## v1.0 Current 🚧\n\n### Phase 5: Five\n', + ); + writeFile( + cwd, + '.planning/workstreams/alpha/STATE.md', + [ + '---', + 'status: planning', + '---', + '', + '## Current Position', + '', + 'Phase: 1 of 2 (One)', + 'Status: planning', + '', + '## Queue', + '', + `- ${stateQueueLine}`, + '', + ].join('\n'), + ); + } + + test('#4257 row 1 (regression): `/gsd-execute-phase 5` in a Queue row is a command mention, not a phase reference — no W002 even though 5 is only declared in a sibling workstream', (t) => { + const cwd = createTempDir('gsd-4257-w002-1-'); + t.after(() => cleanup(cwd)); + makeTwoWorkstreamFixture(cwd, '`/gsd-execute-phase 5`'); + withWorkstreamEnv(t, 'alpha'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W002').check(snapshot), []); + }); + + test('#4257: a quoted roadmap line `- [ ] **Phase 40:**` in a code span is a quoted literal, not a reference — no W002', (t) => { + const cwd = createTempDir('gsd-4257-w002-2-'); + t.after(() => cleanup(cwd)); + writeFile(cwd, '.planning/config.json', '{}'); + writeFile( + cwd, + '.planning/workstreams/alpha/ROADMAP.md', + '## v1.0 Current 🚧\n\n### Phase 1: One\n\n### Phase 2: Two\n', + ); + writeFile( + cwd, + '.planning/workstreams/beta/ROADMAP.md', + '## v1.0 Current 🚧\n\n### Phase 40: Forty\n', + ); + writeFile( + cwd, + '.planning/workstreams/alpha/STATE.md', + [ + '---', + 'status: planning', + '---', + '', + '## Ledger', + '', + // Closed code span (matching the A3 snapshot-level twin and the + // 50-test-matrix.md B2 row). An UNTERMINATED backtick run is literal + // text per CommonMark, so its content is prose and W002 SHOULD fire + // on it — not the fixture this row pins. + '- `- [ ] **Phase 40:** quoted from the beta roadmap`', + '', + ].join('\n'), + ); + withWorkstreamEnv(t, 'alpha'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W002').check(snapshot), []); + }); + + test('#4257: a GENUINE undeclared prose reference still warns under a workstream, and the warning names its scope', (t) => { + const cwd = createTempDir('gsd-4257-w002-3-'); + t.after(() => cleanup(cwd)); + makeTwoWorkstreamFixture(cwd, 'Phase 5 wrap-up blocked on beta'); + withWorkstreamEnv(t, 'alpha'); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W002').check(snapshot); + + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].code, 'W002'); + // The valid set is workstream-scoped BY DESIGN (planningPaths under + // GSD_WORKSTREAM); the message must say so — phase 5 IS declared, in + // sibling `beta`, and the unqualified form read as a false project-wide + // claim (#4257 sub-defect 2). + assert.equal( + diagnostics[0].message, + 'STATE.md references phase 5, but only phases 1, 2 are declared in workstream alpha', + ); + }); + + test('#4257: a command mention naming a DECLARED phase stays silent (no false negative introduced either)', (t) => { + const cwd = createTempDir('gsd-4257-w002-4-'); + t.after(() => cleanup(cwd)); + makeTwoWorkstreamFixture(cwd, '`/gsd-execute-phase 2`'); + withWorkstreamEnv(t, 'alpha'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W002').check(snapshot), []); + }); }); // ─── W011 — STATE current-phase status vs. ROADMAP checkbox disagree ─────── diff --git a/tests/planning-snapshot.test.cjs b/tests/planning-snapshot.test.cjs index 8409b3839..99de16b0a 100644 --- a/tests/planning-snapshot.test.cjs +++ b/tests/planning-snapshot.test.cjs @@ -813,6 +813,248 @@ describe('statePhaseTokens field (Phase 11, #3309)', () => { assert.deepStrictEqual(snap.currentPhaseLabel, { value: null, scope: SCOPE.UNREADABLE }); assert.strictEqual(emitted, 1, 'the shared STATE.md read must not double-emit across fields'); }); + + // ─── #4257: harvest precision — a command MENTION is not a phase REFERENCE ── + // + // The pre-#4257 scan (`[Pp]hase\s+(TOKEN)`, unanchored, over the raw file) + // harvested the `-phase 5` tail of GSD's own command names and any token + // inside an inline code span / fenced block, so a ledger row quoting + // `/gsd-execute-phase 5` fired W002 as an undeclared-phase reference. The + // #4257 grammar: strip fenced blocks, then inline spans (the canonical + // markdown-sectionizer seam + composition order, #2365), THEN match with a + // left word boundary `(? { + const cwd = createTempDir('gsd-4257-spt1-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + appendToState(cwd, [ + '', + '## Current Position', + '', + 'Phase: 1 of 2', + '', + '## Queue', + '', + '- `/gsd-execute-phase 5`', + '', + ].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.statePhaseTokens, { value: [], scope: SCOPE.COMPLETE }); + }); + + test('#4257: a bare `/gsd-execute-phase 5` command mention (no backticks) is NOT harvested — left word boundary', (t) => { + const cwd = createTempDir('gsd-4257-spt2-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + appendToState(cwd, [ + '', + '## Queue', + '', + '- Run /gsd-execute-phase 5 when the ledger clears', + '', + ].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.statePhaseTokens, { value: [], scope: SCOPE.COMPLETE }); + }); + + test('#4257: a quoted roadmap line `- [ ] **Phase 40:**` inside an inline code span is NOT harvested', (t) => { + const cwd = createTempDir('gsd-4257-spt3-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + appendToState(cwd, [ + '', + '## Ledger', + '', + '- `- [ ] **Phase 40:** quoted from a sibling workstream roadmap`', + '', + ].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.statePhaseTokens, { value: [], scope: SCOPE.COMPLETE }); + }); + + test('#4257: a `Phase 9` mention inside a fenced code block is NOT harvested', (t) => { + const cwd = createTempDir('gsd-4257-spt4-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + appendToState(cwd, [ + '', + '## Notes', + '', + '```', + 'Phase 9 was quoted here verbatim', + '```', + '', + ].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.statePhaseTokens, { value: [], scope: SCOPE.COMPLETE }); + }); + + test('#4257 tradeoff (pinned by the issue): a GENUINE reference written in backticks stops counting', (t) => { + const cwd = createTempDir('gsd-4257-spt5-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + appendToState(cwd, [ + '', + '## Decisions', + '', + '- see `Phase 5` notes for the rationale', + '', + ].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.statePhaseTokens, { value: [], scope: SCOPE.COMPLETE }); + }); + + test('#4257: word-suffixed carriers (myphase 5, alphaphase 3) are NOT harvested — boundary covers non-command suffixes too', (t) => { + const cwd = createTempDir('gsd-4257-spt6-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + appendToState(cwd, [ + '', + '## Decisions', + '', + '- myphase 5 and alphaphase 3 are words, not references', + '', + ].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.statePhaseTokens, { value: [], scope: SCOPE.COMPLETE }); + }); + + test('#4257 mixed form: prose reference survives while command mention and quoted literal are dropped from the same file', (t) => { + const cwd = createTempDir('gsd-4257-spt7-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + appendToState(cwd, [ + '', + '## Current Position', + '', + 'Phase: 1 of 2', + '', + '## Queue', + '', + '- `/gsd-execute-phase 9` once Phase 5 wraps (see `Phase 12` notes)', + '', + ].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.statePhaseTokens, { value: ['5'], scope: SCOPE.COMPLETE }); + }); + + // KEEP rows — the #4257 boundary must NOT narrow legitimate references. + test('#4257 keep: bold `**Phase 5:**` and parenthesised `(Phase 5)` and heading `### Phase 5:` forms still harvest', (t) => { + const cwd = createTempDir('gsd-4257-spt8-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + appendToState(cwd, [ + '', + '### Phase 5: Five', + '', + '- **Phase 5:** started', + '- (Phase 5) pending review', + '', + ].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.statePhaseTokens, { value: ['5', '5', '5'], scope: SCOPE.COMPLETE }); + }); + + test('#4257 keep: silent forms stay silent (Phase5, flag form, non-English word)', (t) => { + const cwd = createTempDir('gsd-4257-spt9-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + appendToState(cwd, [ + '', + '## Queue', + '', + '- `gsd-tools phase --insert 5`', + '- `Phase5` shorthand does not count', + '- фаза 5 is not the token either', + '', + ].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.statePhaseTokens, { value: [], scope: SCOPE.COMPLETE }); + }); +}); + +// ─── workstream field (#4257) ──────────────────────────────────────────────── + +describe('workstream field (#4257)', () => { + // The env save/restore pattern mirrors tests/health-diagnostic.test.cjs:598-602 + // and tests/config-loader.test.cjs:499-509 — GSD_WORKSTREAM is the SAME + // discriminator planningDir applies (planning-workspace.cts:130), and the + // CLI bootstrap folds the stored active-workstream pointer into it + // (active-workstream-store.cts:488-494), so setting it directly is the + // faithful rule-level simulation of "workstream alpha is active". + function withWorkstreamEnv(t, name) { + const prev = process.env['GSD_WORKSTREAM']; + if (name === null) delete process.env['GSD_WORKSTREAM']; + else process.env['GSD_WORKSTREAM'] = name; + t.after(() => { + if (prev === undefined) delete process.env['GSD_WORKSTREAM']; + else process.env['GSD_WORKSTREAM'] = prev; + }); + } + + test('flat project (no GSD_WORKSTREAM): workstream is null', (t) => { + const cwd = createTempDir('gsd-4257-ws1-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + withWorkstreamEnv(t, null); + + const snap = buildPlanningSnapshot(cwd); + assert.strictEqual(snap.workstream, null); + }); + + test('GSD_WORKSTREAM=alpha: workstream names the scope every workstream-aware read used', (t) => { + const cwd = createTempDir('gsd-4257-ws2-'); + t.after(() => cleanup(cwd)); + writeFile(cwd, '.planning/workstreams/alpha/STATE.md', [ + '---', + 'milestone: v1.0', + '---', + '', + '## Decisions', + '', + '- Phase 2 wrapped', + '', + ].join('\n')); + withWorkstreamEnv(t, 'alpha'); + + const snap = buildPlanningSnapshot(cwd); + assert.strictEqual(snap.workstream, 'alpha'); + }); + + test('non-divergence: the field names the base statePhaseTokens was actually read from', (t) => { + const cwd = createTempDir('gsd-4257-ws3-'); + t.after(() => cleanup(cwd)); + // Root STATE.md carries Phase 7; workstream STATE.md carries Phase 5. The + // tokens must come from the WORKSTREAM file (planningPaths scoped it) and + // the field must name that same workstream — one resolution point, two + // observable answers that cannot disagree. + writeState(cwd, { milestone: 'v1.0' }); + appendToState(cwd, ['', '- Phase 7 lives at root scope', ''].join('\n')); + writeFile(cwd, '.planning/workstreams/alpha/STATE.md', [ + '---', + 'milestone: v1.0', + '---', + '', + '- Phase 5 lives in the workstream', + '', + ].join('\n')); + withWorkstreamEnv(t, 'alpha'); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.statePhaseTokens, { value: ['5'], scope: SCOPE.COMPLETE }); + assert.strictEqual(snap.workstream, 'alpha'); + }); }); describe('stateStatus field (Phase 11, #3309)', () => {