* test(#3213): add letter-named phase regression for getMilestonePhaseFilter The custom-ID branch's greedy capture excluded every letter-named phase directory (Phase A:..Phase L:) from the milestone, fabricating counts. Add two failing-first regression tests: a single letter phase + numeric control, and the full A..L + 00 tree from the issue's reproduction. RED — fails on next; fix follows in a separate fix: commit. * fix(#3213): segment-boundary membership for letter-named phase dirs The custom-ID branch of isDirInMilestone (getMilestonePhaseFilter) used a greedy capture ^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*) that swallowed the whole hyphenated directory name (A-tool-output-contract was captured as 'A-tool-output-contract', not 'A'). The set lookup then failed and every letter-named phase directory (Phase A:..Phase L: — GSD's own convention, ADR-612 first-class non-numeric IDs) fell out of the milestone, silently fabricating progress/plan counts over whatever numeric dir survived. Replace the capture-then-lookup with a segment-boundary membership test: a directory belongs if its lowercased name EQUALS a declared phase ID or BEGINS with that ID followed by '-' (so 'A-tool-output-contract' matches ID 'a'; 'PROJ-42-description' matches ID 'proj-42'; 'AB-combined' does NOT match 'a'). IDs are sorted longest-first so a hyphenated id (proj-42) is tested before a prefix of it (proj). Additive only — numericRe still handles every leading-digit dir first, and this can only ADMIT a dir the greedy capture wrongly excluded, never exclude one already matched. * chore(#3213): add changeset fragment * chore(#3213): backfill changeset PR number (#3368) --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/tame-river-song.md
Normal file
5
.changeset/tame-river-song.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 3368
|
||||||
|
---
|
||||||
|
**Milestone phase counts no longer drop every letter-named phase directory** — `getMilestonePhaseFilter` now includes letter-named phase directories (`Phase A:`…`Phase L:`, GSD's own non-numeric phase convention per ADR-612) in milestone progress and plan counts. A greedy regex previously captured the whole hyphenated directory name (`A-tool-output-contract` was read as `A-tool-output-contract` instead of `A`), so every letter-named phase silently fell out of its milestone and the progress/plan totals were fabricated over whatever numeric directory happened to survive — a well-formed, plausible number that could even look correct at a phase boundary. Numeric and milestone-prefixed phases are unchanged. (#3213)
|
||||||
@@ -1366,6 +1366,10 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
|
|||||||
);
|
);
|
||||||
|
|
||||||
const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
|
const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
|
||||||
|
// #3213: longest-first so a hyphenated declared ID (e.g. "proj-42") is tested
|
||||||
|
// before a prefix of it (e.g. "proj") in the segment-boundary membership loop
|
||||||
|
// below — otherwise the shorter id would admit a dir that belongs to the longer.
|
||||||
|
const normalizedIdsLongestFirst = [...normalized].sort((a, b) => b.length - a.length);
|
||||||
// #2043: milestone-prefixed sub-phase components must be zero-padded — so a
|
// #2043: milestone-prefixed sub-phase components must be zero-padded — so a
|
||||||
// single-digit slug word after the phase
|
// single-digit slug word after the phase
|
||||||
// number (e.g. dir "46-6-rs-…") captures "46" and is not silently excluded from
|
// number (e.g. dir "46-6-rs-…") captures "46" and is not silently excluded from
|
||||||
@@ -1383,8 +1387,27 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
|
|||||||
function isDirInMilestone(dirName: string): boolean {
|
function isDirInMilestone(dirName: string): boolean {
|
||||||
const m2 = dirName.match(numericRe);
|
const m2 = dirName.match(numericRe);
|
||||||
if (m2 && normalized.has(normalizePhaseIdSegments(m2[1]).toLowerCase())) return true;
|
if (m2 && normalized.has(normalizePhaseIdSegments(m2[1]).toLowerCase())) return true;
|
||||||
const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
|
// #3213: segment-boundary membership test, scoped to LETTER-LEADING (custom-
|
||||||
if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true;
|
// ID) directories only. The prior greedy capture
|
||||||
|
// `^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)` swallowed the WHOLE hyphenated
|
||||||
|
// directory name (A-tool-output-contract was captured as
|
||||||
|
// "A-tool-output-contract", not "A"), so every letter-named phase directory
|
||||||
|
// (Phase A:..Phase L: — GSD's own convention, ADR-612 first-class non-numeric
|
||||||
|
// IDs) fell out of the milestone and counts were silently fabricated over
|
||||||
|
// whatever numeric directory survived. A letter-leading directory belongs if
|
||||||
|
// its lowercased name EQUALS a declared phase ID, or BEGINS with that ID
|
||||||
|
// followed by "-" (so "A-tool-output-contract" matches ID "a";
|
||||||
|
// "PROJ-42-description" matches ID "proj-42"; "AB-combined" does NOT match
|
||||||
|
// "a"). SCOPED TO LETTER-LEADING DIRS because numeric dirs are owned by
|
||||||
|
// numericRe above, which respects the #2232 continuation grammar — a bare
|
||||||
|
// startsWith here would wrongly admit "14-02-photos-…" to phase "14" when its
|
||||||
|
// real token is "14-02" (continuation-absorbed, not declared).
|
||||||
|
if (/^[A-Za-z]/.test(dirName)) {
|
||||||
|
const lowerDir = dirName.toLowerCase();
|
||||||
|
for (const id of normalizedIdsLongestFirst) {
|
||||||
|
if (lowerDir === id || lowerDir.startsWith(id + '-')) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
const stripped = stripProjectCodePrefix(dirName);
|
const stripped = stripProjectCodePrefix(dirName);
|
||||||
if (stripped !== dirName) {
|
if (stripped !== dirName) {
|
||||||
const sm = stripped.match(numericRe);
|
const sm = stripped.match(numericRe);
|
||||||
|
|||||||
@@ -1233,6 +1233,53 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => {
|
|||||||
assert.strictEqual(filter('02-01-setup'), true, 'bracket-prefixed phase 2-01 matched');
|
assert.strictEqual(filter('02-01-setup'), true, 'bracket-prefixed phase 2-01 matched');
|
||||||
assert.strictEqual(filter('02-02-build'), true, 'bracket-prefixed phase 2-02 matched');
|
assert.strictEqual(filter('02-02-build'), true, 'bracket-prefixed phase 2-02 matched');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #3213: the custom-ID branch used a greedy capture
|
||||||
|
// `^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)` that swallowed the whole
|
||||||
|
// hyphenated directory name (A-tool-output-contract → captured
|
||||||
|
// "A-tool-output-contract", not "A"). Every letter-named phase directory
|
||||||
|
// (GSD's own Phase A:..Phase L: convention; ADR-612 first-class non-numeric
|
||||||
|
// IDs) was silently excluded and milestone counts were fabricated over
|
||||||
|
// whatever numeric dir survived. The fix is a segment-boundary membership
|
||||||
|
// test: a dir belongs if it equals a declared phase ID or begins with id + "-".
|
||||||
|
test('#3213: letter-named phase dir is included in the milestone', () => {
|
||||||
|
writeRoadmap(tmpDir, [
|
||||||
|
'## v1.0: Letters',
|
||||||
|
'### Phase A: Tool Output Contract',
|
||||||
|
'**Goal:** contract',
|
||||||
|
'',
|
||||||
|
'### Phase 01: Inventory',
|
||||||
|
'**Goal:** inventory',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const filter = getMilestonePhaseFilter(tmpDir);
|
||||||
|
assert.strictEqual(filter.phaseCount, 2, 'Phase A + Phase 01 declared');
|
||||||
|
assert.strictEqual(filter('A-tool-output-contract'), true, 'letter phase A dir must be in-milestone (#3213)');
|
||||||
|
assert.strictEqual(filter('01-inventory'), true, 'numeric phase 01 dir still matches');
|
||||||
|
assert.strictEqual(filter('B-evidence-artifact-contract'), false, 'undeclared letter phase B stays excluded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#3213: letter-named phases A..L all count (not just the numeric dir)', () => {
|
||||||
|
const headings = ['## v1.0: Alpha', '### Phase 00: Inventory', '**Goal:** inv', ''];
|
||||||
|
for (const letter of ['A','B','C','D','E','F','G','H','I','J','K','L']) {
|
||||||
|
headings.push(`### Phase ${letter}: Phase ${letter}`, '**Goal:** g', '');
|
||||||
|
}
|
||||||
|
writeRoadmap(tmpDir, headings.join('\n'));
|
||||||
|
|
||||||
|
const filter = getMilestonePhaseFilter(tmpDir);
|
||||||
|
assert.strictEqual(filter.phaseCount, 13, 'Phase 00 + A..L = 13 phases');
|
||||||
|
assert.strictEqual(filter('00-inventory-approval-gate'), true, '00 in-milestone');
|
||||||
|
const dirFor = {
|
||||||
|
A: 'A-tool-output-contract',
|
||||||
|
B: 'B-evidence-artifact-contract',
|
||||||
|
C: 'C-attention-triage',
|
||||||
|
L: 'L-framework-distribution',
|
||||||
|
};
|
||||||
|
for (const [letter, dir] of Object.entries(dirFor)) {
|
||||||
|
assert.strictEqual(filter(dir), true, `letter phase ${letter} dir "${dir}" must be in-milestone (#3213)`);
|
||||||
|
}
|
||||||
|
assert.strictEqual(filter('M-not-declared'), false, 'undeclared letter M stays excluded');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── withPhaseSection (ADR-2143 §4 — bounded mutation) ────────────────────────
|
// ─── withPhaseSection (ADR-2143 §4 — bounded mutation) ────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user