* test(#4023): reproduce decimal phase-id coercions * fix(#4023): preserve decimal phase ids in progress signals * test(#4023): align phase token contract expectations * chore(#4023): point the changeset at PR #4110 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
5
.changeset/calm-herons-sort.md
Normal file
5
.changeset/calm-herons-sort.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 4110
|
||||||
|
---
|
||||||
|
**Progress routing preserves decimal phase IDs** — `init progress` now orders parent and inserted phases canonically, and `smart-entry --json` returns the complete current phase token instead of truncating it to an integer.
|
||||||
@@ -92,7 +92,7 @@ const {
|
|||||||
extractCurrentMilestone,
|
extractCurrentMilestone,
|
||||||
} = roadmapParser;
|
} = roadmapParser;
|
||||||
const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
|
const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
|
||||||
const { normalizePhaseName, matchPhaseDirs, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery, isSentinelPhaseId, extractPhaseToken, scopeToPhase } = phaseId;
|
const { comparePhaseNum, normalizePhaseName, matchPhaseDirs, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery, isSentinelPhaseId, extractPhaseToken, scopeToPhase } = phaseId;
|
||||||
const { pruneOrphanedWorktrees } = worktreeSafety;
|
const { pruneOrphanedWorktrees } = worktreeSafety;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -3175,9 +3175,7 @@ function cmdInitProgress(cwd: string, raw: boolean, options: Record<string, unkn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
phases.sort(
|
phases.sort((a, b) => comparePhaseNum(a['number'], b['number']));
|
||||||
(a, b) => parseInt(a['number'] as string, 10) - parseInt(b['number'] as string, 10),
|
|
||||||
);
|
|
||||||
|
|
||||||
// #3581: the frontier is ROADMAP ORDER, not artifact presence. The disk loop
|
// #3581: the frontier is ROADMAP ORDER, not artifact presence. The disk loop
|
||||||
// above could claim nextPhase from a stray out-of-order artifact directory
|
// above could claim nextPhase from a stray out-of-order artifact directory
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import stateDocument = require('./state-document.cjs');
|
|||||||
const { stateFieldValue } = stateDocument;
|
const { stateFieldValue } = stateDocument;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import phaseId = require('./phase-id.cjs');
|
import phaseId = require('./phase-id.cjs');
|
||||||
const { comparePhaseNum, extractPhaseToken, matchPhaseDirs, normalizePhaseName, stripProjectCodePrefix } = phaseId;
|
const { comparePhaseNum, extractPhaseToken, matchPhaseDirs, normalizePhaseName, parsePhaseFromProse, stripProjectCodePrefix } = phaseId;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import stateMod = require('./state.cjs');
|
import stateMod = require('./state.cjs');
|
||||||
const { readStateHeadFreshness } = stateMod;
|
const { readStateHeadFreshness } = stateMod;
|
||||||
@@ -87,7 +87,7 @@ export interface SmartEntryAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SmartEntrySignals {
|
export interface SmartEntrySignals {
|
||||||
current_phase: number | null;
|
current_phase: string | null;
|
||||||
total_phases: number | null;
|
total_phases: number | null;
|
||||||
status: string;
|
status: string;
|
||||||
progress: number | null;
|
progress: number | null;
|
||||||
@@ -306,9 +306,7 @@ function readGitSignals(cwd: string): GitSignals {
|
|||||||
|
|
||||||
/** Leading numeric phase token from a STATE.md scalar or body `Phase:` value. */
|
/** Leading numeric phase token from a STATE.md scalar or body `Phase:` value. */
|
||||||
function phaseTokenFromState(raw: string | null): string | null {
|
function phaseTokenFromState(raw: string | null): string | null {
|
||||||
if (!raw?.trim()) return null;
|
return parsePhaseFromProse(raw).phase;
|
||||||
const match = raw.trim().match(/^(\d+(?:[A-Z])?(?:\.\d+)*)/i);
|
|
||||||
return match ? match[1] : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -513,7 +511,7 @@ export function detectSignals(cwd: string, now: () => number = Date.now): SmartE
|
|||||||
const freshness = readStateHeadFreshness(cwd, stateHeadRaw);
|
const freshness = readStateHeadFreshness(cwd, stateHeadRaw);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
current_phase: parseIntOrNull(currentPhaseRaw),
|
current_phase: phaseTokenFromState(currentPhaseRaw),
|
||||||
total_phases: parseIntOrNull(totalPhasesRaw),
|
total_phases: parseIntOrNull(totalPhasesRaw),
|
||||||
status: (statusRaw || '').toLowerCase(),
|
status: (statusRaw || '').toLowerCase(),
|
||||||
progress: parseIntOrNull(progressRaw),
|
progress: parseIntOrNull(progressRaw),
|
||||||
@@ -555,10 +553,11 @@ function isComplete(s: SmartEntrySignals): boolean {
|
|||||||
if (s.roadmap_total_phases === 0) return false;
|
if (s.roadmap_total_phases === 0) return false;
|
||||||
if (s.roadmap_completed_phases < s.roadmap_total_phases) return false;
|
if (s.roadmap_completed_phases < s.roadmap_total_phases) return false;
|
||||||
} else {
|
} else {
|
||||||
// Legacy path: STATE.md comparison. Still subject to the two-scale bug,
|
// Legacy path: STATE.md comparison. It only fires when ROADMAP.md is
|
||||||
// but only fires when ROADMAP.md is absent or has no Progress table.
|
// absent or has no Progress table, and uses canonical phase-id ordering
|
||||||
|
// so dotted phase ids are never coerced to JavaScript numbers.
|
||||||
if (s.total_phases === null || s.current_phase === null) return false;
|
if (s.total_phases === null || s.current_phase === null) return false;
|
||||||
if (s.current_phase < s.total_phases) return false;
|
if (comparePhaseNum(s.current_phase, s.total_phases) < 0) return false;
|
||||||
}
|
}
|
||||||
// Status regex: require milestone-level completion language. The pre-fix
|
// Status regex: require milestone-level completion language. The pre-fix
|
||||||
// regex matched any "shipped" / "done" substring (per-phase language).
|
// regex matched any "shipped" / "done" substring (per-phase language).
|
||||||
|
|||||||
@@ -4744,6 +4744,70 @@ describe('#3581: init.progress next_phase prefers the roadmap frontier', () => {
|
|||||||
assert.equal(eight.directory, null, 'Phase 8 has no directory (corroborating the stray-only-disk shape)');
|
assert.equal(eight.directory, null, 'Phase 8 has no directory (corroborating the stray-only-disk shape)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('#4023: init.progress sorts decimal phase ids before choosing the roadmap frontier', (t) => {
|
||||||
|
const tmpDir = createTempProject('gsd-4023-init-');
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
|
||||||
|
'# Roadmap',
|
||||||
|
'',
|
||||||
|
'## Milestone v1.1.0',
|
||||||
|
'',
|
||||||
|
'### Phase 12: Parent',
|
||||||
|
'**Goal:** g',
|
||||||
|
'',
|
||||||
|
'### Phase 12.1: Inserted fix',
|
||||||
|
'**Goal:** g',
|
||||||
|
'',
|
||||||
|
'### Phase 12.2: Second insert',
|
||||||
|
'**Goal:** g',
|
||||||
|
'',
|
||||||
|
'### Phase 12.10: Tenth insert',
|
||||||
|
'**Goal:** g',
|
||||||
|
'',
|
||||||
|
'### Phase 13: Follow-up',
|
||||||
|
'**Goal:** g',
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
|
||||||
|
'---',
|
||||||
|
'gsd_state_version: 1.0',
|
||||||
|
'milestone: v1.1.0',
|
||||||
|
'milestone_name: Active',
|
||||||
|
'status: executing',
|
||||||
|
'current_phase: 12.1',
|
||||||
|
'progress:',
|
||||||
|
' total_phases: 13',
|
||||||
|
' completed_phases: 11',
|
||||||
|
' percent: 85',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'# Project State',
|
||||||
|
'',
|
||||||
|
'## Current Position',
|
||||||
|
'',
|
||||||
|
'Phase: 12.1',
|
||||||
|
'Status: Executing',
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
|
for (const dir of ['12.1-inserted-fix', '12.10-tenth-insert']) {
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', dir), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = runGsdTools(['init', 'progress', '--raw'], tmpDir);
|
||||||
|
assert.ok(result.success, `init progress failed: ${result.error}`);
|
||||||
|
const out = JSON.parse(result.output);
|
||||||
|
assert.deepEqual(
|
||||||
|
out.phases.map((phase) => String(phase.number).replace(/^0+(?=\d)/, '')),
|
||||||
|
['12', '12.1', '12.2', '12.10', '13'],
|
||||||
|
'the disk/roadmap union follows component-wise phase-id order (12.2 before 12.10)',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
String(out.next_phase.number).replace(/^0+(?=\d)/, ''),
|
||||||
|
'12',
|
||||||
|
'the pending parent remains the frontier when an inserted decimal directory exists first',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('#3581 (control): a pending roadmap-only phase outranks a later pending directory', (t) => {
|
test('#3581 (control): a pending roadmap-only phase outranks a later pending directory', (t) => {
|
||||||
writeProgressFixture(t, { strayNine: false });
|
writeProgressFixture(t, { strayNine: false });
|
||||||
// pure ordering property, no stray artifacts: roadmap-only pending 8 vs a
|
// pure ordering property, no stray artifacts: roadmap-only pending 8 vs a
|
||||||
@@ -4941,4 +5005,3 @@ describe('init — GSD_PROJECT scoping (#3964)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ describe('smart-entry: real STATE.md schema (nested progress YAML + body Phase f
|
|||||||
roadmap: true,
|
roadmap: true,
|
||||||
}));
|
}));
|
||||||
const signals = detectSignals(dir);
|
const signals = detectSignals(dir);
|
||||||
assert.equal(signals.current_phase, 3, 'current_phase from body Phase: field');
|
assert.equal(signals.current_phase, '3', 'current_phase from body Phase: field');
|
||||||
assert.equal(signals.total_phases, 5, 'total_phases from nested progress.total_phases');
|
assert.equal(signals.total_phases, 5, 'total_phases from nested progress.total_phases');
|
||||||
assert.equal(signals.progress, 40, 'percent from nested progress.percent');
|
assert.equal(signals.progress, 40, 'percent from nested progress.percent');
|
||||||
assert.equal(signals.status, 'verifying');
|
assert.equal(signals.status, 'verifying');
|
||||||
@@ -457,6 +457,19 @@ describe('smart-entry: JSON shape invariants', () => {
|
|||||||
describe('smart-entry: CLI dispatch (gsd-tools smart-entry)', () => {
|
describe('smart-entry: CLI dispatch (gsd-tools smart-entry)', () => {
|
||||||
afterEach(removeAll);
|
afterEach(removeAll);
|
||||||
|
|
||||||
|
test('#4023: --json preserves decimal current_phase as a phase-id string', () => {
|
||||||
|
for (const phase of ['12.1', '12.10']) {
|
||||||
|
const dir = track(makeProject({
|
||||||
|
state: state({ status: 'executing', total_phases: 13, current_phase: phase }),
|
||||||
|
roadmap: true,
|
||||||
|
}));
|
||||||
|
const r = runNode([TOOLS, 'smart-entry', '--json', '--cwd', dir], { timeoutMs: PROBE_TIMEOUT_MS });
|
||||||
|
throwIfFailed(r, 'gsd-tools smart-entry --json');
|
||||||
|
const out = JSON.parse(r.stdout);
|
||||||
|
assert.equal(out.signals.current_phase, phase);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('--json in an empty dir returns no-project machine JSON', () => {
|
test('--json in an empty dir returns no-project machine JSON', () => {
|
||||||
// A bare tmpdir with no .planning is a true no-project.
|
// A bare tmpdir with no .planning is a true no-project.
|
||||||
const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-se-bare-'));
|
const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-se-bare-'));
|
||||||
|
|||||||
@@ -6157,7 +6157,7 @@ describe('#3187 chain-owner identity — every consumer agrees with stateFieldVa
|
|||||||
stateDocument.stateFieldValue(fm, body, null, 'Phase').value;
|
stateDocument.stateFieldValue(fm, body, null, 'Phase').value;
|
||||||
|
|
||||||
const output = JSON.parse(runGsdTools('smart-entry --json', tmpDir).output);
|
const output = JSON.parse(runGsdTools('smart-entry --json', tmpDir).output);
|
||||||
assert.strictEqual(output.signals.current_phase, parseInt(ownerPhaseRaw, 10));
|
assert.strictEqual(output.signals.current_phase, ownerPhaseRaw);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('C5: workstream projection matches the owner', () => {
|
test('C5: workstream projection matches the owner', () => {
|
||||||
@@ -6258,7 +6258,7 @@ describe('#3187 chain-owner identity — every consumer agrees with stateFieldVa
|
|||||||
|
|
||||||
// C4: smart-entry
|
// C4: smart-entry
|
||||||
const smartEntry = JSON.parse(runGsdTools('smart-entry --json', tmpDir).output);
|
const smartEntry = JSON.parse(runGsdTools('smart-entry --json', tmpDir).output);
|
||||||
assert.strictEqual(smartEntry.signals.current_phase, Number(ownerPhase));
|
assert.strictEqual(smartEntry.signals.current_phase, ownerPhase);
|
||||||
|
|
||||||
// C6: complete-phase idempotency guard (no frontmatter in this fixture,
|
// C6: complete-phase idempotency guard (no frontmatter in this fixture,
|
||||||
// so this row does not exercise the frontmatter tier — see C6's own test
|
// so this row does not exercise the frontmatter tier — see C6's own test
|
||||||
|
|||||||
Reference in New Issue
Block a user