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:
159
src/phase.cts
159
src/phase.cts
@@ -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}-` : '';
|
||||
|
||||
Reference in New Issue
Block a user