fix(#2853): roadmap.update-plan-progress preserves hand-written annotations (#2916)

* test(#2853): add failing-first regression for plan-progress annotation preservation

The count-bump regex's trailing [^\n]+ swallowed the whole Plans line and
the replacement wrote back only the regenerated count, deleting any
hand-written annotation after it. 8-row matrix covers bold/plain forms,
bare template form, executed path, CRLF, and idempotency.

* fix(#2853): preserve hand-written annotations in roadmap plan-progress bump

The count-bump regex's trailing [^\n]+ swallowed the entire Plans line and
the replacement wrote back only the regenerated count, deleting any
hand-written prose after the count (e.g. a gap-closure annotation).

The verb owns the count token only. Capture the existing count token ($2)
and the trailing line text ($3), and rebuild the line as
<label><new count><surviving text>. Trailing text is preserved ONLY when a
real count token preceded it, so the fresh-template bracketed placeholder
(`[Number of plans…]`) is still replaced cleanly rather than glued after
the count (pre-#2853 behaviour on the template path preserved). CRLF \r is
preserved via [^\r\n].

Widens replaceInCurrentMilestone to accept a replacement callback (needed to
branch on whether the count group matched). The bare Plans: checklist header
is still skipped — the lazy match lands on the summary line first and a
count-less bare header yields no count to anchor preservation to.

* changeset(#2853): backfill PR number 2916

---------

Co-authored-by: Test <test@example.com>
Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-07-31 09:54:55 -04:00
committed by GitHub
parent 8635cc447a
commit d49a7d0c4d
4 changed files with 305 additions and 5 deletions

View File

@@ -194,15 +194,25 @@ function extractCurrentMilestone(content: string, cwd?: string): string {
/**
* Replace a pattern only in the current milestone section of ROADMAP.md.
*/
function replaceInCurrentMilestone(content: string, pattern: RegExp, replacement: string): string {
type RoadmapReplacer = (match: string, ...captures: string[]) => string;
function replaceInCurrentMilestone(
content: string,
pattern: RegExp,
replacement: string | RoadmapReplacer,
): string {
const apply = (src: string): string =>
typeof replacement === 'function'
? src.replace(pattern, replacement)
: src.replace(pattern, replacement);
const lastDetailsClose = content.lastIndexOf('</details>');
if (lastDetailsClose === -1) {
return content.replace(pattern, replacement);
return apply(content);
}
const offset = lastDetailsClose + '</details>'.length;
const before = content.slice(0, offset);
const after = content.slice(offset);
return before + after.replace(pattern, replacement);
return before + apply(after);
}
/**

View File

@@ -615,14 +615,31 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
// `**Plans**: N plans` — bold word + outer colon (gsd-core/templates/roadmap.md)
// `**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).
//
// 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.
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*)[^\\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`;
roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`);
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 complete: check checkbox
if (isComplete) {