refactor(#3309): add state-consistency health-diagnostic rules
W024 (deliberately inert, no snapshot field yet for stale state_head), W002, W011, W021, W026 — STATE.md cross-checks against ROADMAP/config, migrated onto the frozen rule table per ADR-3180 §8.2.
This commit is contained in:
420
tests/health-diagnostic-rules/state-consistency.test.cjs
Normal file
420
tests/health-diagnostic-rules/state-consistency.test.cjs
Normal file
@@ -0,0 +1,420 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Tests for `src/health-diagnostic-rules/state-consistency.cts` (Phase 11,
|
||||
* #3309, ADR-3180 §8.2/§8.3/§8.5) — group "STATE.md consistency": W024,
|
||||
* W002, W011, W021, W026.
|
||||
*
|
||||
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
|
||||
*
|
||||
* Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"):
|
||||
* every fixture below is a MECHANICAL MUTATION of a realistic multi-phase
|
||||
* ROADMAP/STATE/config.json shape (the shipped `templates/state.md` /
|
||||
* `templates/roadmap.md` field layout, filled in with real values) with
|
||||
* exactly ONE targeted field flipped per rule under test (an extra phase
|
||||
* reference, a checkbox left `[x]` while status stays `In progress`, a
|
||||
* `phase_id_convention` + a milestone-prefixed phase heading placed under
|
||||
* the wrong version section, a `milestone complete` status left with an
|
||||
* unstarted phase) — never a fixture invented purely to trip the rule with
|
||||
* no other realistic content. Every case drives the REAL
|
||||
* `buildPlanningSnapshot(cwd)` (`src/planning-snapshot.cts`) against a REAL
|
||||
* temp `.planning/` tree — no hand-built in-memory `PlanningSnapshot` mock.
|
||||
*
|
||||
* W024 is a deliberate exception: its `check` is documented dead code (see
|
||||
* `src/health-diagnostic-rules/state-consistency.cts`'s `RULE_W024`
|
||||
* comment) — its tests assert the INERT `[] `contract directly rather than
|
||||
* a trigger fixture, since no snapshot field can drive it to fire.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createTempDir, cleanup } = require('../helpers.cjs');
|
||||
|
||||
const stateConsistency = require('../../gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs');
|
||||
const { RULES } = stateConsistency;
|
||||
|
||||
const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs');
|
||||
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = require('../../gsd-core/bin/lib/health-diagnostic.cjs');
|
||||
|
||||
function planningDirOf(cwd) {
|
||||
return path.join(cwd, '.planning');
|
||||
}
|
||||
|
||||
function writeState(cwd, content) {
|
||||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDirOf(cwd), 'STATE.md'), content);
|
||||
}
|
||||
|
||||
function writeRoadmap(cwd, content) {
|
||||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), content);
|
||||
}
|
||||
|
||||
function writeFile(cwd, relPath, content) {
|
||||
const full = path.join(cwd, relPath);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
function writeConfig(cwd, obj) {
|
||||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDirOf(cwd), 'config.json'), JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
function makePhaseDir(cwd, dirName) {
|
||||
writeFile(cwd, `.planning/phases/${dirName}/01-01-PLAN.md`, '# Plan\n');
|
||||
writeFile(cwd, `.planning/phases/${dirName}/01-01-SUMMARY.md`, '# Summary\n');
|
||||
writeFile(cwd, `.planning/phases/${dirName}/01-VERIFICATION.md`, '---\nstatus: passed\n---\n');
|
||||
}
|
||||
|
||||
function ruleFor(code) {
|
||||
const rule = RULES.find((r) => r.code === code);
|
||||
assert.ok(rule, `rule ${code} not found in RULES`);
|
||||
return rule;
|
||||
}
|
||||
|
||||
// ─── RULES shape ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('RULES (state-consistency group)', () => {
|
||||
test('exports exactly 5 rules: W024, W002, W011, W021, W026', () => {
|
||||
assert.deepEqual(
|
||||
RULES.map((r) => r.code).sort(),
|
||||
['W002', 'W011', 'W021', 'W024', 'W026'],
|
||||
);
|
||||
});
|
||||
|
||||
test('every rule is severity WARNING', () => {
|
||||
for (const code of ['W024', 'W002', 'W011', 'W021', 'W026']) {
|
||||
assert.equal(ruleFor(code).severity, SEVERITY.WARNING);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── W024 — STATE.md commit-age freshness (DELIBERATELY INERT) ─────────────
|
||||
|
||||
describe('W024 — deliberately inert (no snapshot field backs git-log freshness)', () => {
|
||||
test('always returns [] on an empty snapshot', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w024-1-');
|
||||
t.after(() => cleanup(cwd));
|
||||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('W024').check(snapshot), []);
|
||||
});
|
||||
|
||||
test('always returns [] even with a state_head-carrying STATE.md and full roadmap/config', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w024-2-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeState(
|
||||
cwd,
|
||||
['---', 'state_head: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', 'status: In progress', '---', '', '## Current Position', '', 'Phase: 1 of 2', ''].join('\n'),
|
||||
);
|
||||
writeRoadmap(cwd, '## v1.0 Current 🚧\n\n### Phase 1: Foo\n\n### Phase 2: Bar\n');
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('W024').check(snapshot), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── W002 — STATE.md references an undeclared phase token ──────────────────
|
||||
|
||||
describe('W002 — STATE.md references a phase not declared on disk or ROADMAP', () => {
|
||||
test('fires when STATE.md mentions a phase not on disk and not in ROADMAP', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w002-1-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeRoadmap(cwd, '## v1.0 Current 🚧\n\n### Phase 1: Foo\n\n### Phase 2: Bar\n');
|
||||
makePhaseDir(cwd, '01-foo');
|
||||
makePhaseDir(cwd, '02-bar');
|
||||
writeState(
|
||||
cwd,
|
||||
[
|
||||
'---',
|
||||
'status: In progress',
|
||||
'---',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 1 of 2',
|
||||
'',
|
||||
'### Decisions',
|
||||
'',
|
||||
'- Phase 9: referenced a phase that does not exist',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
const diagnostics = ruleFor('W002').check(snapshot);
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.equal(diagnostics[0].code, 'W002');
|
||||
assert.equal(diagnostics[0].severity, SEVERITY.WARNING);
|
||||
assert.match(diagnostics[0].message, /STATE\.md references phase 9, but only phases .* are declared/);
|
||||
assert.deepEqual(diagnostics[0].remedy, {
|
||||
action: REMEDY_ACTION.ADVISE,
|
||||
risk: REMEDY_RISK.NONE,
|
||||
args: {
|
||||
command:
|
||||
'Review STATE.md manually before changing it; /gsd-health --repair will not overwrite an existing STATE.md for phase mismatches',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not fire when every STATE.md phase reference is declared (disk or ROADMAP)', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w002-2-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeRoadmap(cwd, '## v1.0 Current 🚧\n\n### Phase 1: Foo\n\n### Phase 2: Bar\n');
|
||||
makePhaseDir(cwd, '01-foo');
|
||||
makePhaseDir(cwd, '02-bar');
|
||||
writeState(
|
||||
cwd,
|
||||
[
|
||||
'---',
|
||||
'status: In progress',
|
||||
'---',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 1 of 2',
|
||||
'',
|
||||
'### Decisions',
|
||||
'',
|
||||
'- Phase 2: fine, this is declared',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('W002').check(snapshot), []);
|
||||
});
|
||||
|
||||
// Boundary: `validPhases.size === 0` guard — mirrors `verify.cts:1765`'s
|
||||
// `if (normalizedValid.size > 0)` exactly, so a project with no declared
|
||||
// phases at all never reports every STATE.md phase mention as invalid.
|
||||
test('does not fire when the valid-phase set is empty (no ROADMAP, no disk phases)', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w002-3-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeState(
|
||||
cwd,
|
||||
['---', 'status: In progress', '---', '', '### Decisions', '', '- Phase 3: referenced with nothing declared anywhere', ''].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('W002').check(snapshot), []);
|
||||
});
|
||||
|
||||
// KNOWN GAP (implementer report): archived-phase-token coverage
|
||||
// (`forEachArchivedPhaseToken`) is not in any PlanningSnapshot field, so a
|
||||
// STATE.md reference to a phase that lives only in a milestone archive is
|
||||
// reported as undeclared here, unlike the original `verify.cts` check.
|
||||
// Documents the gap rather than silently absorbing it.
|
||||
test('KNOWN GAP: fires on a phase reference whose only home is an archived milestone (not modeled by any snapshot field)', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w002-4-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeRoadmap(cwd, '## v2.0 Current 🚧\n\n### Phase 3: Baz\n');
|
||||
makePhaseDir(cwd, '03-baz');
|
||||
writeFile(cwd, '.planning/milestones/v1.0-phases/01-archived-foo/01-VERIFICATION.md', '---\nstatus: passed\n---\n');
|
||||
writeState(
|
||||
cwd,
|
||||
[
|
||||
'---',
|
||||
'status: In progress',
|
||||
'---',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 3 of 3',
|
||||
'',
|
||||
'### Decisions',
|
||||
'',
|
||||
'- Phase 1: this phase is archived, not currently exposed by any snapshot field',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
const diagnostics = ruleFor('W002').check(snapshot);
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.match(diagnostics[0].message, /STATE\.md references phase 1,/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── W011 — STATE current-phase status vs. ROADMAP checkbox disagree ───────
|
||||
|
||||
describe('W011 — STATE current-phase status disagrees with ROADMAP [x] checkbox', () => {
|
||||
test('fires when ROADMAP checkbox says the current phase is [x] complete but STATE status is not complete/done', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w011-1-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeRoadmap(
|
||||
cwd,
|
||||
['## v1.0 Current 🚧', '', '- [x] Phase 3: Auth', '- [ ] Phase 4: Billing', ''].join('\n'),
|
||||
);
|
||||
writeState(
|
||||
cwd,
|
||||
['---', 'status: In progress', '---', '', '## Current Position', '', 'Phase: 3 of 4 (Auth)', 'Status: In progress', ''].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
const diagnostics = ruleFor('W011').check(snapshot);
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.deepEqual(diagnostics[0], {
|
||||
code: 'W011',
|
||||
severity: SEVERITY.WARNING,
|
||||
message:
|
||||
'STATE.md says current phase is 3 (status: in progress) but ROADMAP.md shows it as [x] complete — state files may be out of sync',
|
||||
remedy: {
|
||||
action: REMEDY_ACTION.ADVISE,
|
||||
risk: REMEDY_RISK.NONE,
|
||||
args: { command: 'Run /gsd-progress to re-derive current position, or manually update STATE.md' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not fire when STATE status is already "complete"', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w011-2-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '- [x] Phase 3: Auth', ''].join('\n'));
|
||||
writeState(
|
||||
cwd,
|
||||
['---', 'status: complete', '---', '', '## Current Position', '', 'Phase: 3 of 4 (Auth)', 'Status: complete', ''].join('\n'),
|
||||
);
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('W011').check(snapshot), []);
|
||||
});
|
||||
|
||||
test('does not fire when the ROADMAP checkbox for the current phase is [ ] (not checked)', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w011-3-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '- [ ] Phase 3: Auth', ''].join('\n'));
|
||||
writeState(
|
||||
cwd,
|
||||
['---', 'status: In progress', '---', '', '## Current Position', '', 'Phase: 3 of 4 (Auth)', 'Status: In progress', ''].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('W011').check(snapshot), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── W021 — phase_id_convention integer-prefix/milestone mismatch ──────────
|
||||
|
||||
describe('W021 — milestone-prefixed phase integer-prefix implies a different milestone', () => {
|
||||
test('fires when a milestone-prefixed phase heading is listed under the wrong version section', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w021-1-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeConfig(cwd, { phase_id_convention: 'milestone-prefixed' });
|
||||
writeRoadmap(cwd, ['## v2.0 Current 🚧', '', '### Phase 1-1: Misplaced', ''].join('\n'));
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
const diagnostics = ruleFor('W021').check(snapshot);
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.deepEqual(diagnostics[0], {
|
||||
code: 'W021',
|
||||
severity: SEVERITY.WARNING,
|
||||
message: 'Phase 1-1: integer prefix implies v1.0 but listed under v2.0',
|
||||
remedy: {
|
||||
action: REMEDY_ACTION.ADVISE,
|
||||
risk: REMEDY_RISK.NONE,
|
||||
args: { command: 'gsd-tools roadmap upgrade --convention milestone-prefixed' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not fire when the milestone-prefixed phase is listed under its implied version', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w021-2-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeConfig(cwd, { phase_id_convention: 'milestone-prefixed' });
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1-1: Correctly Placed', ''].join('\n'));
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('W021').check(snapshot), []);
|
||||
});
|
||||
|
||||
test('does not fire when phase_id_convention is not "milestone-prefixed"', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w021-3-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeConfig(cwd, { phase_id_convention: 'flat' });
|
||||
writeRoadmap(cwd, ['## v2.0 Current 🚧', '', '### Phase 1-1: Misplaced', ''].join('\n'));
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('W021').check(snapshot), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── W026 — STATE says milestone complete but ROADMAP lists unstarted phase ─
|
||||
|
||||
describe('W026 — STATE milestone-complete/archived but ROADMAP lists a phase with no disk directory', () => {
|
||||
test('fires when STATE says "milestone complete" and the current milestone still lists an unstarted phase', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w026-1-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', ''].join('\n'));
|
||||
makePhaseDir(cwd, '01-foo');
|
||||
// Phase 2 deliberately has NO disk directory.
|
||||
writeState(cwd, ['---', 'status: milestone complete', 'milestone: v1.0', '---', ''].join('\n'));
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
const diagnostics = ruleFor('W026').check(snapshot);
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.deepEqual(diagnostics[0], {
|
||||
code: 'W026',
|
||||
severity: SEVERITY.WARNING,
|
||||
message: 'STATE says milestone complete but ROADMAP lists 1 unstarted phase(s) (e.g. Phase 2)',
|
||||
remedy: {
|
||||
action: REMEDY_ACTION.ADVISE,
|
||||
risk: REMEDY_RISK.NONE,
|
||||
args: {
|
||||
command: 'Run validate consistency or re-run complete-milestone after verifying all phases are done',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not fire when every phase in the current milestone has a disk directory', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w026-2-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', ''].join('\n'));
|
||||
makePhaseDir(cwd, '01-foo');
|
||||
makePhaseDir(cwd, '02-bar');
|
||||
writeState(cwd, ['---', 'status: milestone complete', 'milestone: v1.0', '---', ''].join('\n'));
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('W026').check(snapshot), []);
|
||||
});
|
||||
|
||||
test('does not fire when STATE status is not "milestone complete"/"archived"', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w026-3-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', ''].join('\n'));
|
||||
makePhaseDir(cwd, '01-foo');
|
||||
writeState(cwd, ['---', 'status: In progress', 'milestone: v1.0', '---', ''].join('\n'));
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('W026').check(snapshot), []);
|
||||
});
|
||||
|
||||
test('fires when STATE status is "archived" (the other trigger token)', (t) => {
|
||||
const cwd = createTempDir('gsd-3309-w026-4-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', ''].join('\n'));
|
||||
makePhaseDir(cwd, '01-foo');
|
||||
writeState(cwd, ['---', 'status: archived', 'milestone: v1.0', '---', ''].join('\n'));
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
const diagnostics = ruleFor('W026').check(snapshot);
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.equal(diagnostics[0].code, 'W026');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user