Merge pull request #2215 from open-gsd/fix/2135-milestone-name-clobber
fix(#2135): anchor milestone heading regex + strip delimiter, widen preserve guard
This commit is contained in:
5
.changeset/eager-bears-wander.md
Normal file
5
.changeset/eager-bears-wander.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 2215
|
||||
---
|
||||
**`milestone_name` is no longer clobbered with a delimiter-led fragment** — getMilestoneInfo's `##` heading regex was unanchored, so it matched a heading quoted inside backticks in the Milestones bullet and wrote garbage like `— Active Milestone` over the curated milestone name on every phase transition. Now consults the 🚧 marker first, anchors the regex to line start, strips the leading delimiter, and widens the preserve guard so a bad derive keeps the existing name. (#2135)
|
||||
@@ -275,6 +275,19 @@ interface MilestoneInfo {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a leading delimiter run (whitespace, em/en-dash, colon, hyphen) from a
|
||||
* milestone-name capture. Markdown headings commonly take the shape
|
||||
* `## vX.Y — Name` or `## vX.Y: Name`; the raw capture includes the delimiter
|
||||
* because `.trim()` only removes whitespace, not punctuation. A name beginning
|
||||
* with punctuation is a delimiter-led fragment, not the curated name (#2135).
|
||||
* NOTE: do not strip `#` — a name beginning with `#` is a heading-parse failure
|
||||
* that should stay loud rather than be silently cleaned.
|
||||
*/
|
||||
function stripLeadingDelimiter(s: string): string {
|
||||
return s.replace(/^[\s—–:-]+/, '').trim();
|
||||
}
|
||||
|
||||
function getMilestoneInfo(cwd: string): MilestoneInfo {
|
||||
try {
|
||||
const roadmap = platformReadSync(path.join(planningDir(cwd), 'ROADMAP.md'));
|
||||
@@ -294,22 +307,34 @@ function getMilestoneInfo(cwd: string): MilestoneInfo {
|
||||
|
||||
if (stateVersion) {
|
||||
const escapedVer = escapeRegex(stateVersion);
|
||||
const headingMatch = roadmap.match(
|
||||
new RegExp(`##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'i')
|
||||
|
||||
// #2135: consult the 🚧 name-bearing marker FIRST. It is the only construct
|
||||
// guaranteed to carry the milestone's curated name adjacent to its version
|
||||
// (the active-milestone bullet). A `##` heading is often nameless
|
||||
// ("## vX.Y — Active Milestone") and, when unanchored, was matched
|
||||
// spuriously on a copy quoted inside backticks in this very bullet.
|
||||
const listMatch = roadmap.match(
|
||||
new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i')
|
||||
);
|
||||
if (headingMatch) {
|
||||
if (!headingMatch[0].includes('✅')) {
|
||||
return { version: stateVersion, name: headingMatch[1].trim() };
|
||||
}
|
||||
} else {
|
||||
const listMatch = roadmap.match(
|
||||
new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i')
|
||||
);
|
||||
if (listMatch) {
|
||||
return { version: stateVersion, name: listMatch[1].trim() };
|
||||
}
|
||||
return { version: stateVersion, name: 'milestone' };
|
||||
if (listMatch) {
|
||||
const name = stripLeadingDelimiter(listMatch[1]);
|
||||
if (name) return { version: stateVersion, name };
|
||||
}
|
||||
|
||||
// Fall back to the `##` heading — ANCHORED to line start (`^` + `m` flag)
|
||||
// so a heading quoted inside backticks or prose mid-line can no longer
|
||||
// match. Skip shipped (✅) headings.
|
||||
const headingMatch = roadmap.match(
|
||||
new RegExp(`^##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'im')
|
||||
);
|
||||
if (headingMatch && !headingMatch[0].includes('✅')) {
|
||||
// Strip a leading delimiter — `.trim()` removes whitespace, not the
|
||||
// em-dash/colon that conventionally separates version from name.
|
||||
const name = stripLeadingDelimiter(headingMatch[1]);
|
||||
if (name) return { version: stateVersion, name };
|
||||
}
|
||||
|
||||
return { version: stateVersion, name: 'milestone' };
|
||||
}
|
||||
|
||||
const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
|
||||
|
||||
@@ -1573,8 +1573,18 @@ function syncStateFrontmatter(content: string, cwd: string | undefined): string
|
||||
// existing frontmatter already holds; only an empty derived value falls through
|
||||
// to this guard (the primary #905 preserve path below handles that).
|
||||
const MILESTONE_NAME_PLACEHOLDER = 'milestone';
|
||||
// #2135: widen the preserve guard. A bad derive is not always the literal
|
||||
// placeholder — getMilestoneInfo can return a delimiter-led fragment
|
||||
// ("— Active Milestone") when the roadmap regex mis-binds. Preserve the
|
||||
// existing curated name unless the derived value actually looks like a name:
|
||||
// non-empty, not the placeholder, and not punctuation-led.
|
||||
const derivedName = derivedFm['milestone_name'];
|
||||
const derivedLooksLikeName = typeof derivedName === 'string'
|
||||
&& derivedName.length > 0
|
||||
&& derivedName !== MILESTONE_NAME_PLACEHOLDER
|
||||
&& !/^[\s—–:-]/.test(derivedName);
|
||||
if (
|
||||
derivedFm['milestone_name'] === MILESTONE_NAME_PLACEHOLDER &&
|
||||
!derivedLooksLikeName &&
|
||||
existingFm['milestone_name'] &&
|
||||
existingFm['milestone_name'] !== MILESTONE_NAME_PLACEHOLDER
|
||||
) {
|
||||
|
||||
@@ -396,6 +396,88 @@ describe('roadmap-parser: getMilestoneInfo', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getMilestoneInfo — #2135 milestone_name clobber ──────────────────────────
|
||||
// The `##` heading regex was unanchored (no `^`/`m`), so it matched a `##`
|
||||
// quoted mid-line inside a Milestones bullet and captured a delimiter-led
|
||||
// fragment into `milestone_name`. The fix: consult the 🚧 name-bearing marker
|
||||
// FIRST, anchor the `##` regex to line start, and strip a leading delimiter.
|
||||
|
||||
describe('roadmap-parser: getMilestoneInfo #2135 — milestone_name clobber', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('case A: 🚧 bullet quoting a nameless ## heading in backticks', () => {
|
||||
writeState(tmpDir, { gsd_state_version: '1.0', milestone: 'v1.8' });
|
||||
writeRoadmap(tmpDir, [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Milestones',
|
||||
'',
|
||||
'- 🚧 **v1.8 user session cleanup** — Phases 36-41 — see `## v1.8 — Active Milestone` below',
|
||||
'',
|
||||
'## v1.8 — Active Milestone',
|
||||
'',
|
||||
'### Phase 36: Something',
|
||||
].join('\n'));
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.version, 'v1.8');
|
||||
assert.strictEqual(info.name, 'user session cleanup');
|
||||
});
|
||||
|
||||
test('case B: nameless ## heading + 🚧 marker carries the real name', () => {
|
||||
writeState(tmpDir, { gsd_state_version: '1.0', milestone: 'v1.9' });
|
||||
writeRoadmap(tmpDir, [
|
||||
'## v1.9 — Active Milestone',
|
||||
'',
|
||||
'### 🚧 v1.9 — Falsifiability',
|
||||
'',
|
||||
'### Phase 1: Hypothesis',
|
||||
].join('\n'));
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.version, 'v1.9');
|
||||
assert.strictEqual(info.name, 'Falsifiability');
|
||||
});
|
||||
|
||||
test('case C: canonical ## vX.Y: Name (no regression)', () => {
|
||||
writeState(tmpDir, { gsd_state_version: '1.0', milestone: 'v2.0' });
|
||||
writeRoadmap(tmpDir, '## v2.0: The Big Launch\n### Phase 1: Setup\n');
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.version, 'v2.0');
|
||||
assert.strictEqual(info.name, 'The Big Launch');
|
||||
});
|
||||
|
||||
test('case D: canonical ## vX.Y — Name (em-dash delimiter stripped)', () => {
|
||||
writeState(tmpDir, { gsd_state_version: '1.0', milestone: 'v2.5' });
|
||||
writeRoadmap(tmpDir, '## v2.5 — Galaxy Release\n### Phase 1: Start\n');
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.version, 'v2.5');
|
||||
assert.strictEqual(info.name, 'Galaxy Release');
|
||||
});
|
||||
|
||||
test('case E: 🚧 bullet only, no ## heading (no regression)', () => {
|
||||
writeState(tmpDir, { gsd_state_version: '1.0', milestone: 'v1.5' });
|
||||
writeRoadmap(tmpDir, 'Some intro text.\n\n- 🚧 **v1.5 Quick Fix** — minor\n');
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.version, 'v1.5');
|
||||
assert.strictEqual(info.name, 'Quick Fix');
|
||||
});
|
||||
|
||||
test('anchored regex never matches a ## heading quoted inside backticks mid-line', () => {
|
||||
writeState(tmpDir, { gsd_state_version: '1.0', milestone: 'v3.0' });
|
||||
writeRoadmap(tmpDir, [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'See `## v3.0 — Active Milestone` referenced here.',
|
||||
'',
|
||||
'## v3.0: Real Name',
|
||||
].join('\n'));
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.name, 'Real Name');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getMilestonePhaseFilter ──────────────────────────────────────────────────
|
||||
|
||||
describe('roadmap-parser: getMilestonePhaseFilter', () => {
|
||||
|
||||
Reference in New Issue
Block a user