fix(sdk): extractCurrentMilestone Backlog leak + state.begin-phase flag parsing (#2455)

* fix(sdk): extractCurrentMilestone Backlog leak + state.begin-phase flag parsing

Closes #2422
Closes #2420

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: patch-version semver in milestone boundary regex + flag-parser validation

Two follow-on correctness issues identified in code review:

1. roadmap.ts: currentVersionMatch and nextMilestoneRegex only captured
   major.minor (v(\d+\.\d+)), collapsing v2.0.1 to "2.0". A sub-heading
   "## v2.0.2 Phase Details" would match the same prefix and be incorrectly
   skipped. Both patterns updated to v(\d+(?:\.\d+)+) to capture full semver.

2. state-mutation.ts: pair-wise flag parsing loop advanced i by 2 unconditionally,
   so a missing flag value caused the next flag token to be assigned as the value
   (e.g. flags['phase'] = '--name'). Fix: iterate with i++ and validate that the
   candidate value exists and does not start with '--' before assigning; throw
   GSDError('missing value for --<key>') on invalid input. Added regression test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-20 10:08:14 -04:00
committed by GitHub
parent 990c3e648d
commit 59cfbbba6a
4 changed files with 145 additions and 22 deletions

View File

@@ -110,7 +110,7 @@ export async function extractCurrentMilestone(content: string, projectDir: strin
// Fallback: derive from ROADMAP in-progress marker
if (!version) {
const inProgressMatch = content.match(/🚧\s*\*\*v(\d+\.\d+)\s/);
const inProgressMatch = content.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s/);
if (inProgressMatch) {
version = 'v' + inProgressMatch[1];
}
@@ -130,30 +130,35 @@ export async function extractCurrentMilestone(content: string, projectDir: strin
const sectionStart = sectionMatch.index;
// Find end: next milestone heading at same or higher level, or EOF
// Find end: next milestone heading at same or higher level, or EOF.
// Skip headings that belong to the SAME version (e.g. "## v2.0 Phase Details").
const headingLevelMatch = sectionMatch[1].match(/^(#{1,3})\s/);
const headingLevel = headingLevelMatch ? headingLevelMatch[1].length : 2;
const restContent = content.slice(sectionStart + sectionMatch[0].length);
const nextMilestonePattern = new RegExp(
`^#{1,${headingLevel}}\\s+(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
'mi'
);
const nextMatch = restContent.match(nextMilestonePattern);
let sectionEnd: number;
if (nextMatch && nextMatch.index !== undefined) {
sectionEnd = sectionStart + sectionMatch[0].length + nextMatch.index;
} else {
sectionEnd = content.length;
// Extract current version so same-version sub-headings are not treated as boundaries.
// Capture full semver (major.minor.patch) so v2.0.1 is not collapsed to "2.0".
const currentVersionMatch = version ? version.match(/v(\d+(?:\.\d+)+)/i) : null;
const currentVersionStr = currentVersionMatch ? currentVersionMatch[1] : '';
const nextMilestoneRegex = new RegExp(
`^#{1,${headingLevel}}\\s+(?:.*v(\\d+(?:\\.\\d+)+)[^\\n]*|.*(?:✅|📋|🚧))`,
'gm'
);
let sectionEnd = content.length;
let m: RegExpExecArray | null;
while ((m = nextMilestoneRegex.exec(restContent)) !== null) {
const matchedVersion = m[1];
// Skip headings that reference the same version (e.g. "## v2.0 Phase Details").
if (matchedVersion && currentVersionStr && matchedVersion === currentVersionStr) continue;
sectionEnd = sectionStart + sectionMatch[0].length + m.index;
break;
}
const beforeMilestones = content.slice(0, sectionStart);
const currentSection = content.slice(sectionStart, sectionEnd);
// Strip <details> from preamble
const preamble = beforeMilestones.replace(/<details>[\s\S]*?<\/details>/gi, '');
return preamble + currentSection;
// Return only the current milestone section — never include the preamble, which
// may contain ## Backlog and other non-current-milestone phases.
return content.slice(sectionStart, sectionEnd);
}
// ─── Internal helpers ─────────────────────────────────────────────────────