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:
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
1137
src/verify.cts
1137
src/verify.cts
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user