enhance(#3180): the sibling validators share one envelope and one owner — Phase 12 (#3407)

This commit is contained in:
Tom Boucher
2026-08-13 11:31:16 -04:00
committed by GitHub
parent bf05479928
commit 6dbc124018
27 changed files with 2212 additions and 354 deletions

View File

@@ -25,6 +25,7 @@ export const CANONICAL_EXACT: ReadonlySet<string> = new Set([
'CLAUDE.md',
'RETROSPECTIVE.md',
'WINDOWS.md', // #3224: broken-windows ledger (src/broken-windows.cts, LEDGER_FILE_NAME)
'STATE-ARCHIVE.md', // state.cts's cmdStatePrune writes this at the .planning/ root
]);
// Pattern-match canonical file names (regex tests on the basename)

View File

@@ -0,0 +1,165 @@
/**
* Health Diagnostic — `validate.consistency`-only rules (Phase 12, #3310,
* ADR-3180 §8.4).
*
* Group: "consistency" — a NEW `C0NN` code namespace, parallel to (not part
* of) `validate.health`'s `E`/`W`/`I` space Phase 11 minted. These four
* subjects are `cmdValidateConsistency`'s (`src/verify.cts:1466-1612`)
* own findings, not `validate.health` findings — see the design doc's
* "Code namespace" section for why they get their own prefix instead of
* competing for the next free `W0NN` number.
*
* Ported behavior-preserving from `cmdValidateConsistency`
* (`src/verify.cts:1504-1519` for C001, `:1570-1576` for C002, `:1584-1587`
* for C003, `:1596-1602` for C004) — relocated, not reinvented. The
* duplicate subjects `cmdValidateConsistency` also computed (phase-in-
* roadmap-no-dir / dir-on-disk-not-in-roadmap) are NOT here: those are
* W006/W007, reused verbatim from
* `src/health-diagnostic-rules/roadmap-disk-consistency.cts` via
* `health-diagnostic.cts`'s `CONSISTENCY_RULES` composition, not
* reimplemented in this file (design doc, "Which rules run where").
*
* All four remedies are `ADVISE` — none of these four subjects had a repair
* path in the pre-migration code either, matching Phase 11's own convention
* for message-only findings with no automated fix.
*
* Design: .gsd/phase/feat-3310-enhance-3180-the-sibling-validators-shar/40-design.md
* Test matrix: .gsd/phase/feat-3310-enhance-3180-the-sibling-validators-shar/50-test-matrix.md
*
* ADR-457 build-at-publish: source in src/health-diagnostic-rules/consistency.cts,
* compiled to gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs (gitignored).
*/
// 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');
type PlanningSnapshot = ReturnType<typeof planningSnapshotMod.buildPlanningSnapshot>;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import healthDiagnosticMod = require('../health-diagnostic-types.cjs');
const { SEVERITY, adviseRemedy } = healthDiagnosticMod;
type Diagnostic = healthDiagnosticMod.Diagnostic;
type Rule = healthDiagnosticMod.Rule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('../phase-id.cjs');
const { isSentinelPhaseId } = phaseIdMod;
// ─── C001 — gap in disk phase numbering (integer sequence) ────────────────
// (verify.cts:1504-1519)
function checkC001(snapshot: PlanningSnapshot): Diagnostic[] {
// verify.cts:1505 — `if (config.phase_naming !== 'custom')`; the
// integer-sequence gap check is skipped entirely under custom phase
// naming, since a custom naming scheme has no integer sequence to have a
// gap in.
if (snapshot.config.value?.['phase_naming'] === 'custom') return [];
const integerPhases = snapshot.allPhaseDirNames.value
// #3225: exclude sentinel phase ids (999.x/0.x) — never part of the
// sequential numbering, mirrors verify.cts:1510 verbatim.
.filter((p) => !p.includes('.') && !isSentinelPhaseId(p))
.map((p) => parseInt(p, 10))
.filter((n) => !Number.isNaN(n))
.sort((a, b) => a - b);
const diagnostics: Diagnostic[] = [];
for (let i = 1; i < integerPhases.length; i++) {
if (integerPhases[i] !== integerPhases[i - 1] + 1) {
diagnostics.push({
code: 'C001',
severity: SEVERITY.WARNING,
message: `Gap in phase numbering: ${integerPhases[i - 1]} → ${integerPhases[i]}`,
remedy: adviseRemedy('Create the missing phase directory or renumber to close the gap'),
});
}
}
return diagnostics;
}
// ─── C002 — gap in plan numbering within a phase ───────────────────────────
// (verify.cts:1570-1576)
// Fidelity note: the original's `phaseLabel` (verify.cts:1532,
// `posixNormalize(path.relative(planBase, phasePath))`) is a
// `.planning/`-relative posix path, which for the flat `phases/<dir>` case
// this rule scans reduces to the bare directory name (`phaseDir` below) —
// same string, not a narrower one, since `perPhasePlanNumbering` only
// enumerates the flat `phases/` root (see `PlanningSnapshot`'s own doc
// comment on this field for the one disclosed scope reduction, an active
// archived milestone's second phase root, which is out of scope here too).
function checkC002(snapshot: PlanningSnapshot): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
for (const { phaseDir, planNums } of snapshot.perPhasePlanNumbering.value) {
for (let i = 1; i < planNums.length; i++) {
if (planNums[i] !== planNums[i - 1] + 1) {
diagnostics.push({
code: 'C002',
severity: SEVERITY.WARNING,
message: `Gap in plan numbering in ${phaseDir}: plan ${planNums[i - 1]} → ${planNums[i]}`,
remedy: adviseRemedy('Create the missing plan or renumber to close the gap'),
});
}
}
}
return diagnostics;
}
// ─── C003 — orphan SUMMARY with no matching live PLAN ──────────────────────
// (verify.cts:1584-1587)
function checkC003(snapshot: PlanningSnapshot): Diagnostic[] {
return snapshot.perPhaseOrphanSummaries.value.map(({ phaseDir, orphanSummary }) => ({
code: 'C003',
severity: SEVERITY.WARNING,
message: `Summary ${orphanSummary} in ${phaseDir} has no matching PLAN.md`,
remedy: adviseRemedy('Create the matching PLAN.md or remove the orphan summary'),
}));
}
// ─── C004 — PLAN missing `wave` frontmatter ────────────────────────────────
// (verify.cts:1596-1602)
function checkC004(snapshot: PlanningSnapshot): Diagnostic[] {
return snapshot.perPhaseWaveMissingPlans.value.map(({ phaseDir, plan }) => ({
code: 'C004',
severity: SEVERITY.WARNING,
message: `${phaseDir}/${plan}: missing 'wave' in frontmatter`,
remedy: adviseRemedy("Add a 'wave' key to the plan's frontmatter"),
}));
}
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [
{
code: 'C001',
severity: SEVERITY.WARNING,
description: 'Gap in disk phase numbering (integer sequence)',
repairable: false,
check: checkC001,
},
{
code: 'C002',
severity: SEVERITY.WARNING,
description: 'Gap in plan numbering within a phase',
repairable: false,
check: checkC002,
},
{
code: 'C003',
severity: SEVERITY.WARNING,
description: 'Orphan SUMMARY with no matching live PLAN',
repairable: false,
check: checkC003,
},
{
code: 'C004',
severity: SEVERITY.WARNING,
description: "PLAN missing 'wave' in frontmatter",
repairable: false,
check: checkC004,
},
];
export = { RULES };

