fix(#4257): harvest only prose phase references; W002 names its workstream scope (#4486)

* test(#4257): W002 harvest precision + workstream-scoped warning regression rows

Tests-only RED commit: A-rows pin the command-mention/code-span harvest
precision on statePhaseTokens, B-rows drive W002 under root and workstream
scope (scope clause asserted, root grammar byte-identical), C-rows pin the
additive workstream snapshot field. All fail on next; fix follows.

* fix(#4257): harvest only prose phase references; W002 names its workstream scope

Sub-defect (a): the statePhaseTokens harvest was the verbatim #3309
relocation of verify.cts's unanchored, markdown-blind scan
([Pp]hase\s+(TOKEN) over the raw file), so GSD's own command names
(/gsd-execute-phase 5, bare or quoted) and any token inside a code
span/fenced block were harvested as phase references and fired W002 on
ledger rows. Now strips fenced blocks then inline spans via the canonical
markdown-sectionizer seam (#2365 composition order) and matches with a
left word boundary (?<![-\w]) so hyphen- or word-suffixed carriers are
mentions, not references. Pinned tradeoff: a genuine reference written
in backticks stops counting (a quoted literal is not a reference).

Sub-defect (b): the valid set is workstream-scoped by construction
(planningPaths under GSD_WORKSTREAM; per-workstream numbering is
deliberate), but the message claimed 'only phases 1, 2 are declared'
unqualified. New additive PlanningSnapshot.workstream field, sourced
from planning-workspace's new resolveEnvWorkstream() — the ONE env
discriminator planningDir itself applies — so the clause cannot disagree
with the base the reads used. Root scope keeps the byte-identical
message; the checker's scope is unchanged.

* test(#4257): close the B2 quoted-literal code span (fixture typo)

The B2 fixture wrote a single opening backtick — an unterminated span is
literal text per CommonMark, so its content is prose and W002 correctly
fired on it. The test's name, the A3 snapshot-level twin, and the B2
matrix row all intend a closed span; pre-fix this was indistinguishable
because the unanchored harvest fired either way.

* chore(#4257): changeset fragment (pr number to backfill)

* chore(#4257): backfill PR number in changeset

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-07 14:31:35 -04:00
committed by GitHub
parent c3a18b5ba0
commit ac6ed6201d
6 changed files with 470 additions and 10 deletions

View File

@@ -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/<active-ws>/`), 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',
),

View File

@@ -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 <name>`), 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 `(?<![-\w])` — the `-phase 5` tail of GSD's own
// command names (`/gsd-execute-phase 5`, bare or in prose) is a
// command mention, not a reference, and word-suffixed carriers
// (`myphase 5`) never were references. `Phase 5` at line start,
// `**Phase 5:**`, `(Phase 5)`, `[Phase 5]`, and `### Phase 5:` all
// still harvest — the char before `Phase` is not in `[-\w]`.
//
// Still scans the WHOLE document (frontmatter included — the `Phase: 3`
// field syntax never matched, `phase` is followed by a colon, not `\s`),
// still `COMPLETE` whenever the file itself was read successfully.
value: [
...stripInlineCode(stripFencedCode(content).text).matchAll(
new RegExp(`(?<![-\\w])[Pp]hase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'g'),
),
].map((m) => 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,
};
}

View File

@@ -125,9 +125,26 @@ const PLANNING_LOCK_RETRY_ERRNOS = new Set([
// compatible with the structural type the store expects.
type WorkstreamAdapterOpts = Record<string, unknown>;
/**
* #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,