diff --git a/src/health-diagnostic-rules/milestone-archive-hygiene.cts b/src/health-diagnostic-rules/milestone-archive-hygiene.cts new file mode 100644 index 000000000..58f617296 --- /dev/null +++ b/src/health-diagnostic-rules/milestone-archive-hygiene.cts @@ -0,0 +1,108 @@ +/** + * Health Diagnostic — Milestone archive + root hygiene rules (Phase 11, + * #3309, ADR-3180 §8.2/§8.3/§8.5). + * + * Group: "Milestone archive + root hygiene" (design doc, "Rule table + * organization" table) — W018, W019. + * + * Ported behavior-preserving from `cmdValidateHealth` + * (`src/verify.cts:2301-2354`), the exact call sites for W018/W019. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * + * ADR-457 build-at-publish: source in + * src/health-diagnostic-rules/milestone-archive-hygiene.cts, compiled to + * gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.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; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import healthDiagnosticMod = require('../health-diagnostic.cjs'); +const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; +type Diagnostic = healthDiagnosticMod.Diagnostic; +type Rule = healthDiagnosticMod.Rule; + +import { isCanonicalPlanningFile } from '../artifacts.cjs'; + +// ─── W018 — MILESTONES.md missing archived milestone(s) (verify.cts:2301-2335) ── +// +// Condition: `snapshot.milestoneArchiveStatus.value.archivedVersions` (list of +// versions with a `.planning/milestones/-ROADMAP.md` snapshot file) minus +// `.documentedVersions` (list of `## ` headings already present in +// MILESTONES.md) — versions present in the archive but not documented in the +// registry. ONE aggregate `Diagnostic` listing every missing version, exactly +// mirroring the original's single `addIssue` call +// (`verify.cts:2321-2330`, `` `MILESTONES.md missing ${missingFromRegistry.length} +// archived milestone(s): ${missingFromRegistry.join(', ')}` ``) — the original +// computes the FULL list first (`missingFromRegistry`), then fires exactly one +// `addIssue` after the loop, not once per version. No diagnostic at all if the +// archive dir has zero recognized `-ROADMAP.md` snapshots (mirrors the +// original's `if (archivedVersions.length > 0)` guard) or if nothing is +// missing. + +function checkW018(snapshot: PlanningSnapshot): Diagnostic[] { + const { archivedVersions, documentedVersions } = snapshot.milestoneArchiveStatus.value; + if (archivedVersions.length === 0) return []; + + const documented = new Set(documentedVersions); + const missingFromRegistry = archivedVersions.filter((ver) => !documented.has(ver)); + if (missingFromRegistry.length === 0) return []; + + return [ + { + code: 'W018', + severity: SEVERITY.WARNING, + message: `MILESTONES.md missing ${missingFromRegistry.length} archived milestone(s): ${missingFromRegistry.join(', ')}`, + remedy: { + action: REMEDY_ACTION.BACKFILL_MILESTONES, + risk: REMEDY_RISK.NONE, + args: {}, + }, + }, + ]; +} + +// ─── W019 — Unrecognized .planning/ root file (verify.cts:2337-2354) ─────── +// +// Condition: for each filename in `snapshot.planningRootFiles.value` ending in +// `.md`, call `isCanonicalPlanningFile(filename)` (bare basename, not a path — +// `src/artifacts.cts:43`); flag any that return false. One `Diagnostic` PER +// unrecognized file (array return), mirroring the original's `addIssue` call +// INSIDE the `for` loop (`verify.cts:2343-2349`) — unlike W018 this is not an +// aggregate. Fix text copied verbatim from `verify.cts:2347`. + +function checkW019(snapshot: PlanningSnapshot): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + for (const filename of snapshot.planningRootFiles.value) { + if (!filename.endsWith('.md')) continue; + if (isCanonicalPlanningFile(filename)) continue; + diagnostics.push({ + code: 'W019', + severity: SEVERITY.WARNING, + message: `Unrecognized .planning/ file: ${filename} — not a canonical GSD artifact`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { + command: + 'Move to .planning/milestones/ archive subdir or delete if stale. See templates/README.md for the canonical artifact list.', + }, + }, + }); + } + return diagnostics; +} + +// ─── Exports ──────────────────────────────────────────────────────────────── + +const RULES: Rule[] = [ + { code: 'W018', severity: SEVERITY.WARNING, check: checkW018 }, + { code: 'W019', severity: SEVERITY.WARNING, check: checkW019 }, +]; + +export = { RULES }; diff --git a/tests/health-diagnostic-rules/milestone-archive-hygiene.test.cjs b/tests/health-diagnostic-rules/milestone-archive-hygiene.test.cjs new file mode 100644 index 000000000..ebc6c90bf --- /dev/null +++ b/tests/health-diagnostic-rules/milestone-archive-hygiene.test.cjs @@ -0,0 +1,231 @@ +'use strict'; + +/** + * Tests for `src/health-diagnostic-rules/milestone-archive-hygiene.cts` + * (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) — W018, W019. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * + * Fixture provenance (CONTRIBUTING.md / repo rule): every case builds a real + * `.planning/` tree in a temp dir and runs it through the REAL compiled + * `buildPlanningSnapshot` (`src/planning-snapshot.cts`) — no hand-built + * `PlanningSnapshot` mocks. W018's fixture is a mechanical mutation of a real + * MILESTONES.md shape (one version's `## ` entry deliberately + * omitted while its archive snapshot file is present). W019's fixture is a + * real stray `.md` file dropped into `.planning/` root alongside the three + * canonical `.md` files (PROJECT.md/ROADMAP.md/STATE.md), confirming those + * three do not false-positive. + * + * TDD RED: `src/health-diagnostic-rules/milestone-archive-hygiene.cts` does + * not exist yet at the start of this batch — this file's + * `require('../../gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs')` + * throws MODULE_NOT_FOUND until this batch's implementation lands. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('../helpers.cjs'); + +const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs'); +const { RULES } = require('../../gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs'); +const { REMEDY_ACTION, REMEDY_RISK, SEVERITY } = require('../../gsd-core/bin/lib/health-diagnostic.cjs'); + +const checkW018 = RULES.find((r) => r.code === 'W018').check; +const checkW019 = RULES.find((r) => r.code === 'W019').check; + +function planningDirOf(cwd) { + return path.join(cwd, '.planning'); +} + +function writeFile(cwd, relPath, content) { + const full = path.join(cwd, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); +} + +function writeMinimalRoadmap(cwd) { + writeFile(cwd, '.planning/ROADMAP.md', ['## v1.0 Current 🚧', '', '### Phase 1: Foo', ''].join('\n')); +} + +// ─── W018 — MILESTONES.md missing archived milestone(s) ──────────────────── + +describe('W018 — archived milestone snapshot not documented in MILESTONES.md', () => { + test('fires ONE aggregate diagnostic listing all missing versions, not one per version', () => { + const cwd = createTempDir('gsd-w018-'); + try { + writeMinimalRoadmap(cwd); + // Real shape, mechanically mutated: MILESTONES.md documents v1.0 but is + // MISSING the v0.9 entry, while the archive dir has snapshot files for + // BOTH v0.9 and v1.0. + writeFile(cwd, '.planning/MILESTONES.md', ['## v1.0', '', 'Shipped.', ''].join('\n')); + writeFile(cwd, '.planning/milestones/v0.9-ROADMAP.md', '## v0.9 Archived\n'); + writeFile(cwd, '.planning/milestones/v1.0-ROADMAP.md', '## v1.0 Archived\n'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(snapshot.milestoneArchiveStatus.value.archivedVersions.sort(), ['v0.9', 'v1.0']); + assert.deepEqual(snapshot.milestoneArchiveStatus.value.documentedVersions, ['v1.0']); + + const diagnostics = checkW018(snapshot); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].code, 'W018'); + assert.equal(diagnostics[0].severity, SEVERITY.WARNING); + assert.equal( + diagnostics[0].message, + 'MILESTONES.md missing 1 archived milestone(s): v0.9', + ); + assert.deepEqual(diagnostics[0].remedy, { + action: REMEDY_ACTION.BACKFILL_MILESTONES, + risk: REMEDY_RISK.NONE, + args: {}, + }); + } finally { + cleanup(cwd); + } + }); + + test('aggregates MULTIPLE missing versions into one message, not one diagnostic each', () => { + const cwd = createTempDir('gsd-w018-multi-'); + try { + writeMinimalRoadmap(cwd); + // MILESTONES.md documents nothing at all; two archive snapshots exist. + writeFile(cwd, '.planning/MILESTONES.md', ''); + writeFile(cwd, '.planning/milestones/v0.9-ROADMAP.md', '## v0.9 Archived\n'); + writeFile(cwd, '.planning/milestones/v1.0-ROADMAP.md', '## v1.0 Archived\n'); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = checkW018(snapshot); + assert.equal(diagnostics.length, 1); + assert.equal( + diagnostics[0].message, + 'MILESTONES.md missing 2 archived milestone(s): v0.9, v1.0', + ); + } finally { + cleanup(cwd); + } + }); + + test('does not fire when every archived version is documented', () => { + const cwd = createTempDir('gsd-w018-clean-'); + try { + writeMinimalRoadmap(cwd); + writeFile(cwd, '.planning/MILESTONES.md', ['## v0.9', '## v1.0', ''].join('\n')); + writeFile(cwd, '.planning/milestones/v0.9-ROADMAP.md', '## v0.9 Archived\n'); + writeFile(cwd, '.planning/milestones/v1.0-ROADMAP.md', '## v1.0 Archived\n'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(checkW018(snapshot), []); + } finally { + cleanup(cwd); + } + }); + + test('does not fire when the archive dir has zero recognized -ROADMAP.md snapshots', () => { + const cwd = createTempDir('gsd-w018-noarchive-'); + try { + writeMinimalRoadmap(cwd); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(snapshot.milestoneArchiveStatus.value.archivedVersions, []); + assert.deepEqual(checkW018(snapshot), []); + } finally { + cleanup(cwd); + } + }); +}); + +// ─── W019 — Unrecognized .planning/ root file ─────────────────────────────── + +describe('W019 — unrecognized .planning/ root file', () => { + test('fires one diagnostic for a genuinely stray .md file at .planning/ root', () => { + const cwd = createTempDir('gsd-w019-'); + try { + writeMinimalRoadmap(cwd); + writeFile(cwd, '.planning/NOTES.md', '# scratch notes\n'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.ok(snapshot.planningRootFiles.value.includes('NOTES.md')); + + const diagnostics = checkW019(snapshot); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].code, 'W019'); + assert.equal(diagnostics[0].severity, SEVERITY.WARNING); + assert.equal( + diagnostics[0].message, + 'Unrecognized .planning/ file: NOTES.md — not a canonical GSD artifact', + ); + assert.deepEqual(diagnostics[0].remedy, { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { + command: + 'Move to .planning/milestones/ archive subdir or delete if stale. See templates/README.md for the canonical artifact list.', + }, + }); + } finally { + cleanup(cwd); + } + }); + + test('fires one diagnostic PER unrecognized file when multiple stray files exist', () => { + const cwd = createTempDir('gsd-w019-multi-'); + try { + writeMinimalRoadmap(cwd); + writeFile(cwd, '.planning/NOTES.md', '# scratch\n'); + writeFile(cwd, '.planning/SCRATCH.md', '# scratch2\n'); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = checkW019(snapshot); + assert.equal(diagnostics.length, 2); + assert.deepEqual(diagnostics.map((d) => d.code), ['W019', 'W019']); + assert.deepEqual( + diagnostics.map((d) => d.message).sort(), + [ + 'Unrecognized .planning/ file: NOTES.md — not a canonical GSD artifact', + 'Unrecognized .planning/ file: SCRATCH.md — not a canonical GSD artifact', + ], + ); + } finally { + cleanup(cwd); + } + }); + + test('PROJECT.md, ROADMAP.md, and STATE.md do NOT false-positive as W019 findings', () => { + const cwd = createTempDir('gsd-w019-canonical-'); + try { + writeMinimalRoadmap(cwd); + writeFile(cwd, '.planning/PROJECT.md', '# Project\n'); + writeFile( + cwd, + '.planning/STATE.md', + ['---', 'status: in-progress', '---', ''].join('\n'), + ); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual( + snapshot.planningRootFiles.value.filter((f) => f.endsWith('.md')).sort(), + ['PROJECT.md', 'ROADMAP.md', 'STATE.md'], + ); + assert.deepEqual(checkW019(snapshot), []); + } finally { + cleanup(cwd); + } + }); + + test('non-.md root files are never considered by the rule (loop skips them before the predicate)', () => { + const cwd = createTempDir('gsd-w019-nonmd-'); + try { + writeMinimalRoadmap(cwd); + writeFile(cwd, '.planning/config.json', '{}'); + writeFile(cwd, '.planning/random.txt', 'not markdown\n'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(checkW019(snapshot), []); + } finally { + cleanup(cwd); + } + }); +});