fix(#4433,#4569,#4126): consolidate the phase-identity seam at name-validity, allocation, and branch-slug (#4640)

* fix(#4433): apply the name-validity guard symmetrically to every milestone-name capture

extractMilestoneHeadingName already refused a punctuation-only captured name
(#4134), but its two sibling capture sites in getMilestoneInfo — the
STATE.md-anchored 🚧-bullet match and the no-STATE.md in-progress 🚧-bullet
fallback — skipped straight to a bare truthiness check, so a malformed bullet
whose only content past the version was punctuation passed through as a real
milestone name.

Extracts the existing inline /[\p{L}\p{N}]/u check into a single shared
hasNameableContent predicate and applies it at all three capture sites, so
the guard is one owner rather than a copy that happened to land at only one
of them.

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

* test(#4433): pin the name-validity guard at all three milestone-name capture sites

Failing-first coverage for the hasNameableContent extraction: a
punctuation-only 🚧-bullet name must not surface as a real milestone name,
either on the STATE.md-anchored path or the no-STATE.md in-progress
fallback, while a real name (including a digits-only one) still resolves
COMPLETE exactly as before.

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

* fix(#4569): consolidate decimal-phase-number allocation into one function

cmdPhaseInsert allocated its next decimal sub-phase number by scanning only
on-disk phases/ directories and ### Phase N.M: headings, never the roadmap
summary checklist — so a decimal that existed only as a checklist bullet
(no heading yet, no on-disk directory yet) was invisible, and phase insert
could silently reallocate an already-used number. It also always nested one
level deeper under afterPhase, with no way to request a sibling.

cmdPhaseNextDecimal had its own separate, near-identical two-source scan
(missing the checklist source too) — the exact "duplicate implementations
kept in sync instead of deleted" pattern this issue exists to close.

Extracts scanExistingDecimalPhaseNumbers (directories + headings + checklist
bullets, in one place) and migrates both cmdPhaseInsert and
cmdPhaseNextDecimal onto it — deleting cmdPhaseNextDecimal's own copy rather
than patching it in parallel. Adds an allocation: 'nested' | 'sibling'
argument to cmdPhaseInsert (default 'nested', matching every existing
caller's behavior); a top-level phase with no existing decimal segment falls
back to nested since there is no sibling level to join. No CLI flag wires
'sibling' yet — that is a separate, disclosed follow-up.

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

* test(#4569): pin decimal-allocation coverage across phase insert and next-decimal

Failing-first coverage for scanExistingDecimalPhaseNumbers: a checklist-only
decimal must not be reallocated by phase insert; a decimal present in
heading, checklist, and on-disk directory simultaneously must count once;
an unrelated phase family's checklist bullet must not cross-pollute; and
phase next-decimal (migrated onto the same shared helper) must see a
checklist-only decimal too, closing the same gap in a second command.

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

* chore(#4634): extend the phase-id drift guard for name-validity and shell arithmetic

The epic's ratchet requirement: lint-phase-id-drift.cjs must cover the two
new predicates this PR introduces, and must also scan shell inside
gsd-core/workflows/**/*.md and gsd-core/references/**/*.md for
integer-coercing phase-number arithmetic ($((10#...)) and friends), which
neither the canonical TypeScript module nor a source-only lint can reach.

Adds findNameValidityDrift (bans re-deriving /[\p{L}\p{N}]/u outside
hasNameableContent's owner file) and findShellPhaseArithDrift +
scanMarkdownShellArith (bans $((10#...)) in workflow/reference markdown,
sanctioned via <!-- phase-id-owner: --> on the preceding line). scanRepo
keeps its existing, narrower contract (src/**/*.cts only) so the
already-passing "the live repo is clean" test is untouched; a new scanAll
merges both for the CLI's full report.

Running the guard directly against this tree correctly reports the 7
pre-existing #4619 shell sites (workflows/execute-phase.md x4,
workflows/execute-phase/steps/completion-reconciliation.md x2,
references/tdd.md x1) as violations — demonstrating the ratchet works, not
fixing them. #4619 is a live regression tracked and fixed separately; this
PR does not touch those markdown files. A characterization test pins the
current count of 7 so a future change to that number is investigated rather
than silently absorbed.

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

* fix(#4569): wire --sibling through phase insert's CLI so the argument is reachable

cmdPhaseInsert's allocation parameter had no CLI path to 'sibling' — shipped,
untested, unreachable code (code-review finding: a guaranteed surviving
mutant). Adds --sibling to phase insert's argument parsing, threads it
through, and documents the flag in docs/CLI-TOOLS.md.

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

* test(#4569): exercise --sibling end-to-end through the real CLI

Confirms --sibling joins afterPhase's parent decimal level rather than
nesting, and falls back to nested when afterPhase has no existing decimal
segment (no sibling level to join).

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

* test(#4634): demonstrate the two new drift detectors end-to-end via a planted violation

The epic asks for the guard to be "demonstrated by watching it go red" on a
reintroduced copy. The two new detectors (name-validity, shell-arith) had
only unit-level fixture tests; mirrors the existing bracket-rule's
planted-violation-in-a-temp-tree test for both, proving they're actually
wired into scanRepo/scanMarkdownShellArith end-to-end, not just correct in
isolation.

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

* chore(#4634): consolidate the drift guard's own owner-sanction-check logic

Standards review flagged the "walk to nearest preceding non-blank line,
check for a phase-id-owner comment" logic as duplicated across all four
detector functions in a PR whose whole point is eliminating exactly that
pattern. Extracts isSanctionedByPrecedingComment, shared by all four;
behavior-preserving (verified: identical output before/after, same 7 known
#4619 violations, zero token/bracket/name-validity).

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

* chore(#4634): add Fixed changeset for the name-validity guard and allocation consolidation

pr:0 placeholder — backfilled once the real PR number exists.

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

* fix(#4126): consolidate branch-name slug substitution into one shared renderer

cmdCommit (commands.cts) and cmdInitExecutePhase (init.cts) each
independently implemented branch-name template substitution, and both
substituted the literal string 'phase' when phase_slug was empty or
undeliverable — producing a non-identifying branch name (gsd/phase-08-phase)
that contradicted the honestly-reported phase_slug: null in the same
payload. Same structural defect as the other three gaps in this epic: two
consumers reimplementing one concept independently instead of sharing an
owner.

Adds renderPhaseBranchName (src/phase-id.cts) as the sole owner: a real slug
substitutes normally; an empty/undeliverable one drops the {slug} token plus
one adjacent separator (collapsing/trimming the result) rather than
substituting a placeholder word, for the shipped default template and any
user-configured shape alike. Both call sites now delegate to it; the old
inline duplicates are deleted, not kept in sync. {project} substitution
stays a separate step in init.cts, unchanged, since it is a config-level
field with its own fallback contract.

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

* test(#4126): pin renderPhaseBranchName and both migrated call sites

Property-based coverage for the shared renderer's degrade-path invariant
(output, when non-null, never contains {slug} and never starts/ends with a
separator), plus example coverage for real-slug substitution, empty/null/
non-string slug, token position at either edge, a doubled-separator
template, and the only-{slug} -> null case. One regression test each in
commands.test.cjs and init.test.cjs confirms a phase with no derivable slug
no longer produces a branch name ending in the literal '-phase'.

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

* fix: route scanExistingDecimalPhaseNumbers through the canonical enumeration owner

Caught by an actual gsd-test run, not a hypothesis: the new decimal-scan
helper (fix(#4569)) enumerated phases/ directories via a raw
fs.readdirSync, which the pre-existing phase-enumeration drift guard
(#3185/#3882) correctly flags as an unsanctioned re-derivation outside its
canonical owner (listAllPhaseDirs / isSentinelPhaseId). Ironic given this
epic's own thesis, and exactly why the guard exists: consolidating one seam
can reintroduce drift in an adjacent one if the new code doesn't route
through what's already there. Migrates the enumeration to listAllPhaseDirs;
identical decimal-detection output for every existing case.

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

* chore(#4634): extend the drift guard for branch-slug fallback; fix a real regex bug

Adds the fourth detector the epic's ratchet section names ("both
branch-name sites"): bans a `.replace('{slug}', ... || 'phase')` call
outright, sanctioned via renderPhaseBranchName or a dedicated comment.
Wired into scanRepo (no per-file exemption — this is a banned anti-pattern
everywhere, not a grammar with one legitimate owner). Now that #4126's fix
(prior commit) has landed, scanRepo reports zero violations across all four
.cts-scanning rules, restoring the simple "the live repo is clean" assertion
instead of a pinned-known-count characterization.

Also fixes a real bug an actual gsd-test run caught: findNameValidityDrift's
regex didn't tolerate the doubled-backslash template-string form its own
test claimed to cover (0 !== 1) — widened to \{1,2} matching
TOKEN_DRIFT_RE's existing tolerance for the same two forms.

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

* docs(#4126): document the {slug} degrade behavior; update changeset for the full seam

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

* fix: detectPhaseNumberFromFiles wrongly rejected bare, slug-less phase directories

Caught by an actual gsd-test run on the #4126 regression test, not a
hypothesis: a bare phase directory with no slug remainder (e.g.
.planning/phases/01/) has extractPhaseToken correctly return "01" — which is
simply identical to the directory name in that case, not its no-match
fallback. A stale `token !== phaseDir` check treated that equality as "no
numeric token found" and rejected it regardless, leaving phaseNum null and
silently skipping cmdCommit's phase-branching block entirely (the commit
proceeded on whatever branch was already checked out instead of the
phase branch).

phaseTokenShape.test(normalized) already excludes every genuine non-phase
case on its own: extractPhaseToken's real no-match fallback only fires for a
dirName that doesn't start with a digit or short letter+digit prefix, and
normalizePhaseName's leading-\d+ requirement rejects those regardless. The
equality check was redundant for real rejections and actively wrong for
bare-numeric directories.

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

* chore: backfill changeset PR number to 4640

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-11 12:42:04 -04:00
committed by GitHub
parent 4cc2a466b5
commit 5e0a7b1b56
17 changed files with 1009 additions and 99 deletions

View File

@@ -23,7 +23,7 @@ import coreUtilsMod = require('./core-utils.cjs');
const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId } = phaseIdMod;
const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, renderPhaseBranchName } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocatorMod = require('./phase-locator.cjs');
const { getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
@@ -1489,19 +1489,33 @@ function detectPhaseNumberFromFiles(files: string[] | undefined): string | null
const phaseDir = segments[i + 1];
if (!phaseDir) continue;
const token = extractPhaseToken(phaseDir);
// extractPhaseToken falls back to returning dirName unchanged when no
// numeric token is found. normalizePhaseName is the canonical arbiter
// of "is this a real phase token": it strips the project-code prefix
// and returns a zero-padded numeric form for a genuine phase token, or
// the input unchanged otherwise. Accept the token only when it
// normalizes to a numeric phase form (the single-owner rule shared by
// every other phase-token reader — see #2528).
const normalized = normalizePhaseName(token);
// normalizePhaseName is the canonical arbiter of "is this a real phase
// token": it strips the project-code prefix and returns a zero-padded
// numeric form for a genuine phase token, or the input unchanged
// otherwise. Accept the token whenever it normalizes to a numeric
// phase form (the single-owner rule shared by every other phase-token
// reader — see #2528).
//
// #4126 fix: this used to also require `token !== phaseDir`, on the
// assumption that extractPhaseToken returning its input unchanged
// always means "no numeric token found" (its no-match fallback).
// That assumption is false for a BARE phase directory with no slug
// remainder (e.g. `.planning/phases/01/`): extractPhaseToken correctly
// reads "01" as the token, which is simply identical to the directory
// name in that case — not a fallback. The stale equality check
// rejected every such directory, leaving `phaseNum` null and silently
// skipping the whole phase-branch block below (undetected because
// `phaseTokenShape.test(normalized)` already excludes genuine
// non-phase fallbacks — e.g. `docs`, `CK-docs` — on its own, since
// extractPhaseToken's real no-match fallback only fires for dirNames
// that do not start with a digit or short letter+digit prefix, which
// normalizePhaseName's leading-`\d+` requirement rejects regardless).
// Built from the single-owner PHASE_NUMBER_TOKEN_SOURCE (the canonical
// phase-number grammar — #2128 anti-divergence guard) so this read-side
// acceptance check cannot drift from every other phase-token reader.
const normalized = normalizePhaseName(token);
const phaseTokenShape = new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}$`, 'i');
if (token !== phaseDir && phaseTokenShape.test(normalized)) {
if (phaseTokenShape.test(normalized)) {
return token;
}
}
@@ -1990,9 +2004,15 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
if (phaseNum && !isSentinelPhaseId(phaseNum)) {
const phaseInfo = findPhaseInternal(cwd, phaseNum) as Record<string, unknown> | null;
if (phaseInfo) {
branchName = (config['phase_branch_template'] as string)
.replace('{phase}', normalizePhaseName(phaseInfo['phase_number']))
.replace('{slug}', (phaseInfo['phase_slug'] as string) || 'phase');
// #4126: shared with init.cts's cmdInitExecutePhase branch_name field
// via the one canonical renderer (src/phase-id.cts) so an undeliverable
// phase_slug degrades identically at both call sites instead of each
// independently substituting the literal word 'phase'.
branchName = renderPhaseBranchName(
config['phase_branch_template'] as string,
phaseInfo['phase_number'],
phaseInfo['phase_slug'],
);
}
}
} else if (branchingStrategy === 'milestone') {

View File

@@ -97,7 +97,7 @@ const {
extractCurrentMilestone,
} = roadmapParser;
const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
const { comparePhaseNum, normalizePhaseName, matchPhaseDirs, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery, isSentinelPhaseId, extractPhaseToken, scopeToPhase } = phaseId;
const { comparePhaseNum, normalizePhaseName, matchPhaseDirs, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery, isSentinelPhaseId, extractPhaseToken, scopeToPhase, renderPhaseBranchName } = phaseId;
const { pruneOrphanedWorktrees } = worktreeSafety;
const {
@@ -989,10 +989,11 @@ function cmdInitExecutePhase(
branch_name:
config.branching_strategy === 'phase' && phaseInfo
? (config.phase_branch_template as string)
.replace('{project}', (config.project_code as string) || '')
.replace('{phase}', normalizePhaseName(phaseInfo['phase_number']))
.replace('{slug}', (phaseInfo['phase_slug'] as string) || 'phase')
? renderPhaseBranchName(
(config.phase_branch_template as string).replace('{project}', (config.project_code as string) || ''),
phaseInfo['phase_number'],
phaseInfo['phase_slug'],
)
: config.branching_strategy === 'milestone'
? (config.milestone_branch_template as string)
.replace('{milestone}', (milestone['version'] as string | undefined) ?? '')

View File

@@ -36,7 +36,13 @@ interface PhaseHandlers {
cmdPhaseNextDecimal: (cwd: string, arg: string | undefined, raw: boolean) => void;
cmdPhaseAdd: (cwd: string, desc: string, raw: boolean, customId: string | null) => void;
cmdPhaseAddBatch: (cwd: string, descriptions: string[], raw: boolean) => void;
cmdPhaseInsert: (cwd: string, pos: string | undefined, desc: string, raw: boolean) => void;
cmdPhaseInsert: (
cwd: string,
pos: string | undefined,
desc: string,
raw: boolean,
allocation?: 'nested' | 'sibling',
) => void;
cmdPhaseRemove: (cwd: string, phaseNum: string, opts: { force: boolean }, raw: boolean) => void;
cmdPhaseComplete: (cwd: string, phaseNum: string | undefined, raw: boolean) => void;
cmdPhaseUatPassed: (cwd: string, phaseNum: string | undefined, raw: boolean, opts?: { policy?: { requireVerification?: boolean } }) => void;
@@ -154,7 +160,19 @@ function routePhaseCommand({ phase, args, cwd, raw, error }: RoutePhaseCommandOp
if (args.includes('--dry-run')) {
return makeInvalidArgs('--dry-run', 'phase insert does not support --dry-run');
}
phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw);
// #4569: --sibling opts into joining afterPhase's parent decimal level
// instead of nesting one level deeper. Filtered out like other
// boolean flags (see `remove`'s --force handling above) so it never
// leaks into the free-text description.
const sibling = args.includes('--sibling');
const insertArgs = args.slice(2).filter(token => token !== '--sibling');
phase.cmdPhaseInsert(
cwd,
insertArgs[0],
insertArgs.slice(1).join(' '),
raw,
sibling ? 'sibling' : 'nested',
);
return { ok: true as const, data: null };
},
remove: (_ctx: Record<string, unknown>) => {

View File

@@ -1471,6 +1471,65 @@ function isForeignPrefixedPhaseQuery(phase: unknown, projectCode: unknown): bool
* form so a canonical heading (`### Phase 117:`) is preferred over a drifted
* prefixed one (`### Phase MANIFOLD-117:`) when both exist in one ROADMAP.
*/
// Separator characters a branch-name template may plausibly use to join
// `{slug}` to its neighbours — the four this repo's shipped templates and
// docs/CONFIGURATION.md's custom-template examples use (`/`, `-`, `_`, `.`).
const BRANCH_TEMPLATE_SEP_RE = /[-_./]/;
const BRANCH_TEMPLATE_SEP_RUN_RE = /([-_./])\1+/g;
const BRANCH_TEMPLATE_EDGE_SEP_RE = /^[-_./]+|[-_./]+$/g;
/**
* #4126: the ONE branch-name-template renderer for the `phase` branching
* strategy, shared by `cmdCommit` (src/commands.cts) and
* `cmdInitExecutePhase` (src/init.cts) so the two call sites cannot diverge
* on how an undeliverable `phaseSlug` degrades (they previously each
* independently substituted the literal word `phase`, producing a
* non-identifying branch name like `gsd/phase-08-phase` that contradicts an
* honestly-reported `phase_slug: null`).
*
* `{project}` is deliberately NOT handled here — init.cts substitutes it
* separately, before calling this, because it is a config-level field with
* its own fallback (`''`), not a phase-derived one.
*
* When `phaseSlug` is a non-empty string, `{slug}` substitutes normally
* (unchanged from prior behavior). When it is empty or not a string (e.g.
* `null`, `undefined`, a number), the `{slug}` token is DROPPED — along with
* one adjacent separator character — rather than replaced with a placeholder
* word: a dropped token keeps the branch name honest about what it does not
* know, where a placeholder reads as a real (but wrong) name. The result is
* always a syntactically plausible git-ref fragment: no leading, trailing, or
* doubled separator, for both the shipped default template
* (`gsd/phase-{phase}-{slug}`) and a differently-shaped custom one (this
* field is user-configurable per docs/CONFIGURATION.md).
*/
function renderPhaseBranchName(template: string, phaseNumber: unknown, phaseSlug: unknown): string | null {
const withPhase = template.replace('{phase}', normalizePhaseName(phaseNumber));
const slug = typeof phaseSlug === 'string' ? phaseSlug : '';
if (slug) return withPhase.replace('{slug}', slug);
const at = withPhase.indexOf('{slug}');
if (at === -1) return withPhase;
let before = withPhase.slice(0, at);
let after = withPhase.slice(at + '{slug}'.length);
// Drop exactly ONE adjacent separator — preferring the one immediately
// before the token — so `a-{slug}` and `{slug}-a` both degrade to `a`
// rather than leaving a dangling `a-` / `-a`.
if (before && BRANCH_TEMPLATE_SEP_RE.test(before[before.length - 1])) {
before = before.slice(0, -1);
} else if (after && BRANCH_TEMPLATE_SEP_RE.test(after[0])) {
after = after.slice(1);
}
// Collapse any doubled separator the drop can leave behind (e.g. a
// `feature//{slug}` template dropping to `feature/`→`feature`), then trim
// a resulting leading/trailing separator — valid for the shipped default
// template AND any user-configured shape, not just the one this repo ships.
const joined = before + after;
const collapsed = joined.replace(BRANCH_TEMPLATE_SEP_RUN_RE, '$1').replace(BRANCH_TEMPLATE_EDGE_SEP_RE, '');
return collapsed || null;
}
function roadmapPhaseLookupSources(phaseNum: unknown): string[] {
const sources: string[] = [];
const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
@@ -1532,4 +1591,5 @@ export = {
stripConfiguredProjectCodePrefix,
isForeignPrefixedPhaseQuery,
roadmapPhaseLookupSources,
renderPhaseBranchName,
};

View File

@@ -55,7 +55,10 @@ const {
import { escapeRegex } from './pattern.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
import phaseLocatorMod = require('./phase-locator.cjs');
const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs } = phaseLocatorMod;
const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs, listAllPhaseDirs } = phaseLocatorMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
import roadmapParserMod = require('./roadmap-parser.cjs');
const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, findMilestoneScopeHeadingLines } = roadmapParserMod;
@@ -338,28 +341,25 @@ function cmdPhaseNextDecimal(cwd: string, basePhase: string, raw: boolean): void
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
baseExists = matchPhaseDirs(dirs, normalized).matches.length > 0;
const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalized)}\\.(\\d+)`);
for (const dir of dirs) {
const match = dir.match(dirPattern);
if (match) decimalSet.add(parseInt(match[1], 10));
}
}
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
if (fs.existsSync(roadmapPath)) {
try {
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const phasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalized)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`,
'gi',
);
let pm: RegExpExecArray | null;
while ((pm = phasePattern.exec(roadmapContent)) !== null) {
decimalSet.add(parseInt(pm[1], 10));
for (const n of scanExistingDecimalPhaseNumbers(phasesDir, roadmapContent, normalized)) {
decimalSet.add(n);
}
} catch {
/* ROADMAP.md read failure is non-fatal */
// ROADMAP.md read failure is non-fatal — fall back to the directory-only
// scan (empty rawContent) so on-disk decimal directories are still counted.
for (const n of scanExistingDecimalPhaseNumbers(phasesDir, '', normalized)) {
decimalSet.add(n);
}
}
} else {
for (const n of scanExistingDecimalPhaseNumbers(phasesDir, '', normalized)) {
decimalSet.add(n);
}
}
@@ -1545,7 +1545,79 @@ function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): vo
publishStateContract(cwd);
}
function cmdPhaseInsert(cwd: string, afterPhase: string, description: string, raw: boolean): void {
// #4569: scans all three representations of an existing decimal sub-phase
// under `base` — on-disk `phases/` directories, `### Phase BASE.N:` headings,
// and `- [ ] Phase BASE.N:` roadmap SUMMARY CHECKLIST bullets. A bullet-only
// roadmap with no heading yet and no on-disk directory yet must still be
// seen, or an allocator can silently reallocate an already-used decimal
// number. Shared by `cmdPhaseInsert`'s normalized-base scan and its
// sibling-allocation parent-base scan so the two never drift apart.
function scanExistingDecimalPhaseNumbers(phasesDir: string, rawContent: string, base: string): Set<number> {
const decimalSet = new Set<number>();
// #2245 audit: existsSync-guarded, mirroring cmdPhaseNextDecimal's identical
// scan above — a missing phasesDir (no decimal sub-phases yet) is the
// expected, silent case (empty decimalSet). A readdirSync failure once the
// dir is confirmed to EXIST is a genuine anomaly; swallowing it used to let
// `phase insert` proceed with an incomplete decimalSet and risk writing a
// decimal phase number that collides with an existing on-disk directory
// the scan simply never saw — surfaced loud instead, like the sibling.
//
// #4634 (lint-phase-enumeration-drift): routed through the canonical
// PHYSICAL-set owner (`listAllPhaseDirs`, phase-locator.cts) instead of a
// hand-rolled `readdirSync`. This scan — like its sibling `cmdPhaseNextDecimal`
// and its caller `cmdPhaseInsert` (both exempted in the drift guard for the
// same reason) — must see EVERY on-disk decimal sub-phase directory
// regardless of the current milestone window, so `listMilestonePhaseDirs`
// (windowed) is the wrong owner here; `includeSentinels: true` preserves this
// function's pre-existing behavior of never sentinel-filtering (the decimal
// regex below only ever matches `base.N`-shaped names, so sentinel inclusion
// is a no-op either way).
if (fs.existsSync(phasesDir)) {
const { value: dirs, scope } = listAllPhaseDirs(phasesDir, { includeSentinels: true });
if (scope === SCOPE.UNREADABLE) {
// The dir EXISTS but could not be read (EACCES/EIO) — a genuine anomaly,
// not the expected empty-decimalSet case above. Surfaced loud, matching
// this function's pre-migration `readdirSync` catch: swallowing it would
// let `phase insert` proceed with an incomplete decimalSet and collide
// with an existing on-disk decimal directory the scan never saw.
error(`Failed to scan phase directories for existing decimal phases: unable to read ${phasesDir}`);
}
const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(base)}\\.(\\d+)`);
for (const dir of dirs) {
const dm = dir.match(decimalPattern);
if (dm) decimalSet.add(parseInt(dm[1], 10));
}
}
const rmPhasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(base)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`,
'gi',
);
let rmMatch: RegExpExecArray | null;
while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
decimalSet.add(parseInt(rmMatch[1], 10));
}
const checklistDecimalPattern = new RegExp(
`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${phaseMarkdownRegexSource(base)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`,
'gi',
);
let clMatch: RegExpExecArray | null;
while ((clMatch = checklistDecimalPattern.exec(rawContent)) !== null) {
decimalSet.add(parseInt(clMatch[1], 10));
}
return decimalSet;
}
function cmdPhaseInsert(
cwd: string,
afterPhase: string,
description: string,
raw: boolean,
allocation: 'nested' | 'sibling' = 'nested',
): void {
if (!afterPhase || !description) {
error('after-phase and description required for phase insert');
}
@@ -1590,49 +1662,22 @@ function cmdPhaseInsert(cwd: string, afterPhase: string, description: string, ra
const phasesDir = path.join(planningDir(cwd), 'phases');
const normalizedBase = normalizePhaseName(afterPhase);
const decimalSet = new Set<number>();
// #2245 audit: existsSync-guarded, mirroring cmdPhaseNextDecimal's identical
// scan above — a missing phasesDir (no decimal sub-phases yet) is the
// expected, silent case (empty decimalSet). A readdirSync failure once the
// dir is confirmed to EXIST is a genuine anomaly; swallowing it used to let
// `phase insert` proceed with an incomplete decimalSet and risk writing a
// decimal phase number that collides with an existing on-disk directory
// the scan simply never saw — surfaced loud instead, like the sibling.
if (fs.existsSync(phasesDir)) {
// Initialized (not just declared) so TS's definite-assignment check is
// satisfied without relying on control-flow narrowing through error()'s
// `never` return, which TS does not propagate through a destructured
// module-property function reference — error() still halts the process
// before `dirs` below is ever computed from this placeholder value.
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(phasesDir, { withFileTypes: true });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
error(`Failed to scan phase directories for existing decimal phases: ${msg}`);
}
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
const decimalPattern = new RegExp(
`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`,
);
for (const dir of dirs) {
const dm = dir.match(decimalPattern);
if (dm) decimalSet.add(parseInt(dm[1], 10));
}
}
const rmPhasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`,
'gi',
);
let rmMatch: RegExpExecArray | null;
while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
decimalSet.add(parseInt(rmMatch[1], 10));
}
const decimalSet = scanExistingDecimalPhaseNumbers(phasesDir, rawContent, normalizedBase);
const nextDecimal = decimalSet.size === 0 ? 1 : Math.max(...decimalSet) + 1;
const _decimalPhase = `${normalizedBase}.${nextDecimal}`;
let _decimalPhase = `${normalizedBase}.${nextDecimal}`;
// #4569: sibling allocation joins afterPhase's PARENT level instead of nesting
// one level deeper under afterPhase itself. A top-level phase (no existing
// decimal segment) has no sibling level to join; nested is the only sensible
// allocation, so we silently fall back for that case.
const lastDotIndex = normalizedBase.lastIndexOf('.');
if (allocation === 'sibling' && lastDotIndex !== -1) {
const parentBase = normalizedBase.slice(0, lastDotIndex);
const siblingDecimalSet = scanExistingDecimalPhaseNumbers(phasesDir, rawContent, parentBase);
const siblingNextDecimal = siblingDecimalSet.size === 0 ? 1 : Math.max(...siblingDecimalSet) + 1;
_decimalPhase = `${parentBase}.${siblingNextDecimal}`;
}
const insertConfig = loadConfig(cwd);
const projectCode = (insertConfig.project_code as string) || '';
const pfx = projectCode ? `${projectCode}-` : '';

View File

@@ -1580,6 +1580,24 @@ function stripLeadingDelimiter(s: string): string {
return s.replace(/^[\s—–:-]+/, '').trim();
}
/**
* #4134/#4433 (§7.2 rule 6 floor, applied symmetrically): a captured "name"
* with no letter or digit anywhere is heading/bullet STRUCTURE, not a curated
* name — e.g. the trailing `)` a name-then-version heading leaves after its
* version token, or a malformed 🚧-bullet whose only content past the version
* is punctuation (`---`, `***`, a lone `:`). #4134 fixed this for
* `extractMilestoneHeadingName`'s heading path only; #4433 found the sibling
* 🚧-bullet capture (`getMilestoneInfo`'s `listMatch`) and the no-STATE.md
* fallback (`inProgressMatch`) both skipped straight to a bare truthiness
* check, so a punctuation-only bullet name passed through as a real one. This
* is now the SOLE name-validity predicate — every capture site in this file
* calls it instead of re-deriving the character class. A name that merely
* CONTAINS punctuation is unaffected; digits alone qualify.
*/
function hasNameableContent(s: string): boolean {
return /[\p{L}\p{N}]/u.test(s);
}
/**
* #3216 (ADR-3180 §7.2's "Name extraction — pinned rule"): the sole "milestone
* heading text → version + curated name" rule. Strips everything through the
@@ -1648,7 +1666,7 @@ function extractMilestoneHeadingName(
// scope TRUNCATED) for an unresolvable name. A name that merely CONTAINS
// punctuation is untouched — `(` is an ordinary name character (#3171) —
// and digits alone qualify (`## v4.0 — 42` is the name `42`).
const name = candidate !== null && /[\p{L}\p{N}]/u.test(candidate) ? candidate : null;
const name = candidate !== null && hasNameableContent(candidate) ? candidate : null;
return { version, name };
}
@@ -1737,7 +1755,7 @@ function getMilestoneInfo(cwd?: string): { value: MilestoneInfo | null; scope: S
);
if (listMatch) {
const name = stripLeadingDelimiter(listMatch[1]);
if (name) return scoped({ version: stateVersion, name }, SCOPE.COMPLETE);
if (name && hasNameableContent(name)) return scoped({ version: stateVersion, name }, SCOPE.COMPLETE);
}
// #3216: heading selection routes through the shared owner
@@ -1773,10 +1791,13 @@ function getMilestoneInfo(cwd?: string): { value: MilestoneInfo | null; scope: S
// (unchanged from the pre-#3216 fallback).
const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
if (inProgressMatch) {
return scoped(
{ version: 'v' + inProgressMatch[1], name: inProgressMatch[2].trim() },
SCOPE.COMPLETE,
);
const inProgressName = inProgressMatch[2].trim();
if (hasNameableContent(inProgressName)) {
return scoped(
{ version: 'v' + inProgressMatch[1], name: inProgressName },
SCOPE.COMPLETE,
);
}
}
// #3216: enumerate every OPEN (non-shipped) milestone heading via the