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:
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
88
tests/lint-phase-id-drift.test.cjs
Normal file
88
tests/lint-phase-id-drift.test.cjs
Normal 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), []);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user