fix(#2043): reject single-digit slug word in phase-token extraction (all sites)
A phase whose slug's first word is a bare single digit (dir
`46-6-rs-pipeline-orchestrator`, roadmap phase "6 Rs Pipeline Orchestrator" →
slug `6-rs-…`) had its phase token over-collected as `46-6` instead of `46`, so
`gsd-tools` phase-by-number lookups (init.plan-phase, init.phase-op, and
downstream execute/verify/ship) resolved phase_dir=null / has_context=false.
Root cause: an over-broad "looks numeric" test (`/^\d/`, `\d+(?:-\d+)*`)
classified token segments and could not distinguish a legitimate zero-padded
sub-phase segment (`01`, `02`) from a single-digit slug word (`6`). Zero-padded
phase/sub-phase segments are always ≥2 digits, so requiring ≥2 digits is the
structural distinguisher. Applied consistently across every same-class
implementation the triage identified (fixing extractPhaseToken alone leaves the
health-check and milestone-filter subsystems exposed):
- src/phase-id.cts extractPhaseToken — a pure-numeric leading segment continues
only with ≥2-digit segments; a letter-prefixed milestone id (`M1`) still
admits its single-digit sub-phase (`M1-2`).
- src/validate.cts PHASE_TOKEN_FROM_DIR_RE (W005/W006/W007 health checks) and
canonicalPlanStem (I001 plan/summary pairing).
- src/roadmap-parser.cts isDirInMilestone numericRe (getMilestonePhaseFilter —
highest blast radius: a false negative silently excludes a phase from the
milestone).
- src/core-utils.cts + its verbatim duplicate src/phase.cts extractCanonicalPlanId.
Regression tests added across tests/{phase-id,health-validation,core-utils,
roadmap-parser}.test.cjs using the shared `46-6-rs-…` fixture, asserting the
token resolves to `46` (not `46-6`) while legit multi-segment tokens
(`01-02`, `02-03-04`, `M1-2`, `68-01`, `02-01`) are unchanged. The
roadmap-parser test exercises the real getMilestonePhaseFilter end-to-end.
The residual ≥2-digit-leading-slug case (e.g. `NN-2024-roadmap`) stays out of
scope — subsumed by the #612 / #565 phase-ID convention work, per the issue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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) ───────────────────────────────────────────
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user