This commit is contained in:
@@ -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)
|
||||
|
||||
165
src/health-diagnostic-rules/consistency.cts
Normal file
165
src/health-diagnostic-rules/consistency.cts
Normal 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 };
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
118
src/state.cts
118
src/state.cts
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
264
src/verify.cts
264
src/verify.cts
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user