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

@@ -1688,6 +1688,44 @@ describe('commit command', () => {
assert.ok(committedFile.includes('# Context'), 'phase commit must land on the phase branch');
});
// #4126: an undeliverable phase_slug (a bare phase directory with no
// slug remainder) must never produce a branch name ending in the literal
// word "phase" — that reads as a real (but wrong) slug rather than
// honestly reflecting that no slug was derivable. Routed through the
// shared renderPhaseBranchName owner (src/phase-id.cts) so this and
// init.cts's cmdInitExecutePhase can never diverge on the fallback.
test('#4126: an undeliverable phase_slug drops the token instead of substituting the literal "phase"', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
commit_docs: true,
branching_strategy: 'phase',
phase_branch_template: 'gsd/phase-{phase}-{slug}',
})
);
// Bare phase directory — no slug remainder after the phase token, so
// phase-locator resolves phase_slug: null for it.
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## Phase 1: Setup\nGoal: Initial setup\n'
);
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '01', '01-CONTEXT.md'), '# Context\n');
const result = runGsdTools(
'commit "docs(01): add context" --files .planning/phases/01/01-CONTEXT.md',
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.committed, true, 'should have committed');
const branch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
assert.ok(!branch.endsWith('-phase'), `branch name must not end in the literal "-phase": ${branch}`);
assert.strictEqual(branch, 'gsd/phase-01', 'expected the {slug} token to be dropped cleanly');
});
test('decimal phase numbers are captured correctly in branching strategy', () => {
// Configure phase branching strategy
fs.writeFileSync(

View File

@@ -1283,6 +1283,37 @@ describe('init plan-phase zero-padded phase number (bug #2391)', () => {
assert.strictEqual(output.branch_name, 'gsd/phase-01-foundation',
'branch_name must use normalized phase number (strip project_code prefix, zero-pad), not raw phase_number');
});
// #4126: an undeliverable phase_slug (a bare phase directory with no slug
// remainder, e.g. seeded here as literally "01") must never produce a
// branch name ending in the literal word "phase" — routed through the
// shared renderPhaseBranchName owner (src/phase-id.cts) so this and
// commands.cts's cmdCommit phase-branching arm can never diverge on the
// fallback.
test('#4126: an undeliverable phase_slug drops the token instead of substituting the literal "phase"', () => {
seedPhase(tmpDir, '01', {
'01-01-PLAN.md': '# Plan',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
git: {
branching_strategy: 'phase',
phase_branch_template: 'gsd/phase-{phase}-{slug}',
},
}, null, 2)
);
const result = runGsdTools('init execute-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_slug, null, 'precondition: phase_slug must be undeliverable');
assert.ok(!output.branch_name.endsWith('-phase'),
`branch_name must not end in the literal "-phase": ${output.branch_name}`);
assert.strictEqual(output.branch_name, 'gsd/phase-01',
'expected the {slug} token to be dropped cleanly');
});
});
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,88 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
findNameValidityDrift,
findBranchSlugFallbackDrift,
findShellPhaseArithDrift,
} = require('../scripts/lint-phase-id-drift.cjs');
test('findNameValidityDrift flags a regex-literal re-derivation of the name-validity class', () => {
const text = [
'function isNameable(s) {',
' return /[\\p{L}\\p{N}]/u.test(s);',
'}',
].join('\n');
const found = findNameValidityDrift(text);
assert.equal(found.length, 1);
assert.equal(found[0].line, 2);
});
test('findNameValidityDrift flags the doubled-backslash template-string form', () => {
const text = [
'const re = new RegExp(\'[\\\\p{L}\\\\p{N}]\', "u");',
].join('\n');
const found = findNameValidityDrift(text);
assert.equal(found.length, 1);
});
test('findNameValidityDrift does NOT flag a line that calls hasNameableContent(', () => {
const text = [
'function wrapper(s) {',
' return hasNameableContent(s);',
'}',
].join('\n');
assert.deepEqual(findNameValidityDrift(text), []);
});
test('findNameValidityDrift does NOT flag a sanctioned site', () => {
const text = [
'// phase-id-owner: deliberate local copy for perf, tracked in #4634',
'const re = /[\\p{L}\\p{N}]/u;',
].join('\n');
assert.deepEqual(findNameValidityDrift(text), []);
});
test('findBranchSlugFallbackDrift flags the commands.cts/init.cts {slug}-fallback shape', () => {
const text =
" .replace('{slug}', (phaseInfo['phase_slug'] as string) || 'phase');";
const found = findBranchSlugFallbackDrift(text);
assert.equal(found.length, 1);
assert.equal(found[0].line, 1);
});
test('findBranchSlugFallbackDrift does NOT flag the milestone-branch || \'milestone\' fallback', () => {
const text =
" .replace('{slug}', generateSlugInternal(milestone.name) || 'milestone');";
assert.deepEqual(findBranchSlugFallbackDrift(text), []);
});
test('findBranchSlugFallbackDrift does NOT flag a sanctioned site', () => {
const text = [
'// phase-id-owner: deliberate, tracked in #4634',
" .replace('{slug}', (phaseInfo['phase_slug'] as string) || 'phase');",
].join('\n');
assert.deepEqual(findBranchSlugFallbackDrift(text), []);
});
test('findShellPhaseArithDrift flags $((10#...)) base-10-forced arithmetic', () => {
const text = 'PHASE_N=$((10#$PHASE_NUM))';
const found = findShellPhaseArithDrift(text);
assert.equal(found.length, 1);
assert.equal(found[0].line, 1);
});
test('findShellPhaseArithDrift does NOT flag ordinary arithmetic', () => {
const text = 'NEXT=$((i+1))';
assert.deepEqual(findShellPhaseArithDrift(text), []);
});
test('findShellPhaseArithDrift does NOT flag a site sanctioned with an HTML comment', () => {
const text = [
'<!-- phase-id-owner: deliberate, tracked in #4634 -->',
'PHASE_N=$((10#$PHASE_NUM))',
].join('\n');
assert.deepEqual(findShellPhaseArithDrift(text), []);
});

