Replaces cmdValidateHealth's hand-rolled addIssue/switch accumulation
(961 lines) with buildPlanningSnapshot -> evaluateRules -> map to the
legacy {code, message, fix, repairable} shape, bucketed by severity.
Two pre-checks (home-dir E010/I010, .planning/-root-missing E001) stay
outside the rule table entirely, per ADR-3180 §8.2 rule 4 ("no
precedence system") — building "some rules suppress others" into the
table would itself be the forbidden precedence system.
W024 (STATE.md commit-age freshness) also stays outside the table:
its committed rule is a documented permanent no-op (readStateHeadFreshness's
git-log shell-out is ambient I/O a Rule.check may never perform, and no
PlanningSnapshot field carries a commits-behind count). Migrating onto
the rule table as designed would have silently regressed 7 passing
tests in tests/health-validation.test.cjs — found while wiring this
function, kept as a real check in the wrapper instead (same I/O
license applyRepairs already relies on), fixed inline per this repo's
no-defer policy rather than accepted as a silent loss.
Ports the real repair-handler bodies (createConfig/resetConfig,
regenerateState, addNyquistKey/addAiIntegrationPhaseKey,
backfillMilestones) into health-diagnostic.cts's applyRepairs,
replacing the skeleton's stub. DESTRUCTIVE-risk remedies
(resetConfig/regenerateState) are refused by --repair — a disclosed
breaking change; repairable now means "an automatic repair will
actually run," not merely "a remedy exists to describe," so E004/E005
now report repairable:false. --backfill alone now actually triggers
backfillMilestones, fixing a latent bug where its gate was unreachable
without --repair also being set (verify.cts:2504, confirmed dead code
pre-migration).
Test updates distinguish the two explicitly-authorized behavior
changes (DESTRUCTIVE refusal, backfill-alone fix, W021->W026 split)
from preservation — every changed assertion is commented with why, and
new regression tests were added for both changes plus W021/W026
mutual independence. Drift-guard bookkeeping (bypass-baseline shrunk
to the one disclosed W024 exception, milestone-window and
phase-enumeration exemptions, test-file-count allowlist) updated for
the relocated/new functions this migration introduces.
593 lines
26 KiB
JavaScript
593 lines
26 KiB
JavaScript
'use strict';
|
|
/**
|
|
* phase-resolution-parity.test.cjs — #2528 resolution-path parity gate
|
|
*
|
|
* The phase-directory matching logic historically existed in three independent
|
|
* copies that had already diverged (different scan idioms, different ambiguity
|
|
* handling): the shared locator (`phase-locator.cjs :: searchPhaseInDir`, used
|
|
* by `findPhaseInternal` and the `init.*` queries), the `find-phase` command
|
|
* scan, and the `phase-plan-index` command scan. #2043/#2232 fixed the shared
|
|
* tokenizer, but any fix needing resolution-level context had to be applied
|
|
* per copy — which is how this bug class kept resurfacing (#2528 is the third
|
|
* instance).
|
|
*
|
|
* A FOURTH copy survived the first pass of that consolidation and was caught in
|
|
* review: `smart-entry.cjs :: detectVerifyFailed`, which resolves the current
|
|
* phase's directory to decide whether its verification failed. It is the worst
|
|
* of the four to get wrong — an unresolved directory reports "not failed",
|
|
* which is indistinguishable from a healthy phase, so the bug is silent by
|
|
* construction. Its absence from this gate is exactly why it was missed.
|
|
*
|
|
* The selection now delegates to one owner (`phase-id.cjs :: matchPhaseDirs`).
|
|
* This gate is the durable guard the #2528 triage asked for: for every corpus
|
|
* scenario, the four resolution paths MUST agree on the same directory for
|
|
* the same bare input — found, not-found, and ambiguous alike. It fails the
|
|
* moment any path re-implements selection and drifts.
|
|
*/
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
const { findPhaseInternal } = require('../gsd-core/bin/lib/phase-locator.cjs');
|
|
const { detectSignals } = require('../gsd-core/bin/lib/smart-entry.cjs');
|
|
|
|
// Path 4 has no JSON resolution surface to read: `detectVerifyFailed` resolves a
|
|
// directory and then reports a boolean about its contents. So selection is
|
|
// observed indirectly — plant the failing verification artifact in exactly one
|
|
// directory and see whether the signal fires. `verify_failed === true` means
|
|
// that directory is the one smart-entry chose; `false` means it chose another
|
|
// or resolved nothing.
|
|
const FAILED_SUMMARY = '# Summary\n\nSTATUS: failed\n';
|
|
const PASSED_SUMMARY = '# Summary\n\nSTATUS: passed\n';
|
|
|
|
function writeState(tmpDir, currentPhase) {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`---\nstatus: executing\ntotal_phases: 99\ncurrent_phase: ${currentPhase}\n---\n\n# State\n\n**Status:** executing\n`,
|
|
);
|
|
}
|
|
|
|
function smartEntrySeesFailureIn(tmpDir, dirs, failingDirs) {
|
|
for (const d of dirs) {
|
|
// Every directory always gets a summary — a passing one where the failure
|
|
// is not planted. Deleting instead would let "resolved a dir with no
|
|
// artifact" pass for the same reason as "resolved the right dir".
|
|
const summary = path.join(tmpDir, '.planning', 'phases', d, 'SUMMARY.md');
|
|
fs.writeFileSync(summary, failingDirs.includes(d) ? FAILED_SUMMARY : PASSED_SUMMARY);
|
|
}
|
|
return detectSignals(tmpDir).verify_failed;
|
|
}
|
|
|
|
// Each scenario: phase dirs on disk, the user's bare input, and the expected
|
|
// resolution ('10-24-7-autonomy' → that dir; null → not found; 'AMBIGUOUS' →
|
|
// every path must surface the ambiguity instead of silently picking one).
|
|
const SCENARIOS = [
|
|
{
|
|
name: '#2528 tokenizer fix: 2-digit slug word + 1-digit word ("24/7 Autonomy")',
|
|
dirs: ['10-24-7-autonomy', '11-other'],
|
|
query: '10',
|
|
expect: '10-24-7-autonomy',
|
|
},
|
|
{
|
|
name: '#2528 bare-integer fallback: 2-digit slug run with non-digit tail ("80/20 Cleanup")',
|
|
dirs: ['05-80-20-cleanup', '11-other'],
|
|
query: '5',
|
|
expect: '05-80-20-cleanup',
|
|
},
|
|
{
|
|
name: '#2528 bare-integer fallback: "12-Factor Refactor"',
|
|
dirs: ['30-12-factor-refactor'],
|
|
query: '30',
|
|
expect: '30-12-factor-refactor',
|
|
},
|
|
{
|
|
name: '#2528 prefixed fallback preserves phase number and phase name boundaries',
|
|
dirs: ['MEM-05-80-20-cleanup'],
|
|
query: '5',
|
|
expect: 'MEM-05-80-20-cleanup',
|
|
expectPhaseNumber: 'MEM-05',
|
|
expectPhaseName: '80-20-cleanup',
|
|
},
|
|
{
|
|
name: '#2232 regression stays green: year-leading slug',
|
|
dirs: ['14-2026-photos-performance'],
|
|
query: '14',
|
|
expect: '14-2026-photos-performance',
|
|
},
|
|
{
|
|
name: '#2043 regression stays green: 1-digit slug word',
|
|
dirs: ['46-6-rs-pipeline-orchestrator'],
|
|
query: '46',
|
|
expect: '46-6-rs-pipeline-orchestrator',
|
|
},
|
|
{
|
|
name: 'genuine sub-phase is still resolvable by its full id',
|
|
dirs: ['10-24-setup'],
|
|
query: '10-24',
|
|
expect: '10-24-setup',
|
|
},
|
|
{
|
|
// #2528 re-review: the regression pin. A genuine sub-phase whose slug starts
|
|
// with a bare digit ("7-Zip Integration") is string-identical to a phase
|
|
// named "24/7 Autonomy", and must stay resolvable by its OWN id on every
|
|
// path — the property an earlier tokenizer-side rewind silently broke.
|
|
name: 'a sub-phase with a digit-leading slug resolves by its full id',
|
|
dirs: ['10-24-7-zip'],
|
|
query: '10-24',
|
|
expect: '10-24-7-zip',
|
|
},
|
|
{
|
|
// The fallback is strictly second: a directory that carries the number in
|
|
// its token wins outright, and the digit-leading NAME is not a rival
|
|
// candidate for it. (Fallback-vs-fallback collisions DO go ambiguous — see
|
|
// the next scenario.)
|
|
name: 'a primary token match is never shadowed by a digit-leading phase name',
|
|
dirs: ['10-24-7-autonomy', '10-second'],
|
|
query: '10',
|
|
expect: '10-second',
|
|
},
|
|
{
|
|
name: 'fallback collisions are ambiguous, never a silent first match',
|
|
dirs: ['05-80-20-a', '05-90-till-late'],
|
|
query: '5',
|
|
expect: 'AMBIGUOUS',
|
|
},
|
|
{
|
|
name: 'a missing phase stays not-found on every path',
|
|
dirs: ['10-24-7-autonomy'],
|
|
query: '99',
|
|
expect: null,
|
|
},
|
|
];
|
|
|
|
describe('#2528 resolution-path parity — locator / find-phase / phase-plan-index / smart-entry', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
tmpDir = null;
|
|
});
|
|
|
|
for (const { name, dirs, query, expect, expectPhaseNumber, expectPhaseName } of SCENARIOS) {
|
|
test(name, () => {
|
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
for (const d of dirs) {
|
|
const dir = path.join(phasesDir, d);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
// One canonical plan per dir so a resolved phase-plan-index proves it
|
|
// actually read the directory (plans: [] was the reported symptom).
|
|
const leadingDigits = d.match(/^\d+/);
|
|
const padded = leadingDigits ? leadingDigits[0] : '01';
|
|
fs.writeFileSync(path.join(dir, `${padded}-01-PLAN.md`), '---\nwave: 1\n---\n');
|
|
}
|
|
|
|
// ── Path 1: the shared locator (findPhaseInternal → searchPhaseInDir) ─
|
|
const located = findPhaseInternal(tmpDir, query);
|
|
const locatorDir =
|
|
located && located.found ? path.basename(located.directory) : null;
|
|
const locatorAmbiguous = Boolean(located && located.ambiguous_matches);
|
|
|
|
// ── Path 2: find-phase ────────────────────────────────────────────────
|
|
const findRes = runGsdTools(`find-phase ${query}`, tmpDir);
|
|
assert.ok(findRes.success, `find-phase failed: ${findRes.error}`);
|
|
const findOut = JSON.parse(findRes.output);
|
|
const findDir = findOut.found ? path.basename(findOut.directory) : null;
|
|
const findAmbiguous = Boolean(findOut.ambiguous_matches);
|
|
|
|
// ── Path 3: phase-plan-index ──────────────────────────────────────────
|
|
const idxRes = runGsdTools(`phase-plan-index ${query}`, tmpDir);
|
|
assert.ok(idxRes.success, `phase-plan-index failed: ${idxRes.error}`);
|
|
const idxOut = JSON.parse(idxRes.output);
|
|
const idxAmbiguous = Boolean(idxOut.ambiguous_matches);
|
|
const idxResolved = !idxOut.error && idxOut.plans.length > 0;
|
|
|
|
// ── Path 4: smart-entry (detectSignals → detectVerifyFailed) ──────────
|
|
// Not a resolution API — it answers "did the current phase fail
|
|
// verification". But it resolves the same directory from the same bare
|
|
// input, and a miss here is SILENT: an unresolved phase reports
|
|
// "not failed", which is byte-identical to a healthy phase. That is why
|
|
// it belongs in this gate and not merely in its own unit test.
|
|
writeState(tmpDir, query);
|
|
|
|
if (expect === 'AMBIGUOUS') {
|
|
assert.ok(locatorAmbiguous, 'locator must surface ambiguity');
|
|
assert.ok(findAmbiguous, 'find-phase must surface ambiguity');
|
|
assert.ok(idxAmbiguous, 'phase-plan-index must surface ambiguity');
|
|
assert.deepStrictEqual(
|
|
[...(located.ambiguous_matches || [])].sort(),
|
|
[...(findOut.ambiguous_matches || [])].sort(),
|
|
'locator and find-phase must list the same candidates',
|
|
);
|
|
assert.deepStrictEqual(
|
|
[...(findOut.ambiguous_matches || [])].sort(),
|
|
[...(idxOut.ambiguous_matches || [])].sort(),
|
|
'find-phase and phase-plan-index must list the same candidates',
|
|
);
|
|
// Path 4 deliberately does NOT fail loud on ambiguity: it is a routing
|
|
// signal with no way to ask the user, so it keeps the first candidate
|
|
// in the already-sorted list, exactly as its prior `.find()` did. What
|
|
// parity still requires is that it picks from the SAME candidate set —
|
|
// so a failure in any ambiguous candidate must be reachable, and a
|
|
// failure outside the set must not be.
|
|
const candidates = [...(located.ambiguous_matches || [])].map((c) => path.basename(c));
|
|
assert.ok(
|
|
smartEntrySeesFailureIn(tmpDir, dirs, candidates),
|
|
'smart-entry must resolve into the ambiguous candidate set',
|
|
);
|
|
const outsiders = dirs.filter((d) => !candidates.includes(d));
|
|
if (outsiders.length > 0) {
|
|
assert.ok(
|
|
!smartEntrySeesFailureIn(tmpDir, dirs, outsiders),
|
|
'smart-entry must not resolve to a directory outside the candidate set',
|
|
);
|
|
}
|
|
} else if (expect === null) {
|
|
assert.strictEqual(locatorDir, null, 'locator must report not-found');
|
|
assert.strictEqual(findDir, null, 'find-phase must report not-found');
|
|
assert.strictEqual(idxOut.error, 'Phase not found', 'phase-plan-index must report not-found');
|
|
assert.ok(
|
|
!smartEntrySeesFailureIn(tmpDir, dirs, dirs),
|
|
'smart-entry must report not-found too — a failing artifact in every '
|
|
+ 'directory must still not be attributed to an unresolvable phase',
|
|
);
|
|
} else {
|
|
assert.strictEqual(locatorDir, expect, 'locator resolved the wrong dir');
|
|
if (expectPhaseNumber) {
|
|
assert.strictEqual(located.phase_number, expectPhaseNumber);
|
|
assert.strictEqual(located.phase_name, expectPhaseName);
|
|
}
|
|
assert.strictEqual(findDir, expect, 'find-phase resolved the wrong dir');
|
|
assert.ok(
|
|
idxResolved,
|
|
`phase-plan-index must resolve and index plans, got: ${idxRes.output}`,
|
|
);
|
|
assert.ok(
|
|
smartEntrySeesFailureIn(tmpDir, dirs, [expect]),
|
|
`smart-entry resolved a different dir — it did not see the failure planted in ${expect}`,
|
|
);
|
|
for (const other of dirs.filter((d) => d !== expect)) {
|
|
assert.ok(
|
|
!smartEntrySeesFailureIn(tmpDir, dirs, [other]),
|
|
`smart-entry resolved ${other} instead of ${expect}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── #2528 consumer parity ───────────────────────────────────────────────────
|
|
/**
|
|
* The four paths above are the resolution APIs. Review found eight further call
|
|
* sites that had each re-implemented the same "resolve a phase directory from a
|
|
* bare number" step by hand — `dirs.find/some(d => phaseTokenMatches(d, n))` —
|
|
* and so reproduced the #2528 symptom in full even after the owner existed.
|
|
*
|
|
* They are covered here rather than in their own files because the failure this
|
|
* gate exists to catch is not "command X is broken" but "a consumer stopped
|
|
* agreeing with the owner". Splitting them up is how the first four drifted.
|
|
*
|
|
* Every path is observed through the surface a user actually sees, never
|
|
* through the matcher:
|
|
*
|
|
* 1. `phases list --phase N` → `error: 'Phase not found'` vs listed files
|
|
* 2. `phase next-decimal N` → `found`
|
|
* 3. `phase remove N --force` → `directory_deleted`
|
|
* 4. `verify schema-drift N` → `Phase directory not found` message
|
|
* 5. `validate health` (W021) → milestone-complete-vs-roadmap consistency
|
|
* 6. `init manager` → the overview table's `disk_status`
|
|
* 7. `milestone complete vX` → the unstarted-phase completion guard
|
|
* 8. `roadmap analyze` → per-phase `disk_status`
|
|
*
|
|
* Paths 1-4 take the phase as a query. Paths 5-8 never see one: they walk the
|
|
* ROADMAP and ask the disk about each phase in turn, so their "query" is the
|
|
* roadmap heading and their answer is whether the phase looks started.
|
|
*/
|
|
|
|
const CONSUMER_SCENARIOS = [
|
|
{
|
|
name: '#2528 bare-integer fallback ("80/20 Cleanup")',
|
|
dirs: ['05-80-20-cleanup', '11-other'],
|
|
query: '5',
|
|
resolvesTo: '05-80-20-cleanup',
|
|
},
|
|
{
|
|
name: '#2528 tokenizer fix ("24/7 Autonomy")',
|
|
dirs: ['10-24-7-autonomy', '11-other'],
|
|
query: '10',
|
|
resolvesTo: '10-24-7-autonomy',
|
|
},
|
|
{
|
|
name: '#2528 bare-integer fallback ("12-Factor Refactor")',
|
|
dirs: ['30-12-factor-refactor'],
|
|
query: '30',
|
|
resolvesTo: '30-12-factor-refactor',
|
|
},
|
|
{
|
|
// Control. Without it every assertion below could be satisfied by a
|
|
// consumer that resolves unconditionally.
|
|
name: 'a phase with no directory stays unresolved on every consumer',
|
|
dirs: ['11-other'],
|
|
query: '99',
|
|
resolvesTo: null,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* #2528 re-review: the AMBIGUOUS row the rows above cannot express.
|
|
*
|
|
* Every scenario in CONSUMER_SCENARIOS is binary — a query either resolves to
|
|
* one directory or to none — so a query that resolves to TWO fell through the
|
|
* gate entirely. That gap is what let the destructive path regress unseen:
|
|
* `phase remove` took `matches[0]` while every guarded sibling refuses, turning
|
|
* "resolve nothing, delete nothing" at base into "delete one of two candidates,
|
|
* and renumber every phase after it".
|
|
*
|
|
* This is a fallback ambiguity specifically: neither directory's TOKEN is `05`
|
|
* (`05-80-20-a` tokenizes to `05-80-20`), so both are reached only by the
|
|
* bare-integer fallback this PR adds — i.e. the ambiguity is one this PR
|
|
* created, which is why the PR owes it a guard.
|
|
*/
|
|
const AMBIGUOUS_SCENARIO = {
|
|
dirs: ['05-80-20-a', '05-90-till-late'],
|
|
query: '5',
|
|
};
|
|
|
|
/**
|
|
* #2528 re-review: sub-phase-shaped directories, pinned in BOTH directions.
|
|
*
|
|
* `05-01-auth` is a genuine deep-decomposition directory for phase 5.1, and it
|
|
* has the same `NN-NN-<slug>` shape as `30-12-factor-refactor` (phase 30 named
|
|
* "12-Factor Refactor"). No rule over directory names alone separates them —
|
|
* "is the second segment a valid decimal sub-phase" accepts `5.1` and `30.12`
|
|
* equally — so the bare-integer fallback necessarily reaches both, and a bare
|
|
* `5` now resolves a lone `05-01-auth` where base found nothing.
|
|
*
|
|
* Both halves are pinned here because the docblock's claim about scope is only
|
|
* true of the QUERY side, and nothing previously observed the directory side:
|
|
* - one such directory → resolves, and the display number is the leading run
|
|
* - two such directories → ambiguous, and the destructive path deletes nothing
|
|
*/
|
|
const SUBPHASE_DIRS = ['05-01-auth', '05-02-api'];
|
|
|
|
describe('#2528 consumer parity — the eight sites migrated to matchPhaseDirs', () => {
|
|
const projects = [];
|
|
|
|
afterEach(() => {
|
|
for (const dir of projects.splice(0)) cleanup(dir);
|
|
});
|
|
|
|
// Each mutating path needs its own project: `phase remove` deletes and
|
|
// renumbers, `milestone complete` archives the whole phases tree.
|
|
function project(dirs, roadmapPhase, status = 'executing') {
|
|
const tmpDir = createTempProject();
|
|
projects.push(tmpDir);
|
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
for (const d of dirs) {
|
|
const dir = path.join(phasesDir, d);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const padded = (d.match(/^\d+/) || ['01'])[0];
|
|
fs.writeFileSync(path.join(dir, `${padded}-01-PLAN.md`), '---\nwave: 1\n---\n');
|
|
}
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n\n## Phase ${roadmapPhase}: Target\n`,
|
|
);
|
|
// `milestone:` is load-bearing: milestone-complete only runs its
|
|
// unstarted-phase guard when STATE names the version being completed.
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`---\nstatus: ${status}\nmilestone: v1.0\ntotal_phases: 99\ncurrent_phase: ${roadmapPhase}\n---\n\n# State\n\n**Status:** ${status}\n`,
|
|
);
|
|
return tmpDir;
|
|
}
|
|
|
|
function json(cmd, cwd) {
|
|
const res = runGsdTools(cmd, cwd);
|
|
assert.ok(res.success, `${cmd} failed: ${res.error}`);
|
|
return JSON.parse(res.output);
|
|
}
|
|
|
|
for (const { name, dirs, query, resolvesTo } of CONSUMER_SCENARIOS) {
|
|
const resolves = resolvesTo !== null;
|
|
|
|
test(`${name} — query-driven consumers`, () => {
|
|
const tmpDir = project(dirs, query);
|
|
|
|
// 1. phases list
|
|
const listed = json(`phases list --phase ${query} --type plans`, tmpDir);
|
|
if (resolves) {
|
|
assert.ok(!listed.error, `phases list: ${listed.error}`);
|
|
assert.deepStrictEqual(
|
|
listed.files,
|
|
[`${resolvesTo.match(/^\d+/)[0]}-01-PLAN.md`],
|
|
'phases list resolved a different directory',
|
|
);
|
|
} else {
|
|
assert.strictEqual(listed.error, 'Phase not found');
|
|
}
|
|
|
|
// 2. phase next-decimal — `found` is the base-phase existence check
|
|
assert.strictEqual(
|
|
json(`phase next-decimal ${query}`, tmpDir).found,
|
|
resolves,
|
|
'next-decimal disagreed on whether the base phase exists',
|
|
);
|
|
|
|
// 3. verify schema-drift
|
|
const drift = json(`verify schema-drift ${query}`, tmpDir);
|
|
assert.strictEqual(
|
|
drift.message === `Phase directory not found: ${query}`,
|
|
!resolves,
|
|
`schema-drift disagreed: ${drift.message}`,
|
|
);
|
|
|
|
// 4. phase remove — mutating, so it runs last and on its own project
|
|
const removeProject = project(dirs, query);
|
|
assert.strictEqual(
|
|
json(`phase remove ${query} --force`, removeProject).directory_deleted,
|
|
resolvesTo,
|
|
'phase remove deleted the wrong directory (or none)',
|
|
);
|
|
});
|
|
|
|
test(`${name} — roadmap-driven consumers`, () => {
|
|
// 5. validate health, W026 (Phase 11, #3309 — split off the
|
|
// pre-migration 'W021' site for this exact subject; the OTHER W021
|
|
// subject, phase_id_convention mismatch, kept its code): STATE must
|
|
// claim the milestone is done for the roadmap-vs-disk consistency
|
|
// check to run at all.
|
|
const health = json('validate health', project(dirs, query, 'milestone complete'));
|
|
const w026 = health.warnings.filter((w) => w.code === 'W026');
|
|
assert.strictEqual(
|
|
w026.length > 0,
|
|
!resolves,
|
|
`W026 disagreed on whether Phase ${query} is started: ${JSON.stringify(w026)}`,
|
|
);
|
|
|
|
const tmpDir = project(dirs, query);
|
|
|
|
// 6. init manager overview table
|
|
const manager = json('init manager', tmpDir);
|
|
const managed = manager.phases.find((p) => p.number === query);
|
|
assert.ok(managed, `init manager did not list Phase ${query}`);
|
|
assert.strictEqual(
|
|
managed.disk_status === 'no_directory',
|
|
!resolves,
|
|
'init manager disagreed on disk_status',
|
|
);
|
|
|
|
// 7. roadmap analyze
|
|
const analyzed = json('roadmap analyze', tmpDir).phases.find((p) => p.number === query);
|
|
assert.ok(analyzed, `roadmap analyze did not list Phase ${query}`);
|
|
assert.strictEqual(
|
|
analyzed.disk_status === 'no_directory',
|
|
!resolves,
|
|
'roadmap analyze disagreed on disk_status',
|
|
);
|
|
assert.strictEqual(
|
|
analyzed.disk_status,
|
|
managed.disk_status,
|
|
'roadmap analyze and init manager disagreed with each other',
|
|
);
|
|
|
|
// 8. milestone complete — mutating, own project. The guard blocks
|
|
// completion while any roadmap phase has no directory.
|
|
const completion = runGsdTools('milestone complete v1.0', project(dirs, query));
|
|
assert.strictEqual(
|
|
completion.success,
|
|
resolves,
|
|
`milestone-complete guard disagreed: ${completion.error || completion.output}`,
|
|
);
|
|
if (!resolves) {
|
|
assert.match(completion.error, /Cannot mark milestone complete/);
|
|
}
|
|
});
|
|
}
|
|
|
|
test('two directories claiming one bare phase number — the destructive path deletes neither', () => {
|
|
const { dirs, query } = AMBIGUOUS_SCENARIO;
|
|
const tmpDir = project(dirs, query);
|
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
|
|
const removed = json(`phase remove ${query} --force`, tmpDir);
|
|
|
|
assert.strictEqual(removed.directory_deleted, null, 'phase remove chose a directory');
|
|
assert.deepStrictEqual(
|
|
removed.ambiguous_matches,
|
|
dirs,
|
|
'phase remove did not surface both candidates',
|
|
);
|
|
assert.match(removed.error, /ambiguous/i);
|
|
|
|
// The load-bearing assertion: the refusal is about the FILESYSTEM, not the
|
|
// report. A `directory_deleted: null` printed after an `rmSync` would pass
|
|
// every check above.
|
|
assert.deepStrictEqual(
|
|
fs.readdirSync(phasesDir).sort(),
|
|
[...dirs].sort(),
|
|
'phase remove deleted a directory it reported refusing to choose',
|
|
);
|
|
assert.deepStrictEqual(removed.renamed_directories, [], 'phase remove renumbered anyway');
|
|
});
|
|
|
|
test('a lone sub-phase-shaped directory resolves, and its two-directory twin does not', () => {
|
|
const [first, second] = SUBPHASE_DIRS;
|
|
|
|
// One directory: the fallback reaches it, and the displayed number is the
|
|
// leading digit run — NOT the mis-absorbed `05-01` token.
|
|
const lone = findPhaseInternal(project([first], '5'), '5');
|
|
assert.ok(lone && lone.found, 'a lone sub-phase-shaped directory did not resolve');
|
|
assert.strictEqual(lone.phase_number, '05');
|
|
assert.strictEqual(lone.phase_name, '01-auth');
|
|
assert.strictEqual(path.basename(lone.directory), first);
|
|
|
|
// Two directories: the same shape is now ambiguous, and the destructive
|
|
// path must delete neither — this is the case the reviewer measured as
|
|
// "deletes 05-01-auth and renumbers 06-next → 05-next".
|
|
const tmpDir = project(SUBPHASE_DIRS, '5');
|
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
const removed = json('phase remove 5 --force', tmpDir);
|
|
assert.strictEqual(removed.directory_deleted, null);
|
|
assert.deepStrictEqual(removed.ambiguous_matches, [first, second]);
|
|
assert.deepStrictEqual(fs.readdirSync(phasesDir).sort(), [...SUBPHASE_DIRS].sort());
|
|
});
|
|
|
|
test('validate health pairs a digit-leading directory with its roadmap phase (W006/W007)', () => {
|
|
// #2528 re-review, the ninth site. W006/W007 resolve roadmap↔disk by
|
|
// intersecting token SETS, which is a dir→token labelling rather than the
|
|
// query→dir selection matchPhaseDirs owns — so the canonical fixture used
|
|
// to emit BOTH halves of the contradiction at once: "Phase 5 … no directory
|
|
// on disk" and "Phase 05-80-20 exists on disk but not in ROADMAP.md".
|
|
const codes = (dirs, roadmapPhase) => json('validate health', project(dirs, roadmapPhase))
|
|
.warnings.filter((w) => w.code === 'W006' || w.code === 'W007')
|
|
.map((w) => w.code)
|
|
.sort();
|
|
|
|
assert.deepStrictEqual(
|
|
codes(['05-80-20-cleanup'], '5'),
|
|
[],
|
|
'validate health still reports phase 5 as both missing and orphaned',
|
|
);
|
|
|
|
// Controls, so the assertion above cannot be satisfied by a check that
|
|
// stopped reporting anything: a roadmap phase with no directory at all must
|
|
// still raise W006, and a directory no roadmap phase resolves to must still
|
|
// raise W007.
|
|
assert.deepStrictEqual(codes(['07-orphan'], '5'), ['W006', 'W007']);
|
|
});
|
|
|
|
test('phase remove counts the surviving phases by identity, not by re-matching the query', () => {
|
|
// #2640 (landed on `next` while this branch was open) resyncs STATE.md's
|
|
// phase count after a removal by filtering `subdirs` for the directory that
|
|
// was deleted. Re-deriving that directory from the QUERY is a tenth site of
|
|
// the #2528 defect: the bare-integer fallback resolves `05-80-20-cleanup`
|
|
// for query `5`, but `phaseTokenMatches` (whose token is the mis-absorbed
|
|
// `05-80-20`) does not — so the just-deleted directory is counted as still
|
|
// present and the written total is one too high. `targetDir` is already the
|
|
// directory that was removed, so identity answers the question exactly.
|
|
const total = (dirs, query) => {
|
|
const tmpDir = project(dirs, query);
|
|
json(`phase remove ${query} --force`, tmpDir);
|
|
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
|
const m = state.match(/^Total Phases:\s*(\d+)/m);
|
|
assert.ok(m, 'phase remove did not resync a phase count into STATE.md');
|
|
return Number(m[1]);
|
|
};
|
|
|
|
assert.strictEqual(total(['05-80-20-cleanup', '11-other'], '5'), 1);
|
|
|
|
// Control: on a directory the tokenizer reads correctly, identity and token
|
|
// re-derivation agree — so the assertion above is about the digit-leading
|
|
// shape, not about the counting rule changing for everything.
|
|
assert.strictEqual(total(['05-cleanup', '11-other'], '5'), 1);
|
|
});
|
|
});
|