* 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:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user