View File

@@ -24,7 +24,7 @@ const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const { findPhaseIdRegexDrift, findBracketGrammarDrift, scanRepo } = require(
const { findPhaseIdRegexDrift, findBracketGrammarDrift, scanRepo, scanMarkdownShellArith } = require(
path.join(ROOT, 'scripts', 'lint-phase-id-drift.cjs'),
);
const phaseId = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-id.cjs'));
@@ -264,7 +264,7 @@ describe('#2761 M3 bracket grammar: one owner, byte-identical to the sites it re
});
describe('#2128 phase-id drift scanner: the live repo is clean', () => {
test('scanRepo finds zero unsanctioned re-derivations (token AND bracket)', () => {
test('scanRepo finds zero unsanctioned re-derivations (token, bracket, name-validity, and branch-slug-fallback)', () => {
const violations = scanRepo(ROOT);
assert.deepEqual(
violations,
@@ -296,6 +296,90 @@ describe('#2128 phase-id drift scanner: the live repo is clean', () => {
cleanup(tmp);
}
});
test('scanRepo actually runs the name-validity rule (coverage, not just a clean result)', () => {
// Same proof shape as the bracket-rule test above, for #4634's new
// name-validity detector: plant a literal re-derivation of
// hasNameableContent's character class in a temp tree and require the
// real scanRepo() to catch it end-to-end.
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'phase-id-drift-'));
try {
fs.mkdirSync(path.join(tmp, 'src'));
fs.writeFileSync(
path.join(tmp, 'src', 'planted.cts'),
'function fakeCheck(s) {\n return /[\\p{L}\\p{N}]/u.test(s);\n}\n',
);
const found = scanRepo(tmp);
assert.equal(found.length, 1, 'scanRepo must report the planted name-validity literal');
assert.equal(found[0].kind, 'name-validity');
assert.equal(found[0].file, path.join('src', 'planted.cts'));
} finally {
cleanup(tmp);
}
});
test('scanRepo actually runs the branch-slug-fallback rule (coverage, not just a clean result)', () => {
// Same proof shape again, for #4634's branch-slug-fallback detector: plant
// the shipped commands.cts/init.cts shape into a temp tree and require the
// real scanRepo() to catch it end-to-end.
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'phase-id-drift-'));
try {
fs.mkdirSync(path.join(tmp, 'src'));
fs.writeFileSync(
path.join(tmp, 'src', 'planted.cts'),
"const x = template.replace('{slug}', (phaseInfo['phase_slug'] as string) || 'phase');\n",
);
const found = scanRepo(tmp);
assert.equal(found.length, 1, 'scanRepo must report the planted branch-slug-fallback literal');
assert.equal(found[0].kind, 'branch-slug-fallback');
assert.equal(found[0].file, path.join('src', 'planted.cts'));
} finally {
cleanup(tmp);
}
});
test('scanMarkdownShellArith actually runs the shell-arith rule (coverage, not just a clean result)', () => {
// Same proof shape again, for #4634's markdown shell-arithmetic detector:
// plant a `$((10#$VAR))` site under the real scan roots
// (gsd-core/workflows/**/*.md) and require scanMarkdownShellArith() to
// catch it end-to-end, over the real MD_SCAN_DIRS walk.
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'phase-id-drift-'));
try {
const workflowsDir = path.join(tmp, 'gsd-core', 'workflows');
fs.mkdirSync(workflowsDir, { recursive: true });
fs.writeFileSync(
path.join(workflowsDir, 'planted.md'),
'```bash\nPHASE_N=$((10#$PHASE_NUM))\n```\n',
);
const found = scanMarkdownShellArith(tmp);
assert.equal(found.length, 1, 'scanMarkdownShellArith must report the planted shell-arith literal');
assert.equal(found[0].kind, 'shell-arith');
assert.equal(found[0].file, path.join('gsd-core', 'workflows', 'planted.md'));
} finally {
cleanup(tmp);
}
});
test(
"#4619 shell-arith violations are known and tracked separately (characterization, not this PR's scope)",
() => {
const violations = scanMarkdownShellArith(ROOT);
assert.equal(
violations.length,
7,
'expected exactly the 7 known #4619 sites (workflows/execute-phase.md x4, ' +
'workflows/execute-phase/steps/completion-reconciliation.md x2, references/tdd.md x1) — ' +
'if this count changed, either #4619 was fixed (great — update/remove this pin) or a NEW ' +
'unrelated shell-arith site was introduced (investigate before adjusting the number)',
);
},
);
});
describe('#2128 phase-id single-owner identity guard', () => {

View File

@@ -1507,3 +1507,78 @@ describe('phaseKeyFrom* — one key space for directories and prose', () => {
);
});
});
// ─── renderPhaseBranchName (#4126) ───────────────────────────────────────────
//
// The ONE branch-name-template renderer shared by cmdCommit (src/commands.cts)
// and cmdInitExecutePhase (src/init.cts), so the two consumers cannot diverge
// on how an undeliverable phase_slug degrades.
describe('renderPhaseBranchName — shared branch-name template renderer', () => {
const DEFAULT_TEMPLATE = 'gsd/phase-{phase}-{slug}';
test('a real slug substitutes normally, unchanged from prior behavior', () => {
assert.strictEqual(
phaseId.renderPhaseBranchName(DEFAULT_TEMPLATE, '8', 'my-feature'),
'gsd/phase-08-my-feature',
);
});
test('an empty slug drops the {slug} token cleanly for the default template', () => {
assert.strictEqual(phaseId.renderPhaseBranchName(DEFAULT_TEMPLATE, '8', ''), 'gsd/phase-08');
assert.strictEqual(phaseId.renderPhaseBranchName(DEFAULT_TEMPLATE, '8', null), 'gsd/phase-08');
assert.strictEqual(phaseId.renderPhaseBranchName(DEFAULT_TEMPLATE, '8', undefined), 'gsd/phase-08');
});
test('the literal word "phase" is never substituted for an undeliverable slug', () => {
const result = phaseId.renderPhaseBranchName(DEFAULT_TEMPLATE, '8', '');
assert.ok(!result.endsWith('-phase'), result);
});
test('an empty slug at the START of a template drops the token and its trailing separator', () => {
assert.strictEqual(phaseId.renderPhaseBranchName('{slug}-gsd-{phase}', '8', ''), 'gsd-08');
});
test('an empty slug at the END of a template drops the token and its leading separator', () => {
assert.strictEqual(phaseId.renderPhaseBranchName('gsd-{phase}-{slug}', '8', ''), 'gsd-08');
});
test('a doubled-separator template does not produce an invalid leading/trailing separator', () => {
assert.strictEqual(phaseId.renderPhaseBranchName('feature//{slug}', '8', ''), 'feature');
assert.strictEqual(phaseId.renderPhaseBranchName('feature//{slug}', '8', 'x'), 'feature//x');
});
test('non-string phaseSlug values (digits, objects) degrade like an empty slug', () => {
assert.strictEqual(phaseId.renderPhaseBranchName(DEFAULT_TEMPLATE, '8', 42), 'gsd/phase-08');
assert.strictEqual(phaseId.renderPhaseBranchName(DEFAULT_TEMPLATE, '8', {}), 'gsd/phase-08');
assert.strictEqual(phaseId.renderPhaseBranchName(DEFAULT_TEMPLATE, '8', []), 'gsd/phase-08');
});
test('a template with no {slug} token at all is returned unchanged, just phase-substituted', () => {
assert.strictEqual(phaseId.renderPhaseBranchName('gsd/phase-{phase}', '8', ''), 'gsd/phase-08');
assert.strictEqual(phaseId.renderPhaseBranchName('gsd/phase-{phase}', '8', 'x'), 'gsd/phase-08');
});
test('a template that is only {slug} degrades to null rather than an empty branch name', () => {
assert.strictEqual(phaseId.renderPhaseBranchName('{slug}', '8', ''), null);
});
test('property: an undeliverable slug never leaves {slug} or a leading/trailing separator behind', () => {
// Realistic slugs (as generateSlugInternal produces them: lowercase
// alnum/dash, never separator-edged) are covered by the "real slug"
// tests above; this property targets the DEGRADE path (#4126's actual
// invariant), so the slug domain here is exactly the undeliverable one.
fc.assert(
fc.property(
fc.constantFrom('gsd/phase-{phase}-{slug}', '{slug}-gsd-{phase}', 'gsd-{phase}-{slug}', 'feature//{slug}', '{phase}.{slug}'),
fc.integer({ min: 0, max: 999 }),
fc.constantFrom('', null, undefined, 0, 42, {}, []),
(template, phaseNumber, phaseSlug) => {
const result = phaseId.renderPhaseBranchName(template, phaseNumber, phaseSlug);
if (result === null) return true;
if (result.includes('{slug}')) return false;
return !/^[-_./]/.test(result) && !/[-_./]$/.test(result);
},
),
);
});
});