View File

@@ -81,6 +81,8 @@ import roadmapDiskConsistencyMod = require('./health-diagnostic-rules/roadmap-di
import worktreeHealthMod = require('./health-diagnostic-rules/worktree-health.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import milestoneArchiveHygieneMod = require('./health-diagnostic-rules/milestone-archive-hygiene.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import consistencyMod = require('./health-diagnostic-rules/consistency.cjs');
const RULES: Rule[] = [
...rootExistenceMod.RULES,
@@ -93,6 +95,20 @@ const RULES: Rule[] = [
...milestoneArchiveHygieneMod.RULES,
];
/**
* `validate.consistency`'s own rule set (Phase 12, #3310, ADR-3180 §8.4,
* design doc "Which rules run where") — W006/W007 REUSED (the exact same
* `Rule` objects `RULES` above already carries, not new copies) plus the
* four new C0NN rules from `consistency.cts`. Deliberately NOT the full
* `RULES` table: running `validate.health`'s config/state/worktree rules
* under `validate.consistency` would be scope creep the issue never asked
* for.
*/
const CONSISTENCY_RULES: Rule[] = [
...roadmapDiskConsistencyMod.RULES.filter((r) => ['W006', 'W007'].includes(r.code)),
...consistencyMod.RULES,
];
// ─── Repair-handler runtime dependencies ───────────────────────────────────
//
// Same owners `cmdValidateHealth`'s pre-migration repair switch used
@@ -143,6 +159,15 @@ function evaluateRules(snapshot: PlanningSnapshot): Diagnostic[] {
return evaluateRuleTable(RULES, snapshot);
}
/**
* Evaluate `CONSISTENCY_RULES` (W006/W007 + C001-C004) against `snapshot` —
* `validate.consistency`'s evaluator entry point, mirroring `evaluateRules`
* exactly but over the smaller, command-specific rule subset.
*/
function evaluateConsistencyRules(snapshot: PlanningSnapshot): Diagnostic[] {
return evaluateRuleTable(CONSISTENCY_RULES, snapshot);
}
// ─── Repair dispatcher ──────────────────────────────────────────────────────
// Repair-handler bodies (real, ported from verify.cts:2405-2553).
@@ -475,6 +500,9 @@ const healthDiagnostic = {
// rule array without mutating the real, still-empty `RULES` export.
evaluateRuleTable,
applyRepairs,
// Phase 12 (#3310) — `validate.consistency`'s own rule subset + evaluator.
CONSISTENCY_RULES,
evaluateConsistencyRules,
};
// Namespace merge (same binding name as the value above) is how a CommonJS

View File

@@ -39,6 +39,9 @@ import { platformReadSync, execGit } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatterMod = require('./frontmatter.cjs');
const { extractFrontmatter, stripFrontmatter } = frontmatterMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
import coreUtilsMod = require('./core-utils.cjs');
const { findOrphanSummaries } = coreUtilsMod;
import { stateFieldValue, stateCurrentPositionSlice } from './state-document.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import unusableInputMod = require('./unusable-input.cjs');
@@ -197,6 +200,16 @@ interface PlanningSnapshot {
// original behavior. This field is W026's own, independently-scoped
// phase-id list — additive-only, no change to `roadmapDeclaredPhases`.
currentMilestoneRoadmapPhaseIds: { value: string[]; scope: Scope };
// ─── Phase 12 (#3310, ADR-3180 §8.4) additions ─────────────────────────────
// Backs C002/C003/C004 (`src/health-diagnostic-rules/consistency.cts`,
// `cmdValidateConsistency`'s migration target). All three relocate
// `verify.cts:1556-1603`'s per-phase-directory plan scan verbatim; see
// `buildPerPhasePlanScanFields`'s own doc comment for why the three share
// one builder and one enumeration base (`allPhaseDirNames`, NOT the
// current-milestone-windowed `phaseDirs`).
perPhasePlanNumbering: { value: { phaseDir: string; planNums: number[] }[]; scope: Scope };
perPhaseOrphanSummaries: { value: { phaseDir: string; orphanSummary: string }[]; scope: Scope };
perPhaseWaveMissingPlans: { value: { phaseDir: string; plan: string }[]; scope: Scope };
}
/**
@@ -787,6 +800,104 @@ function buildCurrentMilestoneRoadmapPhaseIdsField(
return { value, scope: SCOPE.COMPLETE };
}
/**
* Resolve `perPhasePlanNumbering`/`perPhaseOrphanSummaries`/
* `perPhaseWaveMissingPlans` — Phase 12 (#3310, ADR-3180 §8.4), backing
* C002/C003/C004. One shared per-phase-directory scan serves all three
* fields (mirrors `buildStateFields`'s "one builder, several named outputs"
* convention above): each of the three questions below reads the exact same
* `scanPhasePlans(fullPhaseDir)` result, so scanning each phase directory
* three separate times (one function per field) would triple the
* `readdirSync`/frontmatter-read cost for zero behavioral gain — the three
* subjects are independent QUESTIONS, not independent SCANS.
*
* Enumerated over `allPhaseDirNames`, NOT `phaseDirs` (the
* current-milestone-windowed twin): the pre-migration `cmdValidateConsistency`
* (`verify.cts:1521-1608`) walks `collectPhaseRoots(planBase)`'s flat
* `phases/` root via a plain, unfiltered `readdirSync` — every phase
* directory on disk, not just the ones the current milestone window
* resolves as "in scope" — exactly the un-windowed shape `allPhaseDirNames`
* already exposes for W007 (see that field's own doc comment). Using the
* windowed `phaseDirs` here would silently narrow C002/C003/C004's coverage
* relative to the behavior being relocated. Disclosed fidelity note: this
* does NOT walk `collectPhaseRoots`'s second root (an active archived
* milestone's `<ver>-phases/` directory) — `allPhaseDirNames` is scoped to
* the flat `phases/` root only, the same scope every other
* `allPhaseDirNames`-sourced field already carries.
*
* QUESTION 1 — `perPhasePlanNumbering`: the sorted list of `-NN-PLAN.md`
* sequence numbers physically present (superseded or not — a retired plan
* still occupied a number), from `allPlanFiles` via the exact
* `/-(\d{2})-PLAN\.md$/` regex `verify.cts:1558` already uses. This field
* exposes the raw per-phase number list only; the future C002 rule computes
* the gap itself.
*
* QUESTION 2 — `perPhaseOrphanSummaries`: every SUMMARY.md with no matching
* LIVE PLAN.md, via `findOrphanSummaries(planFiles, summaryFiles)`
* (`core-utils.cjs`, `verify.cts:1584` — the same owner
* `src/health-diagnostic-rules/phase-structure.cts`'s I001 rule already
* consumes indirectly via `PhaseSnapshot.planCount`/`summaryCount`, for the
* INVERSE question). Uses the live (superseded-excluded) `planFiles`, not
* `allPlanFiles` — a superseded plan's summary is still an orphan.
*
* QUESTION 3 — `perPhaseWaveMissingPlans`: every LIVE plan (`planFiles`,
* same live set as Question 2 — a superseded plan legitimately carries no
* `wave`) whose frontmatter has no `wave` key, via `extractFrontmatter`,
* mirroring `verify.cts:1596-1603` exactly. A plan file that cannot be read
* is silently skipped, mirroring `cmdValidateConsistency`'s own outer
* `catch { intentionally empty }` (`verify.cts:1605-1607`) around this exact
* loop — a fail-open match to the pre-migration behavior, not a new scope
* degradation.
*/
function buildPerPhasePlanScanFields(
phasesDir: string,
phaseDirNames: string[],
enumerationScope: Scope,
): {
perPhasePlanNumbering: { value: { phaseDir: string; planNums: number[] }[]; scope: Scope };
perPhaseOrphanSummaries: { value: { phaseDir: string; orphanSummary: string }[]; scope: Scope };
perPhaseWaveMissingPlans: { value: { phaseDir: string; plan: string }[]; scope: Scope };
} {
const planNumbering: { phaseDir: string; planNums: number[] }[] = [];
const orphanSummaries: { phaseDir: string; orphanSummary: string }[] = [];
const waveMissingPlans: { phaseDir: string; plan: string }[] = [];
for (const phaseDir of phaseDirNames) {
const fullPhaseDir = path.join(phasesDir, phaseDir);
const { allPlanFiles, planFiles, summaryFiles } = scanPhasePlans(fullPhaseDir);
const planNums = allPlanFiles
.map((p) => {
const m = p.match(/-(\d{2})-PLAN\.md$/);
return m ? parseInt(m[1], 10) : null;
})
.filter((n): n is number => n !== null)
.sort((a, b) => a - b);
planNumbering.push({ phaseDir, planNums });
for (const orphan of findOrphanSummaries(planFiles, summaryFiles)) {
orphanSummaries.push({ phaseDir, orphanSummary: orphan });
}
for (const plan of planFiles) {
try {
const planFilePath = path.join(fullPhaseDir, plan);
const content = fs.readFileSync(planFilePath, 'utf-8');
const fmData = extractFrontmatter(content, planFilePath);
if (!fmData['wave']) waveMissingPlans.push({ phaseDir, plan });
} catch {
/* unreadable plan file — mirrors verify.cts:1605-1607's own silent skip */
}
}
}
return {
perPhasePlanNumbering: { value: planNumbering, scope: enumerationScope },
perPhaseOrphanSummaries: { value: orphanSummaries, scope: enumerationScope },
perPhaseWaveMissingPlans: { value: waveMissingPlans, scope: enumerationScope },
};
}
/**
* Build the full `.planning/` projection for `cwd`. Composes the six §7
* owners named in the design doc's "Owners consumed" table, plus (Phase 11,
@@ -802,6 +913,12 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot {
const phasesValue = phaseDirs.value.map((dir) => buildPhaseSnapshot(paths.phases, dir));
const stateFields = buildStateFields(paths.state);
const allPhaseDirNames = buildAllPhaseDirNamesField(paths.phases);
const perPhasePlanScanFields = buildPerPhasePlanScanFields(
paths.phases,
allPhaseDirNames.value,
allPhaseDirNames.scope,
);
return {
cwd: path.resolve(cwd),
@@ -823,9 +940,12 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot {
researchValidationStatus: buildResearchValidationStatusField(paths.phases, phaseDirs.value, phaseDirs.scope),
milestoneArchiveStatus: buildMilestoneArchiveStatusField(cwd),
planningRootFiles: buildPlanningRootFilesField(cwd),
allPhaseDirNames: buildAllPhaseDirNamesField(paths.phases),
allPhaseDirNames,
archivedPhaseTokens: buildArchivedPhaseTokensField(paths.planning),
currentMilestoneRoadmapPhaseIds: buildCurrentMilestoneRoadmapPhaseIdsField(cwd, paths.roadmap),
perPhasePlanNumbering: perPhasePlanScanFields.perPhasePlanNumbering,
perPhaseOrphanSummaries: perPhasePlanScanFields.perPhaseOrphanSummaries,
perPhaseWaveMissingPlans: perPhasePlanScanFields.perPhaseWaveMissingPlans,
};
}

View File

@@ -68,6 +68,11 @@ import type { HeadingToken } from './markdown-sectionizer.cjs';
import { parseMarkdownTable, updateTableCell, deleteTableRow, insertTableRow, splitTableRow, isDelimiterRow } from './markdown-table.cjs';
import { textEncodingError } from './validate.cjs';
import { clampPercent } from './phase-lifecycle.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import healthDiagnosticTypesMod = require('./health-diagnostic-types.cjs');
const { SEVERITY, adviseRemedy } = healthDiagnosticTypesMod;
type Severity = healthDiagnosticTypesMod.Severity;
type Diagnostic = healthDiagnosticTypesMod.Diagnostic;
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -3244,6 +3249,22 @@ function readStateFrontmatterScoped(content: string, statePath: string): { fm: R
return { fm, body, scope };
}
/**
* Builds an S0NN `Diagnostic` for `cmdStateValidate` (§8.4 rule 3 —
* `cmdStateValidate` is a plain imperative function, not a `Rule.check`, so
* it builds `Diagnostic[]` directly rather than going through
* `evaluateRuleTable`/the `RULES` array machinery). Every S0NN subject is
* advisory-only today (`cmdStateValidate` has never had a repair path), so
* every remedy is `adviseRemedy` — `advice` is the short imperative command
* text shown to the operator, matching the style Phase 11's rule-group files
* already use for their own ADVISE-only findings (e.g.
* `roadmap-disk-consistency.cts`'s `adviseRemedy('Create phase directory or
* remove from roadmap')`).
*/
function stateDiagnostic(code: string, severity: Severity, message: string, advice: string): Diagnostic {
return { code, severity, message, remedy: adviseRemedy(advice) };
}
function cmdStateValidate(cwd: string, raw: boolean): void {
const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) {
@@ -3257,11 +3278,17 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
// searchers downstream, reading as "absent" rather than "corrupt."
const encErr = textEncodingError(content, 'STATE.md');
if (encErr) {
output({ valid: false, warnings: [encErr], drift: {} }, raw, undefined);
// S001 — error-class severity (this branch has always set `valid: false`
// unconditionally and returned immediately, matching every other
// error-class code, not a mere warning). Message reused verbatim from
// `textEncodingError`, not paraphrased.
output({
valid: false,
warnings: [stateDiagnostic('S001', SEVERITY.ERROR, encErr, 'Re-save STATE.md as UTF-8 text with the embedded NUL byte(s) removed')],
}, raw, undefined);
return;
}
const warnings: string[] = [];
const drift: Record<string, unknown> = {};
const warnings: Diagnostic[] = [];
// #1255/#3187: parse frontmatter and strip it from the body ONCE, so the
// chain owner sees the same fm/body precedence every other migrated call
@@ -3279,20 +3306,32 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
const phasesDir = planningPaths(cwd).phases;
if (currentPhase === null) {
warnings.push('Cannot validate phase drift: STATE.md has no usable current_phase, Current Phase, or Current Position Phase value');
drift['phase_reference'] = { reason: 'unresolved', selected: null, sources: resolvedPhase.sources };
output({ valid: false, warnings, drift, scope }, raw, undefined);
warnings.push(stateDiagnostic(
'S002',
SEVERITY.WARNING,
'Cannot validate phase drift: STATE.md has no usable current_phase, Current Phase, or Current Position Phase value',
'Set current_phase (frontmatter) or Current Phase / Current Position Phase (body) in STATE.md',
));
output({ valid: false, warnings, scope }, raw, undefined);
return;
}
const selectedPhaseKey = phaseKeyFromToken(currentPhase);
if (Object.values(resolvedPhase.sources).some(source => source !== null && phaseKeyFromToken(source) !== selectedPhaseKey)) {
warnings.push(`Phase reference conflict: validating authoritative phase ${currentPhase}; align STATE.md phase sources`);
drift['phase_reference'] = { reason: 'conflict', selected: currentPhase, sources: resolvedPhase.sources };
warnings.push(stateDiagnostic(
'S003',
SEVERITY.WARNING,
`Phase reference conflict: validating authoritative phase ${currentPhase}; align STATE.md phase sources`,
'Align STATE.md phase sources (frontmatter, Current Phase, Current Position Phase) on one phase',
));
}
if (!fs.existsSync(phasesDir)) {
warnings.push(`Cannot validate phase drift: phases directory is missing for phase ${currentPhase}`);
drift['phase_directory'] = { reason: 'missing_root', selected: currentPhase };
output({ valid: false, warnings, drift, scope }, raw, undefined);
warnings.push(stateDiagnostic(
'S004',
SEVERITY.WARNING,
`Cannot validate phase drift: phases directory is missing for phase ${currentPhase}`,
'Create the phases directory or correct current_phase to a phase that exists on disk',
));
output({ valid: false, warnings, scope }, raw, undefined);
return;
}
let phaseDirPath: string;
@@ -3300,16 +3339,24 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey);
if (!phaseDir) {
warnings.push(`Cannot validate phase drift: no phase directory matches phase ${currentPhase}`);
drift['phase_directory'] = { reason: 'not_found', selected: currentPhase };
output({ valid: false, warnings, drift, scope }, raw, undefined);
warnings.push(stateDiagnostic(
'S004',
SEVERITY.WARNING,
`Cannot validate phase drift: no phase directory matches phase ${currentPhase}`,
'Create a phase directory matching the current phase or correct current_phase',
));
output({ valid: false, warnings, scope }, raw, undefined);
return;
}
phaseDirPath = path.join(phasesDir, phaseDir.name);
} catch {
warnings.push(`Cannot validate phase drift: phases directory is unreadable for phase ${currentPhase}`);
drift['phase_directory'] = { reason: 'unreadable', selected: currentPhase };
output({ valid: false, warnings, drift, scope }, raw, undefined);
warnings.push(stateDiagnostic(
'S004',
SEVERITY.WARNING,
`Cannot validate phase drift: phases directory is unreadable for phase ${currentPhase}`,
'Check phases directory permissions and re-run validate',
));
output({ valid: false, warnings, scope }, raw, undefined);
return;
}
try {
@@ -3321,8 +3368,12 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
// Check plan count mismatch
if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
warnings.push(`Plan count mismatch: STATE.md says ${totalPlansInPhase} plans, disk has ${diskPlans}`);
drift['plan_count'] = { state: totalPlansInPhase, disk: diskPlans };
warnings.push(stateDiagnostic(
'S005',
SEVERITY.WARNING,
`Plan count mismatch: STATE.md says ${totalPlansInPhase} plans, disk has ${diskPlans}`,
'Run state sync or correct Total Plans in Phase to match the plans on disk',
));
}
// Check for VERIFICATION.md
@@ -3332,8 +3383,12 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
try {
const vContent = fs.readFileSync(path.join(phaseDirPath, vf), 'utf-8');
if (/status:\s*passed/i.test(vContent) && /executing/i.test(status)) {
warnings.push(`Status drift: STATE.md says "${status}" but ${vf} shows verification passed — phase may be complete`);
drift['verification_status'] = { state_status: status, verification: 'passed' };
warnings.push(stateDiagnostic(
'S006',
SEVERITY.WARNING,
`Status drift: STATE.md says "${status}" but ${vf} shows verification passed — phase may be complete`,
'Run state complete-phase (or otherwise advance STATE.md status past "executing")',
));
}
} catch { /* best-effort (#2245 audit): cmdStateValidate is a diagnostic
* warnings scan across N VERIFICATION.md files — one unreadable file
@@ -3346,16 +3401,29 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
// Only warn if no verification exists (if verification passed, the above warning covers it)
if (verificationFiles.length === 0) {
warnings.push(`All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`);
// S007 stays WARNING (not INFO): closely related to S006 (both
// signal "phase may be ready to advance"), and S006 is WARNING —
// giving the sibling condition a different severity for the same
// underlying signal would be a false distinction.
warnings.push(stateDiagnostic(
'S007',
SEVERITY.WARNING,
`All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`,
'Run phase verification, then advance STATE.md status past "executing"',
));
}
}
} catch {
warnings.push(`Cannot validate phase drift: phase directory is unreadable for phase ${currentPhase}`);
drift['phase_directory'] = { reason: 'unreadable', selected: currentPhase };
warnings.push(stateDiagnostic(
'S004',
SEVERITY.WARNING,
`Cannot validate phase drift: phase directory is unreadable for phase ${currentPhase}`,
'Check phase directory permissions and re-run validate',
));
}
const valid = warnings.length === 0;
output({ valid, warnings, drift, scope }, raw, undefined);
output({ valid, warnings, scope }, raw, undefined);
}
/**

View File

@@ -9,8 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { phaseVariants, buildRoadmapPhaseVariants } from './validate.cjs';
import { PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE, textEncodingError } from './validate.cjs';
import { MILESTONE_ARCHIVE_DIR_RE, textEncodingError } from './validate.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
import planningWorkspace = require('./planning-workspace.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -27,7 +26,7 @@ const { findOrphanSummaries, findUnsummarizedPlans } = coreUtilsMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
import { execGit, platformReadSync as safeReadFile, posixNormalize } from './shell-command-projection.cjs';
import { execGit, platformReadSync as safeReadFile } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { detectSchemaFiles, checkSchemaDrift } from './schema-detect.cjs';
import { extractTaggedBlocks } from './markdown-sectionizer.cjs';
@@ -38,20 +37,17 @@ const { checkAgentsInstalled, checkCodexModelPosture } = agentInstallCheck;
import ioMod = require('./io.cjs');
const { output, error } = ioMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configLoaderMod = require('./config-loader.cjs');
const { loadConfig } = configLoaderMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { normalizePhaseName, matchPhaseDirs, stripProjectCodePrefix, isSentinelPhaseId } = phaseIdMod;
const { normalizePhaseName, matchPhaseDirs } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocatorMod = require('./phase-locator.cjs');
const { findPhaseInternal } = phaseLocatorMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const { stripShippedMilestones, extractCurrentMilestone } = roadmapParserMod;
const { stripShippedMilestones } = roadmapParserMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- health-diagnostic.cjs is an export= CommonJS module
import healthDiagnosticMod = require('./health-diagnostic.cjs');
const { SEVERITY: HEALTH_SEVERITY, REMEDY_ACTION, REMEDY_RISK, evaluateRules, applyRepairs } = healthDiagnosticMod;
const { SEVERITY: HEALTH_SEVERITY, REMEDY_ACTION, REMEDY_RISK, evaluateRules, evaluateConsistencyRules, applyRepairs } = healthDiagnosticMod;
type HealthDiagnostic = healthDiagnosticMod.Diagnostic;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-snapshot.cjs is an export= CommonJS module
import planningSnapshotMod = require('./planning-snapshot.cjs');
@@ -1293,96 +1289,19 @@ function listMilestoneArchiveDirs(planBase: string): string[] {
);
} catch (err) {
// #1883: distinguish genuine absence from a permission/I-O failure. ENOENT
// (no milestones/ dir yet) keeps the long-standing [] contract that
// collectPhaseRoots / forEachArchivedPhaseToken depend on for "no archives";
// every other error (EACCES, EIO, …) must propagate — otherwise an unreadable
// milestones/ dir is silently reported as "no archives" and active-milestone
// resolution / archived-phase filtering misbehaves.
// (no milestones/ dir yet) keeps the long-standing [] contract callers of
// this function depend on for "no archives"; every other error (EACCES,
// EIO, …) must propagate — otherwise an unreadable milestones/ dir is
// silently reported as "no archives" and archived-phase resolution
// misbehaves. As of Phase 12 (#3310), `cmdValidateConsistency`'s own
// caller of this function (`collectPhaseRoots` -> `getActiveMilestoneArchiveDir`)
// was migrated onto `buildPlanningSnapshot` and deleted; this function is
// retained solely for its `_listMilestoneArchiveDirs` test seam below.
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
}
function getActiveMilestoneArchiveDir(planBase: string): string | null {
const archiveDirs = listMilestoneArchiveDirs(planBase);
if (archiveDirs.length === 0) return null;
try {
const statePath = path.join(planBase, 'STATE.md');
if (fs.existsSync(statePath)) {
const state = fs.readFileSync(statePath, 'utf-8');
const m = state.match(
/^\s*(?:\*\*)?milestone(?:\*\*)?:\s*\*{0,2}\s*([^\s*\r\n#][^\s\r\n#]*)/mi,
);
if (m && m[1]) {
const milestone = m[1].trim();
const candidate = path.join(planBase, 'milestones', `${milestone}-phases`);
return archiveDirs.includes(candidate) ? candidate : null;
}
}
} catch {
/* intentionally empty — fall through to version-sort below */
}
return archiveDirs[archiveDirs.length - 1];
}
function collectPhaseRoots(planBase: string): string[] {
const roots: string[] = [];
const flatPhasesDir = path.join(planBase, 'phases');
if (fs.existsSync(flatPhasesDir)) roots.push(flatPhasesDir);
const activeArchive = getActiveMilestoneArchiveDir(planBase);
if (activeArchive) roots.push(activeArchive);
return roots;
}
/**
* #2528: the disk-side phase inventory, keyed by extracted token but KEEPING the
* directory names behind each token.
*
* The token alone is what made `validate health` the ninth site of the #2528
* class. W006/W007 pair roadmap phases against disk by intersecting TOKEN SETS
* (`phaseVariants(p)` vs these keys), which is a dir→token labelling, not the
* query→dir selection `matchPhaseDirs` owns — so a `grep phaseTokenMatches`
* never surfaced it. On a digit-leading slug the label is wrong in both
* directions at once: `05-80-20-cleanup` labels itself `05-80-20`, so phase 5
* "has no directory" (W006) AND that directory "is not in the roadmap" (W007).
*
* Carrying the names lets both warnings ask the canonical matcher whether a
* roadmap phase actually resolves to a directory, instead of asking whether two
* independently-derived labels happen to be equal.
*/
function collectDiskPhaseEntries(planBase: string): Map<string, string[]> {
const entriesByToken = new Map<string, string[]>();
const phaseRoots = collectPhaseRoots(planBase);
const scanDir = (dir: string) => {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
const m = e.name.match(PHASE_TOKEN_FROM_DIR_RE);
if (!m) continue;
const token = stripProjectCodePrefix(m[1]);
const dirs = entriesByToken.get(token);
if (dirs) dirs.push(e.name);
else entriesByToken.set(token, [e.name]);
}
}
} catch {
/* dir absent */
}
};
for (const root of phaseRoots) scanDir(root);
return entriesByToken;
}
function collectDiskPhases(planBase: string): Set<string> {
return new Set(collectDiskPhaseEntries(planBase).keys());
}
interface IssueEntry {
code: string;
message: string;
@@ -1467,144 +1386,41 @@ function cmdValidateConsistency(cwd: string, raw: boolean): void {
const planBase = planningDir(cwd);
const roadmapPath = path.join(planBase, 'ROADMAP.md');
const errors: string[] = [];
const warnings: string[] = [];
const warnings: IssueEntry[] = [];
// Pre-check, stays OUTSIDE the rule table — same shape as `validate.health`'s
// E001/E010/I010 (design doc, "Which rules run where"). `.planning/` cannot
// build a `PlanningSnapshot` worth evaluating without a ROADMAP.md to read.
if (!fs.existsSync(roadmapPath)) {
errors.push('ROADMAP.md not found');
output({ passed: false, errors, warnings }, raw, 'failed');
return;
}
const roadmapContentRaw = fs.readFileSync(roadmapPath, 'utf-8');
const roadmapContent = extractCurrentMilestone(roadmapContentRaw, cwd);
// ─── Rule-table evaluation (Phase 12, #3310) ───────────────────────────────
// Replaces this function's entire hand-rolled disk-vs-roadmap /
// numbering-gap / orphan-summary / wave-missing scan — see the design
// doc's "Which rules run where" section. `evaluateConsistencyRules` runs
// W006/W007 (the SAME `Rule` objects `validate.health` evaluates, reused
// verbatim — not a second, independently-drifting copy) plus the four new
// C001-C004 rules, against the same `PlanningSnapshot` `validate.health`
// builds.
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = evaluateConsistencyRules(snapshot);
const { roadmapPhases } = buildRoadmapPhaseVariants(roadmapContent);
const { roadmapPhaseVariants: fullRoadmapPhaseVariants } = buildRoadmapPhaseVariants(roadmapContentRaw);
const diskPhases = collectDiskPhases(planBase);
for (const p of roadmapPhases) {
// #3225: sentinel phase ids are never-on-roadmap by convention.
if (isSentinelPhaseId(p)) continue;
if (!diskPhases.has(p) && !diskPhases.has(normalizePhaseName(p))) {
warnings.push(`Phase ${p} in ROADMAP.md but no directory on disk`);
}
}
for (const p of diskPhases) {
// #3225: a sentinel dir on disk (999-interim, 0-drafts) is defined as
// never-on-roadmap; it must not warn here (same guard as cmdValidateHealth).
if (isSentinelPhaseId(p)) continue;
const variants = phaseVariants(p);
if (![...variants].some((v) => fullRoadmapPhaseVariants.has(v))) {
warnings.push(`Phase ${p} exists on disk but not in ROADMAP.md`);
}
}
const config = loadConfig(cwd);
if (config.phase_naming !== 'custom') {
const integerPhases = [...diskPhases]
// #3225: exclude sentinel phase ids (999.x/0.x) — they are never part of the
// sequential numbering, so a 999-interim dir must not produce a spurious
// "Gap in phase numbering: N → 999".
.filter((p) => !p.includes('.') && !isSentinelPhaseId(p))
.map((p) => parseInt(p, 10))
.sort((a, b) => a - b);
for (let i = 1; i < integerPhases.length; i++) {
if (integerPhases[i] !== integerPhases[i - 1] + 1) {
warnings.push(`Gap in phase numbering: ${integerPhases[i - 1]} → ${integerPhases[i]}`);
}
}
}
const phaseRoots = collectPhaseRoots(planBase);
for (const phaseRoot of phaseRoots) {
try {
const entries = fs.readdirSync(phaseRoot, { withFileTypes: true });
const dirs = entries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
for (const dir of dirs) {
const phasePath = path.join(phaseRoot, dir);
const phaseLabel = posixNormalize(path.relative(planBase, phasePath));
// #3183: this loop mixes two DIFFERENT questions — split explicitly
// rather than migrating it as one blind swap-in.
// QUESTION 1 — physical numbering-gap detection: wants EVERY plan
// file that physically exists, superseded or not (a retired plan
// still occupied a number in the sequence), root+nested. Uses the
// single owner's allPlanFiles rather than a root-only readdirSync
// filter. The strict `-NN-PLAN.md` suffix regex below already
// ignores any entry (nested, loose-named, bare PLAN.md) that isn't
// in the root canonical numbered form, so widening the input set is
// a pure visibility fix with no change to which files feed a number.
//
// One scan serves both questions below: `allPlanFiles` answers
// Question 1 (numbering-gap), `planFiles`/`summaryFiles` answer
// Question 2 (pairing) a few lines down.
const { allPlanFiles, planFiles, summaryFiles } = planScanMod.scanPhasePlans(phasePath);
// Root-canonical numbered plans only (`<phase>-NN-PLAN.md`) — the
// shape the numbering-gap sequence check operates on. Matched via
// the numbering regex itself rather than a separate suffix filter,
// so this stays a single derivation from the owner's output, not a
// second independent re-derivation of its filename grammar.
const numberedPlans = allPlanFiles
.map((p) => {
const pm = p.match(/-(\d{2})-PLAN\.md$/);
return pm ? { file: p, num: parseInt(pm[1], 10) } : null;
})
.filter((e): e is { file: string; num: number } => e !== null)
.sort((a, b) => a.num - b.num);
// numberedPlans (and planNums below) answers Question 1 ONLY — the
// numbering-gap sequence check. It is a strict `-NN-PLAN.md` subset
// and must NOT be reused as a general "all live plans" set: a plan
// whose filename isn't in that canonical 2-digit form (a 3-digit
// continuation, a bare PLAN.md, etc.) is silently absent from it.
const planNums = numberedPlans.map((e) => e.num);
for (let i = 1; i < planNums.length; i++) {
if (planNums[i] !== planNums[i - 1] + 1) {
warnings.push(
`Gap in plan numbering in ${phaseLabel}: plan ${planNums[i - 1]} → ${planNums[i]}`,
);
}
}
// QUESTION 2 — plan↔summary pairing: "does this summary have a
// matching LIVE plan" wants the single owner's superseded-excluded
// planFiles and the canonical summaryCandidates-based pairing
// (findOrphanSummaries) instead of an exact-suffix Set-diff, which
// produced false "orphan summary" warnings for legacy/nested naming
// forms it could not recognize as paired.
const orphanSummaries = findOrphanSummaries(planFiles, summaryFiles);
for (const orphan of orphanSummaries) {
warnings.push(`Summary ${orphan} in ${phaseLabel} has no matching PLAN.md`);
}
// QUESTION 3 — wave-frontmatter presence: "does every LIVE plan
// declare a wave" wants the same live (superseded-excluded) set as
// Question 2's pairing check — planFiles, NOT numberedPlans/Question
// 1's strict 2-digit subset. A superseded plan legitimately carries
// no wave, and a live plan whose filename isn't in canonical 2-digit
// form (a 3-digit continuation, a bare PLAN.md, etc.) must still be
// checked here even though it is invisible to the numbering-gap scan.
for (const plan of planFiles) {
const planFilePath = path.join(phasePath, plan);
const content = fs.readFileSync(planFilePath, 'utf-8');
const fmData = extractFrontmatter(content, planFilePath);
if (!fmData['wave']) {
warnings.push(`${phaseLabel}/${plan}: missing 'wave' in frontmatter`);
}
}
}
} catch {
/* intentionally empty */
}
// Every current C0NN/W006/W007 diagnostic is `SEVERITY.WARNING` (confirmed
// by direct read of `consistency.cts`/`roadmap-disk-consistency.cts`) —
// matching the pre-migration shape, where only the ROADMAP-missing
// pre-check above ever populated `errors`. Bucketed defensively by
// severity anyway, mirroring `cmdValidateHealth`'s own pattern, so a
// future ERROR-severity rule added to `CONSISTENCY_RULES` lands in the
// right bucket without another migration.
const _slashRuntime = resolveRuntime(cwd);
const slash = (name: string) => formatGsdSlash(name, _slashRuntime) as string;
for (const diagnostic of diagnostics) {
const entry = diagnosticToIssueEntry(diagnostic, slash);
if (diagnostic.severity === HEALTH_SEVERITY.ERROR) errors.push(entry.message);
else warnings.push(entry);
}
const passed = errors.length === 0;