fix(#3017): scope state write disk scan to stored milestone (#3105)

* fix(#3017): scope state write's disk scan to stored milestone

buildStateFrontmatter called getMilestonePhaseFilter(cwd) WITHOUT the
stored milestone version, so it auto-derived from ROADMAP.md — and when
getMilestoneInfo mis-bound (the stored milestone had no matching non-✅
heading), it picked a confidently-wrong milestone and clobbered the stored
value + rewrote progress with whole-project counts on every state.* write.

Pass the stored milestone from STATE.md frontmatter through to
buildStateFrontmatter and use it as the explicit versionOverride for
getMilestonePhaseFilter. When the stored milestone is available, the filter
scopes to it instead of auto-deriving.

* chore(#3017): backfill changeset PR number 3105

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-06 01:35:06 -04:00
committed by GitHub
parent 4926c2e904
commit 8fb6681a1e
2 changed files with 15 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3105
---
**`state.*` writes no longer flip the milestone or rewrite progress with whole-project counts** — when the stored milestone had no matching non-shipped ROADMAP heading, `buildStateFrontmatter` auto-derived a confidently-wrong milestone and clobbered the stored value + progress on every write. The disk scan now scopes to the STORED milestone explicitly, so a state write that doesn't change progress leaves the milestone and progress block untouched. (#3017)

View File

@@ -1605,7 +1605,7 @@ function extractRetiredPhaseNumbers(scope: string): Set<string> {
* a YAML frontmatter object. Allows hooks and scripts to read state
* reliably via `state json` instead of fragile regex parsing.
*/
function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Record<string, unknown> {
function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, storedMilestone?: string | null): Record<string, unknown> {
// #2956: scope `Phase` extraction to ## Current Position (mirrors the read
// path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping
// below). Phase canonically lives in ## Current Position (templates/state.md);
@@ -1682,7 +1682,10 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
}
} catch { /* fall through: no roadmap scope → no retired exclusion */ }
const isDirInMilestone = getMilestonePhaseFilter(cwd) as (dir: string) => boolean;
// #3017: scope the milestone filter to the STORED milestone when available,
// so a state.* write doesn't auto-derive (and mis-bind) to a different
// milestone's heading and clobber the stored value + progress counts.
const isDirInMilestone = getMilestonePhaseFilter(cwd, storedMilestone ?? undefined) as (dir: string) => boolean;
const allMatchingDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory()).map(e => e.name)
.filter(isDirInMilestone);
@@ -1855,7 +1858,11 @@ function syncStateFrontmatter(content: string, cwd: string | undefined, authorit
cwd ? planningPaths(cwd).state : undefined,
) as Record<string, unknown>;
const body = stripFrontmatter(content);
const derivedFm = buildStateFrontmatter(body, cwd);
// #3017: pass the stored milestone from the existing frontmatter so
// buildStateFrontmatter scopes its disk scan to the correct milestone
// instead of auto-deriving (and potentially mis-binding).
const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone);
// Preserve existing frontmatter status when body-derived status is 'unknown'.
// This prevents a missing Status: field in the body from overwriting a