View File

@@ -527,6 +527,30 @@ describe('phase next-decimal command', () => {
assert.strictEqual(output.found, false, 'base phase not found');
assert.strictEqual(output.next, '06.1', 'should still suggest 06.1');
});
// #4569: cmdPhaseNextDecimal migrated to the shared scanExistingDecimalPhaseNumbers
// helper (also consumed by cmdPhaseInsert), which counts a checklist-only decimal
// bullet even when no heading and no on-disk directory exist for it yet.
test('#4569: sees a checklist-only decimal with no heading and no on-disk directory', () => {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-something'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 3: Something
**Goal:** Setup
- [ ] Phase 3.1: Something
`
);
const result = runGsdTools('phase next-decimal 3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.next, '03.2', 'checklist-only 3.1 must be counted, not just headings/dirs');
assert.deepStrictEqual(output.existing, ['03.1'], 'checklist-only decimal listed as existing');
});
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -3212,6 +3236,119 @@ describe('phase insert command', () => {
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('Phase 05.1: Hotfix (INSERTED)'), 'roadmap should include inserted phase');
});
// #4569: cmdPhaseInsert's decimal allocation must count an existing decimal
// regardless of WHICH of the three representations (on-disk directory,
// `### Phase N.M:` heading, `- [ ] Phase N.M:` checklist bullet) carries it —
// via the shared scanExistingDecimalPhaseNumbers helper.
test('#4569 core regression: does not reallocate a decimal that exists only as a roadmap checklist bullet', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 3: Something
**Goal:** Setup
- [ ] Phase 3.1: Something
`
);
const result = runGsdTools('phase insert 3 New Thing', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, '03.2', 'checklist-only 3.1 must not be reallocated as 03.1');
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03.2-new-thing')),
'new decimal phase directory should be 03.2, not a collision with the checklist-only 03.1'
);
});
test('#4569 independence check: a decimal present in heading, checklist, and on-disk directory simultaneously is counted once', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 3: Something
**Goal:** Setup
### Phase 3.1: Existing Decimal
**Goal:** Test
- [ ] Phase 3.1: Existing Decimal
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03.1-existing-decimal'), { recursive: true });
const result = runGsdTools('phase insert 3 New Thing', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.phase_number,
'03.2',
'triple-represented 03.1 must count once, not skip ahead or collide'
);
});
test('#4569 negative-space: a checklist bullet for an unrelated phase family does not pollute this phase\'s decimal allocation', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 3: Something
**Goal:** Setup
### Phase 13: Unrelated
**Goal:** Unrelated
- [ ] Phase 13.2: Unrelated Decimal
`
);
const result = runGsdTools('phase insert 3 New Thing', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.phase_number,
'03.1',
'phase 13\'s checklist decimal must not cross-pollute phase 3\'s allocation'
);
});
test('#4569: --sibling flag inserts a sibling of a decimal phase via the real CLI', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 3: Something
**Goal:** Setup
### Phase 3.2: Existing Decimal
**Goal:** Test
`
);
const result = runGsdTools('phase insert 3.2 New Thing --sibling', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, '03.3', 'sibling allocation must join phase 3\'s level, not nest under 3.2');
});
test('#4569: --sibling flag falls back to nested allocation when afterPhase has no decimal segment', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 3: Something
**Goal:** Setup
`
);
const result = runGsdTools('phase insert 3 New --sibling', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_number, '03.1', '--sibling on a top-level phase must fall back to nested allocation');
});
});
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -1111,6 +1111,78 @@ describe('roadmap-parser: getMilestoneInfo #4134 — name-then-version heading',
});
});
// ─── getMilestoneInfo — #4433 name-validity guard on bullet captures ──────────
// `hasNameableContent` (the #4134 name-validity predicate) is not exported
// from roadmap-parser.cjs; these tests exercise it indirectly through
// getMilestoneInfo's two bullet-capture sites (the STATE-anchored 🚧 bullet
// and the no-STATE.md in-progress 🚧 bullet), which #4433 found had skipped
// straight to a bare truthiness check.
describe('roadmap-parser: getMilestoneInfo #4433 — name-validity guard on bullet captures', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); });
test('#4433 — punctuation-only 🚧-bullet name (STATE.md version known) falls through to TRUNCATED, not the bullet fragment', () => {
writeState(tmpDir, { milestone: 'v3.3' });
writeRoadmap(tmpDir, [
'🚧 **v3.3** !!!',
'',
'## v3.4: Something Else',
'### Phase 1: Setup',
].join('\n'));
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.scope, SCOPE.TRUNCATED, `scope: ${JSON.stringify(info)}`);
assert.strictEqual(info.value.version, 'v3.3');
assert.notStrictEqual(info.value.name, '!!!');
assert.strictEqual(info.value.name, null);
});
test('#4433 — real, non-punctuation 🚧-bullet name (STATE.md version known) still resolves COMPLETE (regression control)', () => {
writeState(tmpDir, { milestone: 'v3.3' });
writeRoadmap(tmpDir, [
'🚧 **v3.3** Real Feature Name',
'',
'## v3.4: Something Else',
'### Phase 1: Setup',
].join('\n'));
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.scope, SCOPE.COMPLETE, `scope: ${JSON.stringify(info)}`);
assert.strictEqual(info.value.version, 'v3.3');
assert.match(info.value.name, /Real Feature Name/);
});
test('#4433 — punctuation-only in-progress 🚧-bullet (no STATE.md) is not returned as COMPLETE with the punctuation as name', () => {
writeRoadmap(tmpDir, '🚧 **v3.3 !!!**\n### Phase 1: Setup\n');
const info = getMilestoneInfo(tmpDir);
assert.notStrictEqual(info.value.name, '!!!');
if (info.scope === SCOPE.COMPLETE) {
assert.fail(`punctuation-only in-progress bullet name leaked through as COMPLETE: ${JSON.stringify(info)}`);
}
assert.strictEqual(info.scope, SCOPE.TRUNCATED, `scope: ${JSON.stringify(info)}`);
assert.strictEqual(info.value.version, 'v3.3');
assert.strictEqual(info.value.name, null);
});
test('#4433 — real in-progress 🚧-bullet name (no STATE.md) still resolves COMPLETE (regression control)', () => {
writeRoadmap(tmpDir, '🚧 **v3.3 Some Real Name**\n### Phase 1: Setup\n');
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.scope, SCOPE.COMPLETE, `scope: ${JSON.stringify(info)}`);
assert.strictEqual(info.value.version, 'v3.3');
assert.match(info.value.name, /Some Real Name/);
});
test('#4433 — digits-only captured name is accepted (boundary: digits alone qualify)', () => {
writeState(tmpDir, { milestone: 'v4.0' });
writeRoadmap(tmpDir, '## v4.0 — 42\n### Phase 1: Setup\n');
const info = getMilestoneInfo(tmpDir);
assert.strictEqual(info.scope, SCOPE.COMPLETE, `scope: ${JSON.stringify(info)}`);
assert.strictEqual(info.value.version, 'v4.0');
assert.strictEqual(info.value.name, '42');
});
});
// ─── isMilestoneShippedInRoadmap ──────────────────────────────────────────────
// #2562: this module owns milestone-heading classification, so its own shipped