fix(#3584): the verb owns the count token and nothing else (#3635)

* test(3584): failing-first coverage for Plans-line trailing text

roadmap update-plan-progress preserves trailing text only when the line begins with a
canonical count token. Every other phrasing — including the TBD value the shipped
template itself suggests — is replaced to end-of-line, and a sentence wrapping onto a
second line has its first line deleted, leaving the continuation standing alone so the
roadmap asserts something nobody wrote. Exit 0, updated:true, and the diff reads as a
routine count bump.

These tests fail on that, and pin the arms that must keep working: the template
placeholder is still replaced, the #2853 token-plus-annotation path is unchanged, CRLF is
neither stranded nor duplicated, and a run that leaves the line alone still updates the
Progress table and checkboxes rather than becoming a no-op.

* fix(3584): the verb owns the count token and nothing else

RED proven at 105bdf7c: 7 failures — the preserving cases (freeform prose, wrapped
continuation, TBD, CRLF) failed while the template-placeholder and #2853 token arms
passed on base.

The trailing-text guard fired only when the line began with a canonical count token:
 dropped the rest of the line whenever the
regex's count group did not match. #2853 fixed end-of-line truncation on that one path
only. The in-code comment justified the rest as 'the fresh-template bracketed placeholder
or other freeform guidance, not user prose' — a heuristic that misreads ordinary human
phrasing and destroys even TBD, the value the shipped template itself suggests at
templates/roadmap.md:37.

The sharper failure was the wrapped sentence: only the first line is inside the match, so
the verb deleted line one and left line two standing alone, leaving the roadmap asserting
something nobody wrote — at exit 0, updated:true, in a diff that reads as a routine count
bump.

Inverted the default into three arms. A real count token is rewritten with its annotation
preserved (unchanged, #2853). A bracketed placeholder is detected POSITIVELY and replaced.
Everything else — freeform prose, TBD, a wrapped sentence's first line, an empty value —
returns the match untouched. That last arm resolves the wrapped case by construction: an
untouched first line cannot orphan its continuation.

Positive detection is the load-bearing part. Implemented as 'not a count token, therefore
disposable', rows 1-3 come straight back; the detector instead asks whether the value IS a
bracketed placeholder.

Leaving the line alone does not make the verb a no-op: the phase checkbox, the
Progress-table cells and the plan-checklist row still update in the same run, and that is
asserted. CRLF is unaffected in every arm — the pattern's [^\r\n]* never consumes the
\r, so it sits outside the match regardless of which arm runs.

Fixes #3584

* fix(3584): detect the template placeholder by its text, not by its brackets

Two defects in the arm-2 detector shipped in fc23e49e, both found in review.

Finding A: isBracketedPlaceholder asked only whether the trimmed value was wrapped
in [...]. Brackets are ordinary prose punctuation in a roadmap, so any hand-written
bracketed note — '[Deferred pending re-scope]', '[blocked on #1234]' — was classified
as the fresh-template placeholder and destroyed. That is the very defect #3584 is
about, reintroduced one arm over. The detector now matches the placeholder's TEXT
(/^\[\s*Number of plans\b[\s\S]*\]$/i), so it recognizes the shipped template
value and its short form and nothing else.

Finding B: the count group matched '\\d+\\s+plans' only. The plural is not the
template's own output shape — templates/roadmap.md:62 ships '1 plan' — so a
single-plan phase fell through every arm and its line froze permanently, never
updating again. Widened to 'plans?'. This one was introduced by the arm-3 default:
before it, the singular fell through to the old replace-everything path and at
least stayed current.

Cases 11-14 cover both: a bracketed human note preserved, the short placeholder
still replaced, '1 plan' rewritten, and '1 plan (annotation)' rewritten with the
annotation intact. Verified against the live binary, not just re-read.

Also converted all 15 cases in this block from try/finally to t.after(), per
CONTRIBUTING.md:356-370 which bans try/finally in test bodies. The existing #2853
block above is untouched — it is not in this change's scope and its conversion is
not this fix's concern.

Refs #3584

* chore(3584): add changeset fragment

Fixed-type fragment for the roadmap Plans-line trailing-text fix. pr:0 placeholder, backfilled once the PR number exists.

Refs #3584

* chore(3584): backfill changeset PR number (#3635)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-18 16:39:17 -04:00
committed by GitHub
parent ac1b6d679f
commit 0f417aa6d0
3 changed files with 266 additions and 15 deletions

View File

@@ -874,29 +874,68 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
// `**Plans:** N plans` — bold "Plans:" (colon inside bold)
// `Plans: N plans` — plain text header
//
// #2853: the verb owns the count token ONLY — it must not destroy hand-written
// prose a human placed after the count (e.g. "(11-16 are gap closure ...)").
// Group $1 = phase header → `Plans:` label + trailing whitespace (unchanged).
// Group $2 = the existing count token to replace: matches `N/N plans complete`,
// `N/N plans executed`, or the bare template `N plans` form. Group $3 = whatever
// else is on the line (`[^\r\n]*`, so CRLF `\r` is preserved).
// #2853 / #3584: the verb owns the count token ONLY — it must not destroy
// hand-written prose a human placed on the line. Group $1 = phase header →
// `Plans:` label + trailing whitespace (unchanged). Group $2 = the existing
// count token to replace: matches `N/N plans complete`, `N/N plans executed`,
// or the bare template `N plan(s)` form — singular is part of the tool's OWN
// grammar (gsd-core/templates/roadmap.md:62 ships `**Plans**: 1 plan` as the
// documented one-plan-phase shape), so the `s` is optional there (bug #3584
// Finding B; pre-fix a bare `1 plan` fell into the drop-everything path and
// was accidentally overwritten with the correct count — post-fix it must be
// recognised as a token in its own right or it freezes stale forever). Group
// $3 = whatever else is on the line (`[^\r\n]*`, so a CRLF `\r` is never part
// of the match and rides along untouched in the unmatched remainder of the
// string — never stranded, never duplicated).
//
// Trailing text is preserved ONLY when a real count token ($2) was present —
// i.e. an annotation a human wrote after a real count. When $2 is absent the
// line is the fresh-template bracketed placeholder (`[Number of plans…]`) or
// other freeform guidance, not user prose: the count replaces the whole token,
// preserving the pre-#2853 clean-output behaviour on the template path.
// Three arms, in order:
// 1. $2 present (a real count token) → rewrite the token, preserve $3
// verbatim (an annotation a human wrote after a real count; #2853).
// 2. $2 absent AND $3, trimmed, is the fresh-template PLACEHOLDER shipped
// by gsd-core/templates/roadmap.md — either
// `[Number of plans, e.g., "3 plans" or "TBD"]` (line 37) or
// `[Number of plans]` (lines 51/75/88) → replace it with the computed
// count. Detected POSITIVELY on the distinctive `Number of plans`
// wording (anchored, case-insensitive), NEVER on "wholly bracketed" —
// a bracketed HUMAN annotation such as `[Deferred pending re-scope]`
// is structurally identical but must be arm-3 preserved (bug #3584
// Finding A).
// 3. Anything else (freeform prose, `TBD` / `TBD — annotation`, a
// bracketed human note, the first line of a wrapped sentence, an
// empty value) → leave the whole matched line untouched by returning
// `_match` unchanged. An untouched first line cannot orphan its own
// continuation on the next line, since the pattern never spans past
// `\n` in the first place.
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)(\\d+\\s*\\/\\s*\\d+\\s+plans(?:\\s+(?:complete|executed))?|\\d+\\s+plans)?([^\\r\\n]*)`,
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)(\\d+\\s*\\/\\s*\\d+\\s+plans(?:\\s+(?:complete|executed))?|\\d+\\s+plans?)?([^\\r\\n]*)`,
'i'
);
const planCountText = isComplete
? `${summaryCount}/${planCount} plans complete`
: `${summaryCount}/${planCount} plans executed`;
// Positive detector for the fresh-template placeholder ONLY (bug #3584
// Finding A). Anchored to the distinctive `Number of plans` wording that
// gsd-core/templates/roadmap.md actually ships, not to "anything in
// brackets" — a bracketed human annotation like `[Deferred pending
// re-scope]` is structurally bracketed too but carries none of this
// wording, so it correctly falls through to arm 3 untouched.
const isTemplatePlaceholder = (value: string): boolean => {
const trimmed = value.trim();
return /^\[\s*Number of plans\b[\s\S]*\]$/i.test(trimmed);
};
roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, (_match, label, existingCount, trailing) => {
// Preserve trailing text only when a real count preceded it.
const suffix = existingCount ? trailing : '';
return `${label}${planCountText}${suffix}`;
if (existingCount) {
// Arm 1: real count token — rewrite it, preserve the trailing annotation.
return `${label}${planCountText}${trailing}`;
}
if (isTemplatePlaceholder(trailing)) {
// Arm 2: fresh-template placeholder — replace with the count.
return `${label}${planCountText}`;
}
// Arm 3: freeform prose, TBD, a bracketed human annotation, a wrapped
// sentence's first line, or an empty value — leave the line exactly as
// it was.
return _match;
});
// If complete: check checkbox