* test(3578): failing-first coverage for milestone status on partial completion Completing phase 2 of a 4-phase milestone sets frontmatter status: completed while the same call correctly writes completed_phases: 2 / total_phases: 4. These tests fail on that conflation and pin the boundary either side of it (3-of-4 must not complete, 4-of-4 must), plus milestone_name byte-identity and the 1-of-1 case that legitimately does complete. * fix(3578): derive milestone status from phase counters, not phase-completion prose RED proven at 253843b4 (tests-only): the 2-of-4 and 3-of-4 cases failed while the 4-of-4, milestone_name and 1-of-1 controls passed — the conflation, and nothing else. state complete-phase writes body prose `Phase N complete`. normalizeStateStatus matches 'complete' as a case-insensitive SUBSTRING, so phase-level prose collapsed into milestone-level frontmatter status: completed — even while the same call correctly derived completed_phases: 2 / total_phases: 4 / percent: 50. Check ORDER is why the sibling surface stays correct: completePhaseCore writes 'Ready to plan' for non-final phases, hitting the 'planning' arm before 'complete'. The two phase-completion surfaces disagreed and this was the conflated one — a violation of ADR-2207, which gives milestone termination solely to milestoneCompleteCore. buildStateFrontmatter now honors a 'completed' normalization from phase-completion prose only when the counters it already derived agree. Scoped deliberately: - anchored to bare `Phase <token> complete`, so 'All phases complete' and '<version> milestone complete' are untouched (both out of scope). Verified by executing the guard's own regex from source against both forms. - gated on counter trustworthiness (COMPLETE disk scope, finite counts, positive denominator) so an unknown scope withholds rather than guessing 'not complete', which would be the mirror-image bug - normalizeStateStatus itself is NOT modified — it feeds every state.* write and the read path A 1-of-1 milestone still yields 'completed' by the rule, not by exemption, so the #1255 pinning test stays green on its merits. Fixes #3578 * fix(3578): gate the guard on milestone boundedness and close the review gaps Review findings from two orthogonal passes, all fixed inline. GUARD (correctness, from the standards pass): the guard omitted `milestoneUnbounded`, which is the established trust authority for these very counters in this same function — it nulls progressPercent at :2286 and gates the prose fallback at :2294. An unbounded milestone yields a conflated/understated total, so `completedPhases < totalPhases` could be an artifact of a bad denominator and demote a genuinely-complete milestone. Now gated. TESTS: - Prose/guard parity assertion. The guard regex-matches prose emitted from a DIFFERENT file; if that prose drifts the guard silently stops firing and the bug returns undetected. Per the repo's generative-fix-divergence rule, a test now asserts the emitted body Status still matches the guard's pattern — asserting the emitted value against the pattern rather than duplicating the string. - limit+1: completedPhases > totalPhases must NOT fire; inconsistent counters fall through rather than guessing. - Untrustworthy counters (no phases dir → totalPhases null) must NOT fire. - AC4: MCP invoke-command dispatch parity via handleMessage, the criterion both reviewers independently flagged as asserted-but-untested. - Hand-rolled STATE.md writes routed through the existing writeState fixture helper. The adversarial pass independently verified, by reading rather than trusting the diff's own comments, that: paused/stopped short-circuit before 'completed' so a paused milestone can never be clobbered; only cmdStateCompletePhase emits the targeted prose, so no sibling caller over-fires; the counters come from a fresh disk scan independent of this write, so there is no pre/post off-by-one; and the #1255 pinning fixture creates no phases dir, leaving completedPhases null and the guard inert — so that test is provably unaffected rather than assumed to be. * chore(3578): add changeset fragment * chore(3578): backfill changeset PR number (#3614) --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -2296,7 +2296,39 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
|
||||
if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
|
||||
}
|
||||
|
||||
const normalizedStatus = normalizeStateStatus(status, pausedAt);
|
||||
let normalizedStatus = normalizeStateStatus(status, pausedAt);
|
||||
// #3578: normalizeStateStatus matches 'complete' as a case-insensitive
|
||||
// SUBSTRING, so the phase-completion prose cmdStateCompletePhase writes to
|
||||
// the body (`Phase ${N} complete`) collapses to the milestone-level
|
||||
// 'completed' status even when other phases remain open. Phase-level
|
||||
// prose must never decide milestone-level status — completedPhases /
|
||||
// totalPhases / diskScope, already derived above from a disk scan, are
|
||||
// the authority on whether the MILESTONE is actually done. Only override
|
||||
// when: (a) normalizeStateStatus actually landed on 'completed'; (b) the
|
||||
// raw prose is UNAMBIGUOUSLY phase-completion prose — the anchored
|
||||
// pattern below deliberately excludes "All phases complete" (no `\S+`
|
||||
// phase token) and milestone-close prose like "v1.0 milestone complete"
|
||||
// (no leading "phase"); and (c) the counters are trustworthy (a COMPLETE
|
||||
// disk scope, both counts are finite numbers, and a positive
|
||||
// denominator) and affirmatively disagree with 'completed'. In every
|
||||
// other case normalizedStatus is left exactly as normalizeStateStatus
|
||||
// returned it.
|
||||
if (
|
||||
normalizedStatus === 'completed' &&
|
||||
typeof status === 'string' &&
|
||||
/^\s*phase\s+\S+\s+complete\s*$/i.test(status) &&
|
||||
diskScope === SCOPE.COMPLETE &&
|
||||
// #1761: an unbounded milestone yields a conflated/understated total — the
|
||||
// same authority that nulls progressPercent above. Without this, a bad
|
||||
// denominator could demote a genuinely-complete milestone.
|
||||
!milestoneUnbounded &&
|
||||
typeof completedPhases === 'number' && Number.isFinite(completedPhases) &&
|
||||
typeof totalPhases === 'number' && Number.isFinite(totalPhases) &&
|
||||
totalPhases > 0 &&
|
||||
completedPhases < totalPhases
|
||||
) {
|
||||
normalizedStatus = 'executing';
|
||||
}
|
||||
|
||||
const fm: Record<string, unknown> = { gsd_state_version: '1.0' };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user