diff --git a/.changeset/2043-phase-token-single-digit-slug.md b/.changeset/2043-phase-token-single-digit-slug.md new file mode 100644 index 000000000..ce7b100ac --- /dev/null +++ b/.changeset/2043-phase-token-single-digit-slug.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2059 +--- +**Phase directories whose slug begins with a single digit now resolve correctly.** A phase like `46-6-rs-pipeline-orchestrator` (roadmap name "6 Rs Pipeline Orchestrator") had its phase token over-collected as `46-6` instead of `46`, so `gsd-tools` phase-by-number lookups resolved `phase_dir=null` / `has_context=false` (breaking `init.plan-phase`, `init.phase-op`, and downstream execute/verify/ship). Numeric phase-token components must now be zero-padded (≥2 digits), so a single-digit slug word is no longer absorbed into the token. Fixed consistently across every same-class implementation — `extractPhaseToken`, `PHASE_TOKEN_FROM_DIR_RE` and `canonicalPlanStem` (health checks / plan pairing), `isDirInMilestone`'s numeric matcher (milestone filtering), and `extractCanonicalPlanId` — so the health-check and milestone-filter subsystems are fixed alongside phase resolution. diff --git a/src/core-utils.cts b/src/core-utils.cts index 9c02a6d1c..ec6f18f5c 100644 --- a/src/core-utils.cts +++ b/src/core-utils.cts @@ -178,7 +178,10 @@ function timeAgo(date: Date): string { function extractCanonicalPlanId(filename: string): string { const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, ''); const parts = base.split('-').filter(Boolean); - const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i; + // #2043: a phase/plan token component is either a zero-padded number (≥2 digits) + // or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word, + // so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact. + const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i; const phaseIdx = parts.findIndex(p => tokenRe.test(p)); if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) { return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`; diff --git a/src/phase-id.cts b/src/phase-id.cts index a401e3168..07d0799e6 100644 --- a/src/phase-id.cts +++ b/src/phase-id.cts @@ -205,9 +205,29 @@ function extractPhaseToken(dirName: string): string { const segments = rest.split('-'); const tokenSegments: string[] = []; + // #2043: distinguish a real (zero-padded, ≥2-digit) phase/sub-phase segment + // from a single-digit slug word. A pure-numeric leading segment ("46") only + // continues with ≥2-digit segments, so "46-6-rs-…" yields "46" (the "6" is the + // slug's first word), not "46-6". Milestone-prefixed ids like "M1-2" reach here + // with "M1-" already stripped as a project-code prefix (see + // PROJECT_CODE_PREFIX_CAPTURE_RE_I), so "2" is the leading segment and the same + // pure-numeric rule applies (M1-46-6-rs → "M1-46"). The firstLetterPrefixed + // carve-out covers letter+digit leading segments that survive prefix stripping + // because of punctuation (e.g. "P0.3-2"), whose single-digit continuation is + // intentionally preserved (unchanged from prior behaviour). + let firstLetterPrefixed = false; for (let i = 0; i < segments.length; i++) { const seg = segments[i]; - if (/^\d/.test(seg) || (i === 0 && /^[A-Za-z]{1,3}\d/.test(seg))) { + if (i === 0) { + if (/^\d/.test(seg)) { + tokenSegments.push(seg); + } else if (/^[A-Za-z]{1,3}\d/.test(seg)) { + tokenSegments.push(seg); + firstLetterPrefixed = true; + } else { + break; + } + } else if (/^\d{2,}/.test(seg) || (firstLetterPrefixed && /^\d/.test(seg))) { tokenSegments.push(seg); } else { break; diff --git a/src/phase.cts b/src/phase.cts index f2ca162e4..01acff4a3 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -103,7 +103,10 @@ function extractCanonicalPlanId(filename: string): string { .replace(/-SUMMARY\.md$/i, '') .replace(/\.md$/i, ''); const parts = base.split('-').filter(Boolean); - const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i; + // #2043: a phase/plan token component is either a zero-padded number (≥2 digits) + // or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word, + // so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact. + const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i; const phaseIdx = parts.findIndex((p) => tokenRe.test(p)); if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) { return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`; diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index d4523c98b..1dd7c1c99 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -469,8 +469,12 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p } const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-')); + // #2043: milestone-prefixed sub-phase components must be zero-padded (≥2 digits) + // — "-\d{2,}" instead of "-0*\d+" — so a single-digit slug word after the phase + // number (e.g. dir "46-6-rs-…") captures "46" and is not silently excluded from + // the milestone as a bogus "46-6" id. const numericRe = roadmapUsesHyphenedIds - ? /^0*(\d+(?:-0*\d+)*[A-Za-z]?(?:\.\d+)*)/ + ? /^0*(\d+(?:-\d{2,})*[A-Za-z]?(?:\.\d+)*)/ : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/; function isDirInMilestone(dirName: string): boolean { diff --git a/src/validate.cts b/src/validate.cts index c1167a4d5..5ed32d96c 100644 --- a/src/validate.cts +++ b/src/validate.cts @@ -44,16 +44,25 @@ export const phaseDirNameRe = new RegExp( ); // Extracts the full phase token from a directory name, including milestone-prefixed // multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup". -// Greedily captures all leading all-digit segments before the first letter-start segment. +// #2043: a *continuation* sub-phase segment must be zero-padded (≥2 digits), so a +// single-digit slug word after a phase number (e.g. "46-6-rs-…", slug "6 Rs …") is +// NOT absorbed — it captures "46", not "46-6". The first component stays "\d+" +// (with the "[A-Z]?" suffix) so single-digit letter-suffixed phase ids ("1A") and +// milestone-prefixed single-digit sub-phases ("M1-2" → prefix "M1-" stripped, then +// "2") still match. The trailing boundary "(?:-|$)" (was "(?:-[a-z]|$)") lets a slug +// that starts with a digit terminate the token. export const PHASE_TOKEN_FROM_DIR_RE = new RegExp( - `^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-\\d+)*[A-Z]?(?:\\.\\d+)*)(?:-[a-z]|$)`, + `^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-\\d{2,})*[A-Z]?(?:\\.\\d+)*)(?:-|$)`, 'i', ); export const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i; // ── Issue #26: I001 canonicalization ──────────────────────────────────────── export function canonicalPlanStem(stem: string): string { - const m = stem.match(/^(\d+[A-Z]?(?:\.\d+)*-\d+)/i); + // #2043: the plan component (after the phase number) must be zero-padded + // (≥2 digits), so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken + // for a "46-6" phase/plan pair. + const m = stem.match(/^(\d+[A-Z]?(?:\.\d+)*-\d{2,})/i); return m ? m[1] : stem; } diff --git a/tests/core-utils.test.cjs b/tests/core-utils.test.cjs index 09e78c9e9..c994e0cdf 100644 --- a/tests/core-utils.test.cjs +++ b/tests/core-utils.test.cjs @@ -533,6 +533,21 @@ describe('extractCanonicalPlanId', () => { assert.ok(typeof result === 'string'); assert.ok(result.length > 0); }); + + test('rejects single-digit slug word (#2043)', () => { + // "46-6-rs-pipeline-orchestrator" is not a valid phase-token filename shape + // (the "6" is a slug word, not a sub-phase segment) — must not collapse to + // the pre-fix, buggy "46-6". + assert.notStrictEqual( + coreUtils.extractCanonicalPlanId('46-6-rs-pipeline-orchestrator'), + '46-6', + ); + // Legit multi-segment phase-token filenames are still extracted correctly, + // including a single-digit letter-suffix phase id ("3A") — the "3A" token is + // found and paired with its zero-padded plan index, not left unpaired. + assert.strictEqual(coreUtils.extractCanonicalPlanId('01-02-PLAN.md'), '01-02'); + assert.strictEqual(coreUtils.extractCanonicalPlanId('3A-01-feature-PLAN.md'), '3A-01'); + }); }); // ─── countMatchedSummaries (#1988) ─────────────────────────────────────────── diff --git a/tests/health-validation.test.cjs b/tests/health-validation.test.cjs index 957d3c7d9..94a7841f6 100644 --- a/tests/health-validation.test.cjs +++ b/tests/health-validation.test.cjs @@ -1132,6 +1132,23 @@ describe('Drift item W006-archived — MILESTONE_ARCHIVE_DIR_RE and PHASE_TOKEN_ assert.strictEqual(re.exec('APP1-64-auth')?.[1], '64'); assert.strictEqual(re.exec('APP_1-64-auth')?.[1], '64'); }); + + test('PHASE_TOKEN_FROM_DIR_RE rejects a single-digit slug word after a phase number (#2043)', () => { + const gen = require('../gsd-core/bin/lib/validate.cjs'); + const re = gen.PHASE_TOKEN_FROM_DIR_RE; + // Roadmap phase name "6 Rs Pipeline Orchestrator" slugifies to + // "6-rs-pipeline-orchestrator"; the resulting dir "46-6-rs-pipeline-orchestrator" + // must extract phase token "46", not "46-6" (was the pre-fix, buggy behavior). + assert.strictEqual(re.exec('46-6-rs-pipeline-orchestrator')?.[1], '46'); + // Legit multi-segment (zero-padded) milestone-prefixed tokens are preserved. + assert.strictEqual(re.exec('02-01-setup')?.[1], '02-01'); + // Single-digit letter-suffix phase ids ("1A"/"01A") and milestone-prefixed + // single-digit sub-phases ("M1-2" → "2") must still match (the fix tightens + // only the continuation, not the first component). + assert.strictEqual(re.exec('1A-foo')?.[1], '1A'); + assert.strictEqual(re.exec('01A-foo')?.[1], '01A'); + assert.strictEqual(re.exec('M1-2-setup')?.[1], '2'); + }); }); // ── Drift Item I001: canonicalPlanStem ──────────────────────────────────────── @@ -1186,6 +1203,18 @@ describe('Drift item I001 — canonicalPlanStem: long PLAN stem matches short SU assert.strictEqual(gen.canonicalPlanStem('3A-01-feature'), '3A-01'); assert.strictEqual(gen.canonicalPlanStem('no-match'), 'no-match'); }); + + test('canonicalPlanStem rejects a single-digit slug word after a phase number (#2043)', () => { + const gen = require('../gsd-core/bin/lib/validate.cjs'); + // "46-6-rs-pipeline-orchestrator" is not a valid PLAN stem shape (the "6" + // is a slug word, not a sub-phase segment), so it must return the input + // unchanged rather than the pre-fix, buggy "46-6". + assert.notStrictEqual(gen.canonicalPlanStem('46-6-rs-pipeline-orchestrator'), '46-6'); + // Legit multi-segment stems are still canonicalized correctly, including a + // single-digit letter-suffix phase id ("3A") whose plan component is zero-padded. + assert.strictEqual(gen.canonicalPlanStem('68-01-scaffolding'), '68-01'); + assert.strictEqual(gen.canonicalPlanStem('3A-01-feature'), '3A-01'); + }); }); }); } diff --git a/tests/phase-id.test.cjs b/tests/phase-id.test.cjs index 957723c8e..94746235b 100644 --- a/tests/phase-id.test.cjs +++ b/tests/phase-id.test.cjs @@ -217,6 +217,27 @@ describe('extractPhaseToken', () => { test('stops at first non-numeric-starting segment', () => { assert.strictEqual(phaseId.extractPhaseToken('01-02-name-03'), '01-02'); }); + + test('rejects a single-digit slug word after a phase number (#2043)', () => { + // A phase dir like "46-6-rs-pipeline-orchestrator" (roadmap phase name + // "6 Rs Pipeline Orchestrator" → slug "6-rs-...") must yield token "46", + // not "46-6" — the "6" is the slug's first word, not a sub-phase segment. + assert.strictEqual(phaseId.extractPhaseToken('46-6-rs-pipeline-orchestrator'), '46'); + assert.strictEqual(phaseId.extractPhaseToken('68-6-rs'), '68'); + // Legit cases are unaffected: a real zero-padded milestone-sub-phase pair + // stays intact, and a single-digit sub-phase after a letter-prefixed + // milestone id (e.g. "M1-2") is still valid. + assert.strictEqual(phaseId.extractPhaseToken('01-02-some-name'), '01-02'); + assert.strictEqual(phaseId.extractPhaseToken('M1-2-brain'), 'M1-2'); + // Milestone-prefixed convention: "M1-" strips as a project-code prefix, so + // the same rule fixes the slug-collision there too — a phase 46 named + // "6 Rs …" under milestone M1 yields "M1-46", not "M1-46-6". Phase 6 under + // M1 ("M1-6-rs") correctly stays "M1-6" (the 6 is the phase number). + assert.strictEqual(phaseId.extractPhaseToken('M1-46-6-rs-pipeline-orchestrator'), 'M1-46'); + assert.strictEqual(phaseId.extractPhaseToken('M1-6-rs-pipeline'), 'M1-6'); + // Single-digit + letter-suffix phase id ("1A") is a real token, not a slug word. + assert.strictEqual(phaseId.extractPhaseToken('1A-brain'), '1A'); + }); }); // ─── phaseTokenMatches ──────────────────────────────────────────────────────── diff --git a/tests/roadmap-parser.test.cjs b/tests/roadmap-parser.test.cjs index 0f9280df9..f86b8fcc4 100644 --- a/tests/roadmap-parser.test.cjs +++ b/tests/roadmap-parser.test.cjs @@ -428,6 +428,34 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => { assert.strictEqual(filter('02-03-other'), false, '02-03 not in milestone'); }); + test('single-digit slug word after a phase number is not wrongly excluded (#2043)', () => { + // The roadmap uses milestone-prefixed hyphenated phase IDs (e.g. "2-01"), + // which switches getMilestonePhaseFilter's dir-matching regex into + // hyphenated mode. Phase 46's roadmap name "6 Rs Pipeline Orchestrator" + // slugifies to a dir starting with a single-digit word ("46-6-rs-…"). + // Before #2043, the hyphenated-mode regex over-collected that single + // digit into the phase token ("46-6"), which never matched the roadmap's + // "46" phase number, so the dir was wrongly excluded from the milestone. + writeState(tmpDir, { milestone: 'v1.0' }); + writeRoadmap(tmpDir, [ + '## v1.0: Current', + '### Phase 2-01: Alpha', + '**Goal:** first alpha phase', + '', + '### Phase 46: 6 Rs Pipeline Orchestrator', + '**Goal:** orchestrate the rs', + ].join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual( + filter('46-6-rs-pipeline-orchestrator'), + true, + '46-6-rs-pipeline-orchestrator (phase 46, single-digit slug word "6") must match Phase 46', + ); + // Legit milestone-prefixed dir still matches as before. + assert.strictEqual(filter('02-01-alpha'), true, '02-01-alpha matches Phase 2-01'); + }); + test('versionOverride uses specified version slice', () => { writeRoadmap(tmpDir, [ '## v1.0: Old',