fix(#3945): require a non-empty denominator for milestone-complete (#4164)

* test(#3945): 0-of-0 milestone counters must not render complete

Regression coverage for the string-truthiness vacuous equality in both
statusline renderers: '0' counters, boundary 1/1 vs 0/1 vs 1/0, compact
parity.

* fix(#3945): require a non-empty denominator for milestone-complete

Both statusline renderers compared the regex-captured string counters
directly, so '0' && '0' && '0' === '0' fired Scene 3 on the empty set.
Numeric coercion plus total > 0 restores the guard's intent; all
non-zero shapes and percent=100 render byte-identically.

* chore(#3945): add changeset fragment

* chore(#3945): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-01 20:43:39 -04:00
committed by GitHub
parent c9bff8d2b9
commit 4c6643a8d7
3 changed files with 52 additions and 3 deletions

View File

@@ -389,8 +389,12 @@ function formatGsdState(s) {
// Scene 2: idle + a recommended next command is visible to the user.
// Surfaces "what to run next" without the user opening STATE.md.
parts.push(`next ${s.nextAction} ${phasesStr}`);
} else if (Number(s.percent) === 100 || (s.completedPhases && s.totalPhases && s.completedPhases === s.totalPhases)) {
// Scene 3: milestone complete (every phase done).
} else if (Number(s.percent) === 100 || (Number(s.totalPhases) > 0 && Number(s.completedPhases) === Number(s.totalPhases))) {
// Scene 3: milestone complete (every phase done). #3945: the counters are
// regex-captured STRINGS, so the old `cp && tp && cp === tp` guard fired on
// the empty set ('0' is truthy, '0' === '0') — "0% · milestone complete".
// Numeric coercion + a non-empty denominator makes "nothing to measure"
// stop meaning "everything is done".
parts.push('milestone complete');
} else {
// Backward-compatible default — preserved EXACTLY for STATE.md files that
@@ -500,7 +504,7 @@ function formatGsdStateCompact(s) {
// still completes) wins over milestone-complete (Scene 3), even if a
// non-atomic STATE.md edit leaves percent=100 alongside a lifecycle phase.
const done = !s.activePhase && (Number(s.percent) === 100 ||
(s.completedPhases && s.totalPhases && s.completedPhases === s.totalPhases));
(Number(s.totalPhases) > 0 && Number(s.completedPhases) === Number(s.totalPhases)));
if (done) {
parts.push('complete');