The convergence lint only scanned src/ + gsd-core/bin, so ~35 test files still imported core.cjs. Repoint all 33 behaviour importers to the leaf modules directly (same symbol->leaf map as the src migration; leaves are the objects core re-exported by reference), delete the now-meaningless shim-identity describe blocks in the 8 leaf tests, and delete tests/core.test.cjs (forwarded-behaviour coverage now lives at the leaves; resolveWorktreeRoot test relocated to worktree-safety in T0) and tests/lint-core-spine-imports.test.cjs (the lint is removed in T-final). Dropped the stale core.test.cjs entries from the allow-test-rule-refs allowlist; eslint-rules RuleTester fixture path pointed at io.cjs. After T6: ZERO test imports core.cjs. core.cts still builds (now fully unused); T-final deletes it. No behaviour change. Closes #1291 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
276 lines
13 KiB
JavaScript
276 lines
13 KiB
JavaScript
/**
|
||
* Regression test for bug #730: phase details defined under a milestone-scoped
|
||
* "## Milestone vX.Y — … (Phase Details)" section are invisible to phase
|
||
* resolution (getRoadmapPhaseInternal / init phase-op) when the flat shared
|
||
* "## Phase Details" section for an earlier milestone sits between the shared
|
||
* ## Phases checklist and the per-milestone Phase Details section.
|
||
*
|
||
* The bug manifests ONLY before any .planning/phases/ directory exists because
|
||
* findPhaseInternal masks it once the dir is created. RED step — tests 1 and 3
|
||
* are expected to fail against current code.
|
||
*/
|
||
|
||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared fixture content
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const STATE_CONTENT = `---
|
||
milestone: v1.1
|
||
---
|
||
`;
|
||
|
||
const ROADMAP_CONTENT = `# Roadmap: Example
|
||
|
||
## Phases
|
||
|
||
- [x] **Phase 1: Setup** — initial scaffold
|
||
|
||
### Milestone v1.1 — Second milestone (added 2026-01-01)
|
||
|
||
- [ ] **Phase 2: Feature** — the new thing
|
||
|
||
## Phase Details
|
||
|
||
### Phase 1: Setup
|
||
**Goal:** scaffold the app.
|
||
|
||
## Milestone v1.1 — Second milestone (Phase Details)
|
||
|
||
### Phase 2: Feature
|
||
**Goal:** build the new thing.
|
||
`;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helper: create a bare project with .planning/ but NO .planning/phases/ dir
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function createBareProject() {
|
||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-'));
|
||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||
return tmpDir;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Suite
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('bug #730 — milestone (Phase Details) section scope resolution', () => {
|
||
let dir;
|
||
|
||
beforeEach(() => {
|
||
dir = createBareProject();
|
||
fs.writeFileSync(path.join(dir, '.planning', 'STATE.md'), STATE_CONTENT, 'utf-8');
|
||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT, 'utf-8');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(dir);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Test 1 (AC1): init phase-op resolves phase defined only under its
|
||
// per-milestone "(Phase Details)" section
|
||
// -------------------------------------------------------------------------
|
||
test('init phase-op resolves a current-milestone phase defined only under its (Phase Details) section', () => {
|
||
const r = runGsdTools('init phase-op 2', dir);
|
||
assert.ok(r.success, `init phase-op 2 failed: ${r.error}`);
|
||
|
||
const out = JSON.parse(r.output);
|
||
assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}, expected_phase_dir=${out.expected_phase_dir}`);
|
||
assert.strictEqual(out.phase_name, 'Feature', `phase_name should be 'Feature'; got '${out.phase_name}'`);
|
||
assert.strictEqual(out.padded_phase, '02', `padded_phase should be '02'; got '${out.padded_phase}'`);
|
||
assert.strictEqual(out.expected_phase_dir, '.planning/phases/02-feature', `expected_phase_dir should be '.planning/phases/02-feature'; got '${out.expected_phase_dir}'`);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Test 2 (AC4): first-milestone phase still resolves via the flat
|
||
// "## Phase Details" section — no regression
|
||
// -------------------------------------------------------------------------
|
||
test('init phase-op still resolves a first-milestone phase (no regression on flat Phase Details)', () => {
|
||
const r = runGsdTools('init phase-op 1', dir);
|
||
assert.ok(r.success, `init phase-op 1 failed: ${r.error}`);
|
||
|
||
const out = JSON.parse(r.output);
|
||
assert.strictEqual(out.phase_found, true, `phase_found should be true for phase 1; got ${out.phase_found}`);
|
||
assert.strictEqual(out.phase_name, 'Setup', `phase_name should be 'Setup'; got '${out.phase_name}'`);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Test 3 (AC5): getRoadmapPhaseInternal resolves the current-milestone phase
|
||
// directly before any phases/ dir exists
|
||
// -------------------------------------------------------------------------
|
||
test('getRoadmapPhaseInternal resolves the current-milestone phase directly before any dir exists', () => {
|
||
const { getRoadmapPhaseInternal } = require('../gsd-core/bin/lib/roadmap-parser.cjs');
|
||
|
||
const res = getRoadmapPhaseInternal(dir, '2');
|
||
assert.ok(res !== null && res !== undefined, `getRoadmapPhaseInternal returned null/undefined for phase 2`);
|
||
assert.strictEqual(res.found, true, `res.found should be true; got ${JSON.stringify(res)}`);
|
||
assert.strictEqual(res.phase_name, 'Feature', `res.phase_name should be 'Feature'; got '${res.phase_name}'`);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Test 4 (AC3): validate health raises W006 for a current-milestone phase
|
||
// defined under (Phase Details) with no directory on disk.
|
||
//
|
||
// Before the fix, extractCurrentMilestone's slice stopped before the
|
||
// "## Milestone v1.1 — … (Phase Details)" section, so phase 2's
|
||
// "### Phase 2: Feature" header was invisible and W006 was never raised.
|
||
// After the fix the slice includes that section and W006 is emitted.
|
||
//
|
||
// This test uses its OWN local fixture (separate tmpdir) so it does not
|
||
// disturb the shared beforeEach/afterEach fixture used by tests 1–3.
|
||
// -------------------------------------------------------------------------
|
||
test('validate health raises W006 for a started current-milestone phase defined under (Phase Details) with no directory', () => {
|
||
const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t4-'));
|
||
try {
|
||
const planning = path.join(localDir, '.planning');
|
||
fs.mkdirSync(planning, { recursive: true });
|
||
|
||
// STATE.md — milestone: v1.1
|
||
fs.writeFileSync(
|
||
path.join(planning, 'STATE.md'),
|
||
`---\nmilestone: v1.1\n---\n`,
|
||
'utf-8',
|
||
);
|
||
|
||
// ROADMAP.md — phase 2 is [x] (started/complete) so the not-started
|
||
// guard does NOT suppress W006. Phase 2's details live exclusively in
|
||
// the per-milestone "(Phase Details)" section (the blind-spot pre-fix).
|
||
fs.writeFileSync(
|
||
path.join(planning, 'ROADMAP.md'),
|
||
`# Roadmap: Example\n\n## Phases\n\n- [x] **Phase 1: Setup** — initial scaffold\n\n### Milestone v1.1 — Second milestone (added 2026-01-01)\n\n- [x] **Phase 2: Feature** — the new thing\n\n## Phase Details\n\n### Phase 1: Setup\n**Goal:** scaffold the app.\n\n## Milestone v1.1 — Second milestone (Phase Details)\n\n### Phase 2: Feature\n**Goal:** build the new thing.\n`,
|
||
'utf-8',
|
||
);
|
||
|
||
// Create the phase 1 directory so phase 1 does NOT trigger W006.
|
||
// Phase 2 has NO directory — that's the missing-dir condition under test.
|
||
fs.mkdirSync(path.join(planning, 'phases', '01-setup'), { recursive: true });
|
||
|
||
const result = runGsdTools(['validate', 'health'], localDir);
|
||
const payload = JSON.parse(result.output);
|
||
const warnings = payload.warnings || [];
|
||
|
||
// Find a W006 entry whose message references phase 2 (by number or name).
|
||
const w006ForPhase2 = warnings.find(
|
||
(w) =>
|
||
w.code === 'W006' &&
|
||
(/\b2\b/.test(w.message) || /\b02\b/.test(w.message) || /Feature/i.test(w.message)),
|
||
);
|
||
|
||
assert.ok(
|
||
w006ForPhase2 != null,
|
||
`Expected a W006 warning referencing phase 2 (Feature) — phase 2 is started ([x]) and has no directory on disk, ` +
|
||
`but its ### Phase 2: header lives in the Milestone v1.1 (Phase Details) section which was invisible before the fix. ` +
|
||
`Got warnings: ${JSON.stringify(warnings)}`,
|
||
);
|
||
} finally {
|
||
cleanup(localDir);
|
||
}
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Test 5: three-milestone roadmap, current = latest (v1.2)
|
||
// -------------------------------------------------------------------------
|
||
test('init phase-op resolves the latest milestone phase in a 3-milestone roadmap', () => {
|
||
const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t5-'));
|
||
try {
|
||
const planning = path.join(localDir, '.planning');
|
||
fs.mkdirSync(planning, { recursive: true });
|
||
|
||
fs.writeFileSync(
|
||
path.join(planning, 'STATE.md'),
|
||
`---\nmilestone: v1.2\n---\n`,
|
||
'utf-8',
|
||
);
|
||
|
||
fs.writeFileSync(
|
||
path.join(planning, 'ROADMAP.md'),
|
||
`# Roadmap: Example\n\n## Phases\n\n- [x] **Phase 1: Setup** — done\n\n### Milestone v1.1 — Second (added 2026-01-01)\n\n- [x] **Phase 2: Feature** — done\n\n### Milestone v1.2 — Third (added 2026-02-01)\n\n- [ ] **Phase 3: Polish** — current\n\n## Phase Details\n\n### Phase 1: Setup\n**Goal:** scaffold.\n\n## Milestone v1.1 — Second (Phase Details)\n\n### Phase 2: Feature\n**Goal:** build.\n\n## Milestone v1.2 — Third (Phase Details)\n\n### Phase 3: Polish\n**Goal:** refine.\n`,
|
||
'utf-8',
|
||
);
|
||
|
||
const r = runGsdTools('init phase-op 3', localDir);
|
||
assert.ok(r.success, `init phase-op 3 failed: ${r.error}`);
|
||
|
||
const out = JSON.parse(r.output);
|
||
assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}`);
|
||
assert.strictEqual(out.phase_name, 'Polish', `phase_name should be 'Polish'; got '${out.phase_name}'`);
|
||
assert.strictEqual(out.padded_phase, '03', `padded_phase should be '03'; got '${out.padded_phase}'`);
|
||
assert.strictEqual(out.expected_phase_dir, '.planning/phases/03-polish', `expected_phase_dir should be '.planning/phases/03-polish'; got '${out.expected_phase_dir}'`);
|
||
} finally {
|
||
cleanup(localDir);
|
||
}
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Test 6: sub-milestone sharing a version prefix — closed sibling must NOT
|
||
// cross-pollinate into the active milestone's Phase Details lookup (#730)
|
||
// -------------------------------------------------------------------------
|
||
test('init phase-op anchors Phase Details to the selected sub-milestone, not a closed same-prefix sibling', () => {
|
||
const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t6-'));
|
||
try {
|
||
const planning = path.join(localDir, '.planning');
|
||
fs.mkdirSync(planning, { recursive: true });
|
||
|
||
// STATE.md — milestone: v3.0 (matches v3.0-B active slice)
|
||
fs.writeFileSync(
|
||
path.join(planning, 'STATE.md'),
|
||
`---\nmilestone: v3.0\n---\n`,
|
||
'utf-8',
|
||
);
|
||
|
||
// ROADMAP.md — v3.0-A is SHIPPED (closed), v3.0-B is active.
|
||
// The Phase Details for v3.0-A comes FIRST — without version-boundary
|
||
// anchoring the old code would grab it (first non-closed (Phase Details)
|
||
// heading outside the window), returning phase_name='Alpha' instead of 'Beta'.
|
||
fs.writeFileSync(
|
||
path.join(planning, 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap: Example',
|
||
'',
|
||
'## Phases',
|
||
'',
|
||
'### Milestone v3.0-A — First slice (added 2026-01-01) ✅ SHIPPED',
|
||
'',
|
||
'- [x] **Phase 1: Alpha** — done',
|
||
'',
|
||
'### Milestone v3.0-B — Second slice (added 2026-02-01)',
|
||
'',
|
||
'- [ ] **Phase 2: Beta** — current',
|
||
'',
|
||
'## Phase Details',
|
||
'',
|
||
'## Milestone v3.0-A — First slice (Phase Details)',
|
||
'',
|
||
'### Phase 1: Alpha',
|
||
'**Goal:** alpha goal.',
|
||
'',
|
||
'## Milestone v3.0-B — Second slice (Phase Details)',
|
||
'',
|
||
'### Phase 2: Beta',
|
||
'**Goal:** beta goal.',
|
||
'',
|
||
].join('\n'),
|
||
'utf-8',
|
||
);
|
||
|
||
const r = runGsdTools('init phase-op 2', localDir);
|
||
assert.ok(r.success, `init phase-op 2 failed: ${r.error}`);
|
||
|
||
const out = JSON.parse(r.output);
|
||
assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}, output=${JSON.stringify(out)}`);
|
||
assert.strictEqual(out.phase_name, 'Beta', `phase_name should be 'Beta' (v3.0-B section), not '${out.phase_name}' (would indicate v3.0-A cross-pollination)`);
|
||
assert.strictEqual(out.expected_phase_dir, '.planning/phases/02-beta', `expected_phase_dir should be '.planning/phases/02-beta'; got '${out.expected_phase_dir}'`);
|
||
} finally {
|
||
cleanup(localDir);
|
||
}
|
||
});
|
||
});
|