refactor(#3309): migrate cmdValidateHealth onto the rule table

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.
This commit is contained in:
sim
2026-08-13 02:28:49 -04:00
parent acc1a7abd6
commit d1760e3c31
11 changed files with 820 additions and 1182 deletions

View File

@@ -1,109 +1,11 @@
{
"$comment": "ADR-3180 §8.1 rule 2 ratchet, owned by Phase 11 (#3309). See scripts/lint-planning-snapshot-bypass-drift.cjs. SHRINK-ONLY: entries are removed as cmdValidateHealth migrates onto src/planning-snapshot.cts; new or changed entries fail lint:ci. `count` is the number of byte-identical (file, text) occurrences acknowledged at this site — a run producing fewer fails as a partial migration, more fails as an unacknowledged new copy.",
"entries": [
{
"file": "src/verify.cts",
"text": ".readdirSync(phasesDir, { withFileTypes: true })",
"derivation": "planning-snapshot-bypass",
"owner_issue": "#3309",
"count": 1
},
{
"file": "src/verify.cts",
"text": "? fs.readFileSync(milestonesPath, 'utf-8')",
"derivation": "planning-snapshot-bypass",
"owner_issue": "#3309",
"count": 2
},
{
"file": "src/verify.cts",
"text": "const archiveFiles = fs.readdirSync(milestonesArchiveDir);",
"derivation": "planning-snapshot-bypass",
"owner_issue": "#3309",
"count": 1
},
{
"file": "src/verify.cts",
"text": "const configRaw = fs.readFileSync(configPath, 'utf-8');",
"derivation": "planning-snapshot-bypass",
"owner_issue": "#3309",
"count": 4
},
{
"file": "src/verify.cts",
"text": "const content = fs.readFileSync(projectPath, 'utf-8');",
"derivation": "planning-snapshot-bypass",
"owner_issue": "#3309",
"count": 1
},
{
"file": "src/verify.cts",
"text": "const entries = fs.readdirSync(rootBase, { withFileTypes: true });",
"derivation": "planning-snapshot-bypass",
"owner_issue": "#3309",
"count": 1
},
{
"file": "src/verify.cts",
"text": "const rawCfg = fs.readFileSync(configPath, 'utf-8');",
"derivation": "planning-snapshot-bypass",
"owner_issue": "#3309",
"count": 1
},
{
"file": "src/verify.cts",
"text": "const researchContent = fs.readFileSync(",
"derivation": "planning-snapshot-bypass",
"owner_issue": "#3309",
"count": 1
},
{
"file": "src/verify.cts",
"text": "const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');",
"derivation": "planning-snapshot-bypass",
"owner_issue": "#3309",
"count": 1
},
{
"file": "src/verify.cts",
"text": "const roadmapContentFull = fs.readFileSync(roadmapPath, 'utf-8');",
"derivation": "planning-snapshot-bypass",
"owner_issue": "#3309",
"count": 1
},
{
"file": "src/verify.cts",
"text": "const roadmapContentRaw = fs.readFileSync(roadmapPath, 'utf-8');",
"derivation": "planning-snapshot-bypass",
"owner_issue": "#3309",
"count": 1
},
{
"file": "src/verify.cts",
"text": "const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');",
"derivation": "planning-snapshot-bypass",
"owner_issue": "#3309",
"count": 2
},
{
"file": "src/verify.cts",
"text": "const stateContent = fs.readFileSync(statePath, 'utf-8');",
"derivation": "planning-snapshot-bypass",
"owner_issue": "#3309",
"count": 2
},
{
"file": "src/verify.cts",
"text": "const stateRaw = fs.readFileSync(statePath, 'utf-8');",
"derivation": "planning-snapshot-bypass",
"owner_issue": "#3309",
"count": 1
},
{
"file": "src/verify.cts",
"text": "phaseDirFiles.set(e.name, fs.readdirSync(path.join(phasesDir, e.name)));",
"derivation": "planning-snapshot-bypass",
"owner_issue": "#3309",
"count": 1
}
]

View File

@@ -188,23 +188,13 @@ const OWNER_FILE = path.join('src', 'roadmap-parser.cts');
// question `computeMilestoneSectionEnd` answers — so it cannot diverge
// from that computation; it answers a narrower, different question this
// derivation does not own.
// - verify.cts checkMilestonePrefixMismatches: `sectionRx` ENUMERATES
// every milestone heading in the document to build a list of
// `{version, start, end}` sections (each section's `end` is provisionally
// "rest of document" until the NEXT heading is found, then backfilled) —
// it is answering "what are ALL the milestone sections", to check every
// phase against its OWN enclosing milestone, not "where does THIS ONE
// milestone (the current/asserted one) end" — `computeMilestoneSectionEnd`
// takes a single heading and returns a single boundary; this function
// never calls anything with that shape. (Design brief named this
// `cmdValidateConsistency` — the code actually lives in the sibling
// function `checkMilestonePrefixMismatches`, called from
// `cmdValidateHealth`; `cmdValidateConsistency` itself does not contain
// `sectionRx`. Exempted here under its ACTUAL containing function.) Also:
// `sectionRx` (`/^#{1,3}\s+(?:\[[^\]]{1,200}\]\s*)?.*v(\d+\.\d+)/gim`)
// does not itself carry token (b) as this guard defines it (no
// `(?!Phase` lookahead, no marker-emoji pairing) — this exemption
// currently documents intent rather than suppressing a live match.
// - (Phase 11, #3309: `verify.cts`'s pre-migration `checkMilestonePrefixMismatches`
// — formerly exempted here — was DELETED when `cmdValidateHealth` migrated
// onto the rule table; its `sectionRx` walk relocated verbatim into
// `planning-snapshot.cts`'s `buildRoadmapDeclaredPhasesField`, which needs
// no exemption of its own: like the deleted function, its `sectionRx`
// never carries token (b) as this guard defines it — no `(?!Phase`
// lookahead, no marker-emoji pairing — so it was never a live match.)
// - roadmap-parser.cts isMilestoneShippedInRoadmap: composes the heading
// quantifier with the shipped/active MARKER check (via
// isClosedMilestoneHeading) to answer "is THIS milestone version marked
@@ -235,9 +225,22 @@ const OWNER_FILE = path.join('src', 'roadmap-parser.cts');
// source span. It is a named canonical function defining the grammar,
// not a copy of it — replacing the third independent re-derivation the
// widened guard found at `roadmap.cts:454`.
// - planning-snapshot.cts buildMilestoneArchiveStatusField (Phase 11,
// #3309): its `## <version>` heading scan reads `MILESTONES.md` — a
// FLAT version registry, not `ROADMAP.md` — asking "which versions does
// the registry already document", never "where does THIS milestone's
// ROADMAP section begin/end" (`computeMilestoneSectionEnd`/
// `locateMilestoneHeadings`'s own question). A different document, a
// different question; not a re-derivation of ROADMAP windowing.
// - health-diagnostic.cts computeMissingMilestoneVersions (Phase 11,
// #3309): `applyRepairs` is not a `Rule` and is not handed a
// `PlanningSnapshot` (see that file's header comment), so
// `backfillMilestones` recomputes the IDENTICAL `MILESTONES.md`
// heading-membership check `buildMilestoneArchiveStatusField` already
// performs for the W018 rule's read side — same non-ROADMAP-windowing
// question as that function, for the same reason.
const FUNCTION_SCOPED_EXEMPTIONS = new Map([
[path.join('src', 'roadmap-command-router.cts'), new Set(['checkW021'])],
[path.join('src', 'verify.cts'), new Set(['checkMilestonePrefixMismatches'])],
[
OWNER_FILE,
new Set([
@@ -248,6 +251,8 @@ const FUNCTION_SCOPED_EXEMPTIONS = new Map([
'extractCurrentMilestoneScoped',
]),
],
[path.join('src', 'planning-snapshot.cts'), new Set(['buildMilestoneArchiveStatusField'])],
[path.join('src', 'health-diagnostic.cts'), new Set(['computeMissingMilestoneVersions'])],
]);
// Optional `export ` modifier, mirroring `lint-plan-count-drift.cjs`'s

View File

@@ -195,6 +195,20 @@
* spanning every milestone ever shipped — the union is a strict
* superset of any one milestone's window by design; scoping the live
* half would silently drop history the digest exists to preserve.
* - `src/planning-snapshot.cts` `buildAllPhaseDirNamesField` (Phase 11,
* #3309): the un-windowed twin of `phaseDirs`/`listMilestonePhaseDirs` —
* every directory actually present under the active `phases/` root,
* UNFILTERED by current-milestone-window membership. Backs the migrated
* `cmdValidateHealth`'s W007 rule ("an on-disk phase directory has no
* matching ROADMAP entry"): sourcing that check from the WINDOWED owner
* would make it structurally unable to fire on the exact orphan
* directory it exists to find (an orphan-by-definition can never be a
* member of a set defined as "directories the roadmap already
* declares") — see that field's own doc comment on `PlanningSnapshot`
* for the full, empirically-verified rationale. Same "must see the
* physical set by definition" shape as `collectDiskPhases`/
* `cmdValidateHealth` above, generalized from a raw `readdirSync` call
* site to a dedicated snapshot-builder function.
*
* The tree-walk / root-confinement / regex-literal-tokenizer / sanitizer
* machinery is SHARED with the sibling drift guards via
@@ -270,6 +284,7 @@ const FUNCTION_SCOPED_EXEMPTIONS = new Map([
[path.join('src', 'roadmap-upgrade.cts'), new Set(['computeMigrationPlan'])],
[path.join('src', 'smart-entry.cts'), new Set(['detectVerifyFailed'])],
[path.join('src', 'roadmap-parser.cts'), new Set(['getMilestonePhaseFilter'])],
[path.join('src', 'planning-snapshot.cts'), new Set(['buildAllPhaseDirNamesField'])],
]);
// Optional `export ` modifier, mirroring the sibling guards' function

View File

@@ -7,6 +7,7 @@
"config-field-docs.test.cjs",
"config-get-default.test.cjs",
"config-schema.property.test.cjs",
"config-validation.test.cjs",
"config.test.cjs"
],
"issue": "TBD"
@@ -33,6 +34,7 @@
},
"milestone": {
"files": [
"milestone-archive-hygiene.test.cjs",
"milestone-archive.test.cjs",
"milestone-helper.test.cjs",
"milestone-prefixed-convention.test.cjs",
@@ -48,12 +50,14 @@
"phase-completion-single-owner.test.cjs",
"phase-dependency-levels.test.cjs",
"phase-resolution-parity.test.cjs",
"phase-structure.test.cjs",
"phase.test.cjs"
],
"issue": "3186"
},
"roadmap": {
"files": [
"roadmap-disk-consistency.test.cjs",
"roadmap-mode-field.test.cjs",
"roadmap-phase-fallback.test.cjs",
"roadmap.test.cjs"
@@ -83,6 +87,7 @@
"files": [
"state-acquirestatelock-non-eexist.test.cjs",
"state-command-cutover.test.cjs",
"state-consistency.test.cjs",
"state-field-drift.test.cjs",
"state-prune.test.cjs",
"state-rebuild-cli.test.cjs",

View File

@@ -2,14 +2,24 @@
* Health Diagnostic — frozen rule-table types, enums, and evaluator for
* `validate health` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5).
*
* SKELETON (this phase). Establishes the exact contract every later batch of
* extracted rules builds onto: the frozen `SEVERITY`/`REMEDY_ACTION`/
* `REMEDY_RISK` enums, the `Diagnostic`/`Remedy`/`Rule` shapes, the `RULES`
* container (starts EMPTY — a later migration step appends the 32 rules
* extracted from `cmdValidateHealth`, `src/verify.cts:1616-2577`), the
* `evaluateRules` evaluator, and the `applyRepairs` `--repair`/`--backfill`
* dispatcher. `applyRepairs`'s per-action handlers are stubs in this phase —
* they land alongside the rules that need them.
* Establishes the exact contract every extracted rule builds onto: the
* frozen `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums, the
* `Diagnostic`/`Remedy`/`Rule` shapes, the `RULES` container (the 32 rules
* extracted from `cmdValidateHealth`, `src/verify.cts:1616-2577`, are
* concatenated in from each rule-group file under
* `src/health-diagnostic-rules/`), the `evaluateRules` evaluator, and the
* `applyRepairs` `--repair`/`--backfill` dispatcher — whose per-action
* handlers are REAL here (ported behavior-preserving from
* `verify.cts:2405-2553`'s repair switch), not stubs.
*
* `applyRepairs` does not receive a `PlanningSnapshot` (its call-site
* signature, `(cwd, diagnostics, repair, backfill)`, is a locked contract —
* see `tests/health-diagnostic.test.cjs`) — so, like `cmdValidateHealth`
* itself before this migration, it performs its own bounded filesystem I/O
* to apply a repair. This is not a §8.1 rule 1 violation: that rule
* constrains a RULE's `check(snapshot)` signature (no ambient I/O), not the
* evaluator/dispatcher, which the design doc's "subject-surface gap" section
* already establishes performs I/O once, up front, on the rules' behalf.
*
* `PlanningSnapshot` is deliberately NOT re-exported as a type from
* `planning-snapshot.cts` here (see the design doc's "Known limits" and this
@@ -25,6 +35,9 @@
* gsd-core/bin/lib/health-diagnostic.cjs (gitignored).
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- type-only; erased at compile time, no runtime require emitted
import type planningSnapshotMod = require('./planning-snapshot.cjs');
@@ -80,15 +93,36 @@ const RULES: Rule[] = [
...milestoneArchiveHygieneMod.RULES,
];
// ─── Repair-handler runtime dependencies ───────────────────────────────────
//
// Same owners `cmdValidateHealth`'s pre-migration repair switch used
// (`verify.cts:2405-2553`) — ported verbatim, not reinvented.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspaceMod = require('./planning-workspace.cjs');
const { planningRoot, planningDir } = planningWorkspaceMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configLoaderMod = require('./config-loader.cjs');
const { CONFIG_DEFAULTS } = configLoaderMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestoneInfo } = roadmapParserMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import stateMod = require('./state.cjs');
const { writeStateMd } = stateMod;
import { realClock } from './clock.cjs';
import { platformReadSync as safeReadFile, platformWriteSync } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
// ─── Evaluator ──────────────────────────────────────────────────────────────
/**
* Evaluate an explicit `rules` array against `snapshot`, throwing if any two
* entries share a `code` (defense in depth beside the future static lint
* guard, §8.2 rule 1). Separated from `evaluateRules` so the duplicate-code
* guard is unit-testable against a small, locally-constructed fake rule
* array, independent of whether `RULES` itself has any entries yet (it does
* not, in this skeleton).
* entries share a `code` (defense in depth beside the static lint guard,
* §8.2 rule 1, `scripts/lint-health-diagnostic-rule-table.cjs`). Separated
* from `evaluateRules` so the duplicate-code guard is unit-testable against
* a small, locally-constructed fake rule array, independent of the real
* `RULES` table.
*/
function evaluateRuleTable(rules: Rule[], snapshot: PlanningSnapshot): Diagnostic[] {
const seen = new Set<string>();
@@ -110,17 +144,231 @@ function evaluateRules(snapshot: PlanningSnapshot): Diagnostic[] {
}
// ─── Repair dispatcher ──────────────────────────────────────────────────────
// Repair-handler bodies (real, ported from verify.cts:2405-2553).
/**
* Stub repair handler. Real per-action handlers (`createConfig`,
* `resetConfig`, `regenerateState`, `addNyquistKey`,
* `addAiIntegrationPhaseKey`, `backfillMilestones`) land in a later
* migration batch alongside the rules that need them — see this phase's
* brief. Applying a NONE-risk remedy is a no-op beyond recording it, in this
* skeleton.
* One `repairs_performed`-shaped entry (legacy `cmdValidateHealth` output
* shape), tagged with the diagnostic `code` it came from so
* `applyRepairs`'s caller can build BOTH the code-keyed `applied`/`refused`
* arrays this module's own tests lock (`tests/health-diagnostic.test.cjs`)
* AND the action-keyed `repairs_performed` array `cmdValidateHealth` still
* emits. `code` is stripped by the caller before the entry reaches JSON
* output — the legacy shape never carried it.
*/
function applyStubRepair(_cwd: string, _diagnostic: Diagnostic): void {
/* intentionally empty — real handlers land with the rules that need them */
interface RepairDetail {
code: string;
action: string;
success: boolean;
path?: string;
detail?: string;
error?: string;
}
interface RepairPaths {
rootBase: string;
configPath: string;
statePath: string;
milestonesPath: string;
milestonesArchiveDir: string;
}
/**
* Derive every filesystem path a repair handler needs, from `cwd` alone —
* exactly how `cmdValidateHealth` derived them pre-migration
* (`verify.cts:1644-1652`/`2301-2302`). `config.json`/`MILESTONES.md`/
* `milestones/` are root-scoped (`planningRoot`); `STATE.md` is
* workstream-scoped (`planningDir`) — the same root-vs-workstream split
* `buildConfigField`/`buildStateFields` (`planning-snapshot.cts`) already
* document for the read side.
*/
function repairPaths(cwd: string): RepairPaths {
const rootBase = planningRoot(cwd);
const wsBase = planningDir(cwd);
return {
rootBase,
configPath: path.join(rootBase, 'config.json'),
statePath: path.join(wsBase, 'STATE.md'),
milestonesPath: path.join(rootBase, 'MILESTONES.md'),
milestonesArchiveDir: path.join(rootBase, 'milestones'),
};
}
/** `verify.cts:2413-2429`'s default config.json payload, ported verbatim. */
function defaultConfigPayload(): Record<string, unknown> {
return {
model_profile: CONFIG_DEFAULTS.model_profile,
commit_docs: CONFIG_DEFAULTS.commit_docs,
search_gitignored: CONFIG_DEFAULTS.search_gitignored,
branching_strategy: CONFIG_DEFAULTS.branching_strategy,
phase_branch_template: CONFIG_DEFAULTS.phase_branch_template,
milestone_branch_template: CONFIG_DEFAULTS.milestone_branch_template,
quick_branch_template: CONFIG_DEFAULTS.quick_branch_template,
workflow: {
research: CONFIG_DEFAULTS.research,
plan_check: CONFIG_DEFAULTS.plan_checker,
verifier: CONFIG_DEFAULTS.verifier,
nyquist_validation: CONFIG_DEFAULTS.nyquist_validation,
},
parallelization: CONFIG_DEFAULTS.parallelization,
brave_search: CONFIG_DEFAULTS.brave_search,
};
}
/**
* `verify.cts:2301-2335`'s W018 archived-vs-documented-versions diff,
* relocated verbatim (same two regexes, same two-file read) so
* `backfillMilestones` can recompute exactly which versions are missing
* without a `PlanningSnapshot` (`applyRepairs` is not a `Rule` and is not
* handed one — see this file's header comment). This is the same
* derivation `buildMilestoneArchiveStatusField`
* (`src/planning-snapshot.cts`) already performs for the W018 RULE's read
* side; recomputed here, not re-invented, because the rule's own
* `Diagnostic.remedy.args` carries no version list (confirmed by direct
* read of `src/health-diagnostic-rules/milestone-archive-hygiene.cts`).
*/
function computeMissingMilestoneVersions(milestonesArchiveDir: string, milestonesPath: string): string[] {
let archivedVersions: string[] = [];
try {
if (fs.existsSync(milestonesArchiveDir)) {
const archiveFiles = fs.readdirSync(milestonesArchiveDir);
archivedVersions = archiveFiles
.map((f) => f.match(/^(v\d+\.\d+(?:\.\d+)?)-ROADMAP\.md$/))
.filter((m): m is RegExpMatchArray => m !== null)
.map((m) => m[1]);
}
} catch {
/* intentionally empty — mirrors the original's advisory try/catch */
}
let documentedVersions: string[] = [];
try {
if (fs.existsSync(milestonesPath)) {
const registryContent = fs.readFileSync(milestonesPath, 'utf-8');
documentedVersions = [...registryContent.matchAll(/^##\s+(v\d+\.\d+(?:\.\d+)?)/gm)].map((m) => m[1]);
}
} catch {
/* intentionally empty */
}
const documented = new Set(documentedVersions);
return archivedVersions.filter((v) => !documented.has(v));
}
interface RepairOutcome {
success: boolean;
path?: string;
detail?: string;
error?: string;
// regenerateState's original backup step (verify.cts:2435-2440) pushed its
// own SEPARATE `repairActions` entry before the main one — preserved here
// as extra, prepended detail rows. Unreachable in practice today
// (regenerateState is DESTRUCTIVE and `applyRepairs`'s dispatcher below
// refuses it before this handler is ever invoked), but the handler stays
// complete rather than partially ported, per this batch's brief.
extraDetails?: { action: string; success: boolean; path?: string }[];
}
/**
* Execute exactly one real repair action, ported behavior-preserving from
* `verify.cts:2405-2553`'s `switch (repair)`. Throws are the caller's
* responsibility to catch (mirrors the original's per-action try/catch
* shape, collapsed to one seam here since every case now shares one
* caller).
*/
function runRepairAction(cwd: string, action: RemedyAction, paths: RepairPaths): RepairOutcome {
const { rootBase, configPath, statePath, milestonesPath, milestonesArchiveDir } = paths;
switch (action) {
case REMEDY_ACTION.CREATE_CONFIG:
case REMEDY_ACTION.RESET_CONFIG: {
platformWriteSync(configPath, JSON.stringify(defaultConfigPayload(), null, 2));
return { success: true, path: 'config.json' };
}
case REMEDY_ACTION.REGENERATE_STATE: {
const extraDetails: { action: string; success: boolean; path?: string }[] = [];
if (fs.existsSync(statePath)) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const backupPath = `${statePath}.bak-${timestamp}`;
fs.copyFileSync(statePath, backupPath);
extraDetails.push({ action: 'backupState', success: true, path: backupPath });
}
const milestone = getMilestoneInfo(cwd).value;
const projectRef = path
.relative(cwd, path.join(rootBase, 'PROJECT.md'))
.split(path.sep)
.join('/');
const slashRuntime = resolveRuntime(cwd);
const slash = (name: string) => formatGsdSlash(name, slashRuntime) as string;
let stateContent = `# Session State\n\n`;
stateContent += `## Project Reference\n\n`;
stateContent += `See: ${projectRef}\n\n`;
stateContent += `## Position\n\n`;
stateContent += `**Milestone:** ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n`;
stateContent += `**Current phase:** (determining...)\n`;
stateContent += `**Status:** Resuming\n\n`;
stateContent += `## Session Log\n\n`;
stateContent += `- ${realClock.localToday()}: STATE.md regenerated by ${slash('health')} --repair\n`;
writeStateMd(statePath, stateContent, cwd);
return { success: true, path: 'STATE.md', extraDetails };
}
case REMEDY_ACTION.ADD_NYQUIST_KEY:
case REMEDY_ACTION.ADD_AI_INTEGRATION_PHASE_KEY: {
const key = action === REMEDY_ACTION.ADD_NYQUIST_KEY ? 'nyquist_validation' : 'ai_integration_phase';
const configRaw = fs.readFileSync(configPath, 'utf-8');
const configParsed = JSON.parse(configRaw) as Record<string, unknown>;
if (!configParsed['workflow']) configParsed['workflow'] = {};
const wf = configParsed['workflow'] as Record<string, unknown>;
if (wf[key] === undefined) {
wf[key] = true;
platformWriteSync(configPath, JSON.stringify(configParsed, null, 2));
}
return { success: true, path: 'config.json' };
}
case REMEDY_ACTION.BACKFILL_MILESTONES: {
const missing = computeMissingMilestoneVersions(milestonesArchiveDir, milestonesPath);
const today = realClock.localToday();
const slashRuntime = resolveRuntime(cwd);
const slash = (name: string) => formatGsdSlash(name, slashRuntime) as string;
let backfilled = 0;
for (const ver of missing) {
try {
const snapshotPath = path.join(milestonesArchiveDir, `${ver}-ROADMAP.md`);
const snapshot = safeReadFile(snapshotPath);
const titleMatch = snapshot && snapshot.match(/^#\s+(.+)$/m);
const milestoneName = titleMatch
? titleMatch[1].replace(/^Milestone\s+/i, '').replace(/^v[\d.]+\s*/, '').trim()
: ver;
const entry =
`## ${ver}${milestoneName && milestoneName !== ver ? ` ${milestoneName}` : ''} (Backfilled: ${today})\n\n**Note:** Synthesized from archive snapshot by \`${slash('health')} --backfill\`. Original completion date unknown.\n\n---\n\n`;
const milestonesContent = fs.existsSync(milestonesPath)
? fs.readFileSync(milestonesPath, 'utf-8')
: '';
if (!milestonesContent.trim()) {
platformWriteSync(milestonesPath, `# Milestones\n\n${entry}`);
} else {
const headerMatch = milestonesContent.match(/^(#{1,3}\s+[^\n]*\n\n?)/);
if (headerMatch) {
const header = headerMatch[1];
const rest = milestonesContent.slice(header.length);
platformWriteSync(milestonesPath, header + entry + rest);
} else {
platformWriteSync(milestonesPath, entry + milestonesContent);
}
}
backfilled++;
} catch {
/* intentionally empty — partial backfill is acceptable */
}
}
return { success: true, detail: `Backfilled ${backfilled} milestone(s) into MILESTONES.md` };
}
default:
return { success: false, error: `no repair handler registered for action "${action}"` };
}
}
/**
@@ -136,21 +384,33 @@ function applyStubRepair(_cwd: string, _diagnostic: Diagnostic): void {
* - Requested and `remedy.risk === DESTRUCTIVE` — pushed onto `refused`,
* handler never invoked. This is the §8.3 rule 3 breaking-change
* enforcement point: a DESTRUCTIVE remedy is describable but is never
* applied by `--repair`.
* - Requested and `remedy.risk === NONE` — stub handler invoked, pushed
* onto `applied`.
* applied by `--repair`. A `details` row is still recorded, so the
* refusal is VISIBLE in `cmdValidateHealth`'s `repairs_performed` output,
* not silently dropped.
* - Requested and `remedy.risk === NONE` — the real handler is invoked,
* pushed onto `applied`.
*
* `applied`/`refused` are unchanged in shape from the pre-existing skeleton
* (locked by `tests/health-diagnostic.test.cjs`, rows 11-12): arrays of
* diagnostic `code`s. `details` is ADDITIVE — every real action maps 1:1 to
* exactly one code in this rule table (confirmed: no `REMEDY_ACTION` other
* than `ADVISE` is used by more than one rule), so `cmdValidateHealth` can
* rebuild the legacy action-keyed `repairs_performed` shape directly from
* it.
*/
function applyRepairs(
cwd: string,
diagnostics: Diagnostic[],
repair: boolean,
backfill: boolean,
): { applied: string[]; refused: string[] } {
): { applied: string[]; refused: string[]; details: RepairDetail[] } {
const applied: string[] = [];
const refused: string[] = [];
const details: RepairDetail[] = [];
const paths = repairPaths(cwd);
for (const diagnostic of diagnostics) {
const { remedy } = diagnostic;
const { remedy, code } = diagnostic;
if (remedy.action === REMEDY_ACTION.ADVISE) continue;
const requested =
@@ -158,15 +418,43 @@ function applyRepairs(
if (!requested) continue;
if (remedy.risk === REMEDY_RISK.DESTRUCTIVE) {
refused.push(diagnostic.code);
refused.push(code);
details.push({
code,
action: remedy.action,
success: false,
error: `refused: '${remedy.action}' is a destructive remedy and is not auto-applied by --repair`,
});
continue;
}
applyStubRepair(cwd, diagnostic);
applied.push(diagnostic.code);
try {
const outcome = runRepairAction(cwd, remedy.action, paths);
if (outcome.extraDetails) {
for (const extra of outcome.extraDetails) {
details.push({ code, action: extra.action, success: extra.success, ...(extra.path ? { path: extra.path } : {}) });
}
}
details.push({
code,
action: remedy.action,
success: outcome.success,
...(outcome.path ? { path: outcome.path } : {}),
...(outcome.detail ? { detail: outcome.detail } : {}),
...(outcome.error ? { error: outcome.error } : {}),
});
} catch (err) {
details.push({
code,
action: remedy.action,
success: false,
error: err instanceof Error ? err.message : String(err),
});
}
applied.push(code);
}
return { applied, refused };
return { applied, refused, details };
}
// ─── Exports ────────────────────────────────────────────────────────────────

File diff suppressed because it is too large Load Diff

View File

@@ -6,25 +6,26 @@
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
* Test matrix: .gsd/phase/refactor-3309-health-diagnostic-rule-table/50-test-matrix.md
*
* This file covers ONLY the skeleton's own contract — test-matrix section 2,
* rows 9-14. `RULES` starts EMPTY in this phase (later batches append the 32
* extracted rules); rows 15-16 (the DESTRUCTIVE-refusal proof against REAL
* diagnostics emitted by real rules) and section 3 (per-rule fixtures) are
* deferred to the migration step that adds rules. This file DOES prove
* `applyRepairs`'s risk-gating logic directly against hand-constructed fake
* `Diagnostic` objects, independent of whether any real rule produces them
* yet — per this phase's brief.
*
* TDD RED: `src/health-diagnostic.cts` does not exist yet — this file's
* `require('../gsd-core/bin/lib/health-diagnostic.cjs')` throws
* MODULE_NOT_FOUND until this phase's implementation lands. That is the
* intended starting state.
* Covers test-matrix section 2 (rows 9-16) against the FULLY WIRED rule
* table (`RULES` now carries all 31 rules — see the "RULES" describe block
* below for the exact count and why it is 31, not 32 — extracted from
* `cmdValidateHealth`, `src/verify.cts:1616-2577`). Rows 15-16 (the
* DESTRUCTIVE-refusal proof and the NONE-risk apply proof) run against REAL
* diagnostics emitted by REAL rules over a REAL `buildPlanningSnapshot`
* projection of a temp fixture, not hand-constructed fakes — the
* hand-constructed-fake coverage (rows 11-12 below) is kept alongside it
* since it exercises `applyRepairs`'s gating logic in isolation from any
* particular rule's shape.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const healthDiagnostic = require('../gsd-core/bin/lib/health-diagnostic.cjs');
const { buildPlanningSnapshot } = require('../gsd-core/bin/lib/planning-snapshot.cjs');
const { createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
const {
SEVERITY,
@@ -36,6 +37,50 @@ const {
applyRepairs,
} = healthDiagnostic;
// ─── Shared fixture helpers (mirror tests/orphan-worktree-detection.test.cjs's
// setupHealthyProject, the proven-healthy recipe for the pre-migration
// cmdValidateHealth) ────────────────────────────────────────────────────────
function writeMinimalProjectMd(tmpDir) {
const sections = ['## What This Is', '## Core Value', '## Requirements'];
const content = sections.map((s) => `${s}\n\nContent here.\n`).join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), `# Project\n\n${content}`);
}
function writeMinimalRoadmap(tmpDir) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 1: Setup\n');
}
function writeMinimalStateMd(tmpDir) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Session State\n\n## Current Position\n\nPhase: 1\n',
);
}
function writeValidConfigJson(tmpDir) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify(
{
model_profile: 'balanced',
commit_docs: true,
workflow: { nyquist_validation: true, ai_integration_phase: true },
},
null,
2,
),
);
}
function setupHealthyProject(tmpDir) {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir);
writeMinimalStateMd(tmpDir);
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true });
}
// ─── Row 9 — REMEDY_ACTION locks exactly 7 members ─────────────────────────
describe('REMEDY_ACTION', () => {
@@ -171,9 +216,9 @@ describe('applyRepairs — risk gating (hand-constructed diagnostics)', () => {
// ─── Row 13 — duplicate-code detection, LOCAL fake rule array ──────────────
//
// `RULES` is still empty in this skeleton, so the duplicate check cannot be
// exercised through the real exported table yet. Proven here instead against
// a small, locally-constructed fake rule array — per this phase's brief.
// Proven against a small, locally-constructed fake rule array — independent
// of the real `RULES` table's own (already-unique, see the "RULES" describe
// block below) codes, so this guard's logic is covered in isolation.
describe('evaluateRuleTable — duplicate-code guard (row 13)', () => {
test('throws when two rules share the same code', () => {
@@ -212,15 +257,97 @@ describe('evaluateRuleTable — duplicate-code guard (row 13)', () => {
});
});
// ─── Row 14 — evaluator against an all-clean (here: rule-less) snapshot ───
// ─── RULES — the fully wired table ──────────────────────────────────────────
//
// 31 rule entries, not the design doc's own prose figure of "32" (that doc's
// "Rule table organization" section already flags its own count as
// inconsistent between its table and prose — see this repo's design doc,
// same section). Counted directly from each rule-group file's own exported
// `RULES` array: root-existence (4: E002/E003/E004/W001) + state-consistency
// (5: W024/W002/W011/W021/W026) + config-validation (10: W003/E005/W004/
// W008/W016/W012/W013/W014/W015/W022) + phase-structure (4: W005/W023/I001/
// W009) + agent-install (1: W010) + roadmap-disk-consistency (2: W006/W007)
// + worktree-health (3: W020/W017/W027) + milestone-archive-hygiene (2:
// W018/W019) = 31. E001 and the home-directory guard (E010/I010) are
// deliberately NOT rows (design doc, "Two guards that stay OUTSIDE the rule
// table entirely").
describe('RULES', () => {
test('is the full, frozen 31-rule table with every code unique', () => {
assert.equal(Array.isArray(RULES), true);
assert.equal(RULES.length, 31);
const codes = RULES.map((r) => r.code);
assert.equal(new Set(codes).size, codes.length, 'every rule code must be unique');
});
test('every rule carries a code, severity, and check function', () => {
for (const rule of RULES) {
assert.equal(typeof rule.code, 'string');
assert.ok(Object.values(SEVERITY).includes(rule.severity), `${rule.code}: unknown severity ${rule.severity}`);
assert.equal(typeof rule.check, 'function');
}
});
});
// ─── Row 14 — evaluator against an all-clean REAL snapshot ────────────────
describe('evaluateRules (row 14)', () => {
test('RULES starts empty in this skeleton', () => {
assert.deepEqual(RULES, []);
assert.equal(Array.isArray(RULES), true);
test('evaluateRules(buildPlanningSnapshot(healthyProject)) returns []', (t) => {
const tmpDir = createTempGitProject();
t.after(() => cleanup(tmpDir));
setupHealthyProject(tmpDir);
const snapshot = buildPlanningSnapshot(tmpDir);
const diagnostics = evaluateRules(snapshot);
assert.deepEqual(diagnostics, [], `expected zero diagnostics for a healthy project, got: ${JSON.stringify(diagnostics)}`);
});
});
test('returns [] against any snapshot, since RULES is empty', () => {
assert.deepEqual(evaluateRules({}), []);
// ─── Rows 15-16 — applyRepairs against REAL diagnostics from REAL rules ────
describe('applyRepairs — REAL diagnostics (rows 15-16)', () => {
test('row 15: --repair given a real DESTRUCTIVE E004 finding (STATE.md missing) refuses regenerateState; STATE.md stays absent', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
setupHealthyProject(tmpDir);
fs.unlinkSync(path.join(tmpDir, '.planning', 'STATE.md'));
const snapshot = buildPlanningSnapshot(tmpDir);
const diagnostics = evaluateRules(snapshot);
const e004 = diagnostics.find((d) => d.code === 'E004');
assert.ok(e004, `expected E004 when STATE.md is missing, got: ${JSON.stringify(diagnostics)}`);
assert.equal(e004.remedy.action, REMEDY_ACTION.REGENERATE_STATE);
assert.equal(e004.remedy.risk, REMEDY_RISK.DESTRUCTIVE);
const result = applyRepairs(tmpDir, diagnostics, true, false);
assert.ok(!result.applied.includes('E004'), 'E004 must not be applied');
assert.ok(result.refused.includes('E004'), 'E004 must be refused');
assert.equal(
fs.existsSync(path.join(tmpDir, '.planning', 'STATE.md')),
false,
'STATE.md must remain absent — the DESTRUCTIVE remedy is refused, not silently applied',
);
});
test('row 16: --repair given a real NONE-risk W003 finding (config.json missing) applies createConfig, exactly as pre-migration', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
setupHealthyProject(tmpDir);
fs.unlinkSync(path.join(tmpDir, '.planning', 'config.json'));
const snapshot = buildPlanningSnapshot(tmpDir);
const diagnostics = evaluateRules(snapshot);
const w003 = diagnostics.find((d) => d.code === 'W003');
assert.ok(w003, `expected W003 when config.json is missing, got: ${JSON.stringify(diagnostics)}`);
assert.equal(w003.remedy.action, REMEDY_ACTION.CREATE_CONFIG);
assert.equal(w003.remedy.risk, REMEDY_RISK.NONE);
const result = applyRepairs(tmpDir, diagnostics, true, false);
assert.ok(result.applied.includes('W003'), 'W003 must be applied');
assert.ok(!result.refused.includes('W003'), 'W003 must not be refused');
const configPath = path.join(tmpDir, '.planning', 'config.json');
assert.ok(fs.existsSync(configPath), 'config.json should now exist on disk');
const diskConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(diskConfig.model_profile, 'balanced');
});
});

View File

@@ -439,14 +439,17 @@ describe('#2528 consumer parity — the eight sites migrated to matchPhaseDirs',
});
test(`${name} — roadmap-driven consumers`, () => {
// 5. validate health, W021: STATE must claim the milestone is done for
// the roadmap-vs-disk consistency check to run at all.
// 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 w021 = health.warnings.filter((w) => w.code === 'W021');
const w026 = health.warnings.filter((w) => w.code === 'W026');
assert.strictEqual(
w021.length > 0,
w026.length > 0,
!resolves,
`W021 disagreed on whether Phase ${query} is started: ${JSON.stringify(w021)}`,
`W026 disagreed on whether Phase ${query} is started: ${JSON.stringify(w026)}`,
);
const tmpDir = project(dirs, query);

View File

@@ -2822,9 +2822,14 @@ describe('bug #557 — <details>/<summary> active milestone strip', () => {
);
});
// ── Health check W021: milestone_complete vs unstarted phases ─────────────
// ── Health check W026: milestone_complete vs unstarted phases ─────────────
// Phase 11 (#3309): this subject moved off the pre-migration 'W021' code
// onto the new 'W026' code (the split-off half of the two-subject
// conflation the design doc's "New codes for the two split subjects"
// section documents) — the OTHER W021 subject, phase_id_convention
// mismatch, kept its code.
test('validate health emits W021 when STATE says milestone complete but ROADMAP has unstarted phases', () => {
test('validate health emits W026 when STATE says milestone complete but ROADMAP has unstarted phases', () => {
const planning = path.join(tmpDir, '.planning');
// ROADMAP still has active phases in it
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
@@ -2849,12 +2854,20 @@ Phase: Milestone v1.3 complete
const output = JSON.parse(result.output);
const warnings = output.warnings || [];
const w021 = warnings.find(w => w.code === 'W021');
const w026 = warnings.find(w => w.code === 'W026');
assert.ok(
w021 !== undefined,
`Expected W021 warning (milestone-status vs. roadmap-progress incoherence). ` +
w026 !== undefined,
`Expected W026 warning (milestone-status vs. roadmap-progress incoherence). ` +
`Got warnings: ${JSON.stringify(warnings.map(w => w.code))}`
);
// W021/W026 independence (Phase 11, #3309 split): this fixture's subject
// is the W026 one (milestone-complete vs. unstarted phases) — it must
// NOT also produce a W021 (phase_id_convention mismatch, an unrelated
// subject this config.json-less fixture never triggers).
assert.ok(
warnings.every(w => w.code !== 'W021'),
`W026 fixture must not also fire W021: ${JSON.stringify(warnings.map(w => w.code))}`
);
});
});
});

View File

@@ -173,7 +173,13 @@ describe('validate health command', () => {
// ─── Check 4: STATE.md exists and references valid phases ─────────────────
test('errors when STATE.md is missing with repairable true', () => {
test('errors when STATE.md is missing with repairable false (DESTRUCTIVE remedy is never auto-applied)', () => {
// Phase 11 (#3309): E004's remedy (regenerateState) is DESTRUCTIVE, and
// `--repair` refuses to auto-apply a DESTRUCTIVE remedy (design doc,
// "--repair behavior change" section) — a disclosed breaking change from
// the pre-migration `repairable: true`. `repairable` now means "an
// automatic repair will actually run", not merely "a remedy exists to
// describe".
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeValidConfigJson(tmpDir);
@@ -186,7 +192,7 @@ describe('validate health command', () => {
const output = JSON.parse(result.output);
const e004 = output.errors.find(e => e.code === 'E004');
assert.ok(e004, `Expected E004 in errors: ${JSON.stringify(output.errors)}`);
assert.strictEqual(e004.repairable, true, 'E004 should be repairable');
assert.strictEqual(e004.repairable, false, 'E004 (DESTRUCTIVE remedy) should not be marked repairable');
});
test('warns when STATE.md references nonexistent phase', () => {
@@ -1068,10 +1074,15 @@ describe('validate health --repair command', () => {
assert.strictEqual(diskConfig.milestone_branch_template, 'gsd/{milestone}-{slug}');
});
test('resets config.json when JSON is invalid', () => {
test('Phase 11 (#3309): refuses to reset config.json when JSON is invalid — resetConfig is DESTRUCTIVE, --repair leaves it untouched', () => {
// Pre-migration this repair action applied unconditionally; the design
// doc's "--repair behavior change" section makes this a disclosed
// breaking change: a DESTRUCTIVE remedy is reported (still visible in
// repairs_performed, as a refusal) but never executed by --repair.
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, '{broken json');
const originalContent = '{broken json';
fs.writeFileSync(configPath, originalContent);
const result = runGsdTools('validate health --repair', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
@@ -1082,16 +1093,15 @@ describe('validate health --repair command', () => {
`Expected repairs_performed: ${JSON.stringify(output)}`
);
const resetAction = output.repairs_performed.find(r => r.action === 'resetConfig');
assert.ok(resetAction, `Expected resetConfig action: ${JSON.stringify(output.repairs_performed)}`);
assert.ok(resetAction, `Expected a resetConfig refusal entry: ${JSON.stringify(output.repairs_performed)}`);
assert.strictEqual(resetAction.success, false, 'resetConfig must be refused, not applied');
assert.match(resetAction.error || '', /destructive/i, 'refusal must explain WHY it was not applied');
// Verify config.json is now valid JSON with correct nested structure
const diskConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.ok(typeof diskConfig === 'object', 'config.json should be valid JSON after repair');
assert.ok(diskConfig.workflow, 'reset config should have nested workflow object');
assert.strictEqual(diskConfig.workflow.research, true, 'workflow.research should be true after reset');
// config.json must remain exactly as it was — untouched.
assert.strictEqual(fs.readFileSync(configPath, 'utf-8'), originalContent, 'config.json must not be modified by a refused repair');
});
test('regenerates STATE.md when missing', () => {
test('Phase 11 (#3309): refuses to regenerate STATE.md when missing — regenerateState is DESTRUCTIVE, --repair leaves it absent', () => {
writeValidConfigJson(tmpDir);
// No STATE.md
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
@@ -1106,13 +1116,18 @@ describe('validate health --repair command', () => {
`Expected repairs_performed: ${JSON.stringify(output)}`
);
const regenerateAction = output.repairs_performed.find(r => r.action === 'regenerateState');
assert.ok(regenerateAction, `Expected regenerateState action: ${JSON.stringify(output.repairs_performed)}`);
assert.strictEqual(regenerateAction.success, true, 'regenerateState should succeed');
assert.ok(regenerateAction, `Expected a regenerateState refusal entry: ${JSON.stringify(output.repairs_performed)}`);
assert.strictEqual(regenerateAction.success, false, 'regenerateState must be refused, not applied');
assert.match(regenerateAction.error || '', /destructive/i, 'refusal must explain WHY it was not applied');
// Verify STATE.md now exists and contains "# Session State"
assert.ok(fs.existsSync(statePath), 'STATE.md should now exist on disk');
const stateContent = fs.readFileSync(statePath, 'utf-8');
assert.ok(stateContent.includes('# Session State'), 'regenerated STATE.md should contain "# Session State"');
// STATE.md must remain absent, and no backup file should have been created.
assert.strictEqual(fs.existsSync(statePath), false, 'STATE.md must remain absent — the DESTRUCTIVE remedy is refused');
const planningFiles = fs.readdirSync(path.join(tmpDir, '.planning'));
assert.strictEqual(
planningFiles.some(f => f.startsWith('STATE.md.bak-')),
false,
'no backup file should be created for a refused repair',
);
});
test('does not rewrite existing STATE.md for invalid phase references', () => {
@@ -1168,8 +1183,12 @@ describe('validate health --repair command', () => {
assert.strictEqual(diskConfig.workflow.nyquist_validation, true, 'nyquist_validation should be true');
});
test('reports repairable_count correctly', () => {
// No config.json (W003, repairable=true) and no STATE.md (E004, repairable=true)
test('reports repairable_count correctly — counts NONE-risk findings only, not the DESTRUCTIVE E004', () => {
// No config.json (W003, createConfig, NONE risk -> repairable=true) and no
// STATE.md (E004, regenerateState, DESTRUCTIVE risk -> repairable=false,
// Phase 11 #3309: --repair never auto-applies a DESTRUCTIVE remedy, so it
// is deliberately excluded from this count — see the `diagnosticToIssueEntry`
// comment in src/verify.cts for the full reasoning).
const configPath = path.join(tmpDir, '.planning', 'config.json');
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
@@ -1180,9 +1199,15 @@ describe('validate health --repair command', () => {
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.repairable_count >= 2,
`Expected repairable_count >= 2, got ${output.repairable_count}. Full output: ${JSON.stringify(output)}`
const w003 = output.warnings.find(w => w.code === 'W003');
const e004 = output.errors.find(e => e.code === 'E004');
assert.ok(w003, `Expected W003 in warnings: ${JSON.stringify(output.warnings)}`);
assert.ok(e004, `Expected E004 in errors: ${JSON.stringify(output.errors)}`);
assert.strictEqual(w003.repairable, true, 'W003 (createConfig, NONE risk) should be repairable');
assert.strictEqual(e004.repairable, false, 'E004 (regenerateState, DESTRUCTIVE risk) should not be repairable');
assert.strictEqual(
output.repairable_count, 1,
`Expected repairable_count 1 (W003 only), got ${output.repairable_count}. Full output: ${JSON.stringify(output)}`
);
});

View File

@@ -1938,6 +1938,76 @@ test('--backfill synthesizes missing MILESTONES.md entry from snapshot', () => {
assert.ok(content.includes('Backfilled'), 'should note it was backfilled');
});
// Phase 11 (#3309): pre-migration, `--backfill` ALONE (without `--repair`)
// was dead code — `verify.cts:2504`'s inner backfill gate was unreachable
// because the outer `if (options['repair'] && repairs.length > 0)` gate
// already required `repair`. The migrated `applyRepairs` threads `backfill`
// as its own boolean (`repair || backfill` for `backfillMilestones`
// specifically), so `--backfill` alone now actually works — a disclosed
// latent-bug fix (design doc, "Known limits"), not a preservation
// requirement.
test('--backfill alone (without --repair) now synthesizes the missing MILESTONES.md entry', () => {
const dir = makeTempProject({
'.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n',
'.planning/ROADMAP.md': '# Roadmap\n',
'.planning/STATE.md': '# State\n',
'.planning/config.json': '{}',
'.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0 First Release\n',
});
cmdValidateHealth(dir, { repair: false, backfill: true }, false);
const milestonesPath = path.join(dir, '.planning', 'MILESTONES.md');
assert.ok(fs.existsSync(milestonesPath), '--backfill alone should create MILESTONES.md');
const content = fs.readFileSync(milestonesPath, 'utf-8');
assert.ok(content.includes('## v1.0'), 'backfilled entry should contain v1.0');
assert.ok(content.includes('Backfilled'), 'should note it was backfilled');
});
test('--backfill alone does NOT apply an unrelated NONE-risk repair (createConfig) — only backfillMilestones is gated by backfill', () => {
const dir = makeTempProject({
'.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n',
'.planning/ROADMAP.md': '# Roadmap\n',
'.planning/STATE.md': '# State\n',
// No config.json — W003 (createConfig) would fire and be repairable, but
// must NOT be applied by --backfill alone (only --repair applies it).
'.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0 First Release\n',
});
cmdValidateHealth(dir, { repair: false, backfill: true }, false);
const configPath = path.join(dir, '.planning', 'config.json');
assert.strictEqual(fs.existsSync(configPath), false, 'config.json must not be created by --backfill alone');
const milestonesPath = path.join(dir, '.planning', 'MILESTONES.md');
assert.ok(fs.existsSync(milestonesPath), '--backfill alone should still create MILESTONES.md');
});
// Phase 11 (#3309): W021 (phase_id_convention integer-prefix/milestone
// mismatch) and W026 (STATE milestone-complete vs. unstarted ROADMAP
// phases) are the split-off halves of the pre-migration 'W021' code — two
// genuinely unrelated subjects (design doc, "New codes for the two split
// subjects" section). This fixture triggers ONLY the phase_id_convention
// mismatch (W021's remaining subject) and must not also produce W026.
test('W021 (phase_id_convention mismatch) fires independently of W026 — same fixture never also emits W026', () => {
const dir = makeTempProject({
'.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n',
'.planning/ROADMAP.md': '# Roadmap\n\n## [GSD] v2.0 — Expansion\n\n### Phase 1-01: Setup\n**Goal:** g\n',
// STATE.md status is plainly "In progress" — never "milestone complete"
// or "archived", so W026's precondition never holds for this fixture.
'.planning/STATE.md': '# State\n\n## Current Position\n\nPhase: 1-01\n\n**Status:** In progress\n',
'.planning/config.json': JSON.stringify({ phase_id_convention: 'milestone-prefixed' }),
});
const result = cmdValidateHealth(dir, { repair: false }, false);
const w021 = result.warnings.find(w => w.code === 'W021');
assert.ok(w021, `expected W021 for phase 1-01 (implies v1.0) listed under v2.0: ${JSON.stringify(result.warnings)}`);
assert.ok(
result.warnings.every(w => w.code !== 'W026'),
`W021 fixture must not also fire W026: ${JSON.stringify(result.warnings.map(w => w.code))}`
);
});
test('health.md mentions --backfill flag', () => {
const healthMd = fs.readFileSync(
path.join(__dirname, '../gsd-core/workflows/health.md'), 'utf-8'