Merge pull request #3405 from open-gsd/refactor/3309-health-diagnostic-rule-table

This commit is contained in:
Tom Boucher
2026-08-13 08:47:34 -04:00
committed by GitHub
56 changed files with 8921 additions and 1190 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3405
---
**`validate health --backfill` now works without also passing `--repair`** — previously it silently did nothing unless `--repair` was also set, due to an unreachable internal gate.

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3405
---
**`validate health` splits two previously-conflated warning codes into their own codes** — W021 now covers only the phase-id-convention mismatch it originally meant; the STATE-vs-ROADMAP milestone-complete mismatch it used to also report moves to the new W026. Likewise W017 now covers only orphan worktrees; the stale-worktree case moves to the new W027.

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3405
---
**`validate health --repair` no longer resets config.json or regenerates STATE.md automatically** — these two repairs are destructive (they lose custom settings or session history), so they're now reported with their fix described but never auto-applied; run the suggested command yourself to apply them.

10
.gitignore vendored
View File

@@ -196,6 +196,16 @@ build/
/gsd-core/bin/lib/planning-workspace.cjs
/gsd-core/bin/lib/planning-scope.cjs
/gsd-core/bin/lib/planning-snapshot.cjs
/gsd-core/bin/lib/health-diagnostic-types.cjs
/gsd-core/bin/lib/health-diagnostic.cjs
/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs
/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs
/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs
/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs
/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs
/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs
/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs
/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs
/gsd-core/bin/lib/command-roster.cjs
/gsd-core/bin/lib/runtime-artifact-conversion.cjs
/gsd-core/bin/lib/runtime-artifact-layout.cjs

View File

@@ -106,6 +106,15 @@ Leaf module owning the frozen `SCOPE` discriminator (`COMPLETE` / `TRUNCATED` /
### Planning Snapshot Module
Module owning the parsed projection of `.planning/` that a diagnostic rule may read, per ADR-3180 §8.1 (Decision 8, Phase 10, #3308). `buildPlanningSnapshot(cwd) → PlanningSnapshot` is composed EXCLUSIVELY from the already-consolidated §7 owners — `getMilestoneInfo` (Roadmap Parser Module), `listMilestonePhaseDirs` (Phase Locator Module), `isPhaseComplete` (Verification Module), `scanPhasePlans` (Plan Scan Module), `stateFieldValue`/`stateCurrentPositionSlice` (STATE.md Document Module), `planningPaths` (Planning Workspace Module) — and introduces no new semantic derivation of its own. `PlanningSnapshot` exposes `milestone`/`phaseDirs`/`phases`/`currentPhaseLabel`, each a `{value, scope}` pair per the Planning Scope Module's frozen `SCOPE` enum; `phases` additionally carries a `PhaseSnapshot[]` (`dir`, `complete`, `verificationStatus`, `planCount`, `summaryCount`, `scope`). The one new piece of logic this module adds is `worstScope(...scopes) → Scope`, a pure severity-ordered combinator (`UNREADABLE` > `UNSCOPED` > `TRUNCATED` > `COMPLETE`) that folds several independently-scoped owner answers about the same phase directory into one composite signal — NOT a re-derivation of any owner (each owner's own algorithm is untouched; only their already-computed `scope` verdicts are combined), but new coordination logic no single owner has the visibility to express. Every exposed field carries PARSED values only, never raw document text — this is structural, not advisory: a diagnostic rule given only the parsed value cannot re-derive a field's location the way `#3162`'s three inert `Current Phase` literal-search predicates did. Read failures on STATE.md (exists-but-unreadable, distinct from absent) are reported via the Unusable Input Diagnostic Module's `warnUnusableInput(UNUSABLE_REASON.STATE_UNREADABLE)`. Guarded by `scripts/lint-planning-snapshot-bypass-drift.cjs` (ratcheted per Decision 4(e), scoped to `DIAGNOSTIC_RULE_FUNCTIONS` — currently `cmdValidateHealth` in `src/verify.cts` only, acknowledging its existing raw `.planning/` reads as debt owned by Phase 11, #3309, which migrates it onto this snapshot). Source of truth: `gsd-core/bin/lib/planning-snapshot.cjs` (generated from `src/planning-snapshot.cts`). Design: `.gsd/phase/refactor-3308-planning-snapshot-parsed-projection/40-design.md`.
### Health Diagnostic Types Module
Leaf module owning the `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums and `Diagnostic`/`Remedy`/`Rule` types shared between the Health Diagnostic Module (the evaluator) and the Health Diagnostic Rule Groups (the eight rule-group files it concatenates). Split out of `src/health-diagnostic.cts` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) to break a CJS circular dependency: the evaluator must `require()` every rule-group file to populate `RULES`, and every rule-group file needs these enums/types — if the rule-group files required the evaluator back, the require cycle would resolve `module.exports` before it is assigned. This leaf has no runtime dependency on either side of that cycle. Source of truth: `gsd-core/bin/lib/health-diagnostic-types.cjs` (generated from `src/health-diagnostic-types.cts`).
### Health Diagnostic Module
Module owning the frozen rule-table contract for `validate health`, per ADR-3180 §8.2/§8.3/§8.5 (Phase 11, #3309). Exposes three frozen enums — `SEVERITY` (`error`/`warning`/`info`), `REMEDY_ACTION` (the six real repair actions harvested from `cmdValidateHealth`'s existing `--repair` implementation — `createConfig`, `resetConfig`, `regenerateState`, `addNyquistKey`, `addAiIntegrationPhaseKey`, `backfillMilestones` — plus `advise`, the non-repairable payload every non-actionable finding's fix text becomes), and `REMEDY_RISK` (`none`/`destructive`) — plus the `Diagnostic`/`Remedy`/`Rule` shapes every rule's `check(snapshot: PlanningSnapshot) → Diagnostic[]` signature and every finding's `remedy` conform to. `RULES: Rule[]` is the rule table, fully wired: the static concatenation of the 31 rules exported by the eight Health Diagnostic Rule Groups files below (extracted from `cmdValidateHealth`, `src/verify.cts:1616-2577` — 31, not the design doc's own inconsistent prose figure of 32; see the Rule Groups entry's own note). `evaluateRules(snapshot) → Diagnostic[]` runs every rule in `RULES` against one `PlanningSnapshot` and flattens the results, throwing on any two rules sharing a `code` — defense in depth beside the static 1:1 lint guard (§8.2 rule 1, `scripts/lint-health-diagnostic-rule-table.cjs`). `applyRepairs(cwd, diagnostics, repair, backfill) → {applied, refused, details}` is the `--repair`/`--backfill` dispatcher: a `DESTRUCTIVE` remedy (`resetConfig`/`regenerateState` — health.md's own published table: "loses custom settings" / "loses session history") is reported but never executed by `--repair`, a deliberate, disclosed breaking change (§8.3 rule 3) from `cmdValidateHealth`'s current unconditional application; `backfillMilestones` alone among the `NONE`-risk actions is requested by `--backfill` without `--repair`, mirroring `cmdValidateHealth`'s existing gate (`src/verify.cts:2504`). Per-action repair handlers (`runRepairAction`) are REAL, ported behavior-preserving from `verify.cts:2405-2553`'s repair switch — `createConfig`/`resetConfig` (write the default config.json payload), `regenerateState` (backs up and regenerates STATE.md), `addNyquistKey`/`addAiIntegrationPhaseKey` (add a missing `workflow.*` key), `backfillMilestones` (synthesize missing MILESTONES.md entries from archive snapshots) — not stubs. `applied` records only a repair that actually SUCCEEDED (`outcome.success === true`); a thrown or `{success: false}` attempt is recorded in `details` with `success: false` but is never pushed to `applied`. Source of truth: `gsd-core/bin/lib/health-diagnostic.cjs` (generated from `src/health-diagnostic.cts`). Design: `.gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md`.
### Health Diagnostic Rule Groups
Directory `src/health-diagnostic-rules/` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) owning the 31 rules migrated off `cmdValidateHealth` (not the design doc's own inconsistent prose figure of 32 — 31 is the count actually summed from each group's exported `RULES` array and locked by `tests/health-diagnostic.test.cjs`'s "RULES" describe block), split into eight files — one per subject-area group from the design doc's "Rule table organization" table — each exporting a `RULES: Rule[]` conforming to the Health Diagnostic Module's frozen `Rule` shape. `src/health-diagnostic.cts` concatenates all eight into the single `RULES` table `evaluateRules` runs; no group re-derives its own `Diagnostic`/`Remedy` shapes. Groups: `root-existence.cts` (root `.planning/` + PROJECT.md existence, E002-E004/W001), `state-consistency.cts` (STATE.md vs config/ROADMAP/disk, W002/W011/W021/W026 — W024's state_head freshness check is a disclosed gap, deliberately not migrated), `config-validation.cts` (config.json shape, W003/W004/W022/E005/W008/W012-W016), `phase-structure.cts` (phase directory structure, W005/W023/I001/W009), `agent-install.cts` (agent-installation completeness, W010), `roadmap-disk-consistency.cts` (ROADMAP-vs-disk phase matching via the shared `matchPhaseDirs` matcher, W006/W007), `worktree-health.cts` (worktree health, W020/W017/W027), `milestone-archive-hygiene.cts` (milestone archive + root hygiene, W018/W019). Every rule is a behavior-preserving port of one `addIssue` call site in `cmdValidateHealth` (`src/verify.cts`), reading only the parsed `PlanningSnapshot` fields the Planning Snapshot Module already computes — never raw `.planning/` I/O. Source of truth: `gsd-core/bin/lib/health-diagnostic-rules/*.cjs` (generated from `src/health-diagnostic-rules/*.cts`).
### Planning Workspace Module
Module owning `.planning` path resolution, active workstream pointer policy (`session-scoped > shared`), pointer self-heal behavior, and planning lock semantics for workstream-aware execution.

View File

@@ -992,11 +992,13 @@ v1.40.0, [#2792](https://github.com/open-gsd/gsd-core/issues/2792)).
| Flag | Description |
|------|-------------|
| `--repair` | Auto-fix recoverable issues |
| `--backfill` | Synthesize missing MILESTONES.md entries from `.planning/milestones/vX.Y-ROADMAP.md` snapshots |
| `--context` | Probe context-window utilization; warns at 60 %, critical at 70 % |
```bash
/gsd-health # Check integrity
/gsd-health --repair # Check and fix
/gsd-health --backfill # Backfill missing MILESTONES.md entries
/gsd-health --context # Context-utilization triage
```
@@ -1012,6 +1014,18 @@ rather than that its contents are correct. The advisory never changes health's
pass/fail status, and stays silent when the stamp is absent or the project isn't
a git repo — "unknown" is reported as unknown, not as fresh.
**`--repair` does not apply destructive fixes.** Resetting config.json
(`resetConfig`) and regenerating STATE.md (`regenerateState`) are destructive
— the former loses custom settings, the latter loses session history — so
`--repair` reports these fixes as available but never applies them
automatically; the suggested command must be run by hand (ADR-3180,
[#3309](https://github.com/open-gsd/gsd-core/issues/3309)). The same migration
split two previously-conflated diagnostic codes: `W021` now covers only the
phase-id-convention mismatch, with the STATE-vs-ROADMAP milestone-complete
mismatch it used to also report moving to the new `W026`; likewise `W017` now
covers only orphan worktrees, with the stale-worktree case moving to the new
`W027`.
### `/gsd-cleanup`
Archive accumulated phase directories from completed milestones and prune local branches whose upstream has been deleted.

View File

@@ -644,7 +644,7 @@
### 19. Health Validation
**Command:** `/gsd-health [--repair]`
**Command:** `/gsd-health [--repair] [--backfill]`
**Purpose:** Validate `.planning/` directory integrity and auto-repair issues.
@@ -653,7 +653,8 @@
- REQ-HEALTH-02: System MUST validate configuration consistency
- REQ-HEALTH-03: System MUST detect orphaned plans without summaries
- REQ-HEALTH-04: System MUST check phase numbering and roadmap sync
- REQ-HEALTH-05: `--repair` flag MUST auto-fix recoverable issues
- REQ-HEALTH-05: `--repair` flag MUST auto-fix recoverable issues except DESTRUCTIVE-risk ones, which it MUST report but never auto-apply
- REQ-HEALTH-06: `--backfill` flag MUST synthesize missing MILESTONES.md entries from archived milestone snapshots
---

View File

@@ -378,7 +378,19 @@
"graphify.cjs",
"gsd2-import.cjs",
"handshake-serialized.cjs",
"health-diagnostic-rules/agent-install.cjs",
"health-diagnostic-rules/config-validation.cjs",
"health-diagnostic-rules/milestone-archive-hygiene.cjs",
"health-diagnostic-rules/phase-structure.cjs",
"health-diagnostic-rules/roadmap-disk-consistency.cjs",
"health-diagnostic-rules/root-existence.cjs",
"health-diagnostic-rules/state-consistency.cjs",
"health-diagnostic-rules/worktree-health.cjs",
"health-diagnostic-types.cjs",
"health-diagnostic.cjs",
"hook-bus.cjs",
"host-integration-adapters/cline-sdk-binding.cjs",
"host-integration-adapters/imperative-hook-bus.cjs",
"host-integration-sdk.cjs",
"host-integration.cjs",
"host-runtime-detection.cjs",
@@ -392,6 +404,16 @@
"installer-migration-authoring.cjs",
"installer-migration-report.cjs",
"installer-migrations.cjs",
"installer-migrations/000-first-time-baseline.cjs",
"installer-migrations/001-legacy-orphan-files.cjs",
"installer-migrations/002-codex-legacy-hooks-json.cjs",
"installer-migrations/003-rename-get-shit-done-to-gsd-core.cjs",
"installer-migrations/004-prune-stale-pristine-snapshots.cjs",
"installer-migrations/005-opencode-baseline-commands-dir.cjs",
"installer-migrations/006-pi-extension-cjs-to-js.cjs",
"installer-migrations/007-retire-config-root-commonjs-marker.cjs",
"installer-migrations/008-cursor-retire-commands-surface.cjs",
"installer-migrations/009-pi-retire-reserved-hooks-dir.cjs",
"intel-command-router.cjs",
"intel.cjs",
"io.cjs",
@@ -409,6 +431,9 @@
"model-profiles.cjs",
"model-resolver.cjs",
"normalize-test-command.cjs",
"observability/event.cjs",
"observability/logger.cjs",
"observability/redaction.cjs",
"onboard-projection.cjs",
"package-identity.cjs",
"package-legitimacy.cjs",

View File

@@ -432,9 +432,20 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| Module | Responsibility |
|--------|----------------|
| `installer-migrations/000-first-time-baseline.cjs` | Installer migration: records the first-time installer migration baseline scan — walks per-runtime install surfaces so pre-existing files are classified before any later migration runs |
| `installer-migrations/001-legacy-orphan-files.cjs` | Installer migration: removes manifest-managed legacy orphan hook files (`hooks/gsd-notify.sh`, `hooks/statusline.js`) |
| `installer-migrations/002-codex-legacy-hooks-json.cjs` | Installer migration: removes legacy Codex `hooks.json` GSD hook registrations |
| `installer-migrations/003-rename-get-shit-done-to-gsd-core.cjs` | Installer migration: removes stale legacy `get-shit-done/` runtime directory files after the rename to `gsd-core/` (#604) |<!-- gsd-allow-legacy-name -->
| `installer-migrations/004-prune-stale-pristine-snapshots.cjs` | Installer migration: removes stale `gsd-pristine/get-shit-done/` snapshot files left behind by the get-shit-done → gsd-core rename (#604, #934) |<!-- gsd-allow-legacy-name -->
| `installer-migrations/005-opencode-baseline-commands-dir.cjs` | Installer migration: baselines pre-existing OpenCode `commands/` (plural) files missed by migration 000's RUNTIME_SURFACES list (#2329 follow-up) |
| `installer-migrations/006-pi-extension-cjs-to-js.cjs` | Installer migration: retires pi's stale `extensions/gsd.cjs` after #2470 renamed the installed native extension to `extensions/gsd.js` |
| `installer-migrations/007-retire-config-root-commonjs-marker.cjs` | Installer migration: retires the config-root `{"type":"commonjs"}` marker that pre-#2544 installs wrote over `<configRoot>/package.json` |
| `installer-migrations/008-cursor-retire-commands-surface.cjs` | Installer migration: retires Cursor's duplicate `commands/` surface now that skills are the sole workflow surface (#2644) |
| `installer-migrations/009-pi-retire-reserved-hooks-dir.cjs` | Installer migration: retires pi's legacy `hooks/` directory after GSD's shared hook bundle moved to `gsd-hooks/` (#3023) |
| `active-workstream-store.cjs` | Workstream source precedence and selection (CLI `--ws` > `GSD_WORKSTREAM` env > stored pointer); name validation and environment propagation |
| `adr-parser.cjs` | ADR decision parser for plan-phase ingest express path; normalizes section synonyms, parses status/decision/scope fences, and enforces status rejection gates |
| `agent-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools agent` |
| `health-diagnostic-rules/agent-install.cjs` | Health-diagnostic rule: agent-installation-completeness check (W010) — the single `checkAgentsInstalled` call site's four mutually exclusive conditions, ported behavior-preserving from `cmdValidateHealth` (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) |
| `api-coverage.cjs` | API-coverage detector + matrix validator (#1562, #2365) — pure `detectApiIntegration` (fail-closed: same-clause verb+noun signal + `<Service> API/SDK` surface naming a real service; strips fenced code, inline code, and path-shaped tokens; external hosts count, first-party route paths do not) and `validateCoverageMatrix`/`parseCoverageMatrix`/`renderCoverageMatrix` for the COVERAGE.md artifact (incl. the `No external API integration: <reason>` declaration); STDIN CLI (`echo "$SCOPE" \| node .../api-coverage.cjs [--json]`, exit 0=detected/1=none/2=error); consumed by the `ai-integration` capability's `plan:pre` contribution and blocking `verify:pre` gate (`check api-coverage.verify-pre`) |
| `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint |
| `audit-command-router.cjs` | ADR-959 capability command router for `gsd-tools audit-uat` and `gsd-tools audit-open` — extracted from hardcoded cases in `gsd-tools.cjs`; dispatches to `uat.cjs:cmdAuditUat` and `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; phase 4d-impl-3 |
@@ -457,6 +468,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `claude-orchestration.cjs` | Claude Orchestration capability (#1143) — Workflow-tool backend detection + emitter; `detectWorkflowBackend` fail-closed gate (`{available, backend: 'workflow'\|'inline', reason}`, degrades to today's inline behavior unless every gate opens) and `emitWorkflowScript` (maps GSD's wave/plan model onto Workflow primitives: wave → sequential `parallel()` barriers, plan → `agent(...)` with per-plan worktree isolation mirroring the inline path). Pure, zero external dependencies, never throws; never invokes the Workflow tool itself |
| `cli-exit.cjs` | `ExitError` class and `runMain()` helper — CLI entrypoints throw `ExitError` instead of calling `process.exit()`; `runMain()` translates the outcome into `process.exitCode` so output flushes cleanly |
| `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers |
| `host-integration-adapters/cline-sdk-binding.cjs` | Cline SDK binding — pure AgentPlugin `beforeTool` planning-artifact guard and `createAgentModel` model-override resolution adapters, no `@cline/sdk` import (ADR-1239 Phase D, #2090) |
| `clock.cjs` | Injectable clock seam (now/sleep) for deterministic lock testing |
| `clusters.cjs` | Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2) |
| `code-review-flags.cjs` | Typed flag parser for `/gsd-code-review`; exports `parseCodeReviewFlags(argv)` (→ `{ fix, all, auto, depth, files }`) and `resolveCodeReviewWorkflow(flags)` (→ `'code-review.md' \| 'code-review-fix.md'`); canonical dispatch seam for `--fix`/`--all`/`--auto` routing |
@@ -470,6 +482,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `config-loader.cjs` | Project config loading — defaults merge, legacy-key migration, workstream overlay, unknown-key/profile-override validation (extracted from `core.cjs`, ADR-857) |
| `config-schema.cjs` | Single source of truth for `VALID_CONFIG_KEYS` and dynamic key patterns; imported by both the validator and the config-schema-docs parity test |
| `config-types.cjs` | TypeScript type definitions for the `model_policy` config block — `ModelPolicyConfig`, `TierEntry`, `RuntimeTiers`; compiled from `src/config-types.cts` at publish time (ADR-457) |
| `health-diagnostic-rules/config-validation.cjs` | Health-diagnostic rules: config.json validation checks (W003, W004, W022, E005, W008, W012-W016), reading only `snapshot.config`, ported behavior-preserving from `cmdValidateHealth` (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) |
| `config.cjs` | `config.json` read/write, section initialization; imports validator from `config-schema.cjs` |
| `configuration.cjs` | Configuration Module — legacy-key normalization, defaults merge, and explicit on-disk migration; pure normalization primitives consumed by `config-loader.cjs` and `config-schema.cjs` (loadConfig extracted to config-loader per ADR-857 #885) |
| `context-composer.cjs` | Shared budget-composition seam (ADR-1671, #2929) — `composeWithinBudget` trims an ordered fragment list to a measured budget and returns a PLAN of surviving fragments, never rendered text, so one seam serves both the review pipeline and per-runtime emission. Closed strategy set: `verbatim`, `head-shrink`, `proportional-truncate` (with a per-fragment floor), `drop`. The budget unit is injected via `measure(text)` — tokens for `prompt-budget`, bytes for emission — with `charsPerUnit` as its inverse. Also exports `headShrink`/`tailTruncate`. Compiled from `src/context-composer.cts` |
@@ -485,6 +498,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `eval-command-router.cjs` | Routes the `eval.score` verb (compiled from `src/eval-command-router.cts`, gitignored) — thin dispatcher into the eval scoring module (#1579) |
| `eval.cjs` | Deterministic eval scoring (compiled from `src/eval.cts`, gitignored) — `computeEvalScore` (coverage*0.6 + infra*0.4, bands 80/60/40) + `cmdEvalScore` CLI domain guard; moves the gsd-eval-auditor's weighted arithmetic out of the prompt into code (#10 / #1579) |
| `estimate-cli.cjs` | I/O seam over `phase-estimation.cjs` — the `estimate-check` and `estimate-calibration` query verbs; reads the `workflow.smart_zone_tokens` budget and `.planning/estimation-calibration.json`, both degrading to defaults rather than failing planning (#2630) |
| `observability/event.cjs` | DispatchEvent shape factory for every Hub dispatch — traceId/parentTraceId/command/result/timestamp record consumed by DispatchLogger (#177, ADR-0174 P1.3/P1.4) |
| `fallow-runner.cjs` | Fallow audit adapter for `/gsd-code-review`: binary resolution (`PATH` then `node_modules/.bin`), actionable missing-binary errors, and structural findings normalization |
| `federated-config.cjs` | Defensive merge of capability-declared config slices into the loadConfig return value — ADR-857 phase 3b; exports `mergeFederatedConfig({ configSchema, isCentralKey, userConfig })` → `{ values, validKeys, warnings }`; live for migrated Capability keys that are atomically removed from the central config schema |
| `frontmatter.cjs` | YAML frontmatter CRUD operations |
@@ -493,8 +507,11 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `graphify.cjs` | Knowledge-graph build/query/status/diff for `/gsd-graphify` |
| `graphify-command-router.cjs` | ADR-959 capability command router for `gsd-tools graphify` — dispatches build/query/status/diff subcommands; first real capability command cutover (phase 4d-impl-2) |
| `gsd2-import.cjs` | External-plan ingest for `/gsd-import --from-gsd2` |
| `health-diagnostic-types.cjs` | Shared, dependency-free `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums and `Diagnostic`/`Remedy`/`Rule` types for `validate health` — split out of `health-diagnostic.cjs` so its rule-group files can depend on the enums/types without a CJS circular require back into the evaluator (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) |
| `health-diagnostic.cjs` | Frozen rule-table contract for `validate health` — `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums, `Diagnostic`/`Remedy`/`Rule` shapes, the fully-wired `RULES` table (the static concatenation of the 31 rules exported by the eight `health-diagnostic-rules/*.cjs` group files), `evaluateRules` (throws on duplicate rule codes), and `applyRepairs` (the real `--repair`/`--backfill` dispatcher with real per-action handlers — refuses `DESTRUCTIVE`-risk remedies) (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) |
| `host-integration.cjs` | Host-Integration Interface (ADR-1239 Phase A) — negotiated capability contract over the six host-integration points; `negotiateHostCapabilities` fail-closes on undeclared/unknown/`undocumented` values, typed degradation ladder, host-capability profiles; the 8 `runtime.hostIntegration` axes are validated in `capability-validator.cjs` and sourced per-CLI in `docs/reference/host-integration-capability-matrix.md` |
| `host-runtime-detection.cjs` | Host Runtime Detection Module (ADR-2313 Phase 5, #3245) — the detection rung beneath `GSD_RUNTIME` and `.planning/config.json` `runtime` that lets `init` report `agent_runtime: codex` inside a Codex session instead of the hardcoded `claude` default; `detectHostRuntime` returns the typed `{runtime, source, signal}` from citation-backed Codex signals (`CODEX_SANDBOX`/`CODEX_SANDBOX_NETWORK_DISABLED`, else `CODEX_HOME` + `config.toml`), `resolveReportedRuntime` composes the full ladder. Pure, injectable, never writes, never shells out |
| `host-integration-adapters/imperative-hook-bus.cjs` | Imperative hook-bus adapter — descriptor-driven `hooks.json` binding generalized from the Cursor-specific writer, resolving the negotiated `hookBus` axis against a host's documented `hostBehaviors.managedHookEvents` list; pure, no I/O (ADR-1239 Phase D, #2089) |
| `init-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools init` |
| `init.cjs` | Compound context loading for each workflow type |
| `install-effort-resolver.cjs` | Install-time effort resolution — `readGsdEffectiveEffortConfig` (merges `~/.gsd/defaults.json` + project `.planning/config.json`) + `resolveInstallTimeEffort`, extracted from `bin/install.js` (#2071) so `gsd-tools effort sync` can require it from the shipped runtime instead of the never-copied package-root installer; install.js imports them back (single source) |
@@ -510,10 +527,12 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `io.cjs` | CLI I/O primitives — `output`/`error` emission, JSON-error mode, and large-payload temp-file spillover (extracted from `core.cjs`, ADR-857) |
| `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` |
| `legacy-cleanup.cjs` | Detect and remove leftover get-shit-done-cc artifacts; exports `planLegacyCleanup` (pure scan) and `applyLegacyCleanup` (thin IO applier) that root out stale files from the old package across every GSD-managed runtime config directory (#607) |
| `observability/logger.cjs` | DispatchLogger interface + default implementation — silent on success, structured stderr JSON on error, opt-in `.gsd-trace.jsonl` audit file (`GSD_AUDIT=1`), args omitted unless `GSD_AUDIT_ARGS=1` (#177, ADR-0174 P1.3) |
| `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts for the five-step pipeline (discuss/plan/execute/verify/ship); emitted by `scripts/gen-loop-host-contract.cjs --write` (ADR-894 §3); consumed by `gen-capability-registry.cjs` |
| `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c/6 registry-consuming query; given a canonical loop point, filters `byLoopPoint` by resolved Capability State plus config activation (`when` key traversal with prototype-pollution guard), returns `{ point, activeHooks, rendered }` envelope; `resolveLoopHooks` and `renderLoopHooks` are pure (no I/O); command surface: `gsd-tools loop render-hooks <point> [--config-dir <path>]` |
| `markdown-sectionizer.cjs` | Canonical markdown-structure parsing seam (ADR-1372, epic #1372) — pure, Node built-ins only; exports `stripFencedCode` (CommonMark-correct fence stripper, CRLF-safe), `stripInlineCode` (per-line CommonMark inline-code-span stripper, #2365), `tokenizeHeadings` (ATX headings outside fenced blocks), `collectSections`/`collectSection` (line-by-line section collection with `bodyStart`/`bodyEnd` offsets), `iterateBullets` (dash/checkbox/numbered markers), `extractTaggedBlocks` (inner text of `<tag>…</tag>` blocks, caller decides fence-stripping), `replaceSection` (pure character-offset body splice for read-modify-write callers), and `withSection` (resolve a section by heading/predicate and run an edit callback against ONLY its body, splicing the result back — ADR-2143 §4 bounded mutation); foundation for T0–T7 migration tiers retiring 8+ ad-hoc parsers |
| `markdown-table.cjs` | Canonical GFM table model + `TABLE_SCHEMAS` registry seam (ADR-2143, epic #2143) — pure, Node built-ins only; exports `parseMarkdownTable(sectionText) → Result<MarkdownTable>` (parses the first GFM pipe table, typed parse errors for ragged/malformed rows rather than silent coercion), `MarkdownTable` (`{columns, rows}`, rows addressed by column name), `Result<T>` (`{ok:true,value}\|{ok:false,reason}` — distinct from command-routing-hub's dispatch `Result`), `TABLE_SCHEMAS` (canonical column-header variants for `RoadmapProgress`/`RequirementsTraceability`/`QuickTasks`/`Security` tables), and `matchTableSchema(columns) → {id,label}\|null` (resolves parsed headers back to a canonical schema); consumed by `phase-lifecycle.cts`'s `deriveProgressFromRoadmap` (fixes #2137, the 5-column milestone-grouped Progress table) |
| `health-diagnostic-rules/milestone-archive-hygiene.cjs` | Health-diagnostic rules: milestone archive + root hygiene checks (W018, W019), ported behavior-preserving from `cmdValidateHealth` (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) |
| `milestone.cjs` | Milestone archival, requirements marking |
| `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers |
| `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table |
@@ -525,6 +544,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `phase-id.cjs` | Pure phase-id parsing/matching helpers — normalize, token match, milestone/phase-dir id parsing, phase-markdown regex builders (extracted from `core.cjs`, ADR-857) |
| `phase-lifecycle.cjs` | Pure-computation phase lifecycle helpers extracted from the phase-lifecycle SDK handler |
| `phase-locator.cjs` | Phase-directory search/location — active + archived phase-dir discovery, phase-id matching against the filesystem (extracted from `core.cjs`, ADR-857) |
| `health-diagnostic-rules/phase-structure.cjs` | Health-diagnostic rules: phase directory structure checks (W005, W023, I001, W009), ported behavior-preserving from `cmdValidateHealth` (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) |
| `phase.cjs` | Phase directory operations, decimal numbering, plan indexing |
| `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` |
| `plan-dependency-graph.cjs` | Shared halt-propagation over a plan's `depends_on` DAG — the single topological-order + halt-propagation engine used by both `phase.cjs`'s wave-grouping and `phase-locator.cjs`'s phase-location primitive, so the two can never diverge on which plans a halted plan blocks (#2830) |
@@ -537,6 +557,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `profile-pipeline-command-router.cjs` | ADR-959 capability command router for the profile-pipeline command family — dispatches scan-sessions, extract-messages, profile-sample (pipeline phase) and write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md (output phase); phase 6 cutover |
| `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning |
| `prompt-budget.cjs` | Pure token-budget accounting for review prompts — estimates tokens, applies deterministic trim priority (head-shrink PROJECT.md, proportional plan truncation, drop context/research/requirements, hard-fail guard), returns structured metadata for `review.max_prompt_tokens` (#3081) |
| `observability/redaction.cjs` | Arg redaction policy for dispatch events — args omitted from every emitted event by default, opt-in verbatim inclusion via `GSD_AUDIT_ARGS=1`; stateless env read, no module-level caching (#177) |
| `refactor-trigger-command-router.cjs` | ADR-959 capability command router for `gsd-tools refactor` (issue #1953) — dispatches evaluate/status/accept/decline subcommands for the complexity-triggered refactor capability; owns capability-activation gating, git invocation (via the `git-base-branch.cjs` `phaseStartCommit`/`changedFilesSince` adapters), config reads, phase-directory resolution, and the optional broken-windows ledger integration around the pure `complexity-trigger.cjs` leaf |
| `research-provider.cjs` | Research provider waterfall, confidence tiers, and planResearch (cache-hits + fetch plan) |
| `research-store.cjs` | Content-addressed research cache: sha256 keys, per-source TTL staleness, two-tier (user ~/.gsd / project .planning) store |
@@ -548,9 +569,11 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `review-lane-runner.cjs` | Execution of a reviewer-lane invocation plan (compiled from `src/review-lane-runner.cts`, gitignored; ADR-2782 Phase 5b) — probe, spawn or HTTP call, empty-output policy, egress-host check, and dispatch of the three first-party `handler` modules; exports `runLane`, `probeLane`, `checkEgressHost`, `writeReviewOrStub` |
| `review-reviewer-selection.cjs` | Reviewer selection/normalization helpers for `/gsd-review` default reviewer policy and precedence |
| `roadmap-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools roadmap` |
| `health-diagnostic-rules/roadmap-disk-consistency.cjs` | Health-diagnostic rules: ROADMAP-vs-disk phase directory consistency checks (W006, W007), both resolved through the shared `matchPhaseDirs` matcher, ported behavior-preserving from `cmdValidateHealth` (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) |
| `roadmap-parser.cjs` | ROADMAP.md parsing — milestone slicing, current-milestone extraction, phase/milestone lookups, milestone-phase filter (extracted from `core.cjs`, ADR-857) |
| `roadmap-upgrade.cjs` | Migration tool for converting legacy `Phase N` entries to milestone-prefixed `Phase M-NN` convention; `computeMigrationPlan` + `applyMigration` with dry-run default and atomic rollback |
| `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress |
| `health-diagnostic-rules/root-existence.cjs` | Health-diagnostic rules: root `.planning/` existence + PROJECT.md checks (E002-E004, W001), ported behavior-preserving from `cmdValidateHealth` (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) |
| `runtime-artifact-conversion.cjs` | Runtime artifact conversion module — projects Claude-authored commands, agents, and skills into runtime-specific artifact bodies while preserving installer compatibility exports |
| `runtime-artifact-install-plan.cjs` | Runtime artifact install plan module — stages pre-resolved layout kinds, applies runtime body rewrites, and returns copy-plan items plus cleanup obligations |
| `runtime-artifact-layout.cjs` | Runtime artifact layout module — resolves the artifact directory shapes (commands, agents, skills) for each supported runtime; single source of truth for per-runtime artifact placement (#3663) |
@@ -567,6 +590,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `shell-command-projection.cjs` | Runtime-aware shell command projection for managed hook serialization: decides PowerShell call-operator usage by runtime/platform and normalizes Windows script path tokens |
| `spec-section.cjs` | SPEC section-status helper (compiled from `src/spec-section.cts`, gitignored) — the single source of truth for the canonical SPEC headings (suffix-tolerant) and markdown-table row counting; `specSectionStatus`/`countSectionDataRows` decide per-section "supplied" for plan-phase's spec-less probe fallback, replacing ad-hoc awk (contract pinned by `tests/spec-section.test.cjs`) |
| `state-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools state` |
| `health-diagnostic-rules/state-consistency.cjs` | Health-diagnostic rules: STATE.md consistency checks (W002, W011, W021, W026) against config/ROADMAP/disk, ported behavior-preserving from `cmdValidateHealth`; W024 (state_head freshness) is a documented gap, deliberately not migrated (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) |
| `state.cjs` | STATE.md parsing, updating, progression, metrics |
| `state-document.cjs` | Pure STATE.md field extraction, replacement, status normalization, and progress calculation transforms |
| `surface.cjs` | Runtime surface module — manages the runtime enable/disable surface state independently of the install-time profile marker (ADR-0011 Phase 2) |
@@ -590,6 +614,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `workstream-name-policy.cjs` | Canonical workstream name validation (`isValidActiveWorkstreamName`, `hasInvalidPathSegment`, `validateWorkstreamName`) and slug normalization (`toWorkstreamSlug`) |
| `workstream.cjs` | Workstream CRUD, migration, session-scoped active pointer |
| `worktree-base-ref.cjs` | Worktree base-ref drift detection and degrade decision (`evaluateWorktreeBaseDegrade`) plus no-clobber `worktree.baseRef` settings management for the `base-check`/`set-baseref` subcommands (#683) |
| `health-diagnostic-rules/worktree-health.cjs` | Health-diagnostic rules: worktree health checks (W020, W017, W027 — the split-off stale-worktree subject), ported behavior-preserving from `cmdValidateHealth` (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) |
| `worktree-safety.cjs` | Worktree-root resolution and non-destructive prune policy decisions; owns W017 health-check logic |
| `write-set.cjs` | Shared fail-loud `Result<T>` (`{ok:true,value}\|{ok:false,reason}`) and per-surface write-set contracts (ADR-2143, epic #2143) — `WriteOutcome` (`{surface,applied}`), `WriteSet` (`WriteOutcome[]`), and `writeSetComplete(ws)` (true only when the set is non-empty AND every surface applied, never an OR-into-one-flag); `markdown-table.cjs` re-exports `Result` from here so existing importers are unaffected; consumed by `milestone.cts`'s `requirements mark-complete` handler to report a structured per-surface (`checkbox`/`traceability`) write-set alongside its existing fields (fixes the structural half of #2140) |

View File

@@ -135,6 +135,16 @@ export default tseslint.config(
'gsd-core/bin/lib/configuration.cjs',
'gsd-core/bin/lib/state-document.cjs',
'gsd-core/bin/lib/planning-snapshot.cjs',
'gsd-core/bin/lib/health-diagnostic-types.cjs',
'gsd-core/bin/lib/health-diagnostic.cjs',
'gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs',
'gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs',
'gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs',
'gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs',
'gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs',
'gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs',
'gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs',
'gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs',
'gsd-core/bin/lib/shell-command-projection.cjs',
'gsd-core/bin/lib/security.cjs',
'gsd-core/bin/lib/command-aliases.cjs',

View File

@@ -216,14 +216,14 @@ Report final status.
</process>
<error_codes>
| Code | Severity | Description | Repairable |
|------|----------|-------------|------------|
| E001 | error | .planning/ directory not found | No |
| E002 | error | PROJECT.md not found | No |
| E003 | error | ROADMAP.md not found | No |
| E004 | error | STATE.md not found | Yes |
| E005 | error | config.json parse error | Yes |
| E004 | error | STATE.md not found | No |
| E005 | error | config.json parse error | No |
| E010 | error | CWD resolves to the user's home directory — health check would target the wrong .planning/ | No |
| W001 | warning | PROJECT.md missing required section | No |
| W002 | warning | STATE.md references invalid phase | No |
| W003 | warning | config.json not found | Yes |
@@ -233,24 +233,38 @@ Report final status.
| W007 | warning | Phase on disk but not in ROADMAP | No |
| W008 | warning | config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip) | Yes |
| W009 | warning | Phase has Validation Architecture in RESEARCH.md but no VALIDATION.md | No |
| W010 | warning | GSD agent installation missing or incomplete | No |
| W011 | warning | STATE.md current-phase status disagrees with ROADMAP.md checkbox | No |
| W012 | warning | config.json invalid branching_strategy value | No |
| W013 | warning | config.json context_window not a positive integer | No |
| W014 | warning | config.json phase_branch_template missing {phase} placeholder | No |
| W015 | warning | config.json milestone_branch_template missing {milestone} placeholder | No |
| W016 | warning | config.json: workflow.ai_integration_phase absent (defaults to enabled but agents may skip AI-integration-phase planning) | Yes |
| W017 | warning | Orphan git worktree (path no longer exists on disk) | No |
| W018 | warning | MILESTONES.md missing entry for archived milestone snapshot | Yes (`--backfill`) |
| W019 | warning | Unrecognized .planning/ root file — not a canonical GSD artifact | No |
| W020 | warning | Worktree health scan degraded — git worktree list timed out, failed, or a finding could not be verified | No |
| W021 | warning | Phase's integer prefix implies a different milestone than its ROADMAP section (phase_id_convention: milestone-prefixed) | No |
| W022 | warning | config.json models entry malformed (unknown phase type, invalid tier, or non-object value) | No |
| W023 | warning | Phase directories collide on normalized key | No |
| W024 | warning | STATE.md was written many commits ago — treat its contents as approximate | No |
| W025 | warning | config.json: workflow.use_worktrees enabled on a runtime whose dispatch.isolation is none (#2486) | No |
| W026 | warning | STATE says milestone complete but ROADMAP lists an unstarted phase for that milestone | No |
| W027 | warning | Stale git worktree (not modified in a long time) | No |
| I001 | info | Plan without SUMMARY (may be in progress) | No |
| I010 | info | Resolved CWD reported alongside the E010 home-directory guard | No |
Note: the `W0NN` warning-code namespace is owned by `src/verify.cts` (`validate.health`), which also emits codes this table does not list (`W010`–`W017` and `W020`–`W023` as of #2486). `W001`–`W024` are all allocated, so this workflow's isolation warning is `W025`. Before assigning a new code here, grep `src/verify.cts` for the next free number — the table alone under-represents the live namespace, and two PRs in flight can otherwise claim the same code (which is exactly what happened between #2486 and #2573).
Note: this table is **generated** — do not hand-edit it. It is produced by `node scripts/gen-health-docs.cjs --write` from `src/health-diagnostic.cts`'s `RULES` table (31 rules as of #3309, each carrying a static `description`/`repairable` on its `Rule` entry — see `src/health-diagnostic-types.cts`) plus the 3 pre-checks that stay outside the rule table by design (`E001`, `E010`, `I010` — safety rails in `cmdValidateHealth`, `src/verify.cts`, never `.planning/` findings). `scripts/lint-health-diagnostic-rule-table.cjs` already enforces the 1:1 code invariant this table depends on (no duplicate codes; severity is always a `Rule` property, never set per emit call) — before assigning a new code, add a `Rule` entry under `src/health-diagnostic-rules/` (its `code` is simply the next free number the lint guard has not yet seen) and run `node scripts/gen-health-docs.cjs --write` to regenerate this table; `npm run lint:generated-sync` fails if it drifts. `W025` (the `workflow.use_worktrees`/`dispatch.isolation` check, #2486) is a workflow-layer diagnostic emitted directly by this file's own `run_health_check` step, not by `cmdValidateHealth` — it stays documented in that step, not in this generated table.
</error_codes>
<repair_actions>
| Action | Effect | Risk |
|--------|--------|------|
| createConfig | Create config.json with defaults | None |
| resetConfig | Delete + recreate config.json | Loses custom settings |
| regenerateState | Create STATE.md from ROADMAP structure when it is missing | Loses session history |
| addNyquistKey | Add workflow.nyquist_validation: true to config.json | None — matches existing default |
| addAiIntegrationPhaseKey | Add workflow.ai_integration_phase: true to config.json | None — matches existing default |
| backfillMilestones | Synthesize missing MILESTONES.md entries from `.planning/milestones/vX.Y-ROADMAP.md` snapshots | None — additive only; triggered by `--backfill` flag |
**Not repairable (too risky):**

View File

@@ -114,7 +114,7 @@
"lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs",
"lint:frontmatter-scalar-broad-grep": "node scripts/lint-frontmatter-scalar-broad-grep.cjs",
"lint:removed-but-needed": "node scripts/lint-removed-but-needed.cjs",
"lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs",
"lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs",
"lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
"lint:regression-names": "node scripts/lint-regression-test-names.cjs",
"lint:descriptions": "node scripts/lint-descriptions.cjs",
@@ -122,7 +122,7 @@
"lint:test-file-count": "node scripts/lint-test-file-count.cjs",
"lint:pr-checks": "node scripts/lint-pr-check-project-dir.cjs",
"lint:changeset": "node scripts/changeset/lint.cjs",
"lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check && node scripts/gen-context-index.cjs --check && node scripts/gen-section-manifest.cjs --check",
"lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check && node scripts/gen-context-index.cjs --check && node scripts/gen-section-manifest.cjs --check && node scripts/gen-health-docs.cjs --check",
"lint:docs": "node scripts/lint-docs-required.cjs",
"lint:qa-smells": "node scripts/qa-smell-ratchet.cjs",
"lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs",

View File

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

390
scripts/gen-health-docs.cjs Normal file
View File

@@ -0,0 +1,390 @@
#!/usr/bin/env node
'use strict';
/**
* Generates the `<error_codes>` and `<repair_actions>` tables in
* `gsd-core/workflows/health.md` from `src/health-diagnostic.cts`'s `RULES`
* table (Phase 11 follow-up, #3309 "Proposed behavior": "health.md's tables
* are generated rather than hand-maintained, closing the 16-vs-30+
* documentation gap structurally").
*
* Sources:
* - The 31 real rules in the compiled `RULES` array
* (`gsd-core/bin/lib/health-diagnostic.cjs`, built from
* `src/health-diagnostic.cts` + `src/health-diagnostic-rules/*.cts`),
* each carrying a static `description`/`repairable` (see
* `src/health-diagnostic-types.cts`'s `Rule` interface).
* - `PRECHECK_CODES` below — E001, E010, I010 — the three diagnostics
* `cmdValidateHealth` (`src/verify.cts`) emits as pre-checks OUTSIDE the
* rule table entirely (ADR-3180 §8.2 rule 4, "no precedence system" —
* see `.gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md`,
* "Two guards that stay OUTSIDE the rule table entirely"). These will
* never appear in `RULES`, so they are a small, static, clearly-labeled
* list merged in here instead.
* - `REMEDY_ACTION_METADATA` below — the Effect/Risk prose for each of the
* 6 real repair actions (`REMEDY_ACTION`, excluding `ADVISE`, which never
* acts). Static because the compiled module carries no Effect/Risk text
* of its own — only the action identifier.
*
* Deliberately EXCLUDED from the generated `<error_codes>` table: `W025`
* (the `workflow.use_worktrees`/`dispatch.isolation` check, #2486). It is a
* workflow-layer diagnostic emitted directly by this same file's own
* `run_health_check` step (a bash block in `health.md` itself), never by
* `cmdValidateHealth`/`RULES` — it has no `Rule` entry and is not one of the
* three pre-checks above. It stays fully documented in prose at its own step
* (`<step name="run_health_check">`), which is the authoritative, more
* detailed source `<error_codes>` used to merely summarize; dropping the
* redundant table row is not a loss of information, and folding it back in
* here would require this generator to parse bash, which it does not do.
* Same reasoning for `I002` (stale Windows task-directory cleanup,
* `<stale_task_cleanup>` step) — it was never part of the `<error_codes>`
* tagged region even before this generator existed.
*
* Usage:
* node scripts/gen-health-docs.cjs # print both tables to stdout
* node scripts/gen-health-docs.cjs --write # rewrite the tagged regions in health.md
* node scripts/gen-health-docs.cjs --check # exit 1 if either region is stale
* node scripts/gen-health-docs.cjs --write --target <path> # test-only: target a fixture file
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.resolve(__dirname, '..');
const HEALTH_MD_REL = 'gsd-core/workflows/health.md';
const HEALTH_MD_PATH = path.join(ROOT, HEALTH_MD_REL);
const COMPILED_MODULE_REL = 'gsd-core/bin/lib/health-diagnostic.cjs';
const COMPILED_MODULE_PATH = path.join(ROOT, COMPILED_MODULE_REL);
const ERROR_CODES_START = '<error_codes>';
const ERROR_CODES_END = '</error_codes>';
const REPAIR_ACTIONS_START = '<repair_actions>';
const REPAIR_ACTIONS_END = '</repair_actions>';
/**
* The 3 pre-check diagnostics `cmdValidateHealth` emits OUTSIDE the rule
* table (see module header). All three are non-repairable safety rails, not
* `.planning/` findings a remedy could act on.
*/
const PRECHECK_CODES = [
{
code: 'E001',
severity: 'error',
description: '.planning/ directory not found',
repairable: false,
},
{
code: 'E010',
severity: 'error',
description: "CWD resolves to the user's home directory — health check would target the wrong .planning/",
repairable: false,
},
{
code: 'I010',
severity: 'info',
description: 'Resolved CWD reported alongside the E010 home-directory guard',
repairable: false,
},
];
/**
* Effect/Risk prose per real `REMEDY_ACTION` (everything except `ADVISE`,
* which never acts and has no row in `<repair_actions>`). Text for the 5
* actions the hand-written table already documented is reused VERBATIM;
* `addAiIntegrationPhaseKey` is new — #3309 itself notes it was "live in
* code, missing from docs" (mirrors `addNyquistKey`, its structural sibling:
* same shape, one config key each).
*/
const REMEDY_ACTION_METADATA = new Map([
['createConfig', { effect: 'Create config.json with defaults', risk: 'None' }],
['resetConfig', { effect: 'Delete + recreate config.json', risk: 'Loses custom settings' }],
[
'regenerateState',
{
effect: 'Create STATE.md from ROADMAP structure when it is missing',
risk: 'Loses session history',
},
],
[
'addNyquistKey',
{ effect: 'Add workflow.nyquist_validation: true to config.json', risk: 'None — matches existing default' },
],
[
'addAiIntegrationPhaseKey',
{ effect: 'Add workflow.ai_integration_phase: true to config.json', risk: 'None — matches existing default' },
],
[
'backfillMilestones',
{
effect: 'Synthesize missing MILESTONES.md entries from `.planning/milestones/vX.Y-ROADMAP.md` snapshots',
risk: 'None — additive only; triggered by `--backfill` flag',
},
],
]);
/** Order the Effect/Risk table renders in — matches `REMEDY_ACTION`'s own declaration order. */
const REMEDY_ACTION_ORDER = [
'createConfig',
'resetConfig',
'regenerateState',
'addNyquistKey',
'addAiIntegrationPhaseKey',
'backfillMilestones',
];
/**
* Per-code override for the "Repairable" cell's display text, for codes
* whose remedy is conditional on a flag the plain `Yes`/`No` can't express
* (mirrors the hand-written table's pre-existing `W018` row: `Yes (--backfill)`).
*/
const REPAIRABLE_DISPLAY_OVERRIDE = new Map([['W018', 'Yes (`--backfill`)']]);
const STATIC_NOT_REPAIRABLE_BULLETS = [
'PROJECT.md, ROADMAP.md content',
'Phase directory renaming',
'Orphaned plan cleanup',
];
const FOOTNOTE_PARAGRAPH =
'Note: this table is **generated** — do not hand-edit it. It is produced by ' +
'`node scripts/gen-health-docs.cjs --write` from `src/health-diagnostic.cts`\'s `RULES` table ' +
'(31 rules as of #3309, each carrying a static `description`/`repairable` on its `Rule` entry — ' +
'see `src/health-diagnostic-types.cts`) plus the 3 pre-checks that stay outside the rule table by ' +
'design (`E001`, `E010`, `I010` — safety rails in `cmdValidateHealth`, `src/verify.cts`, never ' +
'`.planning/` findings). `scripts/lint-health-diagnostic-rule-table.cjs` already enforces the 1:1 ' +
'code invariant this table depends on (no duplicate codes; severity is always a `Rule` property, ' +
'never set per emit call) — before assigning a new code, add a `Rule` entry under ' +
'`src/health-diagnostic-rules/` (its `code` is simply the next free number the lint guard has not ' +
'yet seen) and run `node scripts/gen-health-docs.cjs --write` to regenerate this table; ' +
'`npm run lint:generated-sync` fails if it drifts. `W025` (the `workflow.use_worktrees`/' +
'`dispatch.isolation` check, #2486) is a workflow-layer diagnostic emitted directly by this file\'s ' +
'own `run_health_check` step, not by `cmdValidateHealth` — it stays documented in that step, not in ' +
'this generated table.';
/**
* Load the compiled health-diagnostic module. Throws a clear ExitError (not
* a raw MODULE_NOT_FOUND) if `npm run build:lib` has not run — mirrors
* `scripts/lint-health-diagnostic-rule-table.cjs`'s `loadCompiledModule`.
*/
function loadCompiledModule(compiledPath = COMPILED_MODULE_PATH) {
if (!fs.existsSync(compiledPath)) {
throw new ExitError(
2,
`gen-health-docs: compiled artifact not found at ${COMPILED_MODULE_REL}.\n` +
'Run `npm run build:lib` first.',
);
}
return require(compiledPath);
}
/**
* Sort order for the `<error_codes>` table: E-codes, then W-codes
* numerically, then I-codes — matching the hand-written table's pre-existing
* order. NOT insertion order from `RULES` (which is grouped by
* subject-area file, not sorted by code).
*/
const PREFIX_RANK = { E: 0, W: 1, I: 2 };
function parseCode(code) {
const m = code.match(/^([A-Z]+)(\d+)$/);
if (!m) throw new Error(`gen-health-docs: unparseable diagnostic code "${code}"`);
return { prefix: m[1], number: Number(m[2]) };
}
function compareCodes(a, b) {
const pa = parseCode(a.code);
const pb = parseCode(b.code);
const rankA = PREFIX_RANK[pa.prefix] ?? 99;
const rankB = PREFIX_RANK[pb.prefix] ?? 99;
if (rankA !== rankB) return rankA - rankB;
return pa.number - pb.number;
}
/**
* Combine the 31 real rules + the 3 static pre-checks into one sorted row
* list for the `<error_codes>` table.
*
* @param {Array<{code: string, severity: string, description: string, repairable: boolean}>} rules
*/
function buildErrorCodeRows(rules) {
const seen = new Set();
const rows = [];
for (const entry of [...rules, ...PRECHECK_CODES]) {
if (seen.has(entry.code)) {
throw new Error(`gen-health-docs: duplicate diagnostic code "${entry.code}" across RULES + PRECHECK_CODES`);
}
seen.add(entry.code);
rows.push(entry);
}
rows.sort(compareCodes);
return rows;
}
/** Escape a cell's markdown-table-hostile characters (mirrors gen-adr-index.cjs's `cellText`). */
function cellText(text) {
return String(text)
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\r?\n/g, ' ')
.trim();
}
function repairableCell(row) {
if (REPAIRABLE_DISPLAY_OVERRIDE.has(row.code)) return REPAIRABLE_DISPLAY_OVERRIDE.get(row.code);
return row.repairable ? 'Yes' : 'No';
}
function renderErrorCodesRegion(rules) {
const rows = buildErrorCodeRows(rules);
const lines = ['', '| Code | Severity | Description | Repairable |', '|------|----------|-------------|------------|'];
for (const row of rows) {
lines.push(`| ${row.code} | ${row.severity} | ${cellText(row.description)} | ${repairableCell(row)} |`);
}
lines.push('', FOOTNOTE_PARAGRAPH, '');
return lines.join('\n');
}
function renderRepairActionsRegion() {
const lines = ['', '| Action | Effect | Risk |', '|--------|--------|------|'];
for (const action of REMEDY_ACTION_ORDER) {
const meta = REMEDY_ACTION_METADATA.get(action);
if (!meta) {
throw new Error(
`gen-health-docs: no Effect/Risk metadata registered for repair action "${action}" — add an entry to REMEDY_ACTION_METADATA.`,
);
}
lines.push(`| ${action} | ${meta.effect} | ${meta.risk} |`);
}
lines.push('', '**Not repairable (too risky):**');
for (const bullet of STATIC_NOT_REPAIRABLE_BULLETS) lines.push(`- ${bullet}`);
lines.push('');
return lines.join('\n');
}
/**
* Splice `newInner` between `${startTag}`/`${endTag}` inside `text`. Throws
* if either tag is missing, or if the tags appear more than once (this
* generator only ever targets the FIRST occurrence pair, and a duplicate
* tag anywhere in the file would silently corrupt the splice).
*/
function spliceRegion(text, startTag, endTag, newInner) {
const startIdx = text.indexOf(startTag);
const endIdx = text.indexOf(endTag);
if (startIdx === -1 || endIdx === -1) {
throw new ExitError(
1,
`gen-health-docs: ${HEALTH_MD_REL} is missing the ${startTag}/${endTag} tags.`,
);
}
if (text.indexOf(startTag, startIdx + 1) !== -1 || text.indexOf(endTag, endIdx + 1) !== -1) {
throw new ExitError(1, `gen-health-docs: ${HEALTH_MD_REL} has more than one ${startTag}/${endTag} pair.`);
}
const before = text.slice(0, startIdx + startTag.length);
const after = text.slice(endIdx);
return `${before}${newInner}\n${after}`;
}
/**
* Regenerate `health.md`'s full text from `rules` (the compiled `RULES`
* array) and the current on-disk `health.md` content.
*/
function regenerateHealthMd(rules, currentText) {
let out = spliceRegion(currentText, ERROR_CODES_START, ERROR_CODES_END, renderErrorCodesRegion(rules));
out = spliceRegion(out, REPAIR_ACTIONS_START, REPAIR_ACTIONS_END, renderRepairActionsRegion());
return out;
}
/**
* @param {string[]} argv
* @returns {{write: boolean, check: boolean, targetPath: string|null}}
*/
function parseArgs(argv) {
const opts = { write: false, check: false, targetPath: null };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--write') opts.write = true;
else if (arg === '--check') opts.check = true;
else if (arg === '--target') {
const value = argv[i + 1];
if (value === undefined) throw new ExitError(1, '--target requires a path argument.');
opts.targetPath = value;
i++;
} else {
throw new ExitError(1, `unknown flag: ${arg}\nRecognized flags: --write, --check, --target <path>.`);
}
}
return opts;
}
function main() {
const { write, check, targetPath } = parseArgs(process.argv.slice(2));
const { RULES } = loadCompiledModule();
// `--target` overrides the real committed health.md path, exclusively for
// test isolation (mirrors gen-section-manifest.cjs's `--manifest-path`
// override) — no production caller ever passes it.
const resolvedPath = targetPath ? path.resolve(targetPath) : HEALTH_MD_PATH;
const displayPath = targetPath ? targetPath : HEALTH_MD_REL;
const currentText = fs.existsSync(resolvedPath) ? fs.readFileSync(resolvedPath, 'utf8') : null;
if (currentText === null) {
throw new ExitError(1, `gen-health-docs: ${displayPath} not found.`);
}
const expected = regenerateHealthMd(RULES, currentText);
if (write) {
fs.writeFileSync(resolvedPath, expected, 'utf8');
process.stdout.write(
`Wrote ${displayPath} — ${RULES.length + PRECHECK_CODES.length} error/warning/info code(s), ` +
`${REMEDY_ACTION_ORDER.length} repair action(s).\n`,
);
return 0;
}
if (check) {
if (expected !== currentText) {
process.stderr.write(
`${displayPath} is stale — its <error_codes>/<repair_actions> tables do not match ` +
"src/health-diagnostic.cts's RULES table.\nRun:\n node scripts/gen-health-docs.cjs --write\n\n",
);
throw new ExitError(1);
}
process.stdout.write(
`${displayPath} is up to date (${RULES.length + PRECHECK_CODES.length} codes, ${REMEDY_ACTION_ORDER.length} repair actions).\n`,
);
return 0;
}
process.stdout.write(renderErrorCodesRegion(RULES) + '\n\n' + renderRepairActionsRegion() + '\n');
return 0;
}
// Guarded: requiring this module (the test suite imports the pure render
// functions directly) must not also run the generator as a side effect.
if (require.main === module) runMain(main);
module.exports = {
loadCompiledModule,
buildErrorCodeRows,
renderErrorCodesRegion,
renderRepairActionsRegion,
regenerateHealthMd,
spliceRegion,
compareCodes,
parseCode,
PRECHECK_CODES,
REMEDY_ACTION_METADATA,
REMEDY_ACTION_ORDER,
REPAIRABLE_DISPLAY_OVERRIDE,
HEALTH_MD_PATH,
COMPILED_MODULE_PATH,
ERROR_CODES_START,
ERROR_CODES_END,
REPAIR_ACTIONS_START,
REPAIR_ACTIONS_END,
};

View File

@@ -117,6 +117,47 @@ function statOrNull(p) {
}
}
/**
* Collect `<dir>/<subdir>/<file>` entries as `<subdir>/<file>` keys — ONE level of
* subdirectory beneath `dir` itself, where the subdirectory's NAME is the thing being
* collected (unlike `collectNested`, there is no fixed subdir name to look for; every
* child directory of `dir` is scanned). This is what makes `gsd-core/bin/lib/<subdir>/*.cjs`
* (e.g. `health-diagnostic-rules/`, `installer-migrations/`, `host-integration-adapters/`,
* `observability/`) visible to the `cli_modules` family, mirroring the shape
* `docs/INVENTORY.md`'s CLI Modules table already uses for these files.
*
* Same defensive `statOrNull`-based style as `collectNested`: a stat/readdir failure on
* one entry is swallowed rather than thrown, so one unreadable subdirectory cannot take
* down `--check` for the whole repo.
*/
function collectOneLevelSubdirs({ dir, filter }) {
if (!fs.existsSync(dir)) return [];
const out = [];
let children;
try {
children = fs.readdirSync(dir);
} catch {
return [];
}
for (const child of children) {
const childStat = statOrNull(path.join(dir, child));
if (!childStat || !childStat.isDirectory()) continue;
const subdirPath = path.join(dir, child);
let files;
try {
files = fs.readdirSync(subdirPath);
} catch {
continue;
}
for (const file of files) {
const fileStat = statOrNull(path.join(subdirPath, file));
if (!fileStat || !fileStat.isFile() || !filter(file)) continue;
out.push([child, file].join('/'));
}
}
return out.sort();
}
function collectNested({ root, subdir, filter }) {
if (!fs.existsSync(root)) return [];
const out = [];
@@ -150,11 +191,16 @@ function collectNested({ root, subdir, filter }) {
function buildManifest() {
const manifest = { families: {} };
for (const { name, dir, filter, toName } of FAMILIES) {
manifest.families[name] = fs
const flat = fs
.readdirSync(dir)
.filter((f) => fs.statSync(path.join(dir, f)).isFile() && filter(f))
.map(toName)
.sort();
.map(toName);
// `cli_modules` also ships subdirectory modules (`health-diagnostic-rules/`,
// `installer-migrations/`, `host-integration-adapters/`, `observability/`) invisible to
// the flat readdirSync above; merge them into the SAME sorted array, matching the single
// "CLI Modules" table shape docs/INVENTORY.md already uses (#3309).
const nested = name === 'cli_modules' ? collectOneLevelSubdirs({ dir, filter }) : [];
manifest.families[name] = [...flat, ...nested].sort();
}
for (const family of NESTED_FAMILIES) {
manifest.families[family.name] = collectNested(family);
@@ -209,4 +255,4 @@ if (require.main === module) {
// `DEFECT.GENERATIVE-FIX` divergence class: adding a family here while the test kept
// its own list meant the test silently verified fewer families than shipped, and still
// passed. The test now imports these, so the two surfaces cannot drift.
module.exports = { FAMILIES, NESTED_FAMILIES, collectNested, buildManifest };
module.exports = { FAMILIES, NESTED_FAMILIES, collectNested, collectOneLevelSubdirs, buildManifest };

View File

@@ -0,0 +1,295 @@
#!/usr/bin/env node
'use strict';
/**
* lint-health-diagnostic-rule-table.cjs — gate: enforces ADR-3180 §8.2's 1:1
* rule-code invariant and §8.5's fixture-proof invariant for
* `src/health-diagnostic.cts`'s RULES table (Phase 11, #3309).
*
* ## What this enforces
*
* 1. (§8.2 rule 1 — 1:1 code invariant) Every `rule.code` in RULES (exported
* from the compiled `gsd-core/bin/lib/health-diagnostic.cjs`) is unique,
* and every rule's `severity` is one of `SEVERITY`'s values. The severity
* check exists only to confirm the compiled artifact was not hand-edited
* to bypass the `Rule.severity` required field TypeScript already
* enforces at compile time — "severity is a property of the RULE, never
* the emit call."
* 2. (§8.5 — fixture-proof invariant) Every code in RULES has a paired test:
* the code string (e.g. `'W001'`) appears as a literal AND within a
* `describe(`/`test(` block whose title also names that exact code, in
* one of the health-diagnostic test files
* (`tests/health-diagnostic-rules/*.test.cjs`,
* `tests/health-diagnostic.test.cjs`). A mere comment/string mention
* outside a titled block does not count as coverage.
*
* EXCEPTION — `PERMANENTLY_INERT_CODES` (below): a rule whose `check`
* always returns `[]` BY DESIGN (the real check lives outside the rule
* table entirely, because it needs ambient I/O `Rule.check` cannot
* perform — §8.1 rule 1) can never satisfy a real fixture-proof, no
* matter how many tests reference its code. Before this exception
* existed, W024 "passed" this guard only because an unrelated test title
* (the RULES-array shape assertion, "exports exactly 5 rules: W024, ...")
* happened to contain the string "W024" — accidental coverage, not proof
* the rule can fire. `PERMANENTLY_INERT_CODES` makes that exemption
* explicit and auditable instead of relying on a coincidental title
* match, and the PASS output now reports exempted codes SEPARATELY from
* genuinely fixture-covered ones rather than folding them together.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
* ("The lint guard (§8.2 1:1 invariant + §8.5 fixture proof)").
*
* ## Deviation from the design doc's original plan
*
* The design doc assumed fixtures would live as separate files at
* `tests/fixtures/health-diagnostic/<code>.*`. That did not happen during
* implementation — all 8 rule-group test files
* (`tests/health-diagnostic-rules/*.test.cjs`) build fixtures INLINE via
* real temp directories (`createTempDir()` from `tests/helpers.cjs`) and a
* real, non-mocked `buildPlanningSnapshot(tmpCwd)` call (see
* `tests/health-diagnostic-rules/root-existence.test.cjs`). This guard
* therefore verifies the fixture-proof invariant STATICALLY against the
* test files' own text — mirroring `scripts/lint-fix-has-regression-test.cjs`'s
* house style — rather than dynamically re-running fixture-building code
* this guard does not own.
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const COMPILED_MODULE_REL = 'gsd-core/bin/lib/health-diagnostic.cjs';
const COMPILED_MODULE_PATH = path.join(REPO_ROOT, COMPILED_MODULE_REL);
const TEST_GROUP_DIR = path.join(REPO_ROOT, 'tests', 'health-diagnostic-rules');
const SKELETON_TEST_FILE = path.join(REPO_ROOT, 'tests', 'health-diagnostic.test.cjs');
// Matches `describe(`/`test(`/`it(` calls whose first argument is a string
// literal, capturing that literal as the block's title. Line/regex-based
// (not full AST) per this repo's existing lint-guard house style
// (scripts/lint-planning-snapshot-bypass-drift.cjs's scanCode precedent).
const TITLED_BLOCK_RE = /\b(describe|test|it)\(\s*(['"`])((?:\\.|(?!\2)[^\\])*)\2/g;
// Rule codes whose `check` is a documented PERMANENT no-op (always returns
// `[]`) because the real check requires ambient I/O forbidden inside a
// `Rule.check(snapshot)` (§8.1 rule 1) — the real check runs elsewhere,
// outside the rule table. These can never be proven via a real
// diagnostic-firing fixture, so they are exempted from the §8.5 fixture-proof
// invariant explicitly here rather than via an accidental test-title match.
// Adding an entry is a deliberate, reviewed decision — see each reason.
const PERMANENTLY_INERT_CODES = new Map([
[
'W024',
'readStateHeadFreshness requires a git-log shell-out, forbidden ambient I/O for Rule.check ' +
'(§8.1 rule 1) — the real check runs in cmdValidateHealth itself, outside the rule table ' +
'(src/verify.cts). This rule-table entry is a permanent no-op by design, not a fixture gap.',
],
]);
/**
* Load the compiled health-diagnostic module. Throws a clear ExitError
* (not a raw MODULE_NOT_FOUND) if `npm run build:lib` has not run.
*/
function loadCompiledModule(compiledPath = COMPILED_MODULE_PATH) {
if (!fs.existsSync(compiledPath)) {
throw new ExitError(
2,
`lint-health-diagnostic-rule-table: compiled artifact not found at ${COMPILED_MODULE_REL}.\n` +
'Run `npm run build:lib` first.',
);
}
return require(compiledPath);
}
/**
* §8.2 rule 1 — 1:1 code invariant: every rule.code is unique, and every
* rule's severity is a member of SEVERITY's values.
*
* @param {Array<{code: string, severity: string}>} rules
* @param {Record<string, string>} severity SEVERITY export (code -> value)
* @returns {{duplicates: Array<{code: string, count: number}>, badSeverities: Array<{code: string, severity: unknown}>}}
*/
function checkOneToOneInvariant(rules, severity) {
const severityValues = new Set(Object.values(severity));
const counts = new Map();
const badSeverities = [];
for (const rule of rules) {
counts.set(rule.code, (counts.get(rule.code) || 0) + 1);
if (!severityValues.has(rule.severity)) {
badSeverities.push({ code: rule.code, severity: rule.severity });
}
}
const duplicates = [...counts.entries()]
.filter(([, count]) => count > 1)
.map(([code, count]) => ({ code, count }));
return { duplicates, badSeverities };
}
/**
* Extracts every `describe(`/`test(`/`it(` block title found in `text`.
*
* @param {string} text
* @returns {string[]}
*/
function extractTitledBlocks(text) {
const titles = [];
TITLED_BLOCK_RE.lastIndex = 0;
let match;
while ((match = TITLED_BLOCK_RE.exec(text)) !== null) {
titles.push(match[3]);
}
return titles;
}
/**
* True iff `code` appears verbatim, as a whole token, inside at least one of
* `titles`. Whole-token match guards against a shorter code accidentally
* substring-matching inside an unrelated longer token.
*
* @param {string} code
* @param {string[]} titles
*/
function codeAppearsInTitle(code, titles) {
const codeRe = new RegExp(`(?:^|[^A-Za-z0-9])${code}(?:$|[^A-Za-z0-9])`);
return titles.some((title) => codeRe.test(`|${title}|`));
}
/**
* Locates every health-diagnostic test file this guard scans for §8.5
* fixture-proof coverage.
*
* @param {string} repoRoot
* @returns {string[]} absolute paths, sorted
*/
function findHealthDiagnosticTestFiles(repoRoot = REPO_ROOT) {
const groupDir = path.join(repoRoot, 'tests', 'health-diagnostic-rules');
const files = [];
if (fs.existsSync(groupDir)) {
for (const entry of fs.readdirSync(groupDir)) {
if (entry.endsWith('.test.cjs')) {
files.push(path.join(groupDir, entry));
}
}
}
const skeletonTestFile = path.join(repoRoot, 'tests', 'health-diagnostic.test.cjs');
if (fs.existsSync(skeletonTestFile)) {
files.push(skeletonTestFile);
}
return files.sort();
}
/**
* §8.5 — fixture-proof invariant: for every code in `rules`, confirm at
* least one test file in `testFiles` has a `describe(`/`test(`/`it(` block
* whose title names that exact code — UNLESS the code is listed in
* `PERMANENTLY_INERT_CODES`, in which case it is reported separately as
* `exempted` (visibly, not folded into "covered") and never fails the guard
* regardless of test coverage.
*
* @param {Array<{code: string}>} rules
* @param {string[]} testFiles absolute paths to *.test.cjs files to scan
* @param {Map<string, string>} inertCodes PERMANENTLY_INERT_CODES (injectable for tests)
* @returns {{uncovered: string[], exempted: string[], testFilesScanned: string[]}}
*/
function checkFixtureProofInvariant(rules, testFiles, inertCodes = PERMANENTLY_INERT_CODES) {
const allTitles = [];
for (const file of testFiles) {
const text = fs.readFileSync(file, 'utf8');
allTitles.push(...extractTitledBlocks(text));
}
const uncovered = [];
const exempted = [];
for (const rule of rules) {
if (inertCodes.has(rule.code)) {
exempted.push(rule.code);
continue;
}
if (!codeAppearsInTitle(rule.code, allTitles)) {
uncovered.push(rule.code);
}
}
return { uncovered, exempted, testFilesScanned: testFiles };
}
function formatRepoRelative(absPath) {
return path.relative(REPO_ROOT, absPath).split(path.sep).join('/');
}
function main() {
const { RULES, SEVERITY } = loadCompiledModule();
const { duplicates, badSeverities } = checkOneToOneInvariant(RULES, SEVERITY);
const testFiles = findHealthDiagnosticTestFiles(REPO_ROOT);
const { uncovered, exempted } = checkFixtureProofInvariant(RULES, testFiles);
const problems = [];
if (duplicates.length > 0) {
const list = duplicates.map((d) => ` ${d.code} (${d.count} occurrences)`).join('\n');
problems.push(
`§8.2 rule 1 violated: ${duplicates.length} duplicated rule code(s) in RULES ` +
`(${COMPILED_MODULE_REL}):\n${list}\n` +
' remedy: codes are append-only and 1:1 with a single Rule — rename or remove the duplicate.',
);
}
if (badSeverities.length > 0) {
const list = badSeverities
.map((b) => ` ${b.code}: severity=${JSON.stringify(b.severity)}`)
.join('\n');
problems.push(
`§8.2 rule 3 violated: ${badSeverities.length} rule(s) with a severity not in SEVERITY's values:\n${list}\n` +
' remedy: severity is a property of the RULE — set it to SEVERITY.ERROR/WARNING/INFO.',
);
}
if (uncovered.length > 0) {
const scannedList = testFiles.map(formatRepoRelative).join('\n ');
problems.push(
`§8.5 violated: ${uncovered.length} rule code(s) with no describe()/test() block naming them ` +
`(a comment or bare string mention does not count):\n ${uncovered.join(', ')}\n\n` +
` Searched these test files:\n ${scannedList}\n\n` +
' remedy: add or extend a describe()/test() title in the matching ' +
'tests/health-diagnostic-rules/<group>.test.cjs file so the block title ' +
`names the code verbatim (e.g. describe('${uncovered[0]} — ...', () => { ... })), ` +
'and drive the rule to fire against a real fixture built via createTempDir() + buildPlanningSnapshot() ' +
'(see tests/health-diagnostic-rules/root-existence.test.cjs).',
);
}
if (problems.length > 0) {
throw new ExitError(1, `${problems.join('\n\n')}\n`);
}
const coveredCount = RULES.length - exempted.length;
const exemptedDetail = exempted
.map((code) => `${code} (${PERMANENTLY_INERT_CODES.get(code)})`)
.join('; ');
console.log(
`lint-health-diagnostic-rule-table: PASS — ${RULES.length} rule code(s): ${coveredCount} covered by a ` +
`real fixture, ${exempted.length} exempted across ${testFiles.length} test file(s).` +
(exempted.length > 0 ? `\n Exempted: ${exemptedDetail}` : ''),
);
}
runMain(main);
module.exports = {
loadCompiledModule,
checkOneToOneInvariant,
extractTitledBlocks,
codeAppearsInTitle,
findHealthDiagnosticTestFiles,
checkFixtureProofInvariant,
PERMANENTLY_INERT_CODES,
COMPILED_MODULE_PATH,
TEST_GROUP_DIR,
SKELETON_TEST_FILE,
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
/**
* Agent Install rule (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5).
*
* One code, W010, ported behavior-preserving from `cmdValidateHealth`'s
* agent-install block (`src/verify.cts:1992-2027`). That block wraps a
* single `checkAgentsInstalled(_slashRuntime, cwd)` call in a try/catch that
* swallows any thrown exception silently ("agent check is non-blocking",
* `verify.cts:2025-2027`) and then branches on the SAME subject —
* "agent installation is incomplete" — across four mutually exclusive
* combinations of `missing_agents`/`incomplete_agents`, firing at most one
* `addIssue('warning', 'W010', ...)` per call. Per this phase's design doc
* ("Rejected alternatives" §3), these four sites are confirmed to be one
* subject varying only in trigger detail, not four subjects — W010 stays a
* single code.
*
* `snapshot.agentInstall` (`src/planning-snapshot.cts`'s `buildAgentInstallField`)
* already performs the try/catch this rule used to need: `scope` is
* `UNREADABLE` only when the scan itself threw, mirroring
* `cmdValidateHealth`'s silent catch — this rule reproduces that silence by
* returning no diagnostic for `UNREADABLE`, rather than inventing a new,
* more severe 5th case the original never had.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
* ("Rule table organization" — Agent installation group)
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports -- health-diagnostic-types.cjs is an export= CommonJS module
import healthDiagnosticMod = require('../health-diagnostic-types.cjs');
const { SEVERITY, adviseRemedy } = healthDiagnosticMod;
type Rule = healthDiagnosticMod.Rule;
type Diagnostic = healthDiagnosticMod.Diagnostic;
// 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 planningScopeMod = require('../planning-scope.cjs');
const { SCOPE } = planningScopeMod;
import { PACKAGE_NAME } from '../package-identity.cjs';
/**
* `check(snapshot)` for W010 — see module header for the exact 4-way
* branching this ports from `verify.cts:1992-2027`, and its message/fix
* templates copied verbatim (only the interpolated `agentStatus.*` values
* differ per call).
*/
function checkAgentInstall(snapshot: PlanningSnapshot): Diagnostic[] {
const { value: status, scope } = snapshot.agentInstall;
// Mirrors verify.cts:2025-2027's try/catch around the checkAgentsInstalled
// call itself — a thrown scan is swallowed, not reported. `scope` here is
// UNREADABLE only in that same case (buildAgentInstallField's own catch).
if (scope === SCOPE.UNREADABLE) return [];
if (status.agents_installed) return [];
// verify.cts:1995 — zero agents installed at all.
if (status.installed_agents.length === 0) {
return [
{
code: 'W010',
severity: SEVERITY.WARNING,
message: `No GSD agents found in ${status.agents_dir} — Task(subagent_type="gsd-*") will fall back to general-purpose`,
remedy: adviseRemedy(`Run the GSD installer: npx ${PACKAGE_NAME}@latest`),
},
];
}
// verify.cts:2002 — some agents incomplete (missing a generated file), zero fully missing.
if (status.incomplete_agents.length > 0 && status.missing_agents.length === 0) {
return [
{
code: 'W010',
severity: SEVERITY.WARNING,
message: `Incomplete agent installs (missing generated file): ${status.incomplete_agents.join(', ')} — affected workflows may fall back to general-purpose`,
remedy: adviseRemedy(`Re-run the GSD installer to complete the install: npx ${PACKAGE_NAME}@latest`),
},
];
}
// verify.cts:2009 — both missing AND incomplete agents present.
if (status.incomplete_agents.length > 0) {
return [
{
code: 'W010',
severity: SEVERITY.WARNING,
message: `Missing ${status.missing_agents.length} GSD agents: ${status.missing_agents.join(', ')}; incomplete agent installs (missing generated file): ${status.incomplete_agents.join(', ')} — affected workflows will fall back to general-purpose`,
remedy: adviseRemedy(`Run the GSD installer: npx ${PACKAGE_NAME}@latest`),
},
];
}
// verify.cts:2017 — agents missing only (no incomplete).
return [
{
code: 'W010',
severity: SEVERITY.WARNING,
message: `Missing ${status.missing_agents.length} GSD agents: ${status.missing_agents.join(', ')} — affected workflows will fall back to general-purpose`,
remedy: adviseRemedy(`Run the GSD installer: npx ${PACKAGE_NAME}@latest`),
},
];
}
const RULES: Rule[] = [
{
code: 'W010',
severity: SEVERITY.WARNING,
description: 'GSD agent installation missing or incomplete',
repairable: false,
check: checkAgentInstall,
},
];
export = { RULES };

View File

@@ -0,0 +1,334 @@
/**
* Health Diagnostic — config.json validation rules (Phase 11, #3309,
* ADR-3180 §8.2/§8.3/§8.5).
*
* Group: "config.json validation" (design doc, "Rule table organization"
* table) — W003, W004, W022 (one rule, three internal conditions), E005,
* W008, W016, W012, W013, W014, W015.
*
* Ported behavior-preserving from `cmdValidateHealth`'s config.json blocks
* (`src/verify.cts:1777-1835` for W003/W004/W022/E005,
* `src/verify.cts:1837-1865` for W008/W016,
* `src/verify.cts:2136-2191` for W012/W013/W014/W015). Every rule here reads
* ONLY `snapshot.config` (`{value, scope, exists}`, `src/planning-snapshot.cts`'s
* `buildConfigField`).
*
* W022 stays a SINGLE code across its three call sites per the design doc's
* "Rejected alternatives" §3: all three are variations on one question ("is
* `models` well-formed"), not a genuine multi-subject conflation. This rule's
* `checkW022` mirrors the original's exact if / else-if control flow
* (`verify.cts:1799-1824`): the object-shaped branch loops every `models`
* entry (0-N diagnostics, one per malformed entry); the non-object branch
* fires independently and ONLY when the object-shaped branch did not run —
* `models` is never checked against both.
*
* Two disclosed fidelity reductions, forced by `snapshot.config`'s shape
* (neither is available without violating §8.1 rule 1's "no ambient I/O in a
* rule's `check`"):
*
* - E005's original message interpolates the live `JSON.parse` error text
* (`config.json: JSON parse error - ${err.message}`, `verify.cts:1829`).
* `buildConfigField` (`src/planning-snapshot.cts:268-281`) catches and
* discards that error, collapsing an unparseable config.json to
* `{value: null, scope: UNREADABLE, exists: true}` with no error text
* anywhere in the snapshot. This rule's message drops the interpolated
* suffix rather than fabricate error text the snapshot never carried.
* - W016's original message interpolates `${slash('ai-integration-phase')}`
* (`verify.cts:1856`), a per-project runtime-resolved value
* (`formatGsdSlash`, `src/runtime-slash.cts`) this rule's `(snapshot) =>
* Diagnostic[]` signature has no access to. Hardcodes the canonical
* `/gsd-ai-integration-phase` hyphen form instead, mirroring the sibling
* "Phase directory structure" group's W009 rule
* (`src/health-diagnostic-rules/phase-structure.cts`), which hardcodes
* `/gsd-plan-phase` the same way for the identical reason.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
*
* ADR-457 build-at-publish: source in
* src/health-diagnostic-rules/config-validation.cts, compiled to
* gsd-core/bin/lib/health-diagnostic-rules/config-validation.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 -- health-diagnostic-types.cjs is an export= CommonJS module
import healthDiagnosticMod = require('../health-diagnostic-types.cjs');
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK, adviseRemedy } = healthDiagnosticMod;
type Diagnostic = healthDiagnosticMod.Diagnostic;
type Rule = healthDiagnosticMod.Rule;
import { VALID_PROFILES, VALID_TIERS, VALID_PHASE_TYPES } from '../model-catalog.cjs';
// verify.cts:2141 — inlined literal, not exported from anywhere; same list.
const VALID_BRANCHING_STRATEGIES = ['none', 'phase', 'milestone'];
// ─── W003 — config.json not found (verify.cts:1777-1785) ───────────────────
function checkW003(snapshot: PlanningSnapshot): Diagnostic[] {
if (snapshot.config.exists) return [];
return [
{
code: 'W003',
severity: SEVERITY.WARNING,
message: 'config.json not found',
remedy: { action: REMEDY_ACTION.CREATE_CONFIG, risk: REMEDY_RISK.NONE, args: {} },
},
];
}
// ─── E005 — config.json JSON parse error (verify.cts:1825-1834) ────────────
//
// `exists: true, value: null` is exactly `buildConfigField`'s "present but
// unparseable" contract (planning-snapshot.cts:268-281) — the same
// discriminator that separates this from W003's "absent" case.
function checkE005(snapshot: PlanningSnapshot): Diagnostic[] {
if (!snapshot.config.exists || snapshot.config.value !== null) return [];
return [
{
code: 'E005',
severity: SEVERITY.ERROR,
message: 'config.json: JSON parse error',
remedy: { action: REMEDY_ACTION.RESET_CONFIG, risk: REMEDY_RISK.DESTRUCTIVE, args: {} },
},
];
}
// ─── W004 — invalid model_profile (verify.cts:1790-1797) ───────────────────
function checkW004(snapshot: PlanningSnapshot): Diagnostic[] {
const value = snapshot.config.value;
if (!value) return [];
const profile = value['model_profile'];
if (profile && !VALID_PROFILES.includes(profile as string)) {
return [
{
code: 'W004',
severity: SEVERITY.WARNING,
message: `config.json: invalid model_profile "${profile as string}"`,
remedy: adviseRemedy(`Valid values: ${VALID_PROFILES.join(', ')}`),
},
];
}
return [];
}
// ─── W008 — workflow.nyquist_validation absent (verify.cts:1841-1851) ──────
function checkW008(snapshot: PlanningSnapshot): Diagnostic[] {
const value = snapshot.config.value;
const workflow = value ? (value['workflow'] as Record<string, unknown> | undefined) : undefined;
if (workflow && workflow['nyquist_validation'] === undefined) {
return [
{
code: 'W008',
severity: SEVERITY.WARNING,
message: 'config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip)',
remedy: { action: REMEDY_ACTION.ADD_NYQUIST_KEY, risk: REMEDY_RISK.NONE, args: {} },
},
];
}
return [];
}
// ─── W016 — workflow.ai_integration_phase absent (verify.cts:1852-1861) ────
function checkW016(snapshot: PlanningSnapshot): Diagnostic[] {
const value = snapshot.config.value;
const workflow = value ? (value['workflow'] as Record<string, unknown> | undefined) : undefined;
if (workflow && workflow['ai_integration_phase'] === undefined) {
return [
{
code: 'W016',
severity: SEVERITY.WARNING,
message:
'config.json: workflow.ai_integration_phase absent (defaults to enabled — run /gsd-ai-integration-phase before planning AI system phases)',
remedy: { action: REMEDY_ACTION.ADD_AI_INTEGRATION_PHASE_KEY, risk: REMEDY_RISK.NONE, args: {} },
},
];
}
return [];
}
// ─── W012 — invalid branching_strategy (verify.cts:2141-2152) ──────────────
function checkW012(snapshot: PlanningSnapshot): Diagnostic[] {
const value = snapshot.config.value;
if (!value) return [];
const strategy = value['branching_strategy'];
if (strategy && !VALID_BRANCHING_STRATEGIES.includes(strategy as string)) {
return [
{
code: 'W012',
severity: SEVERITY.WARNING,
message: `config.json: invalid branching_strategy "${strategy as string}"`,
remedy: adviseRemedy(`Valid values: ${VALID_BRANCHING_STRATEGIES.join(', ')}`),
},
];
}
return [];
}
// ─── W013 — context_window not a positive integer (verify.cts:2154-2164) ───
function checkW013(snapshot: PlanningSnapshot): Diagnostic[] {
const value = snapshot.config.value;
if (!value) return [];
const cw = value['context_window'];
if (cw !== undefined && (typeof cw !== 'number' || cw <= 0 || !Number.isInteger(cw))) {
return [
{
code: 'W013',
severity: SEVERITY.WARNING,
message: `config.json: context_window should be a positive integer, got "${cw as string}"`,
remedy: adviseRemedy('Set to 200000 (default) or 1000000 (for 1M models)'),
},
];
}
return [];
}
// ─── W014 — phase_branch_template missing {phase} (verify.cts:2166-2176) ───
function checkW014(snapshot: PlanningSnapshot): Diagnostic[] {
const value = snapshot.config.value;
if (!value) return [];
const tmpl = value['phase_branch_template'];
if (tmpl && !(tmpl as string).includes('{phase}')) {
return [
{
code: 'W014',
severity: SEVERITY.WARNING,
message: 'config.json: phase_branch_template missing {phase} placeholder',
remedy: adviseRemedy('Template must include {phase} for phase number substitution'),
},
];
}
return [];
}
// ─── W015 — milestone_branch_template missing {milestone} (verify.cts:2177-2187) ─
function checkW015(snapshot: PlanningSnapshot): Diagnostic[] {
const value = snapshot.config.value;
if (!value) return [];
const tmpl = value['milestone_branch_template'];
if (tmpl && !(tmpl as string).includes('{milestone}')) {
return [
{
code: 'W015',
severity: SEVERITY.WARNING,
message: 'config.json: milestone_branch_template missing {milestone} placeholder',
remedy: adviseRemedy('Template must include {milestone} for version substitution'),
},
];
}
return [];
}
// ─── W022 — models malformed, 3 internal conditions (verify.cts:1798-1824) ─
//
// Mirrors the original if / else-if chain exactly: the object-shaped branch
// (a: unknown phase type, b: invalid tier value) loops every `models` entry,
// pushing 0-N diagnostics; the non-object branch (c) is an ELSE-IF, so it
// only runs when `models` is truthy but did NOT satisfy "object, not array" —
// (a)/(b) are never evaluated against a non-object `models`.
function checkW022(snapshot: PlanningSnapshot): Diagnostic[] {
const value = snapshot.config.value;
if (!value) return [];
const diagnostics: Diagnostic[] = [];
const configModels = value['models'];
if (configModels && typeof configModels === 'object' && !Array.isArray(configModels)) {
for (const [phaseType, tierValue] of Object.entries(configModels as Record<string, unknown>)) {
if (!VALID_PHASE_TYPES.has(phaseType)) {
diagnostics.push({
code: 'W022',
severity: SEVERITY.WARNING,
message: `config.json: models has an unknown phase type "${phaseType}" which will be ignored`,
remedy: adviseRemedy(`Valid phase types: ${[...VALID_PHASE_TYPES].join(', ')}`),
});
} else if (typeof tierValue !== 'string' || !VALID_TIERS.has(tierValue)) {
diagnostics.push({
code: 'W022',
severity: SEVERITY.WARNING,
message: `config.json: models.${phaseType} has an invalid tier value ${JSON.stringify(tierValue)} which will be ignored`,
remedy: adviseRemedy(`Valid tiers: ${[...VALID_TIERS].join(', ')}`),
});
}
}
} else if (configModels !== undefined && configModels !== null) {
diagnostics.push({
code: 'W022',
severity: SEVERITY.WARNING,
message: `config.json: models is set to ${JSON.stringify(configModels)}, but must be an object mapping phase types to tiers — this value will be ignored`,
remedy: adviseRemedy('Set models to an object like {"planning": "sonnet"}, or remove the key to use profile defaults'),
});
}
return diagnostics;
}
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [
{ code: 'W003', severity: SEVERITY.WARNING, description: 'config.json not found', repairable: true, check: checkW003 },
{ code: 'E005', severity: SEVERITY.ERROR, description: 'config.json parse error', repairable: false, check: checkE005 },
{ code: 'W004', severity: SEVERITY.WARNING, description: 'config.json invalid field value', repairable: false, check: checkW004 },
{
code: 'W008',
severity: SEVERITY.WARNING,
description: 'config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip)',
repairable: true,
check: checkW008,
},
{
code: 'W016',
severity: SEVERITY.WARNING,
description:
'config.json: workflow.ai_integration_phase absent (defaults to enabled but agents may skip AI-integration-phase planning)',
repairable: true,
check: checkW016,
},
{
code: 'W012',
severity: SEVERITY.WARNING,
description: 'config.json invalid branching_strategy value',
repairable: false,
check: checkW012,
},
{
code: 'W013',
severity: SEVERITY.WARNING,
description: 'config.json context_window not a positive integer',
repairable: false,
check: checkW013,
},
{
code: 'W014',
severity: SEVERITY.WARNING,
description: 'config.json phase_branch_template missing {phase} placeholder',
repairable: false,
check: checkW014,
},
{
code: 'W015',
severity: SEVERITY.WARNING,
description: 'config.json milestone_branch_template missing {milestone} placeholder',
repairable: false,
check: checkW015,
},
{
code: 'W022',
severity: SEVERITY.WARNING,
description: 'config.json models entry malformed (unknown phase type, invalid tier, or non-object value)',
repairable: false,
check: checkW022,
},
];
export = { RULES };

View File

@@ -0,0 +1,115 @@
/**
* 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<typeof planningSnapshotMod.buildPlanningSnapshot>;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import healthDiagnosticMod = require('../health-diagnostic-types.cjs');
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK, adviseRemedy } = 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/<ver>-ROADMAP.md` snapshot file) minus
// `.documentedVersions` (list of `## <version>` 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: adviseRemedy(
'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,
description: 'MILESTONES.md missing entry for archived milestone snapshot',
repairable: true,
check: checkW018,
},
{
code: 'W019',
severity: SEVERITY.WARNING,
description: 'Unrecognized .planning/ root file — not a canonical GSD artifact',
repairable: false,
check: checkW019,
},
];
export = { RULES };

View File

@@ -0,0 +1,244 @@
/**
* Health Diagnostic — Phase directory structure rules (Phase 11, #3309,
* ADR-3180 §8.2/§8.3/§8.5).
*
* Group: "Phase directory structure" (design doc, "Rule table organization"
* table) — W005, W023, I001, W009.
*
* Ported behavior-preserving from `cmdValidateHealth`
* (`src/verify.cts:1893-1990`, the exact call sites for W005/W023/I001/W009),
* with two disclosed fidelity reductions forced by `PlanningSnapshot`'s
* current shape (see each rule's own comment below):
*
* - I001 cannot name the individual unsummarized PLAN filename (`snapshot.
* phases.value[i]` exposes only `planCount`/`summaryCount`, not per-plan
* filenames) — this rule reports a coarser per-PHASE message instead.
* - W023's original "described" list called `determinePhaseStatus`
* (`commands.cts:154`), a SIX-way status string ('Not Started'/'Planned'/
* 'In Progress'/'Executed'/'Needs Review'/'Complete') computed from its own
* raw `readdirSync` + `*-VERIFICATION.md` frontmatter read of `phaseDir`.
* This rule cannot re-run that raw read (§8.1 rule 1 forbids ambient I/O in
* `check`), so `derivePhaseStatusLabel` below reconstructs the same
* six-way label from fields `PlanningSnapshot` already exposes —
* `PhaseSnapshot.planCount`/`summaryCount` (the plan/no-plan and
* in-progress/planned branches, identical inputs to the original) and
* `PhaseSnapshot.complete`/`verificationStatus` (`isPhaseComplete`'s own
* §7.4 disk-strict routing of the SAME `*-VERIFICATION.md` file) for the
* verification-gated branches. This is a disclosed fidelity reduction, not
* a byte-for-byte guarantee: `verificationStatus` is the DIFFERENT,
* `readVerificationStatus`-routed vocabulary ('passed'/'gaps_found'/
* 'human_needed'/'stale'/'unknown'/'missing') and can disagree with a raw
* frontmatter re-read in edge cases (e.g. a stale-but-frontmatter-"passed"
* VERIFICATION.md routes to 'stale', not 'passed', under §7.4's staleness
* handling — this rule reports 'Executed' there, not 'Complete'). No new
* ambient I/O and no new `PlanningSnapshot` field were needed — every input
* was already on `PhaseSnapshot`.
*
* W009's original message interpolates `${slash('plan-phase')}`
* (`verify.cts:1982`, ``Re-run ${slash('plan-phase')} with --research to
* regenerate``), a per-project runtime-resolved value (`formatGsdSlash`,
* `src/runtime-slash.cts`) this rule's `(snapshot) => Diagnostic[]`
* signature has no access to. Hardcodes the canonical `/gsd-plan-phase`
* hyphen form instead, mirroring the sibling "config.json validation"
* group's W016 rule (`src/health-diagnostic-rules/config-validation.cts`),
* which hardcodes `/gsd-ai-integration-phase` the same way for the
* identical reason.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
*
* ADR-457 build-at-publish: source in
* src/health-diagnostic-rules/phase-structure.cts, compiled to
* gsd-core/bin/lib/health-diagnostic-rules/phase-structure.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 validateMod = require('../validate.cjs');
const { phaseDirNameRe } = validateMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('../phase-id.cjs');
const { extractPhaseToken, normalizePhaseName, comparePhaseNum } = phaseIdMod;
// ─── W005 — phase directory doesn't follow NN-name format (verify.cts:1893-1902) ─
function checkW005(snapshot: PlanningSnapshot): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
for (const name of snapshot.phaseDirs.value) {
if (!name.match(phaseDirNameRe)) {
diagnostics.push({
code: 'W005',
severity: SEVERITY.WARNING,
message: `Phase directory "${name}" doesn't follow NN-name format`,
remedy: adviseRemedy('Rename to match pattern (e.g., 01-setup)'),
});
}
}
return diagnostics;
}
// ─── W023 — phase directories collide on normalized key (verify.cts:1904-1950) ─
//
// Groups `snapshot.phaseDirs.value` by `normalizePhaseName(extractPhaseToken(name))`
// — the exact same two owners (`phase-id.cjs`) the original `verify.cts:1917-1918`
// call site uses, relocated verbatim rather than reimplemented. Sorted with
// `comparePhaseNum` + a `localeCompare` tiebreak, mirroring
// `verify.cts:1930-1932`'s deterministic-output rationale. See the file-level
// comment for the disclosed "described" fidelity reduction.
/**
* Reconstruct `commands.cts:154`'s `determinePhaseStatus` six-way label from
* fields `PhaseSnapshot` already exposes (no ambient I/O, no new snapshot
* field — see file-level comment). `complete`/`verificationStatus` come from
* `isPhaseComplete`'s §7.4 disk-strict routing of the same `*-VERIFICATION.md`
* file the original raw read targeted.
*/
function derivePhaseStatusLabel(
planCount: number,
summaryCount: number,
complete: boolean,
verificationStatus: string,
): string {
if (planCount === 0) return 'Not Started';
if (summaryCount < planCount && summaryCount > 0) return 'In Progress';
if (summaryCount < planCount) return 'Planned';
// summaryCount >= planCount > 0 — verification-gated, same as the original's
// post-count fall-through.
if (complete) return 'Complete';
if (verificationStatus === 'human_needed') return 'Needs Review';
// gaps_found / stale / missing / unknown all land here, same as the
// original's "verification exists but unrecognized" and "no verification
// file" branches both returning 'Executed'.
return 'Executed';
}
function checkW023(snapshot: PlanningSnapshot): Diagnostic[] {
const groups = new Map<string, string[]>();
for (const name of snapshot.phaseDirs.value) {
const token = extractPhaseToken(name);
const key = normalizePhaseName(token);
const list = groups.get(key);
if (list) list.push(name);
else groups.set(key, [name]);
}
const phaseByDir = new Map(snapshot.phases.value.map((p) => [p.dir, p]));
const diagnostics: Diagnostic[] = [];
for (const [key, dirs] of groups) {
if (dirs.length < 2) continue;
const described = dirs
.slice()
.sort((a, b) => comparePhaseNum(a, b) || String(a).localeCompare(String(b)))
.map((d) => {
const phase = phaseByDir.get(d);
const plans = phase ? phase.planCount : 0;
const summaries = phase ? phase.summaryCount : 0;
const complete = phase ? phase.complete : false;
const verificationStatus = phase ? phase.verificationStatus : 'missing';
const status = derivePhaseStatusLabel(plans, summaries, complete, verificationStatus);
return `${d} (${status})`;
})
.join(', ');
diagnostics.push({
code: 'W023',
severity: SEVERITY.WARNING,
message: `Phase directories collide on normalized key "${key}": ${described}`,
remedy: adviseRemedy(
'Inspect each directory; rename or remove the duplicate so only one directory maps to this phase key',
),
});
}
return diagnostics;
}
// ─── I001 — plan(s) without a matching SUMMARY.md (verify.cts:1952-1965) ───
//
// GENUINE FIDELITY GAP (see file-level comment): the original is PER-PLAN
// (`${e.name}/${plan} has no SUMMARY.md`, `plan` an individual PLAN.md
// filename from `findUnsummarizedPlans`). `PlanningSnapshot`'s
// `phases.value[i]` carries only `planCount`/`summaryCount` NUMBERS per
// phase — no per-plan filenames — so this rule cannot name which plan lacks
// a summary without reading the phase directory directly inside `check`
// (forbidden by §8.1 rule 1). This rule instead reports one coarser
// per-PHASE diagnostic naming the deficit count, not the individual
// filename(s).
function checkI001(snapshot: PlanningSnapshot): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
for (const phase of snapshot.phases.value) {
const deficit = phase.planCount - phase.summaryCount;
if (deficit > 0) {
diagnostics.push({
code: 'I001',
severity: SEVERITY.INFO,
message: `Phase ${phase.dir} has ${deficit} plan(s) without a matching summary`,
remedy: adviseRemedy('May be in progress'),
});
}
}
return diagnostics;
}
// ─── W009 — Validation Architecture in RESEARCH.md but no VALIDATION.md ────
// (verify.cts:1967-1990)
function checkW009(snapshot: PlanningSnapshot): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
for (const entry of snapshot.researchValidationStatus.value) {
if (entry.hasValidationArchitecture && !entry.hasValidationMd) {
diagnostics.push({
code: 'W009',
severity: SEVERITY.WARNING,
message: `Phase ${entry.dir}: has Validation Architecture in RESEARCH.md but no VALIDATION.md`,
remedy: adviseRemedy('Re-run /gsd-plan-phase with --research to regenerate'),
});
}
}
return diagnostics;
}
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [
{
code: 'W005',
severity: SEVERITY.WARNING,
description: 'Phase directory naming mismatch',
repairable: false,
check: checkW005,
},
{
code: 'W023',
severity: SEVERITY.WARNING,
description: 'Phase directories collide on normalized key',
repairable: false,
check: checkW023,
},
{
code: 'I001',
severity: SEVERITY.INFO,
description: 'Plan without SUMMARY (may be in progress)',
repairable: false,
check: checkI001,
},
{
code: 'W009',
severity: SEVERITY.WARNING,
description: 'Phase has Validation Architecture in RESEARCH.md but no VALIDATION.md',
repairable: false,
check: checkW009,
},
];
export = { RULES };

View File

@@ -0,0 +1,283 @@
/**
* Health Diagnostic — ROADMAP/disk consistency rules (Phase 11, #3309,
* ADR-3180 §8.2/§8.3/§8.5).
*
* Group: "ROADMAP/disk consistency" (design doc, "Rule table organization"
* table) — W006, W007.
*
* Ported behavior-preserving from `cmdValidateHealth`
* (`src/verify.cts:2029-2101`, the exact call sites for W006/W007).
*
* Both rules share ONE matcher — `matchPhaseDirs` + `normalizePhaseName`
* (`src/phase-id.cts`), the same canonical directory-resolution owner
* `verify.cts:2060/2073` already calls (its own #2528 comment explains why:
* pairing roadmap phases against disk by intersecting independently-derived
* TOKEN SETS mislabels digit-leading slugs like `05-80-20-cleanup` in BOTH
* directions at once — phase 5 reads as missing a directory (W006) AND that
* directory reads as not in the roadmap (W007) — so both rules here resolve
* through `matchPhaseDirs`, never a hand-rolled string/token comparison, and
* `dirsForPhase` below is the single call site both go through, so they
* cannot independently drift on what "matches" means (#2528's own bug
* class).
*
* DISK-SIDE SOURCE — `allPhaseDirNames`, NOT `phaseDirs` (found while
* implementing this file, fixed inline rather than deferred).
* `snapshot.phaseDirs` (Phase 10, `listMilestonePhaseDirs`) is WINDOWED: its
* `inWindow` filter (`getMilestonePhaseFilter`, `src/roadmap-parser.cts:1220`)
* admits a directory only when its phase id is a MEMBER of the roadmap's
* current-milestone-declared phase set (`isDirInMilestone`). That makes
* `phaseDirs.value` a subset that, by construction, can never contain a
* directory the roadmap does NOT declare — exactly the directory W007 exists
* to find. Sourced from `phaseDirs`, W007 would be structurally inert: every
* member of the set is already provably claimable. Verified empirically
* (`node -e` trace against a real `buildPlanningSnapshot`): a genuine orphan
* directory (`04-extra`, no roadmap entry) was silently absent from
* `phaseDirs.value` and W007 fired zero diagnostics. `phaseDirs`'s windowing
* also risks a W006 false positive for a phase declared in a NON-current
* milestone section (`roadmapDeclaredPhases` is built from the FULL raw
* ROADMAP, all milestones — `src/planning-snapshot.cts:398-436` — while
* `phaseDirs` is scoped to the current milestone only), so both rules here
* use the new, additive `allPhaseDirNames` field
* (`src/planning-snapshot.cts`) instead: every directory actually present
* under the active `phases/` root, unfiltered by roadmap declaration.
* Archived-milestone directory names (`verify.cts:2050`,
* `collectArchivedPhaseDirNames`) are still not exposed as directory NAMES
* on `PlanningSnapshot`, but the equivalent TOKEN set is: `checkW006` below
* additionally consults `snapshot.archivedPhaseTokens` (added for the
* W002/state-consistency group's #3652 fix, reused verbatim here — see that
* field's own doc comment) so a phase whose only directory lives in a
* milestone archive (shipped OR the current milestone's own archive layout)
* no longer reads as W006-missing (Bug 1, found post-migration: the archived
* fixtures under `tests/milestone-archive.test.cjs` and
* `tests/verify-health.test.cjs` regressed against the pre-migration
* `verify.cts` behavior). W007 still never scans archived dirs (its loop is
* `allPhaseDirNames.value`, the active `phases/` root only), so an archived
* dir still cannot spuriously read as W007-orphaned either — unchanged.
*
* Bug 2 (found alongside Bug 1): `dirsForPhase` below also runs a
* `phaseVariants()`-based fallback when `matchPhaseDirs` finds nothing — see
* its own doc comment. Pre-migration, `verify.cts:2071-2073`/`2092-2093` ran
* this as a SECOND, independent check the migrated matchPhaseDirs-only path
* had dropped, causing a false W006/W007 whenever ROADMAP and disk spelled
* the same phase with a different zero-padding (e.g. ROADMAP "01A" vs disk
* "1A-...").
*
* Not-started exclusion (verify.cts:2065/2075-2076,
* `buildNotStartedPhaseVariants`, `src/validate.cts:160`): the design doc's
* field table assigns this group only `roadmapDeclaredPhases`/`phaseDirs`,
* and `roadmapDeclaredPhases` (`src/planning-snapshot.cts:398-436`) does
* NOT filter not-started phases out — it returns every heading- and
* checklist-declared phase id regardless of checked state (confirmed by
* direct read: its `buildRoadmapPhaseVariants` call includes BOTH `[x]` and
* `[ ]` checklist entries). Omitting the exclusion here would regress a
* COMMON case: `gsd-core/templates/roadmap.md`'s "Initial Roadmap" shape
* declares every phase as an unchecked `- [ ] **Phase N: [Name]**` checklist
* item before any phase directory exists, so a freshly created ROADMAP.md
* would immediately spam one W006 per phase. `snapshot.roadmapPhaseCheckboxes`
* (`src/planning-snapshot.cts:457-480`, backs W011 in the STATE.md-consistency
* group) already parses exactly this `[x]`/`[ ]` state — `check(snapshot)`'s
* signature grants the full snapshot, not just this group's assigned column,
* so `isPhaseNotStarted` below reads it directly rather than re-deriving a
* third independent regex over raw ROADMAP text (forbidden by §8.1 rule 2).
* KNOWN GAP, disclosed rather than silently dropped: `roadmapPhaseCheckboxes`
* is keyed by `PHASE_NUMBER_TOKEN_SOURCE` (`phase-id.cts:54`, no dash), so a
* milestone-dash-prefixed phase id ("2-01") can never match a checkbox key —
* unlike the original `buildNotStartedPhaseVariants`, which captures the
* fuller `[\w][\w.-]*` grammar (dashes included). For that id shape only,
* this rule's not-started exclusion silently no-ops (never excludes), which
* is the conservative direction (a possible false W006, not a suppressed
* true one).
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
*
* ADR-457 build-at-publish: source in
* src/health-diagnostic-rules/roadmap-disk-consistency.cts, compiled to
* gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-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 planningScopeMod = require('../planning-scope.cjs');
const { SCOPE } = planningScopeMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('../phase-id.cjs');
const { matchPhaseDirs, normalizePhaseName, extractPhaseToken, isSentinelPhaseId } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import validateMod = require('../validate.cjs');
const { phaseVariants } = validateMod;
// ─── Shared matcher — the single call site both W006 and W007 go through ───
/**
* Every on-disk directory (from `allPhaseDirNames.value`) that `phaseId`
* resolves to via the canonical `matchPhaseDirs` selection. Both `checkW006`
* (does ANY directory resolve) and `computeClaimedDirs` (which directories
* does the roadmap claim, for W007) call this — one matcher, reused, per the
* file-level comment.
*/
function dirsForPhase(dirs: string[], phaseId: string): string[] {
const canonical = matchPhaseDirs(dirs, normalizePhaseName(phaseId)).matches;
if (canonical.length > 0) return canonical;
// Bug 2 (#3309 W006/W007 migration cluster, found while fixing the
// originally-reported archived-directory gap): `matchPhaseDirs`'s
// `phaseTokenMatches` compares `extractPhaseToken(dir)` (the directory's
// LITERAL, un-normalized digit run — e.g. "1A" for `1A-suffix-phase`)
// against `normalizePhaseName(phaseId)` (which PADS — "01A") case-
// insensitively, but never unifies a padding/letter-suffix mismatch
// BETWEEN the two sides: "1A" !== "01A" even though they name the same
// phase. Pre-migration, `verify.cts:2071-2073`/`2092-2093` ran a SECOND,
// independent check here — `[...phaseVariants(p)].some((v) =>
// diskPhases.has(v))` — that the migrated matchPhaseDirs-only path
// dropped. `phaseVariants` (`validate.cts:101`) is symmetric
// (padded<->unpadded, letter-suffix preserved both ways), so generating
// variants from `phaseId` and checking raw-disk-token membership is
// equivalent to intersecting `phaseVariants(phaseId)` with
// `phaseVariants(diskToken)` — variants always include their own input
// verbatim, so this fallback catches exactly the cases `matchPhaseDirs`
// alone misses without re-deriving a second matcher.
const variants = phaseVariants(phaseId);
return dirs.filter((d) => {
const token = extractPhaseToken(d).toUpperCase();
for (const variant of variants) {
if (token === variant.toUpperCase()) return true;
}
return false;
});
}
/**
* True when `phaseId` has an unchecked (`[ ]`) checklist entry in
* `roadmapPhaseCheckboxes` under any of its padding/case variants
* (`phaseVariants`, `src/validate.cts:101` — the same variant-expansion
* owner `verify.cts:2071/2075` uses for this exact exclusion). See the
* file-level comment for the KNOWN GAP on dash-shaped ids.
*/
function isPhaseNotStarted(phaseId: string, checkboxes: Record<string, boolean>): boolean {
for (const variant of phaseVariants(phaseId)) {
if (Object.prototype.hasOwnProperty.call(checkboxes, variant) && checkboxes[variant] === false) {
return true;
}
}
return false;
}
// ─── W006 — ROADMAP.md declares a phase with no directory on disk ─────────
// (verify.cts:2067-2084)
function checkW006(snapshot: PlanningSnapshot): Diagnostic[] {
// Mirrors verify.cts:2029's `if (fs.existsSync(roadmapPath))` guard: ROADMAP.md
// absent or unreadable means the field degrades to `{value: [], scope:
// UNREADABLE}` (`src/planning-snapshot.cts:401-403/407-409`) and NEITHER
// W006 nor W007 evaluates — an empty declared-phase list must not be
// mistaken for "the roadmap legitimately declares zero phases" here.
if (snapshot.roadmapDeclaredPhases.scope !== SCOPE.COMPLETE) return [];
const dirs = snapshot.allPhaseDirNames.value;
const archivedTokens = new Set(snapshot.archivedPhaseTokens.value);
const checkboxes = snapshot.roadmapPhaseCheckboxes.value;
const diagnostics: Diagnostic[] = [];
for (const { phaseId } of snapshot.roadmapDeclaredPhases.value) {
// #3225: sentinel phase ids (999.x/0.x) are never-on-roadmap by
// convention; a sentinel heading shouldn't demand a directory.
if (isSentinelPhaseId(phaseId)) continue;
if (dirsForPhase(dirs, phaseId).length > 0) continue;
// Bug 1 (#3309 W006/W007 migration cluster): a phase whose ONLY
// directory lives under a milestone archive
// (`.planning/milestones/vX.Y-phases/<phase>/`, shipped OR the current
// milestone's own archive layout) must not read as "no directory on
// disk" — mirrors `verify.cts:2038`'s
// `forEachArchivedPhaseToken(planBase, (token) => diskPhases.add(token))`
// feeding the archived-phase token set into this exact existence check.
// `snapshot.archivedPhaseTokens` (`src/planning-snapshot.cts`) is the
// same token set, reused verbatim from the W002/state-consistency
// group's own fix for the analogous gap — not a re-derivation.
if ([...phaseVariants(phaseId)].some((v) => archivedTokens.has(v))) continue;
if (isPhaseNotStarted(phaseId, checkboxes)) continue;
diagnostics.push({
code: 'W006',
severity: SEVERITY.WARNING,
message: `Phase ${phaseId} in ROADMAP.md but no directory on disk`,
remedy: adviseRemedy('Create phase directory or remove from roadmap'),
});
}
return diagnostics;
}
// ─── W007 — an on-disk phase directory has no matching ROADMAP entry ──────
// (verify.cts:2086-2101)
/** Every directory in `dirs` that ANY declared roadmap phase resolves to. */
function computeClaimedDirs(
dirs: string[],
declaredPhases: { phaseId: string; milestone: string | null }[],
): Set<string> {
const claimed = new Set<string>();
for (const { phaseId } of declaredPhases) {
for (const dir of dirsForPhase(dirs, phaseId)) claimed.add(dir);
}
return claimed;
}
function checkW007(snapshot: PlanningSnapshot): Diagnostic[] {
// Same guard as W006 — see its comment.
if (snapshot.roadmapDeclaredPhases.scope !== SCOPE.COMPLETE) return [];
const dirs = snapshot.allPhaseDirNames.value;
const claimedDirs = computeClaimedDirs(dirs, snapshot.roadmapDeclaredPhases.value);
const diagnostics: Diagnostic[] = [];
for (const dirName of dirs) {
// `extractPhaseToken` is the phase-id.cts owner `PHASE_TOKEN_FROM_DIR_RE`
// (`src/validate.cts:73-76`) is documented to match exactly
// (verify.cts's original `p` key from `collectDiskPhaseEntries`,
// `verify.cts:1373-1397`) — same token, relocated read, not reinvented.
const token = extractPhaseToken(dirName);
// #3225: a sentinel dir on disk (999-interim, 0-drafts) is defined as
// never-on-roadmap; it must not trigger W007.
if (isSentinelPhaseId(token)) continue;
if (claimedDirs.has(dirName)) continue;
diagnostics.push({
code: 'W007',
severity: SEVERITY.WARNING,
message: `Phase ${token} exists on disk but not in ROADMAP.md`,
remedy: adviseRemedy('Add to roadmap or remove directory'),
});
}
return diagnostics;
}
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [
{
code: 'W006',
severity: SEVERITY.WARNING,
description: 'Phase in ROADMAP but no directory',
repairable: false,
check: checkW006,
},
{
code: 'W007',
severity: SEVERITY.WARNING,
description: 'Phase on disk but not in ROADMAP',
repairable: false,
check: checkW007,
},
];
export = { RULES };

View File

@@ -0,0 +1,178 @@
/**
* Health Diagnostic — Root existence + PROJECT.md rules (Phase 11, #3309,
* ADR-3180 §8.2/§8.3/§8.5).
*
* Group: "Root existence + PROJECT.md" (design doc, "Rule table organization"
* table) — E002, E003, E004, W001. E001 (the `.planning/` root missing guard)
* stays OUTSIDE the rule table entirely per the design doc's "Two guards that
* stay OUTSIDE the rule table entirely" section — it is not a row here.
*
* Ported behavior-preserving from `cmdValidateHealth`
* (`src/verify.cts:1681-1705`), the exact call sites for E002/E003/E004/W001.
*
* E002's original message interpolates `${slash('new-project')}`
* (`verify.cts:1682`, ``Run ${slash('new-project')} to create``) and E003's
* interpolates `${slash('new-milestone')}` (`verify.cts:1694`, ``Run
* ${slash('new-milestone')} to create roadmap``) — both per-project
* runtime-resolved values (`formatGsdSlash`, `src/runtime-slash.cts`) this
* rule's `(snapshot) => Diagnostic[]` signature has no access to (§8.1 rule
* 1 forbids ambient I/O, including `cwd`, inside `check`). Hardcodes the
* canonical `/gsd-new-project`/`/gsd-new-milestone` hyphen form instead,
* mirroring the sibling "config.json validation" group's W016 rule
* (`src/health-diagnostic-rules/config-validation.cts`), which hardcodes
* `/gsd-ai-integration-phase` the same way for the identical reason.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
*
* ADR-457 build-at-publish: source in src/health-diagnostic-rules/root-existence.cts,
* compiled to gsd-core/bin/lib/health-diagnostic-rules/root-existence.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, REMEDY_ACTION, REMEDY_RISK, adviseRemedy } = healthDiagnosticMod;
type Diagnostic = healthDiagnosticMod.Diagnostic;
type Rule = healthDiagnosticMod.Rule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('../planning-scope.cjs');
const { SCOPE } = planningScopeMod;
// ─── E002 — PROJECT.md not found (verify.cts:1682) ─────────────────────────
function checkE002(snapshot: PlanningSnapshot): Diagnostic[] {
if (snapshot.projectSections.exists) return [];
return [
{
code: 'E002',
severity: SEVERITY.ERROR,
message: 'PROJECT.md not found',
remedy: adviseRemedy('/gsd-new-project'),
},
];
}
// ─── E003 — ROADMAP.md not found (verify.cts:1694) ─────────────────────────
//
// Condition uses `snapshot.milestone.scope === SCOPE.UNREADABLE`
// (`getMilestoneInfo`, `src/roadmap-parser.cts`). KNOWN AMBIGUITY (flagged in
// this batch's report, not silently papered over): `getMilestoneInfo` returns
// `SCOPE.UNREADABLE` for TWO distinct causes it does not otherwise
// distinguish — (1) ROADMAP.md absent (`platformReadSync` returns `null` ->
// synthetic `Error('missing')`, no errno, `reportUnreadableRoadmap` finds no
// `.code` and stays silent) and (2) ROADMAP.md present but unreadable (a real
// read fault, e.g. EACCES/EISDIR, which DOES carry an errno and fires
// `warnUnusableInput(ROADMAP_UNREADABLE)`). Unlike `config`/`projectSections`,
// `milestone` carries no `exists` discriminator, so this rule cannot tell the
// two apart from the snapshot alone without adding cwd/fs access to `check`
// (forbidden by §8.1 rule 1). This is a best-effort port of the pre-migration
// condition (`!fs.existsSync(roadmapPath)`), which itself only asked "does
// the file exist" — this rule now also fires (message-mismatched, but
// error-preserving) on a present-but-corrupt ROADMAP.md.
function checkE003(snapshot: PlanningSnapshot): Diagnostic[] {
if (snapshot.milestone.scope !== SCOPE.UNREADABLE) return [];
return [
{
code: 'E003',
severity: SEVERITY.ERROR,
message: 'ROADMAP.md not found',
remedy: adviseRemedy('/gsd-new-milestone'),
},
];
}
// ─── E004 — STATE.md not found (verify.cts:1697) ───────────────────────────
//
// Condition uses `snapshot.currentPhaseLabel.scope === SCOPE.UNREADABLE`
// (`buildStateFields`, `src/planning-snapshot.cts:210-249`). KNOWN GAP
// (flagged in this batch's report): `buildStateFields` collapses TWO distinct
// causes into the same `UNREADABLE` scope with no discriminator field at
// all — STATE.md absent (`platformReadSync` returns `null`, a real
// non-answer, `warnUnusableInput` NOT called) and STATE.md present but
// unreadable (any other read error, e.g. EISDIR, corruption,
// `warnUnusableInput(STATE_UNREADABLE)` fires). Unlike `config`, there is no
// `exists` flag on `currentPhaseLabel` (or on `PlanningSnapshot` generally)
// to distinguish "STATE.md was never created" from "STATE.md exists but
// could not be read" — this is a REAL gap in the current 15-field
// `PlanningSnapshot` shape, not something this rule can work around without
// extending that snapshot (out of this batch's scope per the brief). This
// rule is therefore a best-effort port: it fires E004 ("STATE.md not found")
// for both causes, exactly mirroring what `snapshot.currentPhaseLabel.scope`
// can express today.
//
// Remedy is `regenerateState`, one of the two DESTRUCTIVE-risk actions (loses
// session history, design doc "Risk assignment" section) — per §8.3 rule 3
// `--repair` will refuse to auto-apply it once `applyRepairs`'s dispatch
// wires this rule in; the remedy is still described (ADVISE-shaped for
// display, per `applyRepairs`'s own contract) but never executed.
function checkE004(snapshot: PlanningSnapshot): Diagnostic[] {
if (snapshot.currentPhaseLabel.scope !== SCOPE.UNREADABLE) return [];
return [
{
code: 'E004',
severity: SEVERITY.ERROR,
message: 'STATE.md not found',
remedy: {
action: REMEDY_ACTION.REGENERATE_STATE,
risk: REMEDY_RISK.DESTRUCTIVE,
args: {},
},
},
];
}
// ─── W001 — PROJECT.md missing a required section (verify.cts:1684-1690) ──
//
// `REQUIRED_SECTIONS` carries the exact `## `-prefixed strings
// `verify.cts:1685` uses in its message text; membership is tested against
// `snapshot.projectSections.value`, which `buildProjectSectionsField`
// (`src/planning-snapshot.cts:367-381`) stores WITHOUT the `##` prefix (its
// `/^##\s+(.+)$/gm` capture group), so each required string's own `## `
// prefix is stripped before the membership check. `projectSections.value ===
// null` (PROJECT.md absent OR unreadable) emits zero diagnostics — E002
// already reports absence; this rule does not double-report it.
const REQUIRED_SECTIONS = ['## What This Is', '## Core Value', '## Requirements'];
function checkW001(snapshot: PlanningSnapshot): Diagnostic[] {
const { value } = snapshot.projectSections;
if (value === null) return [];
const diagnostics: Diagnostic[] = [];
for (const required of REQUIRED_SECTIONS) {
const heading = required.replace(/^##\s+/, '');
if (!value.includes(heading)) {
diagnostics.push({
code: 'W001',
severity: SEVERITY.WARNING,
message: `PROJECT.md missing section: ${required}`,
remedy: adviseRemedy('Add section manually'),
});
}
}
return diagnostics;
}
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [
{ code: 'E002', severity: SEVERITY.ERROR, description: 'PROJECT.md not found', repairable: false, check: checkE002 },
{ code: 'E003', severity: SEVERITY.ERROR, description: 'ROADMAP.md not found', repairable: false, check: checkE003 },
{ code: 'E004', severity: SEVERITY.ERROR, description: 'STATE.md not found', repairable: false, check: checkE004 },
{
code: 'W001',
severity: SEVERITY.WARNING,
description: 'PROJECT.md missing required section',
repairable: false,
check: checkW001,
},
];
export = { RULES };

View File

@@ -0,0 +1,311 @@
/**
* Health Diagnostic Rules — STATE.md consistency group (Phase 11, #3309,
* ADR-3180 §8.2/§8.3/§8.5).
*
* Five rules, each a near-mechanical extraction of an already-working
* `addIssue` call site in `cmdValidateHealth` (Gall's Law, design doc "Rule
* table organization" / "Laws applied"):
*
* - W024 (`verify.cts:1709-1729`) — STATE.md `state_head` commit-age
* freshness vs. git HEAD. GENUINE GAP, deliberately NOT migrated — see the
* `RULE_W024` comment below for exactly why.
* - W002 (`verify.cts:1731-1774`) — STATE.md references a phase token not
* declared anywhere (disk or ROADMAP).
* - W011 (`verify.cts:2104-2134`) — STATE's current-phase status disagrees
* with ROADMAP's `[x]` checkbox for that same phase.
* - W021 (`verify.cts:2270-2299`, the FIRST `addIssue('warning', 'W021', ...)`
* call site) — under the `'milestone-prefixed'` `phase_id_convention`, a
* phase's integer prefix implies a different milestone than the ROADMAP
* section it is actually listed under.
* - W026 (`verify.cts:2356-2399`, the SECOND `addIssue('warning', 'W021', ...)`
* call site — split off per the design doc's "New codes for the two split
* subjects" section, since one code covering two unrelated subjects is a
* genuine conflation) — STATE says the milestone is complete/archived, but
* ROADMAP (scoped to that same milestone) still lists a phase with no
* matching disk directory.
*
* - W002's original message interpolates `${slash('health')}`
* (`verify.cts:1770`) and W011's interpolates `${slash('progress')}`
* (`verify.cts:2126`) — both per-project runtime-resolved values
* (`formatGsdSlash`, `src/runtime-slash.cts`) this rule's
* `(snapshot) => Diagnostic[]` signature has no access to. Hardcodes the
* canonical `/gsd-health`/`/gsd-progress` hyphen form instead, mirroring
* the sibling "config.json validation" group's W016 rule
* (`src/health-diagnostic-rules/config-validation.cts`), which hardcodes
* `/gsd-ai-integration-phase` the same way for the identical reason.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
*/
// Runtime values (SEVERITY/REMEDY_ACTION/REMEDY_RISK) are needed here, not
// just types, so this is a normal (non type-only) `import ... = require(...)`
// — unlike `health-diagnostic.cts`'s own type-only import of
// `planning-snapshot.cjs`, which never touches that module's runtime values.
// eslint-disable-next-line @typescript-eslint/no-require-imports -- export= CommonJS module
import healthDiagnosticMod = require('../health-diagnostic-types.cjs');
const { SEVERITY, adviseRemedy } = healthDiagnosticMod;
type Rule = healthDiagnosticMod.Rule;
type Diagnostic = healthDiagnosticMod.Diagnostic;
// Type-only; erased at compile time, no runtime require emitted — mirrors
// `health-diagnostic.cts`'s own import of `planning-snapshot.cjs`.
// eslint-disable-next-line @typescript-eslint/no-require-imports -- type-only; erased at compile time
import type planningSnapshotMod = require('../planning-snapshot.cjs');
type PlanningSnapshot = ReturnType<typeof planningSnapshotMod.buildPlanningSnapshot>;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('../phase-id.cjs');
const { getMilestoneFromPhaseId, matchPhaseDirs, normalizePhaseName, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE } =
phaseIdMod;
// ─── W024 — STATE.md commit-age freshness (DELIBERATELY INERT) ─────────────
/**
* W024's real check (`verify.cts:1709-1729`) calls
* `readStateHeadFreshness(cwd, fm['state_head'])`, which shells out to `git
* log` to count commits between the frontmatter's `state_head` and the
* current HEAD. That is ambient I/O (git history), not `.planning/` content —
* confirmed against `src/planning-snapshot.cts`'s full 15-field
* `PlanningSnapshot` interface: no field wraps `readStateHeadFreshness` or
* exposes a commits-behind count.
*
* §8.1 rule 1 requires a rule's signature to be `(snapshot) => Diagnostic[]`
* with no ambient I/O inside `check` — so this rule does NOT call
* `readStateHeadFreshness` itself (that would violate the constraint the
* skeleton's own `Rule.check` type exists to enforce). Adding a 16th
* `PlanningSnapshot` field (e.g. `stateHeadFreshness: {value:
* {commitsBehind, stateHead}, scope}`) is the fix, but is out of this
* group's scope (`src/planning-snapshot.cts` is a shared file this task was
* not dispatched to extend).
*
* Registered here, `check` always returning `[]`, so the code table stays
* complete per §8.2's 1:1 invariant (every code the old `verify.cts` emitted
* has exactly one `Rule` entry) rather than silently dropping W024 from the
* table. This is a documented, deliberate deferral pending the 16th snapshot
* field — flagged prominently rather than quietly ported as a no-op.
*/
const RULE_W024: Rule = {
code: 'W024',
severity: SEVERITY.WARNING,
description: 'STATE.md was written many commits ago — treat its contents as approximate',
repairable: false,
check: (_snapshot: PlanningSnapshot): Diagnostic[] => [],
};
// ─── W002 — STATE.md references an undeclared phase token ──────────────────
/**
* The "valid phase" set the original code builds from
* `collectDiskPhases(planBase)` (disk dir tokens) + ROADMAP heading tokens +
* `forEachArchivedPhaseToken` (archived milestone-phase-dir tokens,
* `verify.cts:1748`). This rebuilds the disk+ROADMAP two-thirds from parsed
* snapshot fields only: `phaseDirs.value` (disk dir names, tokenized the same
* way `collectDiskPhaseEntries` does — via `extractPhaseToken`) and
* `roadmapDeclaredPhases.value.map(p => p.phaseId)` (ROADMAP-declared phase
* ids). Archived-phase-token coverage is NOT included — no
* `PlanningSnapshot` field exposes archived milestone-phase-dir tokens
* (confirmed against the 15-field interface). Omitting it makes this valid
* set a SUBSET of the original's, which can only make MORE STATE.md phase
* tokens look "invalid" (never fewer) — a conservative, safe direction; a
* project with archived phases still referenced from STATE.md is the
* fixture shape that would expose a false positive, and none of this
* group's fixtures exercise archives, so this gap is disclosed rather than
* silently absorbed.
*
* UPDATE (#3652): archived-phase-token coverage IS now included, via the
* additive `snapshot.archivedPhaseTokens` field
* (`src/planning-snapshot.cts`, added for this fix) — the disclosed gap
* above is closed; the paragraph is kept for the "conservative direction"
* reasoning, which still explains why every OTHER omission in this
* function is safe.
*/
function buildValidPhaseSet(snapshot: PlanningSnapshot): Set<string> {
const valid = new Set<string>();
for (const dir of snapshot.phaseDirs.value) {
const token = extractPhaseToken(dir);
if (token) valid.add(token);
}
for (const entry of snapshot.roadmapDeclaredPhases.value) {
valid.add(entry.phaseId);
}
for (const token of snapshot.archivedPhaseTokens.value) {
valid.add(token);
}
return valid;
}
/** Mirrors `verify.cts:1749-1758`'s zero-padding normalization exactly. */
function normalizePhaseTokenSet(valid: Set<string>): Set<string> {
const normalized = new Set<string>();
for (const p of valid) {
normalized.add(p);
const dotIdx = p.indexOf('.');
const head = dotIdx === -1 ? p : p.slice(0, dotIdx);
const tail = dotIdx === -1 ? '' : p.slice(dotIdx);
if (/^\d+$/.test(head)) {
normalized.add(head.padStart(2, '0') + tail);
}
}
return normalized;
}
const RULE_W002: Rule = {
code: 'W002',
severity: SEVERITY.WARNING,
description: 'STATE.md references invalid phase',
repairable: false,
check: (snapshot: PlanningSnapshot): Diagnostic[] => {
const validPhases = buildValidPhaseSet(snapshot);
// Mirrors `verify.cts:1765`'s `if (normalizedValid.size > 0)` guard
// exactly — a project with zero declared phases emits nothing, never a
// false positive on every STATE.md phase mention.
if (validPhases.size === 0) return [];
const normalizedValid = normalizePhaseTokenSet(validPhases);
const sortedValid = [...validPhases].sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true }),
);
const diagnostics: Diagnostic[] = [];
for (const ref of snapshot.statePhaseTokens.value) {
const dotIdx = ref.indexOf('.');
const head = dotIdx === -1 ? ref : ref.slice(0, dotIdx);
const tail = dotIdx === -1 ? '' : ref.slice(dotIdx);
const padded = /^\d+$/.test(head) ? head.padStart(2, '0') + tail : ref;
if (normalizedValid.has(ref) || normalizedValid.has(padded)) continue;
diagnostics.push({
code: 'W002',
severity: SEVERITY.WARNING,
message: `STATE.md references phase ${ref}, but only phases ${sortedValid.join(', ')} are declared`,
remedy: adviseRemedy(
'Review STATE.md manually before changing it; /gsd-health --repair will not overwrite an existing STATE.md for phase mismatches',
),
});
}
return diagnostics;
},
};
// ─── W011 — STATE current-phase status vs. ROADMAP checkbox disagree ───────
/**
* `currentPhaseLabel.value` is a prose string (e.g. `"3 of 8 (User Auth)"`),
* not a clean phase id — the leading integer (optionally letter-suffixed /
* dotted, the same `PHASE_NUMBER_TOKEN_SOURCE` grammar) is the "current
* phase" proxy the original `verify.cts:2109-2113` derives via its own
* `**Current Phase:**`/`Current Phase:` regex + `.replace(/^0+/, '')`. That
* literal field name does not exist in the current `state.md` template
* (which uses `Phase: [X] of [Y] ([Phase name])` under `## Current
* Position`) — `currentPhaseLabel` is the parsed owner of that exact field,
* so extracting its leading number is the equivalent-intent read against
* the template STATE.md actually ships.
*/
function currentPhaseIdFromLabel(label: string | null): string | null {
if (!label) return null;
const m = label.match(new RegExp(`^0*(${PHASE_NUMBER_TOKEN_SOURCE})`));
return m ? m[1] : null;
}
const RULE_W011: Rule = {
code: 'W011',
severity: SEVERITY.WARNING,
description: 'STATE.md current-phase status disagrees with ROADMAP.md checkbox',
repairable: false,
check: (snapshot: PlanningSnapshot): Diagnostic[] => {
const phaseId = currentPhaseIdFromLabel(snapshot.currentPhaseLabel.value);
if (phaseId === null) return [];
const checked = snapshot.roadmapPhaseCheckboxes.value[phaseId];
if (checked !== true) return [];
const statusVal = (snapshot.stateStatus.value ?? '').trim().toLowerCase();
if (statusVal === 'complete' || statusVal === 'done') return [];
return [
{
code: 'W011',
severity: SEVERITY.WARNING,
message: `STATE.md says current phase is ${phaseId} (status: ${statusVal || 'unknown'}) but ROADMAP.md shows it as [x] complete — state files may be out of sync`,
remedy: adviseRemedy('Run /gsd-progress to re-derive current position, or manually update STATE.md'),
},
];
},
};
// ─── W021 — phase_id_convention integer-prefix/milestone mismatch ──────────
const RULE_W021: Rule = {
code: 'W021',
severity: SEVERITY.WARNING,
description:
"Phase's integer prefix implies a different milestone than its ROADMAP section (phase_id_convention: milestone-prefixed)",
repairable: false,
check: (snapshot: PlanningSnapshot): Diagnostic[] => {
const convention = snapshot.config.value?.['phase_id_convention'];
if (convention !== 'milestone-prefixed') return [];
const diagnostics: Diagnostic[] = [];
for (const entry of snapshot.roadmapDeclaredPhases.value) {
// `entry.milestone === null` means the builder never found this phase
// heading inside any versioned (`v\d+\.\d+`) section — the original
// `checkMilestonePrefixMismatches` only ever iterates phases found
// WITHIN a section, so a phase outside any section is equivalently
// never checked here.
if (entry.milestone === null) continue;
const expectedMilestone = getMilestoneFromPhaseId(entry.phaseId);
if (expectedMilestone === null || expectedMilestone === entry.milestone) continue;
diagnostics.push({
code: 'W021',
severity: SEVERITY.WARNING,
message: `Phase ${entry.phaseId}: integer prefix implies ${expectedMilestone} but listed under ${entry.milestone}`,
remedy: adviseRemedy('gsd-tools roadmap upgrade --convention milestone-prefixed'),
});
}
return diagnostics;
},
};
// ─── W026 — STATE says milestone complete but ROADMAP lists unstarted phase ─
const RULE_W026: Rule = {
code: 'W026',
severity: SEVERITY.WARNING,
description: 'STATE says milestone complete but ROADMAP lists an unstarted phase for that milestone',
repairable: false,
check: (snapshot: PlanningSnapshot): Diagnostic[] => {
const statusVal = (snapshot.stateStatus.value ?? '').trim().toLowerCase();
if (!/milestone complete|archived/.test(statusVal)) return [];
// `currentMilestoneRoadmapPhaseIds` is already scoped to the current
// milestone (`extractCurrentMilestone(roadmapRaw, cwd)`, the same
// `<details>`/`<summary>`-tolerant owner `verify.cts:2364` used) — no
// separate `currentMilestone` resolution/filter needed here (see the
// field's own doc comment on `PlanningSnapshot` for why
// `roadmapDeclaredPhases`'s `milestone` attribution is the wrong fit).
const unstarted: string[] = [];
for (const phaseId of snapshot.currentMilestoneRoadmapPhaseIds.value) {
const normalized = normalizePhaseName(phaseId);
// `allPhaseDirNames` — every directory under `phases/`, UNWINDOWED by
// ROADMAP-declaration membership — mirrors the original's own
// unwindowed `phaseDirNames2` (`verify.cts:2372-2382`, a direct
// `readdirSync` of the phases dir), not the current-milestone-windowed
// `phaseDirs`.
const hasDirectory = matchPhaseDirs(snapshot.allPhaseDirNames.value, normalized).matches.length > 0;
if (!hasDirectory) unstarted.push(phaseId);
}
if (unstarted.length === 0) return [];
return [
{
code: 'W026',
severity: SEVERITY.WARNING,
message: `STATE says milestone complete but ROADMAP lists ${unstarted.length} unstarted phase(s) (e.g. Phase ${unstarted[0]})`,
remedy: adviseRemedy(
'Run validate consistency or re-run complete-milestone after verifying all phases are done',
),
},
];
},
};
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [RULE_W024, RULE_W002, RULE_W011, RULE_W021, RULE_W026];
export = { RULES };

View File

@@ -0,0 +1,189 @@
/**
* Health Diagnostic — Worktree health rules (Phase 11, #3309, ADR-3180
* §8.2/§8.3/§8.5).
*
* Group: "Worktree health" (design doc, "Rule table organization" table) —
* W020 (×3 internal conditions, one subject: "the worktree health scan
* itself is degraded", design doc "Rejected alternatives" §3), W017 (orphan
* worktree), W027 (NEW — the split-off "stale worktree" subject, design
* doc's "New codes for the two split subjects" section).
*
* Ported behavior-preserving from `cmdValidateHealth`
* (`src/verify.cts:2193-2268`), the exact call sites for W020/W017/W027 (the
* pre-migration source still names the split-off stale-worktree site
* 'W017' — this batch is what actually applies the W027 split).
*
* W020's original THREE conditions were git_timed_out / git_list_failed / a
* per-finding 'unverified' kind, each with its own message. The first two
* are scan-level failures reported by `inspectWorktreeHealth`'s own `reason`
* field ('git_timed_out' vs 'git_list_failed' vs 'not_a_git_repo') —
* `planning-snapshot.cts`'s `buildWorktreeHealthField` now carries `reason`
* straight through on `PlanningSnapshot.worktreeHealth`, so `checkW020`
* below reproduces the original's exact branch-per-reason messages instead
* of collapsing them (a prior version of this file collapsed both into one
* message AND, worse, warned on 'not_a_git_repo' too — a regression, since
* the original silently skips a non-git cwd; see `verify.cts:2202-2217`).
*
* W027 restores the pre-migration active-worktree exclusion
* (`verify.cts:2233-2242`) via `PlanningSnapshot.cwd` — see `checkW027`'s own
* comment below for the mechanism.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
*
* ADR-457 build-at-publish: source in
* src/health-diagnostic-rules/worktree-health.cts, compiled to
* gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs (gitignored).
*/
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- type-only; erased at compile time, no runtime require emitted
import type planningSnapshotMod = require('../planning-snapshot.cjs');
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 planningScopeMod = require('../planning-scope.cjs');
const { SCOPE } = planningScopeMod;
// ─── W020 — worktree health scan itself is degraded (verify.cts:2193-2264) ─
//
// ONE rule, THREE internal conditions, all the same subject ("the worktree
// health scan itself is degraded" — design doc "Rejected alternatives" §3):
// (a) `git worktree list` timed out (verify.cts:2202-2209), (b) `git
// worktree list` failed outright (verify.cts:2210-2217), (c) a specific
// 'unverified' finding (existsSync ok, statSync threw,
// verify.cts:2256-2263). A fourth `!ok` reason, 'not_a_git_repo', and a
// thrown exception ('exception') are DELIBERATELY silent — the original's
// `if` ladder never matches 'not_a_git_repo', and the outer try/catch around
// the whole block is commented "git worktree not available or not a git
// repo — skip silently".
function checkW020(snapshot: PlanningSnapshot): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
const { scope, reason } = snapshot.worktreeHealth;
const degraded = scope === SCOPE.UNREADABLE;
// (a) — git worktree list timed out (verify.cts:2202-2209).
if (degraded && reason === 'git_timed_out') {
diagnostics.push({
code: 'W020',
severity: SEVERITY.WARNING,
message:
'Worktree health check degraded: git worktree list timed out after 10s — orphan/stale worktrees could not be inspected',
remedy: adviseRemedy(
'Run: git worktree list --porcelain to diagnose; check for .git/index.lock or a hung git process',
),
});
}
// (b) — git worktree list failed outright (verify.cts:2210-2217).
if (degraded && reason === 'git_list_failed') {
diagnostics.push({
code: 'W020',
severity: SEVERITY.WARNING,
message:
'Worktree health check degraded: git worktree list failed — orphan/stale worktrees could not be inspected',
remedy: adviseRemedy(
'Run: git worktree list --porcelain to diagnose; check git repository state and permissions',
),
});
}
// (c) — per-finding 'unverified' (existsSync ok, statSync threw).
for (const finding of snapshot.worktreeHealth.value) {
if (finding.kind !== 'unverified') continue;
diagnostics.push({
code: 'W020',
severity: SEVERITY.WARNING,
message: `Worktree health check degraded: could not stat ${finding.path} — presence/staleness could not be verified`,
remedy: adviseRemedy('Check filesystem permissions on the worktree path, or investigate why statSync failed for it'),
});
}
return diagnostics;
}
// ─── W017 — orphan git worktree (verify.cts:2222-2229) ─────────────────────
//
// `finding.kind === 'orphan'` — path no longer exists on disk. One
// Diagnostic per orphan finding. Remedy mirrors the exact original literal
// fix, `verify.cts:2227`: `'Run: git worktree prune'`.
function checkW017(snapshot: PlanningSnapshot): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
for (const finding of snapshot.worktreeHealth.value) {
if (finding.kind !== 'orphan') continue;
diagnostics.push({
code: 'W017',
severity: SEVERITY.WARNING,
message: `Orphan git worktree: ${finding.path} (path no longer exists on disk)`,
remedy: adviseRemedy('git worktree prune'),
});
}
return diagnostics;
}
// ─── W027 — stale git worktree (verify.cts:2232-2249, the split-off half of
// the pre-migration 'W017' site) ─────────────────────────────────────────
//
// `finding.kind === 'stale'` — age-based. Excludes the active session's own
// worktree, restored via `snapshot.cwd` (see module doc, gap 2 — RESOLVED):
// a 'stale' finding is skipped when `snapshot.cwd` equals the finding's path
// or is nested under it, the exact comparison `verify.cts:2238-2241` made
// against `process.cwd()`. Per this batch's brief: the interpolated command
// (with the real path) lives in `message`; `remedy.args.command` stays a
// static `<path>` template, mirroring the split the brief specifies.
function checkW027(snapshot: PlanningSnapshot): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
const activeCwd = snapshot.cwd;
for (const finding of snapshot.worktreeHealth.value) {
if (finding.kind !== 'stale') continue;
const normalizedWorktree = path.resolve(finding.path);
const isActiveWorktree =
activeCwd === normalizedWorktree || activeCwd.startsWith(normalizedWorktree + path.sep);
if (isActiveWorktree) continue;
diagnostics.push({
code: 'W027',
severity: SEVERITY.WARNING,
message: `Stale git worktree: ${finding.path} (last modified ${finding.ageMinutes} minutes ago). Run: git worktree remove ${finding.path} --force`,
remedy: adviseRemedy('git worktree remove <path> --force'),
});
}
return diagnostics;
}
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [
{
code: 'W020',
severity: SEVERITY.WARNING,
description: 'Worktree health scan degraded — git worktree list timed out, failed, or a finding could not be verified',
repairable: false,
check: checkW020,
},
{
code: 'W017',
severity: SEVERITY.WARNING,
description: 'Orphan git worktree (path no longer exists on disk)',
repairable: false,
check: checkW017,
},
{
code: 'W027',
severity: SEVERITY.WARNING,
description: 'Stale git worktree (not modified in a long time)',
repairable: false,
check: checkW027,
},
];
export = { RULES };

View File

@@ -0,0 +1,160 @@
/**
* Health Diagnostic Types — shared, dependency-free rule-table types (Phase
* 11, #3309, ADR-3180 §8.2/§8.3/§8.5).
*
* Split out from `src/health-diagnostic.cts` to break a CJS circular
* dependency between the evaluator and its own rule-group files
* (`src/health-diagnostic-rules/*.cts`): those files need the frozen
* `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums and the `Diagnostic`/
* `Remedy`/`Rule` shapes, but the evaluator (`health-diagnostic.cts`) also
* needs to `require()` every rule-group file to populate its `RULES` array —
* a rule-group file requiring `health-diagnostic.cjs` back, mid-load, reads
* `module.exports` before it is assigned, so the destructured enums come
* back `undefined`. This leaf has NO runtime dependency on anything in that
* cycle, so both sides can depend on it directly.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
* Test matrix: .gsd/phase/refactor-3309-health-diagnostic-rule-table/50-test-matrix.md
*
* ADR-457 build-at-publish: source in src/health-diagnostic-types.cts,
* compiled to gsd-core/bin/lib/health-diagnostic-types.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>;
// ─── Severity ───────────────────────────────────────────────────────────────
const SEVERITY = Object.freeze({
ERROR: 'error',
WARNING: 'warning',
INFO: 'info',
});
type Severity = (typeof SEVERITY)[keyof typeof SEVERITY];
// ─── Remedy action / risk ───────────────────────────────────────────────────
// Harvested from health.md's published table + the corrected 6-action
// implementation (`src/verify.cts:2405-2553`) — not 5; `addAiIntegrationPhaseKey`
// (verify.cts:1860/2481-2502) was live in code, missing from docs (design
// doc, "Ground truth vs. issue #3309's claims" section).
const REMEDY_ACTION = Object.freeze({
CREATE_CONFIG: 'createConfig',
RESET_CONFIG: 'resetConfig',
REGENERATE_STATE: 'regenerateState',
ADD_NYQUIST_KEY: 'addNyquistKey',
ADD_AI_INTEGRATION_PHASE_KEY: 'addAiIntegrationPhaseKey',
BACKFILL_MILESTONES: 'backfillMilestones',
// §8.3 rule 5 — every non-repairable finding's `fix` string becomes an
// ADVISE payload; ADVISE never acts, only describes.
ADVISE: 'advise',
});
type RemedyAction = (typeof REMEDY_ACTION)[keyof typeof REMEDY_ACTION];
const REMEDY_RISK = Object.freeze({
NONE: 'none',
DESTRUCTIVE: 'destructive',
});
type RemedyRisk = (typeof REMEDY_RISK)[keyof typeof REMEDY_RISK];
// ─── Diagnostic / Rule shapes ───────────────────────────────────────────────
interface Remedy {
action: RemedyAction;
risk: RemedyRisk;
args: Record<string, unknown>;
}
interface Diagnostic {
code: string; // e.g. 'W010' — append-only, never renumbered (§8.2 rule 2)
severity: Severity; // property of the RULE, never the emit call (§8.2 rule 3)
message: string;
remedy: Remedy;
}
interface Rule {
code: string;
severity: Severity;
/**
* Short, static, human-readable summary of what this rule checks — the
* source of `gsd-core/workflows/health.md`'s generated `<error_codes>`
* table (`scripts/gen-health-docs.cjs`). Deliberately distinct from a
* fired `Diagnostic`'s `message`, which is dynamic/per-instance (e.g.
* W001's message names the specific PROJECT.md section that is missing);
* `description` is exactly one fixed sentence per code, matching the
* hand-written table's pre-existing style for the codes it already
* documented (E001-E005, W001-W009, W018, W019, W024, I001).
*/
description: string;
/**
* Whether `--repair` will actually apply this rule's remedy (`true`) or
* never will (`false`) — the source of the generated table's "Repairable"
* column. This MUST match `diagnosticToIssueEntry`'s (`src/verify.cts`)
* per-diagnostic semantics: `remedy.action !== ADVISE && remedy.risk !==
* REMEDY_RISK.DESTRUCTIVE`. `false` covers TWO distinct cases, and both
* must map to `false` here:
*
* 1. ADVISE-only rules — no real `REMEDY_ACTION` exists to apply.
* 2. DESTRUCTIVE-risk rules (`regenerateState`, `resetConfig`) — a real
* action exists and is described, but `applyRepairs`'s dispatcher
* (`src/health-diagnostic.cts`) refuses to auto-apply any
* DESTRUCTIVE-risk remedy (§8.3 rule 3), so `--repair` never applies it
* either. "A remedy exists to describe" is NOT sufficient for `true` —
* only "an unattended `--repair` run will actually apply it" is.
*
* STATIC field, not derived by executing `check` against a fixture at
* doc-gen time: confirmed by direct read of all 8
* `src/health-diagnostic-rules/*.cts` files that every rule in this
* codebase uses exactly ONE `remedy.action` (and therefore one
* `remedy.risk`) across every `Diagnostic` it can ever emit — no rule mixes
* ADVISE with a real action, or NONE-risk with DESTRUCTIVE-risk, depending
* on the triggering condition (the design doc's "primary remedy" ambiguity
* this field's doc comment was asked to consider does not arise in
* practice). A single static boolean is therefore a faithful,
* execution-free summary, and cheaper/simpler than adding a second
* `primaryRemedyAction` field or having the generator import and execute
* every rule against a synthetic snapshot.
*/
repairable: boolean;
check: (snapshot: PlanningSnapshot) => Diagnostic[]; // §8.1 rule 1 signature, verbatim
}
// ─── adviseRemedy — shared ADVISE-remedy builder ───────────────────────────
/**
* Every rule-group file needs the same `{action: ADVISE, risk: NONE, args:
* {command}}` shape for a non-repairable finding's `fix` string (§8.3 rule
* 5). Was defined identically in `config-validation.cts` and
* `agent-install.cts`, and repeated inline elsewhere — moved to this shared,
* dependency-free leaf so every rule-group file imports one implementation
* instead of duplicating it.
*/
function adviseRemedy(command: string): Remedy {
return { action: REMEDY_ACTION.ADVISE, risk: REMEDY_RISK.NONE, args: { command } };
}
// ─── Exports ────────────────────────────────────────────────────────────────
const healthDiagnosticTypes = {
SEVERITY,
REMEDY_ACTION,
REMEDY_RISK,
adviseRemedy,
};
// Namespace merge (same binding name as the value above) is how a CommonJS
// `export =` module exposes a type alongside its runtime export — `export
// type` is rejected by TS2309 ("An export assignment cannot be used in a
// module with other exported elements") when combined with `export =`, so
// these types ride along on the exported object via declaration merging
// instead. Mirrors `src/planning-scope.cts`'s exact mechanism. Consumers
// doing `import x = require('./health-diagnostic-types.cjs')` can reference
// the types as `x.Severity`, `x.RemedyAction`, etc.
// eslint-disable-next-line @typescript-eslint/no-namespace
declare namespace healthDiagnosticTypes {
export { Severity, RemedyAction, RemedyRisk, Remedy, Diagnostic, Rule };
}
export = healthDiagnosticTypes;

493
src/health-diagnostic.cts Normal file
View File

@@ -0,0 +1,493 @@
/**
* Health Diagnostic — frozen rule-table types, enums, and evaluator for
* `validate health` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5).
*
* Establishes the exact contract every extracted rule builds onto: the
* frozen `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums, the
* `Diagnostic`/`Remedy`/`Rule` shapes, the `RULES` container (the 32 rules
* extracted from `cmdValidateHealth`, `src/verify.cts:1616-2577`, are
* concatenated in from each rule-group file under
* `src/health-diagnostic-rules/`), the `evaluateRules` evaluator, and the
* `applyRepairs` `--repair`/`--backfill` dispatcher — whose per-action
* handlers are REAL here (ported behavior-preserving from
* `verify.cts:2405-2553`'s repair switch), not stubs.
*
* `applyRepairs` does not receive a `PlanningSnapshot` (its call-site
* signature, `(cwd, diagnostics, repair, backfill)`, is a locked contract —
* see `tests/health-diagnostic.test.cjs`) — so, like `cmdValidateHealth`
* itself before this migration, it performs its own bounded filesystem I/O
* to apply a repair. This is not a §8.1 rule 1 violation: that rule
* constrains a RULE's `check(snapshot)` signature (no ambient I/O), not the
* evaluator/dispatcher, which the design doc's "subject-surface gap" section
* already establishes performs I/O once, up front, on the rules' behalf.
*
* `PlanningSnapshot` is deliberately NOT re-exported as a type from
* `planning-snapshot.cts` here (see the design doc's "Known limits" and this
* phase's brief): `ReturnType<typeof buildPlanningSnapshot>` is used inline
* instead, via a type-only `import ... = require(...)` that is fully erased
* at compile time — zero changes to the already-shipped, already-tested
* `planning-snapshot.cts`.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
* Test matrix: .gsd/phase/refactor-3309-health-diagnostic-rule-table/50-test-matrix.md
*
* ADR-457 build-at-publish: source in src/health-diagnostic.cts, compiled to
* gsd-core/bin/lib/health-diagnostic.cjs (gitignored).
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- type-only; erased at compile time, no runtime require emitted
import type planningSnapshotMod = require('./planning-snapshot.cjs');
type PlanningSnapshot = ReturnType<typeof planningSnapshotMod.buildPlanningSnapshot>;
// Runtime values (SEVERITY/REMEDY_ACTION/REMEDY_RISK) are needed here — not
// just types — for `applyRepairs`'s comparisons, so this is a normal
// (non type-only) `import ... = require(...)`. `health-diagnostic-types.cjs`
// is the leaf module these enums/types were extracted to, so that this file
// can `require()` every rule-group file below without a circular dependency
// (see that module's file-level comment for the full explanation).
// eslint-disable-next-line @typescript-eslint/no-require-imports
import healthDiagnosticTypesMod = require('./health-diagnostic-types.cjs');
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticTypesMod;
type Severity = healthDiagnosticTypesMod.Severity;
type RemedyAction = healthDiagnosticTypesMod.RemedyAction;
type RemedyRisk = healthDiagnosticTypesMod.RemedyRisk;
type Remedy = healthDiagnosticTypesMod.Remedy;
type Diagnostic = healthDiagnosticTypesMod.Diagnostic;
type Rule = healthDiagnosticTypesMod.Rule;
// ─── Rule table ─────────────────────────────────────────────────────────────
// Populated by concatenating each rule group's exported `RULES` array (design
// doc, "Rule table organization" section) — the 32 rule functions extracted
// from `cmdValidateHealth`, `src/verify.cts:1616-2577`.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import rootExistenceMod = require('./health-diagnostic-rules/root-existence.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import stateConsistencyMod = require('./health-diagnostic-rules/state-consistency.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configValidationMod = require('./health-diagnostic-rules/config-validation.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseStructureMod = require('./health-diagnostic-rules/phase-structure.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import agentInstallMod = require('./health-diagnostic-rules/agent-install.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapDiskConsistencyMod = require('./health-diagnostic-rules/roadmap-disk-consistency.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
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');
const RULES: Rule[] = [
...rootExistenceMod.RULES,
...stateConsistencyMod.RULES,
...configValidationMod.RULES,
...phaseStructureMod.RULES,
...agentInstallMod.RULES,
...roadmapDiskConsistencyMod.RULES,
...worktreeHealthMod.RULES,
...milestoneArchiveHygieneMod.RULES,
];
// ─── Repair-handler runtime dependencies ───────────────────────────────────
//
// Same owners `cmdValidateHealth`'s pre-migration repair switch used
// (`verify.cts:2405-2553`) — ported verbatim, not reinvented.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspaceMod = require('./planning-workspace.cjs');
const { planningRoot, planningDir } = planningWorkspaceMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configLoaderMod = require('./config-loader.cjs');
const { CONFIG_DEFAULTS } = configLoaderMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestoneInfo } = roadmapParserMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import stateMod = require('./state.cjs');
const { writeStateMd } = stateMod;
import { realClock } from './clock.cjs';
import { platformReadSync as safeReadFile, platformWriteSync } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
// ─── Evaluator ──────────────────────────────────────────────────────────────
/**
* Evaluate an explicit `rules` array against `snapshot`, throwing if any two
* entries share a `code` (defense in depth beside the static lint guard,
* §8.2 rule 1, `scripts/lint-health-diagnostic-rule-table.cjs`). Separated
* from `evaluateRules` so the duplicate-code guard is unit-testable against
* a small, locally-constructed fake rule array, independent of the real
* `RULES` table.
*/
function evaluateRuleTable(rules: Rule[], snapshot: PlanningSnapshot): Diagnostic[] {
const seen = new Set<string>();
for (const rule of rules) {
if (seen.has(rule.code)) {
throw new Error(`health-diagnostic: duplicate rule code "${rule.code}" in rule table`);
}
seen.add(rule.code);
}
return rules.flatMap((rule) => rule.check(snapshot));
}
/**
* Evaluate every rule in `RULES` against `snapshot`, flattening each rule's
* `Diagnostic[]` into one array.
*/
function evaluateRules(snapshot: PlanningSnapshot): Diagnostic[] {
return evaluateRuleTable(RULES, snapshot);
}
// ─── Repair dispatcher ──────────────────────────────────────────────────────
// Repair-handler bodies (real, ported from verify.cts:2405-2553).
/**
* One `repairs_performed`-shaped entry (legacy `cmdValidateHealth` output
* shape), tagged with the diagnostic `code` it came from so
* `applyRepairs`'s caller can build BOTH the code-keyed `applied`/`refused`
* arrays this module's own tests lock (`tests/health-diagnostic.test.cjs`)
* AND the action-keyed `repairs_performed` array `cmdValidateHealth` still
* emits. `code` is stripped by the caller before the entry reaches JSON
* output — the legacy shape never carried it.
*/
interface RepairDetail {
code: string;
action: string;
success: boolean;
path?: string;
detail?: string;
error?: string;
}
interface RepairPaths {
rootBase: string;
configPath: string;
statePath: string;
milestonesPath: string;
milestonesArchiveDir: string;
}
/**
* Derive every filesystem path a repair handler needs, from `cwd` alone —
* exactly how `cmdValidateHealth` derived them pre-migration
* (`verify.cts:1644-1652`/`2301-2302`). `config.json`/`MILESTONES.md`/
* `milestones/` are root-scoped (`planningRoot`); `STATE.md` is
* workstream-scoped (`planningDir`) — the same root-vs-workstream split
* `buildConfigField`/`buildStateFields` (`planning-snapshot.cts`) already
* document for the read side.
*/
function repairPaths(cwd: string): RepairPaths {
const rootBase = planningRoot(cwd);
const wsBase = planningDir(cwd);
return {
rootBase,
configPath: path.join(rootBase, 'config.json'),
statePath: path.join(wsBase, 'STATE.md'),
milestonesPath: path.join(rootBase, 'MILESTONES.md'),
milestonesArchiveDir: path.join(rootBase, 'milestones'),
};
}
/** `verify.cts:2413-2429`'s default config.json payload, ported verbatim. */
function defaultConfigPayload(): Record<string, unknown> {
return {
model_profile: CONFIG_DEFAULTS.model_profile,
commit_docs: CONFIG_DEFAULTS.commit_docs,
search_gitignored: CONFIG_DEFAULTS.search_gitignored,
branching_strategy: CONFIG_DEFAULTS.branching_strategy,
phase_branch_template: CONFIG_DEFAULTS.phase_branch_template,
milestone_branch_template: CONFIG_DEFAULTS.milestone_branch_template,
quick_branch_template: CONFIG_DEFAULTS.quick_branch_template,
workflow: {
research: CONFIG_DEFAULTS.research,
plan_check: CONFIG_DEFAULTS.plan_checker,
verifier: CONFIG_DEFAULTS.verifier,
nyquist_validation: CONFIG_DEFAULTS.nyquist_validation,
},
parallelization: CONFIG_DEFAULTS.parallelization,
brave_search: CONFIG_DEFAULTS.brave_search,
};
}
/**
* `verify.cts:2301-2335`'s W018 archived-vs-documented-versions diff,
* relocated verbatim (same two regexes, same two-file read) so
* `backfillMilestones` can recompute exactly which versions are missing
* without a `PlanningSnapshot` (`applyRepairs` is not a `Rule` and is not
* handed one — see this file's header comment). This is the same
* derivation `buildMilestoneArchiveStatusField`
* (`src/planning-snapshot.cts`) already performs for the W018 RULE's read
* side; recomputed here, not re-invented, because the rule's own
* `Diagnostic.remedy.args` carries no version list (confirmed by direct
* read of `src/health-diagnostic-rules/milestone-archive-hygiene.cts`).
*/
function computeMissingMilestoneVersions(milestonesArchiveDir: string, milestonesPath: string): string[] {
let archivedVersions: string[] = [];
try {
if (fs.existsSync(milestonesArchiveDir)) {
const archiveFiles = fs.readdirSync(milestonesArchiveDir);
archivedVersions = archiveFiles
.map((f) => f.match(/^(v\d+\.\d+(?:\.\d+)?)-ROADMAP\.md$/))
.filter((m): m is RegExpMatchArray => m !== null)
.map((m) => m[1]);
}
} catch {
/* intentionally empty — mirrors the original's advisory try/catch */
}
let documentedVersions: string[] = [];
try {
if (fs.existsSync(milestonesPath)) {
const registryContent = fs.readFileSync(milestonesPath, 'utf-8');
documentedVersions = [...registryContent.matchAll(/^##\s+(v\d+\.\d+(?:\.\d+)?)/gm)].map((m) => m[1]);
}
} catch {
/* intentionally empty */
}
const documented = new Set(documentedVersions);
return archivedVersions.filter((v) => !documented.has(v));
}
interface RepairOutcome {
success: boolean;
path?: string;
detail?: string;
error?: string;
// regenerateState's original backup step (verify.cts:2435-2440) pushed its
// own SEPARATE `repairActions` entry before the main one — preserved here
// as extra, prepended detail rows. Unreachable in practice today
// (regenerateState is DESTRUCTIVE and `applyRepairs`'s dispatcher below
// refuses it before this handler is ever invoked), but the handler stays
// complete rather than partially ported, per this batch's brief.
extraDetails?: { action: string; success: boolean; path?: string }[];
}
/**
* Execute exactly one real repair action, ported behavior-preserving from
* `verify.cts:2405-2553`'s `switch (repair)`. Throws are the caller's
* responsibility to catch (mirrors the original's per-action try/catch
* shape, collapsed to one seam here since every case now shares one
* caller).
*/
function runRepairAction(cwd: string, action: RemedyAction, paths: RepairPaths): RepairOutcome {
const { rootBase, configPath, statePath, milestonesPath, milestonesArchiveDir } = paths;
switch (action) {
case REMEDY_ACTION.CREATE_CONFIG:
case REMEDY_ACTION.RESET_CONFIG: {
platformWriteSync(configPath, JSON.stringify(defaultConfigPayload(), null, 2));
return { success: true, path: 'config.json' };
}
case REMEDY_ACTION.REGENERATE_STATE: {
const extraDetails: { action: string; success: boolean; path?: string }[] = [];
if (fs.existsSync(statePath)) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const backupPath = `${statePath}.bak-${timestamp}`;
fs.copyFileSync(statePath, backupPath);
extraDetails.push({ action: 'backupState', success: true, path: backupPath });
}
const milestone = getMilestoneInfo(cwd).value;
const projectRef = path
.relative(cwd, path.join(rootBase, 'PROJECT.md'))
.split(path.sep)
.join('/');
const slashRuntime = resolveRuntime(cwd);
const slash = (name: string) => formatGsdSlash(name, slashRuntime) as string;
let stateContent = `# Session State\n\n`;
stateContent += `## Project Reference\n\n`;
stateContent += `See: ${projectRef}\n\n`;
stateContent += `## Position\n\n`;
stateContent += `**Milestone:** ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n`;
stateContent += `**Current phase:** (determining...)\n`;
stateContent += `**Status:** Resuming\n\n`;
stateContent += `## Session Log\n\n`;
stateContent += `- ${realClock.localToday()}: STATE.md regenerated by ${slash('health')} --repair\n`;
writeStateMd(statePath, stateContent, cwd);
return { success: true, path: 'STATE.md', extraDetails };
}
case REMEDY_ACTION.ADD_NYQUIST_KEY:
case REMEDY_ACTION.ADD_AI_INTEGRATION_PHASE_KEY: {
const key = action === REMEDY_ACTION.ADD_NYQUIST_KEY ? 'nyquist_validation' : 'ai_integration_phase';
const configRaw = fs.readFileSync(configPath, 'utf-8');
const configParsed = JSON.parse(configRaw) as Record<string, unknown>;
if (!configParsed['workflow']) configParsed['workflow'] = {};
const wf = configParsed['workflow'] as Record<string, unknown>;
if (wf[key] === undefined) {
wf[key] = true;
platformWriteSync(configPath, JSON.stringify(configParsed, null, 2));
}
return { success: true, path: 'config.json' };
}
case REMEDY_ACTION.BACKFILL_MILESTONES: {
const missing = computeMissingMilestoneVersions(milestonesArchiveDir, milestonesPath);
const today = realClock.localToday();
const slashRuntime = resolveRuntime(cwd);
const slash = (name: string) => formatGsdSlash(name, slashRuntime) as string;
let backfilled = 0;
for (const ver of missing) {
try {
const snapshotPath = path.join(milestonesArchiveDir, `${ver}-ROADMAP.md`);
const snapshot = safeReadFile(snapshotPath);
const titleMatch = snapshot && snapshot.match(/^#\s+(.+)$/m);
const milestoneName = titleMatch
? titleMatch[1].replace(/^Milestone\s+/i, '').replace(/^v[\d.]+\s*/, '').trim()
: ver;
const entry =
`## ${ver}${milestoneName && milestoneName !== ver ? ` ${milestoneName}` : ''} (Backfilled: ${today})\n\n**Note:** Synthesized from archive snapshot by \`${slash('health')} --backfill\`. Original completion date unknown.\n\n---\n\n`;
const milestonesContent = fs.existsSync(milestonesPath)
? fs.readFileSync(milestonesPath, 'utf-8')
: '';
if (!milestonesContent.trim()) {
platformWriteSync(milestonesPath, `# Milestones\n\n${entry}`);
} else {
const headerMatch = milestonesContent.match(/^(#{1,3}\s+[^\n]*\n\n?)/);
if (headerMatch) {
const header = headerMatch[1];
const rest = milestonesContent.slice(header.length);
platformWriteSync(milestonesPath, header + entry + rest);
} else {
platformWriteSync(milestonesPath, entry + milestonesContent);
}
}
backfilled++;
} catch {
/* intentionally empty — partial backfill is acceptable */
}
}
return { success: true, detail: `Backfilled ${backfilled} milestone(s) into MILESTONES.md` };
}
default:
return { success: false, error: `no repair handler registered for action "${action}"` };
}
}
/**
* `--repair`/`--backfill` dispatcher (design doc "`--repair` behavior
* change" section; §8.3 rule 3). For each diagnostic whose remedy is not
* `ADVISE`:
*
* - Not requested — `repair` is false, and for `backfillMilestones`
* specifically `backfill` is also false (mirrors `cmdValidateHealth`'s
* existing `backfillMilestones` gate, `verify.cts:2504`:
* `if (!options['backfill'] && !options['repair']) break;`) — skipped
* entirely, recorded in neither `applied` nor `refused`.
* - Requested and `remedy.risk === DESTRUCTIVE` — pushed onto `refused`,
* handler never invoked. This is the §8.3 rule 3 breaking-change
* enforcement point: a DESTRUCTIVE remedy is describable but is never
* applied by `--repair`. A `details` row is still recorded, so the
* refusal is VISIBLE in `cmdValidateHealth`'s `repairs_performed` output,
* not silently dropped.
* - Requested and `remedy.risk === NONE` — the real handler is invoked,
* pushed onto `applied`.
*
* `applied`/`refused` are unchanged in shape from the pre-existing skeleton
* (locked by `tests/health-diagnostic.test.cjs`, rows 11-12): arrays of
* diagnostic `code`s. `details` is ADDITIVE — every real action maps 1:1 to
* exactly one code in this rule table (confirmed: no `REMEDY_ACTION` other
* than `ADVISE` is used by more than one rule), so `cmdValidateHealth` can
* rebuild the legacy action-keyed `repairs_performed` shape directly from
* it.
*/
function applyRepairs(
cwd: string,
diagnostics: Diagnostic[],
repair: boolean,
backfill: boolean,
): { applied: string[]; refused: string[]; details: RepairDetail[] } {
const applied: string[] = [];
const refused: string[] = [];
const details: RepairDetail[] = [];
const paths = repairPaths(cwd);
for (const diagnostic of diagnostics) {
const { remedy, code } = diagnostic;
if (remedy.action === REMEDY_ACTION.ADVISE) continue;
const requested =
remedy.action === REMEDY_ACTION.BACKFILL_MILESTONES ? repair || backfill : repair;
if (!requested) continue;
if (remedy.risk === REMEDY_RISK.DESTRUCTIVE) {
refused.push(code);
details.push({
code,
action: remedy.action,
success: false,
error: `refused: '${remedy.action}' is a destructive remedy and is not auto-applied by --repair`,
});
continue;
}
try {
const outcome = runRepairAction(cwd, remedy.action, paths);
if (outcome.extraDetails) {
for (const extra of outcome.extraDetails) {
details.push({ code, action: extra.action, success: extra.success, ...(extra.path ? { path: extra.path } : {}) });
}
}
details.push({
code,
action: remedy.action,
success: outcome.success,
...(outcome.path ? { path: outcome.path } : {}),
...(outcome.detail ? { detail: outcome.detail } : {}),
...(outcome.error ? { error: outcome.error } : {}),
});
// `applied` means "the repair actually succeeded", not "was attempted"
// — a handler that returns `{success: false}` (or throws, caught
// below) is recorded in `details` with its failure, but must not be
// reported as applied. `refused` is reserved for the DESTRUCTIVE-risk
// gate above; a failed attempt is neither applied nor refused.
if (outcome.success) applied.push(code);
} catch (err) {
details.push({
code,
action: remedy.action,
success: false,
error: err instanceof Error ? err.message : String(err),
});
}
}
return { applied, refused, details };
}
// ─── Exports ────────────────────────────────────────────────────────────────
const healthDiagnostic = {
SEVERITY,
REMEDY_ACTION,
REMEDY_RISK,
RULES,
evaluateRules,
// Additive beyond the phase's required-exports list — exposed so the
// duplicate-code guard (row 13) is directly unit-testable against a fake
// rule array without mutating the real, still-empty `RULES` export.
evaluateRuleTable,
applyRepairs,
};
// Namespace merge (same binding name as the value above) is how a CommonJS
// `export =` module exposes a type alongside its runtime export — `export
// type` is rejected by TS2309 ("An export assignment cannot be used in a
// module with other exported elements") when combined with `export =`, so
// these types ride along on the exported object via declaration merging
// instead. Mirrors `src/planning-scope.cts`'s exact mechanism. Consumers
// doing `import x = require('./health-diagnostic.cjs')` can reference the
// types as `x.Severity`, `x.RemedyAction`, etc.
// eslint-disable-next-line @typescript-eslint/no-namespace
declare namespace healthDiagnostic {
export { Severity, RemedyAction, RemedyRisk, Remedy, Diagnostic, Rule };
}
export = healthDiagnostic;

View File

@@ -19,10 +19,11 @@
* gsd-core/bin/lib/planning-snapshot.cjs (gitignored).
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestoneInfo } = roadmapParserMod;
const { getMilestoneInfo, extractCurrentMilestone } = roadmapParserMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocatorMod = require('./phase-locator.cjs');
const { listMilestonePhaseDirs } = phaseLocatorMod;
@@ -33,8 +34,8 @@ const { isPhaseComplete } = verificationMod;
import scanPhasePlans = require('./plan-scan.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningPaths } = planningWorkspace;
import { platformReadSync } from './shell-command-projection.cjs';
const { planningPaths, planningRoot } = planningWorkspace;
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;
@@ -46,6 +47,17 @@ const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
type Scope = planningScopeMod.Scope;
import { resolveRuntime } from './runtime-slash.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
import agentInstallCheckMod = require('./agent-install-check.cjs');
const { checkAgentsInstalled } = agentInstallCheckMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- worktree-safety.cjs is an export= CommonJS module
import worktreeSafetyMod = require('./worktree-safety.cjs');
const { inspectWorktreeHealth } = worktreeSafetyMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { PHASE_NUMBER_TOKEN_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, stripProjectCodePrefix } = phaseIdMod;
import { buildRoadmapPhaseVariants, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE } from './validate.cjs';
// ─── worstScope — the one new piece of coordination logic ───────────────────
@@ -85,10 +97,106 @@ interface PhaseSnapshot {
}
interface PlanningSnapshot {
// The resolved absolute `cwd` this snapshot was built for — `cwd` is
// already `buildPlanningSnapshot`'s own input, not a new ambient read, so
// exposing it is a "parsed value" per §8.1 rule 2, not §8.1 rule 1 ambient
// I/O. Backs W027's active-worktree exclusion
// (`src/health-diagnostic-rules/worktree-health.cts`), the one pre-migration
// behavior (`verify.cts:2233-2242`) that genuinely needed the caller's cwd.
cwd: string;
milestone: ReturnType<typeof getMilestoneInfo>;
phaseDirs: ReturnType<typeof listMilestonePhaseDirs>;
phases: { value: PhaseSnapshot[]; scope: Scope };
currentPhaseLabel: { value: string | null; scope: Scope };
// ─── Phase 11 (#3309, ADR-3180 §8.2/§8.3/§8.5) additions ───────────────────
// Additive-only — see the design doc's "The subject-surface gap" section.
// `config` genuinely lives under `.planning/`; `agentInstall` and
// `worktreeHealth` do not (named as such so a future reader does not
// mistake them for §7 derivations) but are exposed here anyway so every
// rule's `check(snapshot)` signature stays the single object §8.1 rule 1
// names, "the snapshot".
config: { value: Record<string, unknown> | null; scope: Scope; exists: boolean };
agentInstall: { value: ReturnType<typeof checkAgentsInstalled>; scope: Scope };
worktreeHealth: { value: ReturnType<typeof inspectWorktreeHealth>['findings']; scope: Scope; reason: string };
// ─── Phase 11 (#3309) "Rule table organization" additions ─────────────────
// The design doc's own "Rule table organization" table and prose disagree
// on the count: the table lists EIGHT rows (through `planningRootFiles`,
// W019) but the prose says "7 more fields" / "14 fields after this batch".
// This implementation follows the table (and the task brief, which
// separately enumerates all eight) — every field a reused owner or a
// small, relocated (not new-algorithm) derivation. `PlanningSnapshot`
// therefore totals 15 fields after this batch, not 14; flagged here rather
// than silently reconciled, since correcting the design doc's prose is
// outside this diff's scope.
projectSections: { value: string[] | null; scope: Scope; exists: boolean };
statePhaseTokens: { value: string[]; scope: Scope };
stateStatus: { value: string | null; scope: Scope };
roadmapDeclaredPhases: { value: { phaseId: string; milestone: string | null }[]; scope: Scope };
roadmapPhaseCheckboxes: { value: Record<string, boolean>; scope: Scope };
researchValidationStatus: {
value: { dir: string; hasValidationArchitecture: boolean; hasValidationMd: boolean }[];
scope: Scope;
};
milestoneArchiveStatus: {
value: { archivedVersions: string[]; documentedVersions: string[] };
scope: Scope;
};
planningRootFiles: { value: string[]; scope: Scope };
// W006/W007 (ROADMAP/disk consistency group) fidelity fix, found while
// implementing `src/health-diagnostic-rules/roadmap-disk-consistency.cts`:
// `phaseDirs` (Phase 10) is deliberately WINDOWED to the phases
// `listMilestonePhaseDirs`'s `inWindow` filter (`getMilestonePhaseFilter`,
// `src/roadmap-parser.cts:1220`) resolves as belonging to the CURRENT
// milestone window — a directory whose phase id is NOT declared anywhere
// in ROADMAP.md is EXCLUDED from `phaseDirs.value` by construction
// (`isDirInMilestone` membership test). That is exactly the directory
// W007 exists to find ("an on-disk phase dir has no matching ROADMAP
// entry"), so sourcing W007 from `phaseDirs.value` would make it
// structurally unable to fire on the very case it names: an orphan
// directory can never be a member of the set that is itself defined as
// "directories the roadmap already declares." `allPhaseDirNames` is the
// un-windowed twin — every directory actually present under the active
// `phases/` root, unfiltered by roadmap declaration (sentinel-id
// exclusion is left to the RULE, mirroring `verify.cts:2091`'s own
// per-entry `isSentinelPhaseId` guard rather than baking it into the
// field). Archived-milestone directories are out of scope here exactly as
// they already are for `phaseDirs` (see this batch's own disclosed
// fidelity reduction for that).
allPhaseDirNames: { value: string[]; scope: Scope };
// W002 (STATE.md-consistency group) fidelity fix, found while implementing
// `src/health-diagnostic-rules/state-consistency.cts`. The original
// `cmdValidateHealth` W002 check unions THREE sources into its "valid
// phase" set — disk dirs, ROADMAP headings, and
// `forEachArchivedPhaseToken(planBase, ...)` (`verify.cts:1748`, every
// phase-token-shaped subdirectory under `.planning/milestones/*-phases/`,
// via the same `MILESTONE_ARCHIVE_DIR_RE`/`PHASE_TOKEN_FROM_DIR_RE`
// `listMilestoneArchiveDirs`/`forEachArchivedPhaseToken` use, both already
// exported from `validate.cjs` — no new regex derivation here). Without the
// third source, a STATE.md reference to a phase whose only directory lives
// in a shipped-milestone archive reads as an undeclared phase (#3652).
// Additive-only per this batch's own field-table constraint. Also now reused
// by `src/health-diagnostic-rules/roadmap-disk-consistency.cts`'s `checkW006`
// (Bug 1, #3309 W006/W007 migration cluster) for the same "was this token
// archived" question a ROADMAP *entry* needs answered, not just a STATE.md
// *reference* — same token set, two independent consumers, no re-derivation.
archivedPhaseTokens: { value: string[]; scope: Scope };
// W026 (STATE.md-consistency group) fidelity fix, found while implementing
// `src/health-diagnostic-rules/state-consistency.cts`. W026's original
// logic (`verify.cts:2356-2399`, the second `addIssue('warning', 'W021',
// ...)` call site before the #3309 code split) scopes ROADMAP.md to the
// CURRENT milestone via `extractCurrentMilestone(roadmapRaw, cwd)` — the
// same shared, `<details>`/`<summary>`-tolerant scoping owner every other
// milestone-aware consumer uses (`roadmap-parser.cts`) — then scans
// `#{2,4}\s*Phase\s+(TOKEN)...` headings within that scoped slice.
// `roadmapDeclaredPhases`'s `milestone` attribution (above) is NOT a fit
// here even though it looks adjacent: it exists to relocate
// `checkMilestonePrefixMismatches`'s OWN narrower `sectionRx`
// (`verify.cts:1429-1459`, `^#{1,3}\s+...vX.Y`, no `<details>` support) —
// faithful for W021 (which never supported `<details>` either), but
// reusing it for W026 would regress W026's ALREADY-`<details>`-tolerant
// original behavior. This field is W026's own, independently-scoped
// phase-id list — additive-only, no change to `roadmapDeclaredPhases`.
currentMilestoneRoadmapPhaseIds: { value: string[]; scope: Scope };
}
/**
@@ -113,10 +221,27 @@ function buildPhaseSnapshot(phasesDir: string, dir: string): PhaseSnapshot {
};
}
interface StateFields {
currentPhaseLabel: { value: string | null; scope: Scope };
statePhaseTokens: { value: string[]; scope: Scope };
stateStatus: { value: string | null; scope: Scope };
}
/**
* Resolve `currentPhaseLabel` — the raw `Phase:` field STATE.md records under
* `## Current Position` (e.g. `"3 of 8 (User Auth)"`), not a normalized
* phase-directory id (see the design doc's Known limits).
* Resolve every STATE.md-sourced field in one place: `currentPhaseLabel` (the
* raw `Phase:` field under `## Current Position`, e.g. `"3 of 8 (User
* Auth)"`, not a normalized phase-directory id — see the design doc's Known
* limits), `statePhaseTokens` (Phase 11, #3309 — every phase-number-shaped
* token found anywhere in STATE.md's raw text, backs W002), and `stateStatus`
* (Phase 11, #3309 — the `status`/`Status` field, backs W011).
*
* Phase 10 shipped `currentPhaseLabel` as its own single-purpose reader
* (`buildCurrentPhaseLabel(statePath)`); this phase folds two more STATE.md
* derivations in rather than reading and parsing the same file three times
* per `buildPlanningSnapshot` call — the read, `extractFrontmatter`, and
* `stripFrontmatter` are genuinely shared inputs for all three, and sharing
* them means `warnUnusableInput(STATE_UNREADABLE)` also stays a single call
* site instead of a risk of tripling on one degraded read.
*
* This module performs the one STATE.md read no §7 owner does, mirroring
* every existing STATE.md caller (`cmdStateSnapshot`, `cmdStatePrune`):
@@ -126,36 +251,548 @@ function buildPhaseSnapshot(phasesDir: string, dir: string): PhaseSnapshot {
* non-answer, NOT corruption — a project that never ran `state.init`
* legitimately has no STATE.md yet. `warnUnusableInput` is NOT called.
* - STATE.md present but unreadable (any other read error, e.g. EISDIR) is
* corruption — `warnUnusableInput(STATE_UNREADABLE)` fires exactly once.
* corruption — `warnUnusableInput(STATE_UNREADABLE)` fires exactly once,
* and all three fields degrade to their UNREADABLE non-answer together.
* - An unterminated frontmatter fence is reported by `extractFrontmatter`
* itself (`FRONTMATTER_UNTERMINATED`) — this function does not duplicate
* that diagnostic; it still attempts a body-only field read on whatever
* `stripFrontmatter` leaves behind.
* - `currentPhaseLabel`/`stateStatus` both live under `## Current Position`
* (`gsd-core/templates/state.md`) and both use `stateFieldValue`
* (`state-document.cts:296`) the exact way `smart-entry.cts:448`/
* `state.cts:1561,3273` already call it for `'status'`/`'Status'` — so a
* missing `## Current Position` section degrades BOTH to `TRUNCATED` with
* a whole-body fallback, together.
* - `statePhaseTokens` scans the WHOLE document (`verify.cts`'s exact
* `PHASE_NUMBER_TOKEN_SOURCE` regex, relocated verbatim from
* `verify.cts:1731-1735`), not just the Current Position section, so it is
* NOT degraded to `TRUNCATED` by a missing section header — it stays
* `COMPLETE` whenever the file itself was read successfully.
*/
function buildCurrentPhaseLabel(statePath: string): { value: string | null; scope: Scope } {
function buildStateFields(statePath: string): StateFields {
let content: string | null;
try {
content = platformReadSync(statePath);
} catch {
warnUnusableInput({ reason: UNUSABLE_REASON.STATE_UNREADABLE, source: statePath });
return { value: null, scope: SCOPE.UNREADABLE };
return {
currentPhaseLabel: { value: null, scope: SCOPE.UNREADABLE },
statePhaseTokens: { value: [], scope: SCOPE.UNREADABLE },
stateStatus: { value: null, scope: SCOPE.UNREADABLE },
};
}
if (content === null) {
return { value: null, scope: SCOPE.UNREADABLE };
return {
currentPhaseLabel: { value: null, scope: SCOPE.UNREADABLE },
statePhaseTokens: { value: [], scope: SCOPE.UNREADABLE },
stateStatus: { value: null, scope: SCOPE.UNREADABLE },
};
}
const frontmatter = extractFrontmatter(content, statePath);
const body = stripFrontmatter(content);
const section = stateCurrentPositionSlice(body);
return stateFieldValue(frontmatter, section ?? body, null, 'Phase', {
scope: section === null ? SCOPE.TRUNCATED : SCOPE.COMPLETE,
const currentPositionScope = section === null ? SCOPE.TRUNCATED : SCOPE.COMPLETE;
// #1760 fallback ladder (mirrors `state.cts:1499-1500`'s `resolveStatePhase`
// exactly, same `section ?? body` scope for both reads): the legacy bold
// `**Current Phase:**` field (what `verify.cts:2109-2111` originally
// matched, and what pre-template-migration STATE.md fixtures still use)
// takes priority over the current template's bare `Phase: [X] of [Y]`
// field — a document carrying both is read the same way `resolveStatePhase`
// reads it elsewhere.
const legacyCurrentPhaseLabel = stateFieldValue(frontmatter, section ?? body, null, 'Current Phase', {
scope: currentPositionScope,
});
const templateCurrentPhaseLabel = stateFieldValue(frontmatter, section ?? body, null, 'Phase', {
scope: currentPositionScope,
});
const currentPhaseLabel = {
value: legacyCurrentPhaseLabel.value ?? templateCurrentPhaseLabel.value,
scope: legacyCurrentPhaseLabel.value !== null ? legacyCurrentPhaseLabel.scope : templateCurrentPhaseLabel.scope,
};
const stateStatus = stateFieldValue(frontmatter, section ?? body, 'status', 'Status', {
scope: currentPositionScope,
});
const statePhaseTokens = {
value: [...content.matchAll(new RegExp(`[Pp]hase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'g'))].map(
(m) => m[1],
),
scope: SCOPE.COMPLETE,
};
return { currentPhaseLabel, statePhaseTokens, stateStatus };
}
/**
* Build the full `.planning/` projection for `cwd`. Composes exactly the six
* §7 owners named in the design doc's "Owners consumed" table — no
* re-derivation, no new semantic answer. See the design doc for the
* Resolve `config` — the parsed `.planning/config.json`, preserving the same
* three-way distinction `cmdValidateHealth` (`src/verify.cts` W003/E005)
* already makes without going through `loadConfig` (which collapses that
* distinction): absent is a real non-answer — `{value: null, scope:
* UNREADABLE, exists: false}`, no `warnUnusableInput` call, mirrors
* `buildCurrentPhaseLabel`'s treatment of an absent STATE.md; present but
* unparseable JSON IS corruption — `{value: null, scope: UNREADABLE, exists:
* true}`, `warnUnusableInput(CONFIG_UNREADABLE)` fires exactly once, so a
* later health-diagnostic rule can tell "config.json not found" (W003,
* repairable via `createConfig`) apart from "config.json: JSON parse error"
* (E005, repairable via `resetConfig`) — the `exists` flag is exactly that
* discriminator. `config.json` is root-scoped (`planningRoot`), NOT
* workstream-scoped (`planningPaths(cwd).config` would resolve under
* `.planning/workstreams/<ws>/` instead) — see verify.cts's own
* rootBase-vs-wsBase split at cmdValidateHealth's top.
*/
function buildConfigField(cwd: string): { value: Record<string, unknown> | null; scope: Scope; exists: boolean } {
const configPath = path.join(planningRoot(cwd), 'config.json');
if (!fs.existsSync(configPath)) {
return { value: null, scope: SCOPE.UNREADABLE, exists: false };
}
try {
const raw = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
return { value: parsed, scope: SCOPE.COMPLETE, exists: true };
} catch {
warnUnusableInput({ reason: UNUSABLE_REASON.CONFIG_UNREADABLE, source: configPath });
return { value: null, scope: SCOPE.UNREADABLE, exists: true };
}
}
/**
* Resolve `agentInstall` — wraps `checkAgentsInstalled(runtime, cwd)` with
* the same `runtime` `cmdValidateHealth` resolves (`resolveRuntime(cwd)`,
* its `_slashRuntime`). Not `.planning/`-sourced (see design doc). `scope`
* is `COMPLETE` whenever the scan itself ran, even when it reports missing
* or incomplete agents — that is a real answer, not a non-answer.
* `UNREADABLE` only if the scan itself throws, mirroring cmdValidateHealth's
* own try/catch around this same call (there, the exception is swallowed as
* "non-blocking"; here it is surfaced via `scope` instead of silently
* dropped, since a snapshot field has nowhere else to carry that fact).
*/
function buildAgentInstallField(cwd: string): { value: ReturnType<typeof checkAgentsInstalled>; scope: Scope } {
const runtime = resolveRuntime(cwd);
try {
return { value: checkAgentsInstalled(runtime, cwd), scope: SCOPE.COMPLETE };
} catch {
return {
value: {
agents_installed: false,
missing_agents: [],
installed_agents: [],
incomplete_agents: [],
agents_dir: '',
agent_runtime: runtime,
},
scope: SCOPE.UNREADABLE,
};
}
}
/**
* Resolve `worktreeHealth` — wraps `inspectWorktreeHealth(cwd, { staleAfterMs
* }, deps)` with the exact same arguments `cmdValidateHealth` passes
* (`src/verify.cts` W017/W020/W027 call sites): a 1-hour staleness window,
* and the raw `execGit`/`fs.existsSync`/`fs.statSync` seam (not
* `worktree-safety.cts`'s own `execGitDefault` wrapper). Not
* `.planning/`-sourced (see design doc). `scope` is `COMPLETE` only when the
* underlying `git worktree list` scan itself succeeded (`ok: true`) — a
* timed-out or failed scan (`ok: false`, mirroring W020's degraded-check
* report) or a thrown exception (mirrors cmdValidateHealth's own
* "git worktree not available or not a git repo — skip silently" catch)
* both degrade to `UNREADABLE` with an empty findings array, since neither
* case has real per-worktree data to report. `reason` carries
* `inspectWorktreeHealth`'s own discriminator ('ok' | 'git_timed_out' |
* 'git_list_failed' | 'not_a_git_repo') straight through — NOT discarded —
* so `checkW020` (`src/health-diagnostic-rules/worktree-health.cts`) can
* reproduce `verify.cts:2202-2217`'s exact branching: it warns on
* 'git_timed_out' or 'git_list_failed' but stays silent on 'not_a_git_repo'
* (a `.planning/`-only fixture/tmp dir with no git repo at all is not a
* degraded scan). A thrown exception reports 'exception', which also stays
* silent, matching the original's catch-all "skip silently" comment.
*/
function buildWorktreeHealthField(cwd: string): { value: ReturnType<typeof inspectWorktreeHealth>['findings']; scope: Scope; reason: string } {
try {
const result = inspectWorktreeHealth(
cwd,
{ staleAfterMs: 60 * 60 * 1000 },
{ execGit, existsSync: fs.existsSync, statSync: fs.statSync },
);
if (!result.ok) {
return { value: [], scope: SCOPE.UNREADABLE, reason: result.reason };
}
return { value: result.findings, scope: SCOPE.COMPLETE, reason: result.reason };
} catch {
return { value: [], scope: SCOPE.UNREADABLE, reason: 'exception' };
}
}
// ─── Phase 11 (#3309) "Rule table organization" builders ────────────────────
// Each relocates (not reinvents) an existing `verify.cts` derivation. See the
// design doc's "Rule table organization" table for the exact source lines.
/**
* Resolve `projectSections` — the `##`-level section headings actually
* present in `.planning/PROJECT.md`, as a plain list (NOT filtered against a
* required-sections list — the caller, the future W001/E002 rules, do that
* comparison). Relocates the read+parse half of `verify.cts:1681-1691`
* (E002/W001), generalized from "does the file include these three fixed
* strings" to "what headings does the file actually have."
*
* PROJECT.md is root-scoped (`planningRoot(cwd)`), NOT workstream-scoped —
* mirrors `cmdValidateHealth`'s own `projectPath = path.join(rootBase,
* 'PROJECT.md')` (`verify.cts:1649`), the same root-vs-workstream split
* `buildConfigField` already documents for config.json.
*
* Same `exists`-discriminator shape as `config`: absent file is a real
* non-answer (`{value: null, scope: UNREADABLE, exists: false}`, no
* `warnUnusableInput`); present but unreadable IS corruption —
* `{value: null, scope: UNREADABLE, exists: true}`,
* `warnUnusableInput(PROJECT_UNREADABLE)` fires exactly once, mirroring
* `buildConfigField`'s treatment of a present-but-unparseable config.json.
*/
function buildProjectSectionsField(cwd: string): { value: string[] | null; scope: Scope; exists: boolean } {
const projectPath = path.join(planningRoot(cwd), 'PROJECT.md');
if (!fs.existsSync(projectPath)) {
return { value: null, scope: SCOPE.UNREADABLE, exists: false };
}
let content: string;
try {
content = fs.readFileSync(projectPath, 'utf-8');
} catch {
warnUnusableInput({ reason: UNUSABLE_REASON.PROJECT_UNREADABLE, source: projectPath });
return { value: null, scope: SCOPE.UNREADABLE, exists: true };
}
const value = [...content.matchAll(/^##\s+(.+)$/gm)].map((m) => m[1].trim());
return { value, scope: SCOPE.COMPLETE, exists: true };
}
/**
* Resolve `roadmapDeclaredPhases` — every phase id ROADMAP.md declares
* (heading-style AND checklist-style, not filtered to disk presence), each
* paired with the milestone-version section it was found under (`null` when
* found outside any versioned section). Backs W006/W007 (declared-phase
* half) and W021(2288)/W026(2392) (milestone-attribution half).
*
* The declared-phase-id half reuses `buildRoadmapPhaseVariants`
* (`validate.cts:136`, already imported by `verify.cts:12` — genuine existing
* reuse). The milestone-attribution half relocates
* `checkMilestonePrefixMismatches`'s `sectionRx`-based section walk
* (`verify.cts:1429-1459`, local/unexported there), generalized from "record
* only the mismatches" to "record every attribution" — this field exposes
* the parsed fact; the future W021/W026 rules make the mismatch judgment.
*/
function buildRoadmapDeclaredPhasesField(
roadmapPath: string,
): { value: { phaseId: string; milestone: string | null }[]; scope: Scope } {
if (!fs.existsSync(roadmapPath)) {
return { value: [], scope: SCOPE.UNREADABLE };
}
let content: string;
try {
content = fs.readFileSync(roadmapPath, 'utf-8');
} catch {
return { value: [], scope: SCOPE.UNREADABLE };
}
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
const milestoneByPhase = new Map<string, string>();
const sectionRx = /^#{1,3}\s+(?:\[[^\]]{1,200}\]\s*)?.*v(\d+\.\d+)/gim;
const sections: { version: string; start: number; end: number }[] = [];
let sm: RegExpExecArray | null;
while ((sm = sectionRx.exec(content)) !== null) {
if (sections.length > 0) sections[sections.length - 1].end = sm.index;
sections.push({ version: `v${sm[1]}`, start: sm.index, end: content.length });
}
const phaseRx = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
for (const section of sections) {
const sectionContent = content.slice(section.start, section.end);
phaseRx.lastIndex = 0;
let pm: RegExpExecArray | null;
while ((pm = phaseRx.exec(sectionContent)) !== null) {
if (!milestoneByPhase.has(pm[1])) milestoneByPhase.set(pm[1], section.version);
}
}
const value = [...roadmapPhases].map((phaseId) => ({
phaseId,
milestone: milestoneByPhase.get(phaseId) ?? null,
}));
return { value, scope: SCOPE.COMPLETE };
}
/**
* Resolve `roadmapPhaseCheckboxes` — parsed `[x]`/`[ ]` checkbox state per
* phase from ROADMAP.md's progress-table region, keyed by phase id. Backs
* W011.
*
* Relocates and generalizes `verify.cts`'s W011 block (`verify.cts:2104-
* 2134`): that call site builds ONE hardcoded `phaseCheckboxRe` testing a
* single target phase id (STATE's current phase) for a `[x]` match. This
* builder is the same regex shape, generalized to CAPTURE both the check
* character and the phase id instead of interpolating one fixed target, so
* every declared checkbox is recorded, not just one.
*
* NOT a re-derivation of `isPhaseComplete` (`verification.cts:557`, ADR-3180
* §7.4, disk-strict): that owner explicitly refuses to consult the ROADMAP
* checkbox at all when DECIDING phase completion (`verification.cts:536-
* 537`). This field only exposes what the checkbox literally says, for a
* diagnostic (W011) whose entire purpose is flagging when the two DISAGREE —
* reading the data is not re-litigating who is authoritative.
*/
function buildRoadmapPhaseCheckboxesField(
roadmapPath: string,
): { value: Record<string, boolean>; scope: Scope } {
if (!fs.existsSync(roadmapPath)) {
return { value: {}, scope: SCOPE.UNREADABLE };
}
let content: string;
try {
content = fs.readFileSync(roadmapPath, 'utf-8');
} catch {
return { value: {}, scope: SCOPE.UNREADABLE };
}
const checkboxRe = new RegExp(
`-\\s*\\[([xX ])\\].*?Phase\\s+0*(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`,
'gi',
);
const value: Record<string, boolean> = {};
let m: RegExpExecArray | null;
while ((m = checkboxRe.exec(content)) !== null) {
value[m[2]] = m[1].toLowerCase() === 'x';
}
return { value, scope: SCOPE.COMPLETE };
}
/**
* Resolve `researchValidationStatus` — per phase directory, whether its
* `*-RESEARCH.md` contains the literal heading `## Validation Architecture`,
* and whether a `*-VALIDATION.md` file exists in the same directory. Backs
* W009.
*
* Relocates the file-naming convention `verify.cts:1967-1990` (W009) uses to
* find "the" RESEARCH.md / VALIDATION.md in a phase dir: a flat,
* non-recursive `readdirSync` of the phase dir, then the first entry whose
* name ends `-RESEARCH.md` / any entry ending `-VALIDATION.md`. Computed for
* EVERY phase dir unconditionally (verify.cts's W009 only reads RESEARCH.md
* when `hasResearch && !hasValidation`; this field exposes both booleans
* regardless, so the future W009 rule does its own `hasResearch &&
* hasValidationArchitecture && !hasValidationMd` check against parsed data,
* not raw text).
*
* `scope` mirrors `phaseDirs.scope` (the caller-supplied enumeration): a
* per-directory read failure degrades that single entry's booleans to
* `false` and is silently skipped, mirroring `verify.cts`'s own
* `catch { intentionally empty }` around this exact read — this is a
* deliberate fail-open match to the pre-migration behavior, not a scope
* degradation, since the original never surfaced these failures either.
*/
function buildResearchValidationStatusField(
phasesDir: string,
phaseDirNames: string[],
enumerationScope: Scope,
): {
value: { dir: string; hasValidationArchitecture: boolean; hasValidationMd: boolean }[];
scope: Scope;
} {
const value = phaseDirNames.map((dir) => {
const fullPhaseDir = path.join(phasesDir, dir);
let files: string[];
try {
files = fs.readdirSync(fullPhaseDir);
} catch {
return { dir, hasValidationArchitecture: false, hasValidationMd: false };
}
const researchFile = files.find((f) => f.endsWith('-RESEARCH.md'));
const hasValidationMd = files.some((f) => f.endsWith('-VALIDATION.md'));
let hasValidationArchitecture = false;
if (researchFile) {
try {
const researchContent = fs.readFileSync(path.join(fullPhaseDir, researchFile), 'utf-8');
hasValidationArchitecture = researchContent.includes('## Validation Architecture');
} catch {
/* intentionally empty — mirrors verify.cts:1986-1988's own silent skip */
}
}
return { dir, hasValidationArchitecture, hasValidationMd };
});
return { value, scope: enumerationScope };
}
/**
* Resolve `milestoneArchiveStatus` — `archivedVersions` (versions with a
* `milestones/<ver>-ROADMAP.md` snapshot file present) and `documentedVersions`
* (`## <version>` headings already present in MILESTONES.md). Backs W018.
*
* Relocates `verify.cts:2301-2335` (W018)'s directory-scan glob
* (`^(v\d+\.\d+(?:\.\d+)?)-ROADMAP\.md$` against a flat, non-recursive
* `readdirSync` of `.planning/milestones/`) and its MILESTONES.md
* heading-membership check, generalized from "is THIS archived version's
* heading present" to "list every `## <version>` heading MILESTONES.md has."
*
* Confirmed NOT a fit for `listArchiveVersionDirs`
* (`phase-locator.cts:127`): that function scans `milestones/*-phases/`
* DIRECTORIES, a different target than this field's `milestones/*-ROADMAP.md`
* FILES — reusing it here would silently answer the wrong question.
*
* Root-scoped (`planningRoot(cwd)`), matching `verify.cts`'s own
* `rootBase`-based `milestonesPath`/`milestonesArchiveDir`.
*/
function buildMilestoneArchiveStatusField(
cwd: string,
): { value: { archivedVersions: string[]; documentedVersions: string[] }; scope: Scope } {
const rootBase = planningRoot(cwd);
const milestonesArchiveDir = path.join(rootBase, 'milestones');
const milestonesPath = path.join(rootBase, 'MILESTONES.md');
let archivedVersions: string[] = [];
let scope: Scope = SCOPE.COMPLETE;
if (fs.existsSync(milestonesArchiveDir)) {
try {
const archiveFiles = fs.readdirSync(milestonesArchiveDir);
archivedVersions = archiveFiles
.map((f) => f.match(/^(v\d+\.\d+(?:\.\d+)?)-ROADMAP\.md$/))
.filter((m): m is RegExpMatchArray => m !== null)
.map((m) => m[1]);
} catch {
scope = SCOPE.UNREADABLE;
}
}
let documentedVersions: string[] = [];
if (fs.existsSync(milestonesPath)) {
try {
const registryContent = fs.readFileSync(milestonesPath, 'utf-8');
documentedVersions = [...registryContent.matchAll(/^##\s+(v\d+\.\d+(?:\.\d+)?)/gm)].map(
(m) => m[1],
);
} catch {
scope = worstScope(scope, SCOPE.UNREADABLE);
}
}
return { value: { archivedVersions, documentedVersions }, scope };
}
/**
* Resolve `planningRootFiles` — plain listing of file (not directory) names
* directly under `.planning/` root. Backs W019.
*
* Pairs with the existing exported `isCanonicalPlanningFile` predicate
* (`artifacts.cts:43`) — but per the design doc, that predicate is called by
* the future W019 RULE per filename, not by this builder; this field only
* needs to BE the raw filename list.
*/
function buildPlanningRootFilesField(cwd: string): { value: string[]; scope: Scope } {
try {
const entries = fs.readdirSync(planningRoot(cwd), { withFileTypes: true });
return { value: entries.filter((e) => e.isFile()).map((e) => e.name), scope: SCOPE.COMPLETE };
} catch {
return { value: [], scope: SCOPE.UNREADABLE };
}
}
/**
* Resolve `allPhaseDirNames` — every directory name directly under the
* active `phases/` root, UNFILTERED by `listMilestonePhaseDirs`'s
* current-milestone-window membership test (unlike `phaseDirs`). Backs
* W007 (see the field's own doc comment on `PlanningSnapshot` for why
* `phaseDirs` cannot). An absent `phases/` root is a real empty, not a
* failure (mirrors `listMilestonePhaseDirs`'s own treatment); a present but
* unreadable root degrades to `UNREADABLE` with an empty list.
*/
function buildAllPhaseDirNamesField(phasesDir: string): { value: string[]; scope: Scope } {
if (!fs.existsSync(phasesDir)) return { value: [], scope: SCOPE.COMPLETE };
try {
const value = fs
.readdirSync(phasesDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
return { value, scope: SCOPE.COMPLETE };
} catch {
return { value: [], scope: SCOPE.UNREADABLE };
}
}
/**
* Resolve `archivedPhaseTokens` — every phase-number token belonging to a
* directory directly under any `.planning/milestones/*-phases/` archive.
* Backs W002's archived-phase exemption (#3652); see the field's own doc
* comment on `PlanningSnapshot`. Mirrors `verify.cts`'s
* `forEachArchivedPhaseToken` + `listMilestoneArchiveDirs` exactly — same
* `MILESTONE_ARCHIVE_DIR_RE` archive-dir filter, same `PHASE_TOKEN_FROM_DIR_RE`
* per-entry match, same `stripProjectCodePrefix` normalization — just
* collecting into a value array instead of an `onPhase` callback. An absent
* `milestones/` dir is a real empty (no archives yet), not a failure; a
* present-but-unreadable per-archive-dir entry is silently skipped, mirroring
* `forEachArchivedPhaseToken`'s own per-directory `catch { /* absent/unreadable *\/ }`.
*/
function buildArchivedPhaseTokensField(planBase: string): { value: string[]; scope: Scope } {
const milestonesDir = path.join(planBase, 'milestones');
let archiveDirs: string[];
try {
archiveDirs = fs
.readdirSync(milestonesDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && MILESTONE_ARCHIVE_DIR_RE.test(e.name))
.map((e) => path.join(milestonesDir, e.name));
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { value: [], scope: SCOPE.COMPLETE };
return { value: [], scope: SCOPE.UNREADABLE };
}
const value: string[] = [];
for (const archiveDir of archiveDirs) {
try {
const entries = fs.readdirSync(archiveDir, { withFileTypes: true });
for (const e of entries) {
if (!e.isDirectory()) continue;
const m = e.name.match(PHASE_TOKEN_FROM_DIR_RE);
if (m) value.push(stripProjectCodePrefix(m[1]));
}
} catch {
/* archive dir absent/unreadable — mirrors forEachArchivedPhaseToken */
}
}
return { value, scope: SCOPE.COMPLETE };
}
/**
* Resolve `currentMilestoneRoadmapPhaseIds` — every phase-number token found
* in ROADMAP.md's content once scoped to the CURRENT milestone via
* `extractCurrentMilestone(content, cwd)`. Backs W026's archive-tolerant
* unstarted-phase scan; see the field's own doc comment on `PlanningSnapshot`
* for why `roadmapDeclaredPhases` cannot serve this. An absent/unreadable
* ROADMAP.md degrades to an empty list, mirroring every other
* ROADMAP-sourced field's absent-file handling.
*/
function buildCurrentMilestoneRoadmapPhaseIdsField(
cwd: string,
roadmapPath: string,
): { value: string[]; scope: Scope } {
if (!fs.existsSync(roadmapPath)) return { value: [], scope: SCOPE.UNREADABLE };
let content: string;
try {
content = fs.readFileSync(roadmapPath, 'utf-8');
} catch {
return { value: [], scope: SCOPE.UNREADABLE };
}
const scoped = extractCurrentMilestone(content, cwd);
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal
// mirror of OPTIONAL_PHASE_TAG_SOURCE) — verbatim from `verify.cts:2366`.
const phasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`,
'gi',
);
const value = [...scoped.matchAll(phasePattern)].map((m) => m[1]);
return { value, scope: SCOPE.COMPLETE };
}
/**
* Build the full `.planning/` projection for `cwd`. Composes the six §7
* owners named in the design doc's "Owners consumed" table, plus (Phase 11,
* #3309) the three additive subject-surface fields `config`/`agentInstall`/
* `worktreeHealth` — no re-derivation, no new semantic answer beyond what
* their respective owners already compute. See the design doc for the
* behavior table and rejected alternatives.
*/
function buildPlanningSnapshot(cwd: string): PlanningSnapshot {
@@ -164,15 +801,31 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot {
const phaseDirs = listMilestonePhaseDirs(paths.phases, { cwd });
const phasesValue = phaseDirs.value.map((dir) => buildPhaseSnapshot(paths.phases, dir));
const stateFields = buildStateFields(paths.state);
return {
cwd: path.resolve(cwd),
milestone,
phaseDirs,
phases: {
value: phasesValue,
scope: worstScope(phaseDirs.scope, ...phasesValue.map((p) => p.scope)),
},
currentPhaseLabel: buildCurrentPhaseLabel(paths.state),
currentPhaseLabel: stateFields.currentPhaseLabel,
config: buildConfigField(cwd),
agentInstall: buildAgentInstallField(cwd),
worktreeHealth: buildWorktreeHealthField(cwd),
projectSections: buildProjectSectionsField(cwd),
statePhaseTokens: stateFields.statePhaseTokens,
stateStatus: stateFields.stateStatus,
roadmapDeclaredPhases: buildRoadmapDeclaredPhasesField(paths.roadmap),
roadmapPhaseCheckboxes: buildRoadmapPhaseCheckboxesField(paths.roadmap),
researchValidationStatus: buildResearchValidationStatusField(paths.phases, phaseDirs.value, phaseDirs.scope),
milestoneArchiveStatus: buildMilestoneArchiveStatusField(cwd),
planningRootFiles: buildPlanningRootFilesField(cwd),
allPhaseDirNames: buildAllPhaseDirNamesField(paths.phases),
archivedPhaseTokens: buildArchivedPhaseTokensField(paths.planning),
currentMilestoneRoadmapPhaseIds: buildCurrentMilestoneRoadmapPhaseIdsField(cwd, paths.roadmap),
};
}

View File

@@ -64,6 +64,20 @@ const UNUSABLE_REASON = Object.freeze({
* planning-snapshot's current-phase field)
*/
STATE_UNREADABLE: 'state_unreadable',
/**
* A config.json exists but could not be read/parsed (EACCES/EIO/malformed JSON/…).
* Distinct from a project that has not run any config-writing command yet: absence
* returns the same non-answer, silently — only an exists-but-unreadable config.json
* is corruption. (#3309, eighth #1879 site — planning-snapshot's config field)
*/
CONFIG_UNREADABLE: 'config_unreadable',
/**
* A PROJECT.md exists but could not be read (EACCES/EIO/…). Distinct from a project that has
* not run any project-writing command yet: absence returns the same non-answer, silently —
* only an exists-but-unreadable PROJECT.md is corruption. (#3309, ninth #1879 site —
* planning-snapshot's projectSections field)
*/
PROJECT_UNREADABLE: 'project_unreadable',
} as const);
type UnusableReason = (typeof UNUSABLE_REASON)[keyof typeof UNUSABLE_REASON];
@@ -78,6 +92,10 @@ const REASON_PROSE: Readonly<Record<UnusableReason, string>> = Object.freeze({
'last_activity in STATE.md is present but unparseable as a date; stale_activity fell back to false (idle-stranded suppressed)',
[UNUSABLE_REASON.STATE_UNREADABLE]:
'STATE.md exists but could not be read; the current-phase label fell back to unavailable',
[UNUSABLE_REASON.CONFIG_UNREADABLE]:
'config.json exists but could not be read or parsed; the config field fell back to unavailable',
[UNUSABLE_REASON.PROJECT_UNREADABLE]:
'PROJECT.md exists but could not be read; the projectSections field fell back to unavailable',
});
// ─── Dedup state ──────────────────────────────────────────────────────────────

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
{
"version": 1,
"paths": {
"health.md": "#2573 registers W024 (STATE.md written many commits ago \u2014 treat its contents as approximate) in the health workflow's <error_codes> table, so the advisory health now emits is documented where every other W-code is listed. The growth is that single table row written inline, not relocated into an eagerly @-imported reference (ADR-1610 Decision 4). 12246 -> 12348 bytes (+102), DEFAULT tier, cap 40960.\n\nAlso acknowledged here because the seam permits exactly one ack source per path and #2573 landed on next first (#2486 rebase, 2026-08-11): #2486 \u2014 adds the W025 diagnostic that detects a persisted workflow.use_worktrees:true on a runtime whose declared isolation cannot honor it, resolved via the sentinel-free inspect-dispatch-isolation query. Growth is the new check, its prose, and the error_codes row."
}
}

View File

@@ -0,0 +1,8 @@
{
"version": 1,
"paths": {
"health.md": {
"reason": "#3309 (epic #3180 Phase 11, ADR-3180): the `<error_codes>`/`<repair_actions>` tables and their footnote are now GENERATED by `scripts/gen-health-docs.cjs` from the full 31-rule `RULES` table, replacing a hand-maintained 16-code table. #3309 explicitly required closing the 16-vs-30+ documentation gap structurally, so the growth is the deliberate, expected result of that acceptance criterion — not accidental bloat. Regeneration is verified deterministic via `node scripts/gen-health-docs.cjs --check` (wired into `npm run lint:generated-sync`)."
}
}
}

View File

@@ -0,0 +1,258 @@
'use strict';
/**
* gen-health-docs.cjs regression tests (#3309, "health.md's tables are
* generated rather than hand-maintained, closing the 16-vs-30+ documentation
* gap structurally").
*
* Every CLI-level test spawns the real generator (execFileSync) against a
* temp copy of the shipped `gsd-core/workflows/health.md`, using the
* generator's `--target <path>` override — never mutates the real committed
* file. No fs monkeypatching is needed for these cases.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const {
buildErrorCodeRows,
renderErrorCodesRegion,
renderRepairActionsRegion,
regenerateHealthMd,
spliceRegion,
compareCodes,
PRECHECK_CODES,
REMEDY_ACTION_ORDER,
ERROR_CODES_START,
ERROR_CODES_END,
} = require('../scripts/gen-health-docs.cjs');
const ROOT = path.resolve(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'gen-health-docs.cjs');
const SHIPPED_HEALTH_MD = path.join(ROOT, 'gsd-core', 'workflows', 'health.md');
const COMPILED_MODULE_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'health-diagnostic.cjs');
function loadRealRules() {
// Real compiled RULES — build:lib is a pretest dependency for the whole
// suite (package.json `pretest`), so this is always present by the time
// node:test runs these files.
return require(COMPILED_MODULE_PATH).RULES;
}
/**
* @param {string[]} args
* @param {string} cwd
* @returns {{code: number, stdout: string, stderr: string}}
*/
function runGenHealthDocs(args, cwd = ROOT) {
try {
const stdout = execFileSync(process.execPath, [SCRIPT, ...args], {
cwd,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 30000,
});
return { code: 0, stdout, stderr: '' };
} catch (err) {
return {
code: err.status ?? 1,
stdout: err.stdout ? err.stdout.toString() : '',
stderr: err.stderr ? err.stderr.toString() : '',
};
}
}
function copyShippedHealthMd(destDir) {
const dest = path.join(destDir, 'health.md');
fs.copyFileSync(SHIPPED_HEALTH_MD, dest);
return dest;
}
// ─── CLI: --check / --write round trip ─────────────────────────────────────
describe('gen-health-docs.cjs --check / --write (CLI, --target fixture)', () => {
test('--check passes on a freshly-written file', (t) => {
const tmpRoot = createTempDir('gen-health-docs-');
t.after(() => cleanup(tmpRoot));
const target = copyShippedHealthMd(tmpRoot);
const w = runGenHealthDocs(['--write', '--target', target]);
assert.equal(w.code, 0, `stderr: ${w.stderr}`);
const c = runGenHealthDocs(['--check', '--target', target]);
assert.equal(c.code, 0, `--check must be clean immediately after --write; stderr: ${c.stderr}`);
assert.match(c.stdout, /up to date/);
});
test('--check fails when the tagged region is stale (mutate a temp copy)', (t) => {
const tmpRoot = createTempDir('gen-health-docs-');
t.after(() => cleanup(tmpRoot));
const target = copyShippedHealthMd(tmpRoot);
// Mutate the committed, already-up-to-date table so it drifts from what
// the generator would produce — a single row edit is enough.
let content = fs.readFileSync(target, 'utf8');
assert.ok(content.includes('| E001 | error |'), 'sanity: shipped health.md must carry the E001 row');
content = content.replace('| E001 | error |', '| E001 | error-STALE-MUTATION |');
fs.writeFileSync(target, content, 'utf8');
const c = runGenHealthDocs(['--check', '--target', target]);
assert.equal(c.code, 1, 'a hand-mutated table must fail --check');
assert.match(c.stderr, /is stale/);
assert.match(c.stderr, /gen-health-docs\.cjs --write/);
});
test('--write on a stale copy regenerates it back to a clean --check', (t) => {
const tmpRoot = createTempDir('gen-health-docs-');
t.after(() => cleanup(tmpRoot));
const target = copyShippedHealthMd(tmpRoot);
let content = fs.readFileSync(target, 'utf8');
content = content.replace('| W010 |', '| W010-DRIFTED |');
fs.writeFileSync(target, content, 'utf8');
const failedCheck = runGenHealthDocs(['--check', '--target', target]);
assert.equal(failedCheck.code, 1, 'sanity: the mutated copy must fail --check first');
const w = runGenHealthDocs(['--write', '--target', target]);
assert.equal(w.code, 0, `stderr: ${w.stderr}`);
const c = runGenHealthDocs(['--check', '--target', target]);
assert.equal(c.code, 0, `stderr: ${c.stderr}`);
});
test('plain invocation (no flag) prints both tables to stdout and exits 0', () => {
const r = runGenHealthDocs([]);
assert.equal(r.code, 0, `stderr: ${r.stderr}`);
assert.match(r.stdout, /\| Code \| Severity \| Description \| Repairable \|/);
assert.match(r.stdout, /\| Action \| Effect \| Risk \|/);
});
test('an unrecognized flag exits 1 rather than silently falling through', () => {
const r = runGenHealthDocs(['--bogus']);
assert.equal(r.code, 1);
assert.match(r.stderr, /unknown flag/);
});
test('the shipped gsd-core/workflows/health.md already passes --check against the real repo', () => {
const r = runGenHealthDocs(['--check']);
assert.equal(r.code, 0, `the committed health.md must already be up to date; stderr: ${r.stderr}`);
});
});
// ─── Row content: representative codes, including previously-undocumented ─
describe('gen-health-docs.cjs row content (representative codes)', () => {
const rules = loadRealRules();
test('produces a 34-row <error_codes> table: 31 rules + 3 pre-checks (E001, E010, I010)', () => {
const rows = buildErrorCodeRows(rules);
assert.equal(rows.length, 34);
const codes = rows.map((r) => r.code);
for (const precheck of PRECHECK_CODES) {
assert.ok(codes.includes(precheck.code), `missing pre-check code ${precheck.code}`);
}
});
test('W010 (previously-undocumented, agent-install) renders with its Rule-sourced description and Repairable=No', () => {
const region = renderErrorCodesRegion(rules);
const row = region.split('\n').find((line) => line.startsWith('| W010 |'));
assert.ok(row, 'W010 row must be present');
const w010Rule = rules.find((r) => r.code === 'W010');
assert.ok(row.includes(w010Rule.description));
assert.match(row, /\| No \|$/);
});
test('W026 (previously-undocumented, new post-migration split code) renders with its Rule-sourced description', () => {
const region = renderErrorCodesRegion(rules);
const row = region.split('\n').find((line) => line.startsWith('| W026 |'));
assert.ok(row, 'W026 row must be present');
const w026Rule = rules.find((r) => r.code === 'W026');
assert.ok(row.includes(w026Rule.description));
});
test('E004 (already-documented, DESTRUCTIVE-risk remedy) renders with Repairable=No — --repair refuses to auto-apply regenerateState', () => {
const region = renderErrorCodesRegion(rules);
const row = region.split('\n').find((line) => line.startsWith('| E004 |'));
assert.ok(row);
assert.match(row, /\| No \|$/);
});
test('W018 renders the --backfill-qualified Repairable override, not a bare "Yes"', () => {
const region = renderErrorCodesRegion(rules);
const row = region.split('\n').find((line) => line.startsWith('| W018 |'));
assert.ok(row);
assert.match(row, /Yes \(`--backfill`\)/);
});
test('W025 (workflow-layer diagnostic, not a Rule) is absent from the generated table', () => {
const region = renderErrorCodesRegion(rules);
assert.ok(
!region.split('\n').some((line) => line.startsWith('| W025 |')),
'W025 must not appear as a generated row — it is documented in its own workflow step, not the RULES table',
);
});
test('<error_codes> rows are sorted E-codes, then W-codes numerically, then I-codes', () => {
const rows = buildErrorCodeRows(rules);
const sorted = [...rows].sort(compareCodes);
assert.deepEqual(rows, sorted, 'buildErrorCodeRows must already return its rows in sorted order');
// Spot-check the three-group boundary explicitly.
const codes = rows.map((r) => r.code);
const lastE = codes.lastIndexOf(codes.filter((c) => c.startsWith('E')).at(-1));
const firstW = codes.findIndex((c) => c.startsWith('W'));
const lastW = codes.lastIndexOf(codes.filter((c) => c.startsWith('W')).at(-1));
const firstI = codes.findIndex((c) => c.startsWith('I'));
assert.ok(lastE < firstW, 'every E-code must sort before every W-code');
assert.ok(lastW < firstI, 'every W-code must sort before every I-code');
});
test('renderRepairActionsRegion lists all 6 real repair actions, including the previously-undocumented addAiIntegrationPhaseKey', () => {
const region = renderRepairActionsRegion();
for (const action of REMEDY_ACTION_ORDER) {
assert.ok(region.includes(`| ${action} |`), `missing repair action row: ${action}`);
}
assert.equal(REMEDY_ACTION_ORDER.length, 6);
assert.ok(region.includes('addAiIntegrationPhaseKey'), '#3309: this action was "live in code, missing from docs"');
});
});
// ─── spliceRegion / regenerateHealthMd — pure-function edge cases ─────────
describe('gen-health-docs.cjs spliceRegion (pure function)', () => {
test('throws when a tag is missing', () => {
assert.throws(
() => spliceRegion('no tags here', ERROR_CODES_START, ERROR_CODES_END, 'x'),
/missing the .*tags/,
);
});
test('throws when a tag appears more than once', () => {
const text = `${ERROR_CODES_START}a${ERROR_CODES_END}${ERROR_CODES_START}b${ERROR_CODES_END}`;
assert.throws(() => spliceRegion(text, ERROR_CODES_START, ERROR_CODES_END, 'x'), /more than one/);
});
test('preserves content strictly outside the tags, byte-for-byte', () => {
const before = 'PROSE BEFORE\n';
const after = '\nPROSE AFTER';
const text = `${before}${ERROR_CODES_START}old inner${ERROR_CODES_END}${after}`;
const out = spliceRegion(text, ERROR_CODES_START, ERROR_CODES_END, 'new inner');
assert.ok(out.startsWith(before + ERROR_CODES_START));
assert.ok(out.endsWith(ERROR_CODES_END + after));
assert.ok(!out.includes('old inner'));
assert.ok(out.includes('new inner'));
});
test('regenerateHealthMd is idempotent: regenerating an already-generated document is a no-op', () => {
const rules = loadRealRules();
const shipped = fs.readFileSync(SHIPPED_HEALTH_MD, 'utf8');
const regenerated = regenerateHealthMd(rules, shipped);
assert.equal(regenerated, shipped);
});
});

View File

@@ -0,0 +1,263 @@
'use strict';
/**
* Tests for `src/health-diagnostic-rules/agent-install.cts` (Phase 11, #3309,
* ADR-3180 §8.2/§8.3/§8.5) — the W010 rule (agent installation is
* incomplete), 4 mutually exclusive trigger conditions ported from
* `verify.cts:1992-2027`, plus the "0 missing 0 incomplete" (no diagnostic)
* case and the `scope === SCOPE.UNREADABLE` silent case.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
* Test matrix: .gsd/phase/refactor-3309-health-diagnostic-rule-table/50-test-matrix.md
*
* Fixture provenance (#2371): `checkAgentsInstalled` scans a REAL filesystem
* agents directory, not `.planning/`. Per the design doc's Fixture
* provenance §, this file REUSES rather than reinvents:
* - `createCompleteAgentsDir`/`withAgentsDirOverride` are copied verbatim
* from `tests/planning-snapshot.test.cjs`'s own `agentInstall field`
* describe block (Phase 11's own foundational batch already established
* this exact GSD_AGENTS_DIR-override technique for driving
* `buildPlanningSnapshot` against a controlled agents dir).
* - The manifest-driven "incomplete" fixture shape (a `gsd-file-manifest.json`
* alongside the agents dir, tracking a `.toml` key that is absent on disk
* for one agent) is copied from `tests/agent-install-check.test.cjs`'s
* "a partial manifest-backed local installation remains selected and
* incomplete" / "partial manifest: agent.toml absent but agent.md
* present" tests — the same manifest resolution
* (`readInstallManifest(path.dirname(agentsDir))`) `checkAgentsInstalled`
* itself uses.
* Every fixture below is structural absence/presence of agent files, exempt
* from the provenance concern (no document format is being modeled).
*
* Uses the REAL `buildPlanningSnapshot(cwd)` (`src/planning-snapshot.cts`)
* for every case except the UNREADABLE-scope case, which constructs the
* minimal `{agentInstall: {value, scope}}` slice a `Rule.check(snapshot)`
* actually reads — not a mock of `checkAgentsInstalled` (no owner is
* reimplemented or stubbed), just the documented `Scope` contract's
* UNREADABLE member, which is not otherwise reachable through the real
* filesystem scan without monkeypatching an owner internal.
*/
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 planningSnapshotLib = require('../../gsd-core/bin/lib/planning-snapshot.cjs');
const { buildPlanningSnapshot } = planningSnapshotLib;
const { SCOPE } = require('../../gsd-core/bin/lib/planning-scope.cjs');
const { PACKAGE_NAME } = require('../../gsd-core/bin/lib/package-identity.cjs');
const { MODEL_PROFILES } = require('../../gsd-core/bin/lib/model-profiles.cjs');
const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES);
const { RULES } = require('../../gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs');
const rule = RULES.find((r) => r.code === 'W010');
// ─── Fixture helpers (copied verbatim from tests/planning-snapshot.test.cjs's
// agentInstall describe block — see module header) ─────────────────────────
function createCompleteAgentsDir(agentsDir) {
fs.mkdirSync(agentsDir, { recursive: true });
for (const agent of EXPECTED_AGENTS) {
fs.writeFileSync(path.join(agentsDir, `${agent}.toml`), `name = "${agent}"\n`);
}
}
function withAgentsDirOverride(t, agentsDir) {
const saved = process.env['GSD_AGENTS_DIR'];
process.env['GSD_AGENTS_DIR'] = agentsDir;
t.after(() => {
if (saved === undefined) delete process.env['GSD_AGENTS_DIR'];
else process.env['GSD_AGENTS_DIR'] = saved;
});
}
// Manifest-driven "incomplete agent" fixture shape, copied from
// tests/agent-install-check.test.cjs's partial-manifest tests (see module
// header). `agentsDir`'s PARENT directory is where checkAgentsInstalled
// resolves gsd-file-manifest.json from (readInstallManifest(dirname(agentsDir))).
function writeManifest(agentsDir, manifestFiles) {
fs.writeFileSync(
path.join(path.dirname(agentsDir), 'gsd-file-manifest.json'),
JSON.stringify({ files: manifestFiles }),
);
}
describe('agent-install rule (W010)', () => {
test('module exports exactly one W010 rule', () => {
assert.ok(rule, 'RULES must contain a W010 entry');
assert.strictEqual(RULES.length, 1);
assert.strictEqual(rule.code, 'W010');
assert.strictEqual(rule.severity, 'warning');
});
test('0 missing 0 incomplete: all agents present — no diagnostic', (t) => {
const cwd = createTempDir('gsd-3309-w010-clean-');
t.after(() => cleanup(cwd));
const agentsDir = path.join(cwd, 'agents-complete');
createCompleteAgentsDir(agentsDir);
withAgentsDirOverride(t, agentsDir);
const snapshot = buildPlanningSnapshot(cwd);
assert.strictEqual(snapshot.agentInstall.scope, SCOPE.COMPLETE);
assert.deepStrictEqual(rule.check(snapshot), []);
});
test('condition 1: zero agents installed at all (agents dir absent)', (t) => {
const cwd = createTempDir('gsd-3309-w010-zero-');
t.after(() => cleanup(cwd));
const agentsDir = path.join(cwd, 'agents-absent');
withAgentsDirOverride(t, agentsDir);
// agentsDir deliberately never created.
const snapshot = buildPlanningSnapshot(cwd);
assert.strictEqual(snapshot.agentInstall.scope, SCOPE.COMPLETE);
assert.strictEqual(snapshot.agentInstall.value.installed_agents.length, 0);
const diagnostics = rule.check(snapshot);
assert.strictEqual(diagnostics.length, 1);
const [d] = diagnostics;
assert.strictEqual(d.code, 'W010');
assert.strictEqual(d.severity, 'warning');
assert.strictEqual(
d.message,
`No GSD agents found in ${agentsDir} — Task(subagent_type="gsd-*") will fall back to general-purpose`,
);
assert.deepStrictEqual(d.remedy, {
action: 'advise',
risk: 'none',
args: { command: `Run the GSD installer: npx ${PACKAGE_NAME}@latest` },
});
});
test('condition 2: some agents incomplete (missing generated file), zero fully missing', (t) => {
const cwd = createTempDir('gsd-3309-w010-incomplete-');
t.after(() => cleanup(cwd));
const agentsDir = path.join(cwd, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
for (const agent of EXPECTED_AGENTS) {
fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`);
}
const incompleteAgent = EXPECTED_AGENTS[0];
// Manifest tracks every agent's .md (present) plus a .toml for
// incompleteAgent only (absent on disk) — makes exactly one agent
// incomplete while presence (missing_agents) stays empty.
const manifestFiles = {};
for (const agent of EXPECTED_AGENTS) manifestFiles[`agents/${agent}.md`] = {};
manifestFiles[`agents/${incompleteAgent}.toml`] = {};
writeManifest(agentsDir, manifestFiles);
withAgentsDirOverride(t, agentsDir);
const snapshot = buildPlanningSnapshot(cwd);
assert.strictEqual(snapshot.agentInstall.value.missing_agents.length, 0);
assert.deepStrictEqual(snapshot.agentInstall.value.incomplete_agents, [incompleteAgent]);
const diagnostics = rule.check(snapshot);
assert.strictEqual(diagnostics.length, 1);
const [d] = diagnostics;
assert.strictEqual(d.code, 'W010');
assert.strictEqual(
d.message,
`Incomplete agent installs (missing generated file): ${incompleteAgent} — affected workflows may fall back to general-purpose`,
);
assert.deepStrictEqual(d.remedy, {
action: 'advise',
risk: 'none',
args: { command: `Re-run the GSD installer to complete the install: npx ${PACKAGE_NAME}@latest` },
});
});
test('condition 3: both missing AND incomplete agents present', (t) => {
const cwd = createTempDir('gsd-3309-w010-both-');
t.after(() => cleanup(cwd));
const agentsDir = path.join(cwd, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
const [missingAgent, incompleteAgent, ...restAgents] = EXPECTED_AGENTS;
// missingAgent: no files at all, no manifest entry — stays purely missing.
for (const agent of [incompleteAgent, ...restAgents]) {
fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`);
}
const manifestFiles = {};
manifestFiles[`agents/${incompleteAgent}.md`] = {};
manifestFiles[`agents/${incompleteAgent}.toml`] = {}; // absent on disk -> incomplete
for (const agent of restAgents) manifestFiles[`agents/${agent}.md`] = {};
writeManifest(agentsDir, manifestFiles);
withAgentsDirOverride(t, agentsDir);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snapshot.agentInstall.value.missing_agents, [missingAgent]);
assert.deepStrictEqual(snapshot.agentInstall.value.incomplete_agents, [incompleteAgent]);
const diagnostics = rule.check(snapshot);
assert.strictEqual(diagnostics.length, 1);
const [d] = diagnostics;
assert.strictEqual(d.code, 'W010');
assert.strictEqual(
d.message,
`Missing 1 GSD agents: ${missingAgent}; incomplete agent installs (missing generated file): ${incompleteAgent} — affected workflows will fall back to general-purpose`,
);
assert.deepStrictEqual(d.remedy, {
action: 'advise',
risk: 'none',
args: { command: `Run the GSD installer: npx ${PACKAGE_NAME}@latest` },
});
});
test('condition 4: agents missing only (no incomplete)', (t) => {
const cwd = createTempDir('gsd-3309-w010-missing-only-');
t.after(() => cleanup(cwd));
const agentsDir = path.join(cwd, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
const [missingAgent, ...restAgents] = EXPECTED_AGENTS;
for (const agent of restAgents) {
fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`);
}
const manifestFiles = {};
for (const agent of restAgents) manifestFiles[`agents/${agent}.md`] = {};
writeManifest(agentsDir, manifestFiles);
withAgentsDirOverride(t, agentsDir);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snapshot.agentInstall.value.missing_agents, [missingAgent]);
assert.deepStrictEqual(snapshot.agentInstall.value.incomplete_agents, []);
const diagnostics = rule.check(snapshot);
assert.strictEqual(diagnostics.length, 1);
const [d] = diagnostics;
assert.strictEqual(d.code, 'W010');
assert.strictEqual(
d.message,
`Missing 1 GSD agents: ${missingAgent} — affected workflows will fall back to general-purpose`,
);
assert.deepStrictEqual(d.remedy, {
action: 'advise',
risk: 'none',
args: { command: `Run the GSD installer: npx ${PACKAGE_NAME}@latest` },
});
});
test('scope UNREADABLE (agent scan itself threw): no diagnostic, mirrors verify.cts\'s silent catch', () => {
// Minimal snapshot slice — see module header for why this is not an
// owner mock: UNREADABLE is a real, documented Scope member that
// buildAgentInstallField sets when checkAgentsInstalled throws
// (planning-snapshot.cts's own try/catch), and the rule's whole
// contract is `(snapshot) => Diagnostic[]` — it never calls the owner
// itself.
const snapshot = {
agentInstall: {
scope: SCOPE.UNREADABLE,
value: {
agents_installed: false,
missing_agents: [],
installed_agents: [],
incomplete_agents: [],
agents_dir: '',
agent_runtime: 'claude',
},
},
};
assert.deepStrictEqual(rule.check(snapshot), []);
});
});

View File

@@ -0,0 +1,699 @@
'use strict';
/**
* Tests for `src/health-diagnostic-rules/config-validation.cts` (Phase 11,
* #3309, ADR-3180 §8.2/§8.3/§8.5) — group "config.json validation": W003,
* E005, W004, W008, W016, W012, W013, W014, W015, W022.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
* Test matrix: .gsd/phase/refactor-3309-health-diagnostic-rule-table/50-test-matrix.md
*
* Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"):
*
* - W003, W008, W016 are STRUCTURAL ABSENCE (a file, or a key, is missing) —
* exempt from external-citation provenance, mirrors
* tests/health-diagnostic-rules/root-existence.test.cjs's own framing.
* - E005, W004, W012, W013, W014, W015, W022 are MECHANICAL MUTATION: each
* fixture starts from `gsd-core/templates/config.json` (the real shipped
* shape, parsed once as `BASE_CONFIG`) with exactly ONE field changed to
* the invalid value under test, per the design doc's Fixture provenance
* §4. `w022`'s `models` key and `branching_strategy`/`context_window`/
* `phase_branch_template`/`milestone_branch_template` are not present in
* the shipped default at all (they are optional, additive keys) — for
* those the "one field changed" mutation is adding exactly that one key
* with its invalid value, the generic-mutation equivalent when there is no
* existing value to corrupt.
*
* Every fixture is driven through the REAL `buildPlanningSnapshot(cwd)`
* against a REAL temp `.planning/` directory with a REAL config.json file
* written to disk — mirrors tests/planning-snapshot.test.cjs's own
* `writeConfig` helper exactly (same shape, same call site convention).
*
* TDD RED: `src/health-diagnostic-rules/config-validation.cts` does not
* exist yet at the start of this batch — this file's
* `require('../../gsd-core/bin/lib/health-diagnostic-rules/config-validation.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 configValidation = require('../../gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs');
const { RULES } = configValidation;
const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs');
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = require('../../gsd-core/bin/lib/health-diagnostic.cjs');
// Real shipped default, parsed once — the mutation base for every
// MECHANICAL MUTATION fixture below (design doc Fixture provenance §4).
const TEMPLATE_CONFIG_PATH = path.join(__dirname, '..', '..', 'gsd-core', 'templates', 'config.json');
const BASE_CONFIG = JSON.parse(fs.readFileSync(TEMPLATE_CONFIG_PATH, 'utf-8'));
function planningDirOf(cwd) {
return path.join(cwd, '.planning');
}
function writeConfig(cwd, obj) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'config.json'), JSON.stringify(obj));
}
function writeRawConfig(cwd, rawText) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'config.json'), rawText);
}
// Deep-clones BASE_CONFIG and applies exactly one mutation, mirroring the
// design doc's "mechanical, rule-blind mutation of the real shipped
// template" fixture class.
function mutatedConfig(mutate) {
const clone = JSON.parse(JSON.stringify(BASE_CONFIG));
mutate(clone);
return clone;
}
function ruleFor(code) {
const rule = RULES.find((r) => r.code === code);
assert.ok(rule, `rule ${code} not found in RULES`);
return rule;
}
// ─── RULES shape ────────────────────────────────────────────────────────────
describe('RULES (config-validation group)', () => {
test('exports exactly 10 rules: W003, E005, W004, W008, W016, W012, W013, W014, W015, W022', () => {
assert.deepEqual(
RULES.map((r) => r.code).sort(),
['E005', 'W003', 'W004', 'W008', 'W012', 'W013', 'W014', 'W015', 'W016', 'W022'].sort(),
);
});
test('E005 is severity ERROR; the rest are severity WARNING', () => {
assert.equal(ruleFor('E005').severity, SEVERITY.ERROR);
for (const code of ['W003', 'W004', 'W008', 'W012', 'W013', 'W014', 'W015', 'W016', 'W022']) {
assert.equal(ruleFor(code).severity, SEVERITY.WARNING, `${code} should be WARNING`);
}
});
});
// ─── W003 — config.json not found ───────────────────────────────────────────
describe('W003 — config.json not found', () => {
test('fires when config.json is absent (structural absence)', (t) => {
const cwd = createTempDir('gsd-3309-w003-1-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W003').check(snapshot);
assert.deepEqual(diagnostics, [
{
code: 'W003',
severity: SEVERITY.WARNING,
message: 'config.json not found',
remedy: { action: REMEDY_ACTION.CREATE_CONFIG, risk: REMEDY_RISK.NONE, args: {} },
},
]);
});
test('does not fire when config.json exists', (t) => {
const cwd = createTempDir('gsd-3309-w003-2-');
t.after(() => cleanup(cwd));
writeConfig(cwd, BASE_CONFIG);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W003').check(snapshot), []);
});
});
// ─── E005 — config.json JSON parse error ────────────────────────────────────
describe('E005 — config.json invalid JSON', () => {
test('fires when config.json exists but is unparseable', (t) => {
const cwd = createTempDir('gsd-3309-e005-1-');
t.after(() => cleanup(cwd));
writeRawConfig(cwd, '{ not valid json');
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('E005').check(snapshot);
assert.deepEqual(diagnostics, [
{
code: 'E005',
severity: SEVERITY.ERROR,
message: 'config.json: JSON parse error',
remedy: { action: REMEDY_ACTION.RESET_CONFIG, risk: REMEDY_RISK.DESTRUCTIVE, args: {} },
},
]);
});
test('does not fire when config.json is absent (that is W003s job)', (t) => {
const cwd = createTempDir('gsd-3309-e005-2-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('E005').check(snapshot), []);
assert.equal(ruleFor('W003').check(snapshot).length, 1);
});
test('does not fire when config.json is well-formed', (t) => {
const cwd = createTempDir('gsd-3309-e005-3-');
t.after(() => cleanup(cwd));
writeConfig(cwd, BASE_CONFIG);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('E005').check(snapshot), []);
});
});
// ─── W004 — invalid model_profile ───────────────────────────────────────────
describe('W004 — invalid model_profile', () => {
test('fires on an invalid model_profile value (mutated shipped config)', (t) => {
const cwd = createTempDir('gsd-3309-w004-1-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.model_profile = 'not-a-real-profile';
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W004').check(snapshot);
assert.deepEqual(diagnostics, [
{
code: 'W004',
severity: SEVERITY.WARNING,
message: 'config.json: invalid model_profile "not-a-real-profile"',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'Valid values: quality, balanced, budget, adaptive, inherit' },
},
},
]);
});
test('does not fire on a valid model_profile', (t) => {
const cwd = createTempDir('gsd-3309-w004-2-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.model_profile = 'balanced';
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W004').check(snapshot), []);
});
test('does not fire when model_profile is absent', (t) => {
const cwd = createTempDir('gsd-3309-w004-3-');
t.after(() => cleanup(cwd));
writeConfig(cwd, BASE_CONFIG);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W004').check(snapshot), []);
});
});
// ─── W008 — workflow.nyquist_validation absent ──────────────────────────────
describe('W008 — workflow.nyquist_validation absent', () => {
test('fires when workflow is present but nyquist_validation key is deleted', (t) => {
const cwd = createTempDir('gsd-3309-w008-1-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
delete c.workflow.nyquist_validation;
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W008').check(snapshot);
assert.deepEqual(diagnostics, [
{
code: 'W008',
severity: SEVERITY.WARNING,
message: 'config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip)',
remedy: { action: REMEDY_ACTION.ADD_NYQUIST_KEY, risk: REMEDY_RISK.NONE, args: {} },
},
]);
});
test('does not fire when nyquist_validation is present (shipped default)', (t) => {
const cwd = createTempDir('gsd-3309-w008-2-');
t.after(() => cleanup(cwd));
writeConfig(cwd, BASE_CONFIG);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W008').check(snapshot), []);
});
test('does not fire when workflow itself is absent (guard mirrors original)', (t) => {
const cwd = createTempDir('gsd-3309-w008-3-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
delete c.workflow;
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W008').check(snapshot), []);
});
});
// ─── W016 — workflow.ai_integration_phase absent ────────────────────────────
describe('W016 — workflow.ai_integration_phase absent', () => {
test('fires on the unmodified shipped default — ai_integration_phase is not in the template at all (structural absence)', (t) => {
const cwd = createTempDir('gsd-3309-w016-1-');
t.after(() => cleanup(cwd));
writeConfig(cwd, BASE_CONFIG);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W016').check(snapshot);
assert.deepEqual(diagnostics, [
{
code: 'W016',
severity: SEVERITY.WARNING,
message:
'config.json: workflow.ai_integration_phase absent (defaults to enabled — run /gsd-ai-integration-phase before planning AI system phases)',
remedy: { action: REMEDY_ACTION.ADD_AI_INTEGRATION_PHASE_KEY, risk: REMEDY_RISK.NONE, args: {} },
},
]);
});
test('does not fire when ai_integration_phase key is present', (t) => {
const cwd = createTempDir('gsd-3309-w016-2-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.workflow.ai_integration_phase = true;
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W016').check(snapshot), []);
});
test('does not fire when workflow itself is absent (guard mirrors original)', (t) => {
const cwd = createTempDir('gsd-3309-w016-3-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
delete c.workflow;
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W016').check(snapshot), []);
});
});
// ─── W012 — invalid branching_strategy ──────────────────────────────────────
describe('W012 — invalid branching_strategy', () => {
test('fires on an invalid branching_strategy value', (t) => {
const cwd = createTempDir('gsd-3309-w012-1-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.branching_strategy = 'bogus-strategy';
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W012').check(snapshot);
assert.deepEqual(diagnostics, [
{
code: 'W012',
severity: SEVERITY.WARNING,
message: 'config.json: invalid branching_strategy "bogus-strategy"',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'Valid values: none, phase, milestone' },
},
},
]);
});
for (const valid of ['none', 'phase', 'milestone']) {
test(`does not fire on valid branching_strategy "${valid}"`, (t) => {
const cwd = createTempDir('gsd-3309-w012-valid-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.branching_strategy = valid;
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W012').check(snapshot), []);
});
}
test('does not fire when branching_strategy is absent', (t) => {
const cwd = createTempDir('gsd-3309-w012-2-');
t.after(() => cleanup(cwd));
writeConfig(cwd, BASE_CONFIG);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W012').check(snapshot), []);
});
});
// ─── W013 — context_window not a positive integer ───────────────────────────
describe('W013 — context_window not a positive integer', () => {
test('fires on a non-integer context_window', (t) => {
const cwd = createTempDir('gsd-3309-w013-1-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.context_window = 3.5;
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W013').check(snapshot);
assert.deepEqual(diagnostics, [
{
code: 'W013',
severity: SEVERITY.WARNING,
message: 'config.json: context_window should be a positive integer, got "3.5"',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'Set to 200000 (default) or 1000000 (for 1M models)' },
},
},
]);
});
test('fires on a non-positive context_window (boundary: 0)', (t) => {
const cwd = createTempDir('gsd-3309-w013-2-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.context_window = 0;
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
assert.equal(ruleFor('W013').check(snapshot).length, 1);
});
test('fires on a negative context_window', (t) => {
const cwd = createTempDir('gsd-3309-w013-3-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.context_window = -1;
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
assert.equal(ruleFor('W013').check(snapshot).length, 1);
});
test('does not fire on a valid positive integer (boundary: 1)', (t) => {
const cwd = createTempDir('gsd-3309-w013-4-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.context_window = 1;
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W013').check(snapshot), []);
});
test('does not fire on the conventional default (200000)', (t) => {
const cwd = createTempDir('gsd-3309-w013-5-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.context_window = 200000;
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W013').check(snapshot), []);
});
test('does not fire when context_window is absent', (t) => {
const cwd = createTempDir('gsd-3309-w013-6-');
t.after(() => cleanup(cwd));
writeConfig(cwd, BASE_CONFIG);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W013').check(snapshot), []);
});
});
// ─── W014 — phase_branch_template missing {phase} ───────────────────────────
describe('W014 — phase_branch_template missing {phase} placeholder', () => {
test('fires when the placeholder is stripped', (t) => {
const cwd = createTempDir('gsd-3309-w014-1-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.phase_branch_template = 'phase/no-placeholder-here';
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W014').check(snapshot);
assert.deepEqual(diagnostics, [
{
code: 'W014',
severity: SEVERITY.WARNING,
message: 'config.json: phase_branch_template missing {phase} placeholder',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'Template must include {phase} for phase number substitution' },
},
},
]);
});
test('does not fire when the placeholder is present', (t) => {
const cwd = createTempDir('gsd-3309-w014-2-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.phase_branch_template = 'phase/{phase}';
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W014').check(snapshot), []);
});
test('does not fire when phase_branch_template is absent', (t) => {
const cwd = createTempDir('gsd-3309-w014-3-');
t.after(() => cleanup(cwd));
writeConfig(cwd, BASE_CONFIG);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W014').check(snapshot), []);
});
});
// ─── W015 — milestone_branch_template missing {milestone} ──────────────────
describe('W015 — milestone_branch_template missing {milestone} placeholder', () => {
test('fires when the placeholder is stripped', (t) => {
const cwd = createTempDir('gsd-3309-w015-1-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.milestone_branch_template = 'milestone/no-placeholder-here';
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W015').check(snapshot);
assert.deepEqual(diagnostics, [
{
code: 'W015',
severity: SEVERITY.WARNING,
message: 'config.json: milestone_branch_template missing {milestone} placeholder',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'Template must include {milestone} for version substitution' },
},
},
]);
});
test('does not fire when the placeholder is present', (t) => {
const cwd = createTempDir('gsd-3309-w015-2-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.milestone_branch_template = 'milestone/{milestone}';
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W015').check(snapshot), []);
});
test('does not fire when milestone_branch_template is absent', (t) => {
const cwd = createTempDir('gsd-3309-w015-3-');
t.after(() => cleanup(cwd));
writeConfig(cwd, BASE_CONFIG);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W015').check(snapshot), []);
});
});
// ─── W022 — models malformed (3 internal conditions, 1 code) ───────────────
describe('W022 — config.json models malformed', () => {
test('(a) unknown phase type key fires', (t) => {
const cwd = createTempDir('gsd-3309-w022a-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.models = { not_a_real_phase_type: 'sonnet' };
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W022').check(snapshot);
assert.deepEqual(diagnostics, [
{
code: 'W022',
severity: SEVERITY.WARNING,
message: 'config.json: models has an unknown phase type "not_a_real_phase_type" which will be ignored',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: {
command: 'Valid phase types: planning, discuss, research, execution, verification, completion',
},
},
},
]);
});
test('(b) known phase type with an invalid tier value fires', (t) => {
const cwd = createTempDir('gsd-3309-w022b-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.models = { planning: 'not-a-real-tier' };
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W022').check(snapshot);
assert.deepEqual(diagnostics, [
{
code: 'W022',
severity: SEVERITY.WARNING,
message: 'config.json: models.planning has an invalid tier value "not-a-real-tier" which will be ignored',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'Valid tiers: opus, sonnet, haiku, inherit' },
},
},
]);
});
test('(c) models present but not an object fires, and does NOT also run (a)/(b)', (t) => {
const cwd = createTempDir('gsd-3309-w022c-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.models = 'not-an-object';
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W022').check(snapshot);
assert.deepEqual(diagnostics, [
{
code: 'W022',
severity: SEVERITY.WARNING,
message:
'config.json: models is set to "not-an-object", but must be an object mapping phase types to tiers — this value will be ignored',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: {
command: 'Set models to an object like {"planning": "sonnet"}, or remove the key to use profile defaults',
},
},
},
]);
});
test('(c) an array also counts as "not an object" (Array.isArray guard)', (t) => {
const cwd = createTempDir('gsd-3309-w022c-array-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.models = ['sonnet'];
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W022').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].code, 'W022');
});
test('multiple malformed entries in one models object each produce their own diagnostic', (t) => {
const cwd = createTempDir('gsd-3309-w022-multi-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.models = { planning: 'bogus-tier', not_a_real_phase_type: 'sonnet' };
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W022').check(snapshot);
assert.equal(diagnostics.length, 2);
for (const d of diagnostics) assert.equal(d.code, 'W022');
});
test('does not fire when models is a well-formed object', (t) => {
const cwd = createTempDir('gsd-3309-w022-ok-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.models = { planning: 'sonnet', research: 'inherit' };
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W022').check(snapshot), []);
});
test('does not fire when models is absent', (t) => {
const cwd = createTempDir('gsd-3309-w022-absent-');
t.after(() => cleanup(cwd));
writeConfig(cwd, BASE_CONFIG);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W022').check(snapshot), []);
});
test('does not fire when models is null', (t) => {
const cwd = createTempDir('gsd-3309-w022-null-');
t.after(() => cleanup(cwd));
const cfg = mutatedConfig((c) => {
c.models = null;
});
writeConfig(cwd, cfg);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W022').check(snapshot), []);
});
});

View File

@@ -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 `## <version>` 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);
}
});
});

View File

@@ -0,0 +1,291 @@
'use strict';
/**
* Tests for `src/health-diagnostic-rules/phase-structure.cts` (Phase 11,
* #3309, ADR-3180 §8.2/§8.3/§8.5) — the "Phase directory structure" rule
* group: W005, W023, I001, W009.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
*
* Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"):
* - W005/W023 are MECHANICAL MUTATION — a malformed directory name / two
* directories deliberately constructed to collide on the same normalized
* phase key. Both are directory-NAME shapes, not a document format being
* modeled, so there is nothing to mutate from a template; the mutation IS
* the directory name itself.
* - I001/W009 are STRUCTURAL ABSENCE — a missing SUMMARY.md / missing
* VALIDATION.md file. Exempt from the provenance concern per §8.5's own
* category 1: the fixture *is* the absence, no format is being modeled.
*
* Every case calls the REAL `buildPlanningSnapshot(cwd)` (Phase 10,
* `src/planning-snapshot.cts`) against real temp `.planning/` trees, then
* calls the REAL rule `check` functions from the compiled module under
* test — no hand-built in-memory snapshot mocks. Fixture helpers mirror
* `tests/planning-snapshot.test.cjs`'s own `writeState`/`writeRoadmap`/
* `writeFile`/`makeCompletePhaseDir` verbatim.
*
* All fixtures use a ROADMAP.md with NO `Phase N:` headings under the
* current milestone section. `getMilestonePhaseFilter`
* (`src/roadmap-parser.cts:1341-1355`) degrades to a pass-all filter
* whenever `milestonePhaseNums.size === 0`, so `listMilestonePhaseDirs`
* enumerates every on-disk phase directory regardless of whether its name
* parses as a phase id — which is exactly what these rules need to exercise
* (a malformed dir name would otherwise never reach `phaseDirs.value` in the
* first place, since a non-matching name also fails the window's own
* numeric-prefix membership test).
*/
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/phase-structure.cjs');
const ruleByCode = Object.fromEntries(RULES.map((r) => [r.code, r]));
// ─── Fixture helpers (mirrors tests/planning-snapshot.test.cjs) ────────────
function planningDirOf(cwd) {
return path.join(cwd, '.planning');
}
function writeRoadmap(cwd, content) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), content);
}
function writeState(cwd, fields) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const lines = ['---'];
for (const [k, v] of Object.entries(fields)) lines.push(`${k}: ${v}`);
lines.push('---', '');
fs.writeFileSync(path.join(planningDirOf(cwd), 'STATE.md'), lines.join('\n'));
}
function writeFile(cwd, relPath, content) {
const full = path.join(cwd, relPath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
function makeCompletePhaseDir(cwd, relPhaseDir) {
writeFile(cwd, `${relPhaseDir}/01-01-PLAN.md`, '# Plan\n');
writeFile(cwd, `${relPhaseDir}/01-01-SUMMARY.md`, '# Summary\n');
writeFile(cwd, `${relPhaseDir}/01-VERIFICATION.md`, '---\nstatus: passed\n---\n');
}
// No `Phase N:` heading anywhere -> getMilestonePhaseFilter's pass-all
// degrade -> listMilestonePhaseDirs enumerates every on-disk phase dir name
// verbatim, malformed or not.
function writePassAllRoadmap(cwd) {
writeRoadmap(cwd, ['## v1.0 Current 🚧', ''].join('\n'));
}
function baseFixture(cwd) {
writeState(cwd, { milestone: 'v1.0' });
writePassAllRoadmap(cwd);
}
// ─── W005 — phase directory doesn't follow NN-name format ──────────────────
describe('W005 — phase directory naming', () => {
test('MECHANICAL MUTATION: a directory name with no NN- prefix fires W005', (t) => {
const cwd = createTempDir('gsd-3309-w005-');
t.after(() => cleanup(cwd));
baseFixture(cwd);
makeCompletePhaseDir(cwd, '.planning/phases/01-foo');
writeFile(cwd, '.planning/phases/notaphase/README.md', '# not a phase dir\n');
const snap = buildPlanningSnapshot(cwd);
assert.ok(snap.phaseDirs.value.includes('notaphase'), 'fixture sanity: malformed dir enumerated');
const diagnostics = ruleByCode['W005'].check(snap);
assert.deepEqual(
diagnostics.map((d) => d.code),
['W005'],
);
assert.match(diagnostics[0].message, /"notaphase"/);
assert.match(diagnostics[0].message, /doesn't follow NN-name format/);
assert.equal(diagnostics[0].severity, 'warning');
assert.equal(diagnostics[0].remedy.action, 'advise');
assert.equal(diagnostics[0].remedy.risk, 'none');
});
test('baseline: only well-formed directory names produces no diagnostics', (t) => {
const cwd = createTempDir('gsd-3309-w005-neg-');
t.after(() => cleanup(cwd));
baseFixture(cwd);
makeCompletePhaseDir(cwd, '.planning/phases/01-foo');
makeCompletePhaseDir(cwd, '.planning/phases/02-bar');
const snap = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleByCode['W005'].check(snap), []);
});
});
// ─── W023 — phase directories collide on normalized key ────────────────────
describe('W023 — colliding phase directories', () => {
test('MECHANICAL MUTATION: two directories that normalize to the same key fire W023', (t) => {
const cwd = createTempDir('gsd-3309-w023-');
t.after(() => cleanup(cwd));
baseFixture(cwd);
// extractPhaseToken("05-real") === "05"; extractPhaseToken("05-real-stray")
// === "05" too (the tokenizer stops at the first non-continuation segment,
// "real"/"stray" is a slug word, not a zero-padded continuation) — both
// normalize to phase key "05" via normalizePhaseName.
makeCompletePhaseDir(cwd, '.planning/phases/05-real');
writeFile(cwd, '.planning/phases/05-real-stray/01-01-PLAN.md', '# Plan\n');
const snap = buildPlanningSnapshot(cwd);
assert.ok(
snap.phaseDirs.value.includes('05-real') && snap.phaseDirs.value.includes('05-real-stray'),
'fixture sanity: both colliding dirs enumerated',
);
const diagnostics = ruleByCode['W023'].check(snap);
assert.deepEqual(
diagnostics.map((d) => d.code),
['W023'],
);
assert.match(diagnostics[0].message, /collide on normalized key "05"/);
assert.match(diagnostics[0].message, /05-real \(/);
assert.match(diagnostics[0].message, /05-real-stray \(/);
assert.equal(diagnostics[0].severity, 'warning');
});
test('baseline: distinct phase keys produce no diagnostics', (t) => {
const cwd = createTempDir('gsd-3309-w023-neg-');
t.after(() => cleanup(cwd));
baseFixture(cwd);
makeCompletePhaseDir(cwd, '.planning/phases/01-foo');
makeCompletePhaseDir(cwd, '.planning/phases/02-bar');
const snap = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleByCode['W023'].check(snap), []);
});
});
// ─── I001 — plan(s) without a matching SUMMARY.md ──────────────────────────
describe('I001 — unsummarized plans', () => {
test('STRUCTURAL ABSENCE: a PLAN.md with no matching SUMMARY.md fires I001', (t) => {
const cwd = createTempDir('gsd-3309-i001-');
t.after(() => cleanup(cwd));
baseFixture(cwd);
writeFile(cwd, '.planning/phases/01-foo/01-01-PLAN.md', '# Plan\n');
const snap = buildPlanningSnapshot(cwd);
const phase = snap.phases.value.find((p) => p.dir === '01-foo');
assert.equal(phase.planCount, 1);
assert.equal(phase.summaryCount, 0);
const diagnostics = ruleByCode['I001'].check(snap);
assert.deepEqual(
diagnostics.map((d) => d.code),
['I001'],
);
assert.match(diagnostics[0].message, /Phase 01-foo has 1 plan\(s\) without a matching summary/);
assert.equal(diagnostics[0].severity, 'info');
});
test('baseline: matched plan/summary pairs produce no diagnostics', (t) => {
const cwd = createTempDir('gsd-3309-i001-neg-');
t.after(() => cleanup(cwd));
baseFixture(cwd);
makeCompletePhaseDir(cwd, '.planning/phases/01-foo');
const snap = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleByCode['I001'].check(snap), []);
});
});
// ─── W009 — Validation Architecture in RESEARCH.md but no VALIDATION.md ───
describe('W009 — missing VALIDATION.md', () => {
test('STRUCTURAL ABSENCE: RESEARCH.md has Validation Architecture but no VALIDATION.md fires W009', (t) => {
const cwd = createTempDir('gsd-3309-w009-');
t.after(() => cleanup(cwd));
baseFixture(cwd);
makeCompletePhaseDir(cwd, '.planning/phases/01-foo');
writeFile(
cwd,
'.planning/phases/01-foo/01-RESEARCH.md',
'# Research\n\n## Validation Architecture\n\nSome content.\n',
);
const snap = buildPlanningSnapshot(cwd);
const entry = snap.researchValidationStatus.value.find((e) => e.dir === '01-foo');
assert.equal(entry.hasValidationArchitecture, true);
assert.equal(entry.hasValidationMd, false);
const diagnostics = ruleByCode['W009'].check(snap);
assert.deepEqual(
diagnostics.map((d) => d.code),
['W009'],
);
assert.match(
diagnostics[0].message,
/Phase 01-foo: has Validation Architecture in RESEARCH\.md but no VALIDATION\.md/,
);
assert.equal(diagnostics[0].severity, 'warning');
});
test('baseline: VALIDATION.md present alongside Validation Architecture produces no diagnostics', (t) => {
const cwd = createTempDir('gsd-3309-w009-neg-');
t.after(() => cleanup(cwd));
baseFixture(cwd);
makeCompletePhaseDir(cwd, '.planning/phases/01-foo');
writeFile(
cwd,
'.planning/phases/01-foo/01-RESEARCH.md',
'# Research\n\n## Validation Architecture\n\nSome content.\n',
);
writeFile(cwd, '.planning/phases/01-foo/01-VALIDATION.md', '# Validation\n');
const snap = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleByCode['W009'].check(snap), []);
});
test('baseline: RESEARCH.md without Validation Architecture heading produces no diagnostics', (t) => {
const cwd = createTempDir('gsd-3309-w009-neg2-');
t.after(() => cleanup(cwd));
baseFixture(cwd);
makeCompletePhaseDir(cwd, '.planning/phases/01-foo');
writeFile(cwd, '.planning/phases/01-foo/01-RESEARCH.md', '# Research\n\nNo relevant heading here.\n');
const snap = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleByCode['W009'].check(snap), []);
});
});
// ─── §8.2 rule 1 — every diagnostic's severity matches its rule's declared severity ─
describe('rule/severity 1:1 (§8.2 rule 1)', () => {
test('every emitted diagnostic carries the same severity as its rule entry', (t) => {
const cwd = createTempDir('gsd-3309-sev-');
t.after(() => cleanup(cwd));
baseFixture(cwd);
makeCompletePhaseDir(cwd, '.planning/phases/01-foo');
writeFile(cwd, '.planning/phases/notaphase/README.md', '# not a phase dir\n');
writeFile(cwd, '.planning/phases/05-real-stray/01-01-PLAN.md', '# Plan\n');
writeFile(cwd, '.planning/phases/02-bar/01-01-PLAN.md', '# Plan\n');
writeFile(
cwd,
'.planning/phases/02-bar/01-RESEARCH.md',
'## Validation Architecture\n',
);
const snap = buildPlanningSnapshot(cwd);
for (const rule of RULES) {
for (const diagnostic of rule.check(snap)) {
assert.equal(diagnostic.code, rule.code);
assert.equal(diagnostic.severity, rule.severity);
}
}
});
});

View File

@@ -0,0 +1,270 @@
'use strict';
/**
* Tests for `src/health-diagnostic-rules/roadmap-disk-consistency.cts`
* (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) — group "ROADMAP/disk
* consistency": W006, W007.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
* Test matrix: .gsd/phase/refactor-3309-health-diagnostic-rule-table/50-test-matrix.md
* | W006 | ROADMAP phase with no disk dir | reused/representative | roadmap entry added, no matching dir created |
* | W007 | disk dir with no ROADMAP entry | reused/representative | dir created, no roadmap entry |
*
* Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"): both
* rules use CONTENT-SHAPE/MECHANICAL-MUTATION provenance — a realistic
* multi-phase ROADMAP.md (mirroring `gsd-core/templates/roadmap.md`'s
* heading shape) paired with a matching on-disk phase-dir tree, with exactly
* ONE entry perturbed (one dir withheld for W006, one extra dir added for
* W007). Every fixture is built via the REAL `buildPlanningSnapshot(cwd)`
* against a REAL temp directory (mirrors `tests/planning-snapshot.test.cjs`
* and `tests/health-diagnostic-rules/root-existence.test.cjs`) — no
* hand-constructed fake `PlanningSnapshot` object.
*
* TDD RED: `src/health-diagnostic-rules/roadmap-disk-consistency.cts` does
* not exist yet at the start of this batch — this file's
* `require('../../gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.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 roadmapDiskConsistency = require('../../gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs');
const { RULES } = roadmapDiskConsistency;
const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs');
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = require('../../gsd-core/bin/lib/health-diagnostic.cjs');
const { SCOPE } = require('../../gsd-core/bin/lib/planning-scope.cjs');
function planningDirOf(cwd) {
return path.join(cwd, '.planning');
}
function writeRoadmap(cwd, content) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), content);
}
function makePhaseDir(cwd, dirName) {
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases', dirName), { recursive: true });
}
function ruleFor(code) {
const rule = RULES.find((r) => r.code === code);
assert.ok(rule, `rule ${code} not found in RULES`);
return rule;
}
// ─── RULES shape ────────────────────────────────────────────────────────────
describe('RULES (roadmap-disk-consistency group)', () => {
test('exports exactly 2 rules: W006, W007', () => {
assert.deepEqual(RULES.map((r) => r.code).sort(), ['W006', 'W007']);
});
test('both rules are severity WARNING', () => {
assert.equal(ruleFor('W006').severity, SEVERITY.WARNING);
assert.equal(ruleFor('W007').severity, SEVERITY.WARNING);
});
});
// ─── W006 — ROADMAP phase with no disk dir ─────────────────────────────────
describe('W006 — ROADMAP phase with no disk dir', () => {
test('fires for exactly the one perturbed phase (3-phase roadmap, dir withheld for phase 2)', (t) => {
const cwd = createTempDir('gsd-3309-w006-1-');
t.after(() => cleanup(cwd));
writeRoadmap(
cwd,
['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', '', '### Phase 3: Baz'].join('\n'),
);
makePhaseDir(cwd, '01-foo');
// Phase 2 deliberately has no matching directory.
makePhaseDir(cwd, '03-baz');
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W006').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.deepEqual(diagnostics[0], {
code: 'W006',
severity: SEVERITY.WARNING,
message: 'Phase 2 in ROADMAP.md but no directory on disk',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'Create phase directory or remove from roadmap' },
},
});
});
test('does not fire when every declared phase resolves to a directory (padding/token tolerant)', (t) => {
const cwd = createTempDir('gsd-3309-w006-2-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n'));
makePhaseDir(cwd, '01-foo');
makePhaseDir(cwd, '02-bar');
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W006').check(snapshot), []);
});
test('does not fire for a sentinel phase id (999.x) even with no matching directory', (t) => {
const cwd = createTempDir('gsd-3309-w006-3-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 999.1: Icebox'].join('\n'));
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W006').check(snapshot), []);
});
test('does not fire for a phase explicitly marked "not started" (unchecked checklist entry, no dir)', (t) => {
const cwd = createTempDir('gsd-3309-w006-4-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## Phases', '', '- [ ] **Phase 5: Widgets** - build them'].join('\n'));
const snapshot = buildPlanningSnapshot(cwd);
// Sanity: the phase IS declared (so this is genuinely testing the
// not-started exclusion, not an empty-declared-phases no-op).
assert.ok(snapshot.roadmapDeclaredPhases.value.some((p) => p.phaseId === '5'));
assert.deepEqual(ruleFor('W006').check(snapshot), []);
});
test('DOES fire for an unrelated checked phase with no dir (not-started exclusion is per-phase, not global)', (t) => {
const cwd = createTempDir('gsd-3309-w006-5-');
t.after(() => cleanup(cwd));
writeRoadmap(
cwd,
['## Phases', '', '- [ ] **Phase 5: Widgets** - build them', '- [x] **Phase 6: Gadgets** - build them'].join(
'\n',
),
);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W006').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].message, 'Phase 6 in ROADMAP.md but no directory on disk');
});
test('boundary: zero declared phases and zero phase directories produces zero findings', (t) => {
const cwd = createTempDir('gsd-3309-w006-6-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## Progress', '', '(no phases declared yet)'].join('\n'));
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(snapshot.roadmapDeclaredPhases.value, []);
assert.deepEqual(ruleFor('W006').check(snapshot), []);
});
test('guard: ROADMAP.md absent does not fire (empty declared-phase list is a non-answer, not "zero declared")', (t) => {
const cwd = createTempDir('gsd-3309-w006-7-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W006').check(snapshot), []);
});
});
// ─── W007 — disk dir with no ROADMAP entry ─────────────────────────────────
describe('W007 — disk dir with no ROADMAP entry', () => {
test('fires for exactly the one perturbed directory (3-phase roadmap, one extra orphan dir)', (t) => {
const cwd = createTempDir('gsd-3309-w007-1-');
t.after(() => cleanup(cwd));
writeRoadmap(
cwd,
['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', '', '### Phase 3: Baz'].join('\n'),
);
makePhaseDir(cwd, '01-foo');
makePhaseDir(cwd, '02-bar');
makePhaseDir(cwd, '03-baz');
// Deliberately orphaned: no roadmap entry claims this directory.
makePhaseDir(cwd, '04-extra');
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W007').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.deepEqual(diagnostics[0], {
code: 'W007',
severity: SEVERITY.WARNING,
message: 'Phase 04 exists on disk but not in ROADMAP.md',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'Add to roadmap or remove directory' },
},
});
});
test('does not fire when every directory is claimed by a declared phase', (t) => {
const cwd = createTempDir('gsd-3309-w007-2-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n'));
makePhaseDir(cwd, '01-foo');
makePhaseDir(cwd, '02-bar');
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W007').check(snapshot), []);
});
test('does not fire for a sentinel directory (999-interim) even with no roadmap entry', (t) => {
const cwd = createTempDir('gsd-3309-w007-3-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
makePhaseDir(cwd, '01-foo');
makePhaseDir(cwd, '999-interim');
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W007').check(snapshot), []);
});
test('boundary: zero declared phases and zero phase directories produces zero findings', (t) => {
const cwd = createTempDir('gsd-3309-w007-4-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## Progress', '', '(no phases declared yet)'].join('\n'));
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(snapshot.allPhaseDirNames.value, []);
assert.deepEqual(ruleFor('W007').check(snapshot), []);
});
test('regression: fires for a genuine orphan directory outside the roadmap-declared window (the W007-inert defect)', (t) => {
const cwd = createTempDir('gsd-3309-w007-6-');
t.after(() => cleanup(cwd));
// ROADMAP declares only phase 1 — "04-extra" is not declared anywhere,
// so `phaseDirs` (windowed to declared phases) would silently drop it
// and W007 would never see it; `allPhaseDirNames` must not.
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
makePhaseDir(cwd, '01-foo');
makePhaseDir(cwd, '04-extra');
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(
snapshot.phaseDirs.value,
['01-foo'],
'sanity: the windowed phaseDirs field must NOT include the orphan (confirms the defect this test guards)',
);
assert.ok(snapshot.allPhaseDirNames.value.includes('04-extra'));
const diagnostics = ruleFor('W007').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].message, 'Phase 04 exists on disk but not in ROADMAP.md');
});
test('guard: ROADMAP.md absent does not fire for a pre-existing phase directory (no false positive)', (t) => {
const cwd = createTempDir('gsd-3309-w007-5-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
makePhaseDir(cwd, '01-foo');
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(snapshot.roadmapDeclaredPhases, { value: [], scope: SCOPE.UNREADABLE });
assert.deepEqual(ruleFor('W007').check(snapshot), []);
});
});

View File

@@ -0,0 +1,303 @@
'use strict';
/**
* Tests for `src/health-diagnostic-rules/root-existence.cts` (Phase 11,
* #3309, ADR-3180 §8.2/§8.3/§8.5) — group "Root existence + PROJECT.md":
* E002, E003, E004, W001.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
*
* Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"): all
* four rules here are STRUCTURAL ABSENCE conditions (a file, or a section
* heading, is missing) — exempt from external-citation provenance per the
* design doc's "Fixture provenance" §1: "the fixture *is* the absence — no
* format being modeled, only a presence/absence fact." Every fixture is built
* via the REAL `buildPlanningSnapshot(cwd)` against a REAL temp directory
* (mirrors `tests/planning-snapshot.test.cjs` exactly) — no hand-constructed
* fake `PlanningSnapshot` object.
*
* TDD RED: `src/health-diagnostic-rules/root-existence.cts` does not exist
* yet at the start of this batch — this file's
* `require('../../gsd-core/bin/lib/health-diagnostic-rules/root-existence.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 rootExistence = require('../../gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs');
const { RULES } = rootExistence;
const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs');
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = require('../../gsd-core/bin/lib/health-diagnostic.cjs');
function planningDirOf(cwd) {
return path.join(cwd, '.planning');
}
function writeProject(cwd, content) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'PROJECT.md'), content);
}
function writeRoadmap(cwd, content) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), content);
}
function writeState(cwd, content) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'STATE.md'), content);
}
// Makes a FILE unreadable-as-a-file: a DIRECTORY node where a regular file is
// expected. `platformReadSync`/`fs.readFileSync` throws EISDIR on it,
// deterministically and cross-platform — no chmod (mirrors
// tests/planning-snapshot.test.cjs's `makeFileUnreadableAsDir`).
function makeFileUnreadableAsDir(fullPath) {
fs.mkdirSync(fullPath, { recursive: true });
}
function ruleFor(code) {
const rule = RULES.find((r) => r.code === code);
assert.ok(rule, `rule ${code} not found in RULES`);
return rule;
}
// ─── RULES shape ────────────────────────────────────────────────────────────
describe('RULES (root-existence group)', () => {
test('exports exactly 4 rules: E002, E003, E004, W001', () => {
assert.deepEqual(
RULES.map((r) => r.code).sort(),
['E002', 'E003', 'E004', 'W001'],
);
});
test('E002/E003/E004 are severity ERROR; W001 is severity WARNING', () => {
assert.equal(ruleFor('E002').severity, SEVERITY.ERROR);
assert.equal(ruleFor('E003').severity, SEVERITY.ERROR);
assert.equal(ruleFor('E004').severity, SEVERITY.ERROR);
assert.equal(ruleFor('W001').severity, SEVERITY.WARNING);
});
});
// ─── E002 — PROJECT.md not found ────────────────────────────────────────────
describe('E002 — PROJECT.md not found', () => {
test('fires when PROJECT.md is absent', (t) => {
const cwd = createTempDir('gsd-3309-e002-1-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('E002').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.deepEqual(diagnostics[0], {
code: 'E002',
severity: SEVERITY.ERROR,
message: 'PROJECT.md not found',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: '/gsd-new-project' },
},
});
});
test('does not fire when PROJECT.md exists', (t) => {
const cwd = createTempDir('gsd-3309-e002-2-');
t.after(() => cleanup(cwd));
writeProject(cwd, '# My Project\n\n## What This Is\n\ntext\n');
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('E002').check(snapshot), []);
});
});
// ─── E003 — ROADMAP.md not found ────────────────────────────────────────────
describe('E003 — ROADMAP.md not found', () => {
test('fires when ROADMAP.md is absent (milestone.scope === UNREADABLE)', (t) => {
const cwd = createTempDir('gsd-3309-e003-1-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('E003').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.deepEqual(diagnostics[0], {
code: 'E003',
severity: SEVERITY.ERROR,
message: 'ROADMAP.md not found',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: '/gsd-new-milestone' },
},
});
});
test('does not fire when ROADMAP.md exists and is readable', (t) => {
const cwd = createTempDir('gsd-3309-e003-2-');
t.after(() => cleanup(cwd));
writeState(cwd, '---\nmilestone: v1.0\n---\n');
writeRoadmap(cwd, '## v1.0 Current 🚧\n\n### Phase 1: Foo\n');
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('E003').check(snapshot), []);
});
// Documents the KNOWN AMBIGUITY flagged in this batch's implementer report:
// `snapshot.milestone.scope` collapses "absent" and "present-but-unreadable"
// into the same UNREADABLE scope, so this rule ALSO fires (with its
// absence-shaped message) on a present-but-corrupt ROADMAP.md. Asserted
// explicitly here rather than left undocumented, per §8.5 fixture-proof.
test('KNOWN AMBIGUITY: also fires (message says "not found") when ROADMAP.md exists but is unreadable', (t) => {
const cwd = createTempDir('gsd-3309-e003-3-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'ROADMAP.md'));
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('E003').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].code, 'E003');
});
});
// ─── E004 — STATE.md not found ──────────────────────────────────────────────
describe('E004 — STATE.md not found', () => {
test('fires when STATE.md is absent (currentPhaseLabel.scope === UNREADABLE)', (t) => {
const cwd = createTempDir('gsd-3309-e004-1-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('E004').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.deepEqual(diagnostics[0], {
code: 'E004',
severity: SEVERITY.ERROR,
message: 'STATE.md not found',
remedy: {
action: REMEDY_ACTION.REGENERATE_STATE,
risk: REMEDY_RISK.DESTRUCTIVE,
args: {},
},
});
});
test('does not fire when STATE.md exists and is readable', (t) => {
const cwd = createTempDir('gsd-3309-e004-2-');
t.after(() => cleanup(cwd));
writeState(cwd, '---\nmilestone: v1.0\n---\n\n## Current Position\n\nPhase: 1 of 2\n');
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('E004').check(snapshot), []);
});
// Documents the KNOWN GAP flagged in this batch's implementer report:
// unlike `config`, `currentPhaseLabel` carries no `exists` discriminator —
// `buildStateFields` cannot distinguish "STATE.md absent" from
// "STATE.md present but unreadable/corrupt" at all, so this rule fires
// identically for both. Asserted explicitly here, per §8.5 fixture-proof.
test('KNOWN GAP: also fires (message says "not found") when STATE.md exists but is unreadable', (t) => {
const cwd = createTempDir('gsd-3309-e004-3-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'STATE.md'));
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('E004').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].code, 'E004');
});
});
// ─── W001 — PROJECT.md missing a required section ─────────────────────────
describe('W001 — PROJECT.md missing section', () => {
test('fires once per missing required section (all 3 missing)', (t) => {
const cwd = createTempDir('gsd-3309-w001-1-');
t.after(() => cleanup(cwd));
writeProject(cwd, '# My Project\n\nno sections at all\n');
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W001').check(snapshot);
assert.equal(diagnostics.length, 3);
assert.deepEqual(
diagnostics.map((d) => d.message).sort(),
[
'PROJECT.md missing section: ## Core Value',
'PROJECT.md missing section: ## Requirements',
'PROJECT.md missing section: ## What This Is',
],
);
for (const d of diagnostics) {
assert.equal(d.code, 'W001');
assert.equal(d.severity, SEVERITY.WARNING);
assert.deepEqual(d.remedy, {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'Add section manually' },
});
}
});
test('fires only for the sections actually missing (boundary: 1 of 3 missing)', (t) => {
const cwd = createTempDir('gsd-3309-w001-2-');
t.after(() => cleanup(cwd));
writeProject(
cwd,
['# My Project', '', '## What This Is', '', '## Core Value', ''].join('\n'),
);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W001').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].message, 'PROJECT.md missing section: ## Requirements');
});
test('does not fire when all 3 required sections are present', (t) => {
const cwd = createTempDir('gsd-3309-w001-3-');
t.after(() => cleanup(cwd));
writeProject(
cwd,
[
'# My Project',
'',
'## What This Is',
'',
'## Core Value',
'',
'## Requirements',
'',
].join('\n'),
);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W001').check(snapshot), []);
});
test('does not fire when PROJECT.md is absent — that is E002s job, not W001s', (t) => {
const cwd = createTempDir('gsd-3309-w001-4-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W001').check(snapshot), []);
// E002 covers the absence case instead.
assert.equal(ruleFor('E002').check(snapshot).length, 1);
});
});

View File

@@ -0,0 +1,418 @@
'use strict';
/**
* Tests for `src/health-diagnostic-rules/state-consistency.cts` (Phase 11,
* #3309, ADR-3180 §8.2/§8.3/§8.5) — group "STATE.md consistency": W024,
* W002, W011, W021, W026.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
*
* Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"):
* every fixture below is a MECHANICAL MUTATION of a realistic multi-phase
* ROADMAP/STATE/config.json shape (the shipped `templates/state.md` /
* `templates/roadmap.md` field layout, filled in with real values) with
* exactly ONE targeted field flipped per rule under test (an extra phase
* reference, a checkbox left `[x]` while status stays `In progress`, a
* `phase_id_convention` + a milestone-prefixed phase heading placed under
* the wrong version section, a `milestone complete` status left with an
* unstarted phase) — never a fixture invented purely to trip the rule with
* no other realistic content. Every case drives the REAL
* `buildPlanningSnapshot(cwd)` (`src/planning-snapshot.cts`) against a REAL
* temp `.planning/` tree — no hand-built in-memory `PlanningSnapshot` mock.
*
* W024 is a deliberate exception: its `check` is documented dead code (see
* `src/health-diagnostic-rules/state-consistency.cts`'s `RULE_W024`
* comment) — its tests assert the INERT `[] `contract directly rather than
* a trigger fixture, since no snapshot field can drive it to fire.
*/
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 stateConsistency = require('../../gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs');
const { RULES } = stateConsistency;
const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs');
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = require('../../gsd-core/bin/lib/health-diagnostic.cjs');
function planningDirOf(cwd) {
return path.join(cwd, '.planning');
}
function writeState(cwd, content) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'STATE.md'), content);
}
function writeRoadmap(cwd, content) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), content);
}
function writeFile(cwd, relPath, content) {
const full = path.join(cwd, relPath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
function writeConfig(cwd, obj) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'config.json'), JSON.stringify(obj, null, 2));
}
function makePhaseDir(cwd, dirName) {
writeFile(cwd, `.planning/phases/${dirName}/01-01-PLAN.md`, '# Plan\n');
writeFile(cwd, `.planning/phases/${dirName}/01-01-SUMMARY.md`, '# Summary\n');
writeFile(cwd, `.planning/phases/${dirName}/01-VERIFICATION.md`, '---\nstatus: passed\n---\n');
}
function ruleFor(code) {
const rule = RULES.find((r) => r.code === code);
assert.ok(rule, `rule ${code} not found in RULES`);
return rule;
}
// ─── RULES shape ────────────────────────────────────────────────────────────
describe('RULES (state-consistency group)', () => {
test('exports exactly 5 rules: W024, W002, W011, W021, W026', () => {
assert.deepEqual(
RULES.map((r) => r.code).sort(),
['W002', 'W011', 'W021', 'W024', 'W026'],
);
});
test('every rule is severity WARNING', () => {
for (const code of ['W024', 'W002', 'W011', 'W021', 'W026']) {
assert.equal(ruleFor(code).severity, SEVERITY.WARNING);
}
});
});
// ─── W024 — STATE.md commit-age freshness (DELIBERATELY INERT) ─────────────
describe('W024 — deliberately inert (no snapshot field backs git-log freshness)', () => {
test('always returns [] on an empty snapshot', (t) => {
const cwd = createTempDir('gsd-3309-w024-1-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W024').check(snapshot), []);
});
test('always returns [] even with a state_head-carrying STATE.md and full roadmap/config', (t) => {
const cwd = createTempDir('gsd-3309-w024-2-');
t.after(() => cleanup(cwd));
writeState(
cwd,
['---', 'state_head: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', 'status: In progress', '---', '', '## Current Position', '', 'Phase: 1 of 2', ''].join('\n'),
);
writeRoadmap(cwd, '## v1.0 Current 🚧\n\n### Phase 1: Foo\n\n### Phase 2: Bar\n');
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W024').check(snapshot), []);
});
});
// ─── W002 — STATE.md references an undeclared phase token ──────────────────
describe('W002 — STATE.md references a phase not declared on disk or ROADMAP', () => {
test('fires when STATE.md mentions a phase not on disk and not in ROADMAP', (t) => {
const cwd = createTempDir('gsd-3309-w002-1-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, '## v1.0 Current 🚧\n\n### Phase 1: Foo\n\n### Phase 2: Bar\n');
makePhaseDir(cwd, '01-foo');
makePhaseDir(cwd, '02-bar');
writeState(
cwd,
[
'---',
'status: In progress',
'---',
'',
'## Current Position',
'',
'Phase: 1 of 2',
'',
'### Decisions',
'',
'- Phase 9: referenced a phase that does not exist',
'',
].join('\n'),
);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W002').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].code, 'W002');
assert.equal(diagnostics[0].severity, SEVERITY.WARNING);
assert.match(diagnostics[0].message, /STATE\.md references phase 9, but only phases .* are declared/);
assert.deepEqual(diagnostics[0].remedy, {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: {
command:
'Review STATE.md manually before changing it; /gsd-health --repair will not overwrite an existing STATE.md for phase mismatches',
},
});
});
test('does not fire when every STATE.md phase reference is declared (disk or ROADMAP)', (t) => {
const cwd = createTempDir('gsd-3309-w002-2-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, '## v1.0 Current 🚧\n\n### Phase 1: Foo\n\n### Phase 2: Bar\n');
makePhaseDir(cwd, '01-foo');
makePhaseDir(cwd, '02-bar');
writeState(
cwd,
[
'---',
'status: In progress',
'---',
'',
'## Current Position',
'',
'Phase: 1 of 2',
'',
'### Decisions',
'',
'- Phase 2: fine, this is declared',
'',
].join('\n'),
);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W002').check(snapshot), []);
});
// Boundary: `validPhases.size === 0` guard — mirrors `verify.cts:1765`'s
// `if (normalizedValid.size > 0)` exactly, so a project with no declared
// phases at all never reports every STATE.md phase mention as invalid.
test('does not fire when the valid-phase set is empty (no ROADMAP, no disk phases)', (t) => {
const cwd = createTempDir('gsd-3309-w002-3-');
t.after(() => cleanup(cwd));
writeState(
cwd,
['---', 'status: In progress', '---', '', '### Decisions', '', '- Phase 3: referenced with nothing declared anywhere', ''].join(
'\n',
),
);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W002').check(snapshot), []);
});
// `snapshot.archivedPhaseTokens` (#3652) now covers archived-milestone
// phase-dir tokens, so `buildValidPhaseSet` includes them — a STATE.md
// reference to a phase whose only home is an archived milestone is
// correctly treated as declared and does NOT fire.
test('does not fire on a phase reference whose only home is an archived milestone', (t) => {
const cwd = createTempDir('gsd-3309-w002-4-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, '## v2.0 Current 🚧\n\n### Phase 3: Baz\n');
makePhaseDir(cwd, '03-baz');
writeFile(cwd, '.planning/milestones/v1.0-phases/01-archived-foo/01-VERIFICATION.md', '---\nstatus: passed\n---\n');
writeState(
cwd,
[
'---',
'status: In progress',
'---',
'',
'## Current Position',
'',
'Phase: 3 of 3',
'',
'### Decisions',
'',
'- Phase 1: this phase is archived, covered by snapshot.archivedPhaseTokens',
'',
].join('\n'),
);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W002').check(snapshot);
assert.deepEqual(diagnostics, []);
});
});
// ─── W011 — STATE current-phase status vs. ROADMAP checkbox disagree ───────
describe('W011 — STATE current-phase status disagrees with ROADMAP [x] checkbox', () => {
test('fires when ROADMAP checkbox says the current phase is [x] complete but STATE status is not complete/done', (t) => {
const cwd = createTempDir('gsd-3309-w011-1-');
t.after(() => cleanup(cwd));
writeRoadmap(
cwd,
['## v1.0 Current 🚧', '', '- [x] Phase 3: Auth', '- [ ] Phase 4: Billing', ''].join('\n'),
);
writeState(
cwd,
['---', 'status: In progress', '---', '', '## Current Position', '', 'Phase: 3 of 4 (Auth)', 'Status: In progress', ''].join(
'\n',
),
);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W011').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.deepEqual(diagnostics[0], {
code: 'W011',
severity: SEVERITY.WARNING,
message:
'STATE.md says current phase is 3 (status: in progress) but ROADMAP.md shows it as [x] complete — state files may be out of sync',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'Run /gsd-progress to re-derive current position, or manually update STATE.md' },
},
});
});
test('does not fire when STATE status is already "complete"', (t) => {
const cwd = createTempDir('gsd-3309-w011-2-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '- [x] Phase 3: Auth', ''].join('\n'));
writeState(
cwd,
['---', 'status: complete', '---', '', '## Current Position', '', 'Phase: 3 of 4 (Auth)', 'Status: complete', ''].join('\n'),
);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W011').check(snapshot), []);
});
test('does not fire when the ROADMAP checkbox for the current phase is [ ] (not checked)', (t) => {
const cwd = createTempDir('gsd-3309-w011-3-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '- [ ] Phase 3: Auth', ''].join('\n'));
writeState(
cwd,
['---', 'status: In progress', '---', '', '## Current Position', '', 'Phase: 3 of 4 (Auth)', 'Status: In progress', ''].join(
'\n',
),
);
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W011').check(snapshot), []);
});
});
// ─── W021 — phase_id_convention integer-prefix/milestone mismatch ──────────
describe('W021 — milestone-prefixed phase integer-prefix implies a different milestone', () => {
test('fires when a milestone-prefixed phase heading is listed under the wrong version section', (t) => {
const cwd = createTempDir('gsd-3309-w021-1-');
t.after(() => cleanup(cwd));
writeConfig(cwd, { phase_id_convention: 'milestone-prefixed' });
writeRoadmap(cwd, ['## v2.0 Current 🚧', '', '### Phase 1-1: Misplaced', ''].join('\n'));
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W021').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.deepEqual(diagnostics[0], {
code: 'W021',
severity: SEVERITY.WARNING,
message: 'Phase 1-1: integer prefix implies v1.0 but listed under v2.0',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'gsd-tools roadmap upgrade --convention milestone-prefixed' },
},
});
});
test('does not fire when the milestone-prefixed phase is listed under its implied version', (t) => {
const cwd = createTempDir('gsd-3309-w021-2-');
t.after(() => cleanup(cwd));
writeConfig(cwd, { phase_id_convention: 'milestone-prefixed' });
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1-1: Correctly Placed', ''].join('\n'));
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W021').check(snapshot), []);
});
test('does not fire when phase_id_convention is not "milestone-prefixed"', (t) => {
const cwd = createTempDir('gsd-3309-w021-3-');
t.after(() => cleanup(cwd));
writeConfig(cwd, { phase_id_convention: 'flat' });
writeRoadmap(cwd, ['## v2.0 Current 🚧', '', '### Phase 1-1: Misplaced', ''].join('\n'));
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W021').check(snapshot), []);
});
});
// ─── W026 — STATE says milestone complete but ROADMAP lists unstarted phase ─
describe('W026 — STATE milestone-complete/archived but ROADMAP lists a phase with no disk directory', () => {
test('fires when STATE says "milestone complete" and the current milestone still lists an unstarted phase', (t) => {
const cwd = createTempDir('gsd-3309-w026-1-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', ''].join('\n'));
makePhaseDir(cwd, '01-foo');
// Phase 2 deliberately has NO disk directory.
writeState(cwd, ['---', 'status: milestone complete', 'milestone: v1.0', '---', ''].join('\n'));
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W026').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.deepEqual(diagnostics[0], {
code: 'W026',
severity: SEVERITY.WARNING,
message: 'STATE says milestone complete but ROADMAP lists 1 unstarted phase(s) (e.g. Phase 2)',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: {
command: 'Run validate consistency or re-run complete-milestone after verifying all phases are done',
},
},
});
});
test('does not fire when every phase in the current milestone has a disk directory', (t) => {
const cwd = createTempDir('gsd-3309-w026-2-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', ''].join('\n'));
makePhaseDir(cwd, '01-foo');
makePhaseDir(cwd, '02-bar');
writeState(cwd, ['---', 'status: milestone complete', 'milestone: v1.0', '---', ''].join('\n'));
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W026').check(snapshot), []);
});
test('does not fire when STATE status is not "milestone complete"/"archived"', (t) => {
const cwd = createTempDir('gsd-3309-w026-3-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', ''].join('\n'));
makePhaseDir(cwd, '01-foo');
writeState(cwd, ['---', 'status: In progress', 'milestone: v1.0', '---', ''].join('\n'));
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W026').check(snapshot), []);
});
test('fires when STATE status is "archived" (the other trigger token)', (t) => {
const cwd = createTempDir('gsd-3309-w026-4-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', ''].join('\n'));
makePhaseDir(cwd, '01-foo');
writeState(cwd, ['---', 'status: archived', 'milestone: v1.0', '---', ''].join('\n'));
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W026').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].code, 'W026');
});
});

View File

@@ -0,0 +1,384 @@
'use strict';
/**
* Tests for `src/health-diagnostic-rules/worktree-health.cts` (Phase 11,
* #3309, ADR-3180 §8.2/§8.3/§8.5) — group "Worktree health": W020 (×3
* internal conditions), W017 (orphan), W027 (NEW — split-off stale-worktree
* subject).
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
*
* Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"): every
* fixture here is built via the REAL `buildPlanningSnapshot(cwd)` against a
* REAL temp directory. The `git worktree list` seam is mocked at
* `child_process.spawnSync` — REUSED, not re-derived, from
* `tests/planning-snapshot.test.cjs`'s `mockGitWorktreeListOk` /
* `mockGitWorktreeListTimeout` helpers (that file's own comment cites this as
* "the repo's convention for driving the real execGit rather than a hand-set
* deps.execGit stub", since `buildWorktreeHealthField`
* (`src/planning-snapshot.cts`) accepts no `deps` parameter to inject
* `execGit` directly — mirrors `tests/worktree-safety.test.cjs`'s
* "execGitDefault (real spawn seam)" section). Orphan/stale findings use REAL
* `fs.existsSync`/`fs.statSync` against real temp-dir paths (no mocking
* needed: a genuinely-absent path is naturally an orphan; a real directory
* with an old mtime, set via `fs.utimesSync`, is naturally stale). Only the
* 'unverified' finding (statSync throws on an existing path) needs a
* `fs.statSync` passthrough mock, scoped to one target path.
*
* TDD RED: `src/health-diagnostic-rules/worktree-health.cts` does not exist
* yet at the start of this batch — this file's
* `require('../../gsd-core/bin/lib/health-diagnostic-rules/worktree-health.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 childProcess = require('node:child_process');
const { createTempDir, cleanup } = require('../helpers.cjs');
const worktreeHealth = require('../../gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs');
const { RULES } = worktreeHealth;
const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs');
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = require('../../gsd-core/bin/lib/health-diagnostic.cjs');
function planningDirOf(cwd) {
return path.join(cwd, '.planning');
}
function ruleFor(code) {
const rule = RULES.find((r) => r.code === code);
assert.ok(rule, `rule ${code} not found in RULES`);
return rule;
}
// ─── Reused git-porcelain mocking (tests/planning-snapshot.test.cjs) ───────
function mockGitWorktreeListOk(t, porcelain) {
t.mock.method(childProcess, 'spawnSync', () => ({
status: 0,
stdout: porcelain,
stderr: '',
signal: null,
error: null,
}));
}
function mockGitWorktreeListTimeout(t) {
t.mock.method(childProcess, 'spawnSync', () => ({
status: null,
stdout: '',
stderr: '',
signal: null,
error: Object.assign(new Error('spawnSync git ETIMEDOUT'), { code: 'ETIMEDOUT' }),
}));
}
// New: outright failure (non-zero exit, NOT a timeout) — the second of
// W020's two scan-level conditions.
function mockGitWorktreeListFailed(t) {
t.mock.method(childProcess, 'spawnSync', () => ({
status: 1,
stdout: '',
stderr: 'fatal: some git error',
signal: null,
error: null,
}));
}
// Builds `git worktree list --porcelain` output for the given paths, in
// order. Entry 0 is always treated as "the main worktree" by
// `listLinkedWorktreePaths`'s own `.slice(1)` and dropped before any
// finding is computed — mirrors the exact block shape used by
// tests/worktree-safety.test.cjs's inspectWorktreeHealth fixtures.
function buildPorcelain(paths) {
const lines = [];
paths.forEach((p, i) => {
lines.push(`worktree ${p}`);
lines.push(`HEAD ${'a'.repeat(40)}`);
lines.push(`branch refs/heads/${i === 0 ? 'main' : `feat-${i}`}`);
lines.push('');
});
return lines.join('\n');
}
// ─── RULES shape ────────────────────────────────────────────────────────────
describe('RULES (worktree-health group)', () => {
test('exports exactly 3 rules: W020, W017, W027', () => {
assert.deepEqual(RULES.map((r) => r.code).sort(), ['W017', 'W020', 'W027']);
});
test('all three are severity WARNING', () => {
assert.equal(ruleFor('W020').severity, SEVERITY.WARNING);
assert.equal(ruleFor('W017').severity, SEVERITY.WARNING);
assert.equal(ruleFor('W027').severity, SEVERITY.WARNING);
});
});
// ─── W020 — worktree health scan itself is degraded ────────────────────────
describe('W020 — worktree health scan degraded', () => {
test('fires the git_timed_out-specific scan-degraded message when git worktree list times out', (t) => {
const cwd = createTempDir('gsd-3309-w020-timeout-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
mockGitWorktreeListTimeout(t);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W020').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.deepEqual(diagnostics[0], {
code: 'W020',
severity: SEVERITY.WARNING,
message:
'Worktree health check degraded: git worktree list timed out after 10s — orphan/stale worktrees could not be inspected',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: {
command:
'Run: git worktree list --porcelain to diagnose; check for .git/index.lock or a hung git process',
},
},
});
});
// `inspectWorktreeHealth`'s `reason` field ('git_timed_out' vs
// 'git_list_failed') is carried straight through on
// `PlanningSnapshot.worktreeHealth` (`planning-snapshot.cts`'s
// `buildWorktreeHealthField`), so `checkW020` distinguishes the two scan
// failures with distinct messages/remedies (verify.cts:2204-2219) — this
// asserts the git_list_failed-specific one.
test('fires the git_list_failed-specific message when git worktree list fails outright (not a timeout)', (t) => {
const cwd = createTempDir('gsd-3309-w020-failed-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
mockGitWorktreeListFailed(t);
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W020').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.deepEqual(diagnostics[0], {
code: 'W020',
severity: SEVERITY.WARNING,
message:
'Worktree health check degraded: git worktree list failed — orphan/stale worktrees could not be inspected',
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: {
command:
'Run: git worktree list --porcelain to diagnose; check git repository state and permissions',
},
},
});
});
test('fires once per unverified finding — exact port of verify.cts:2256-2263', (t) => {
const cwd = createTempDir('gsd-3309-w020-unverified-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const unverifiedPath = path.join(cwd, 'wt-unverified');
fs.mkdirSync(unverifiedPath, { recursive: true }); // existsSync must be true
const originalStatSync = fs.statSync;
t.mock.method(fs, 'statSync', function mockedStatSync(p, ...rest) {
if (p === unverifiedPath) {
throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
}
return originalStatSync.call(fs, p, ...rest);
});
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', unverifiedPath]));
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W020').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.deepEqual(diagnostics[0], {
code: 'W020',
severity: SEVERITY.WARNING,
message: `Worktree health check degraded: could not stat ${unverifiedPath} — presence/staleness could not be verified`,
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'Check filesystem permissions on the worktree path, or investigate why statSync failed for it' },
},
});
});
test('does not fire when the scan succeeds and no finding is unverified', (t) => {
const cwd = createTempDir('gsd-3309-w020-clean-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo']));
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W020').check(snapshot), []);
});
});
// ─── W017 — orphan git worktree ─────────────────────────────────────────────
describe('W017 — orphan git worktree', () => {
test('fires once per orphan finding (path no longer exists on disk)', (t) => {
const cwd = createTempDir('gsd-3309-w017-1-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const orphanPath = path.join(cwd, 'wt-orphan-does-not-exist');
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', orphanPath]));
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W017').check(snapshot);
assert.equal(diagnostics.length, 1);
assert.deepEqual(diagnostics[0], {
code: 'W017',
severity: SEVERITY.WARNING,
message: `Orphan git worktree: ${orphanPath} (path no longer exists on disk)`,
remedy: {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'git worktree prune' },
},
});
});
test('does not fire for stale or unverified findings — isolates from W020/W027', (t) => {
const cwd = createTempDir('gsd-3309-w017-2-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const stalePath = path.join(cwd, 'wt-stale');
fs.mkdirSync(stalePath, { recursive: true });
fs.utimesSync(stalePath, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000));
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', stalePath]));
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W017').check(snapshot), []);
});
});
// ─── W027 — stale git worktree (NEW, split off pre-migration 'W017') ──────
describe('W027 — stale git worktree', () => {
test('fires once per stale finding, message carries the interpolated command, args.command is a static <path> template', (t) => {
const cwd = createTempDir('gsd-3309-w027-1-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const stalePath = path.join(cwd, 'wt-stale');
fs.mkdirSync(stalePath, { recursive: true });
fs.utimesSync(stalePath, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000));
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', stalePath]));
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W027').check(snapshot);
assert.equal(diagnostics.length, 1);
const d = diagnostics[0];
assert.equal(d.code, 'W027');
assert.equal(d.severity, SEVERITY.WARNING);
assert.ok(
d.message.startsWith(`Stale git worktree: ${stalePath} (last modified `),
`message must start with the stale-worktree prefix and path: ${d.message}`,
);
assert.ok(
d.message.endsWith(`minutes ago). Run: git worktree remove ${stalePath} --force`),
`message must end with the interpolated remove command: ${d.message}`,
);
assert.deepEqual(d.remedy, {
action: REMEDY_ACTION.ADVISE,
risk: REMEDY_RISK.NONE,
args: { command: 'git worktree remove <path> --force' },
});
});
test('does not fire for orphan or unverified findings — isolates from W017/W020', (t) => {
const cwd = createTempDir('gsd-3309-w027-2-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const orphanPath = path.join(cwd, 'wt-orphan-does-not-exist');
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', orphanPath]));
const snapshot = buildPlanningSnapshot(cwd);
assert.deepEqual(ruleFor('W027').check(snapshot), []);
});
// Regression proof (restores `verify.cts:2233-2242`'s pre-migration
// behavior via `PlanningSnapshot.cwd`, see the rule module's own header
// comment): the active session's cwd is a LINKED (non-first) `git worktree
// list` entry, not the main repo root — `buildPlanningSnapshot(cwd)` is
// called with `cwd` itself listed as entry index 1 (not the dropped
// index-0 "main" entry) and made stale. W027 must NOT fire for it.
test('excludes the active session\'s own (stale) worktree — matches snapshot.cwd exactly', (t) => {
const cwd = createTempDir('gsd-3309-w027-3-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.utimesSync(cwd, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000));
// Entry 0 is a fake "main repo" (dropped by listLinkedWorktreePaths's own
// .slice(1)); entry 1 is cwd itself — the active session's worktree.
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', cwd]));
const snapshot = buildPlanningSnapshot(cwd);
assert.equal(snapshot.cwd, path.resolve(cwd), 'snapshot.cwd must be the resolved active cwd');
const diagnostics = ruleFor('W027').check(snapshot);
assert.deepEqual(
diagnostics,
[],
'the active worktree must be excluded from stale-worktree diagnostics, matching pre-migration behavior',
);
});
test('excludes the active session\'s own (stale) worktree when cwd is NESTED under the worktree path — mirrors verify.cts:2239-2241\'s startsWith check', (t) => {
const cwd = createTempDir('gsd-3309-w027-4-');
t.after(() => cleanup(cwd));
const worktreePath = path.join(cwd, 'wt-active');
const nestedCwd = path.join(worktreePath, 'sub', 'dir');
fs.mkdirSync(nestedCwd, { recursive: true });
fs.mkdirSync(planningDirOf(nestedCwd), { recursive: true });
fs.utimesSync(worktreePath, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000));
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', worktreePath]));
const snapshot = buildPlanningSnapshot(nestedCwd);
const diagnostics = ruleFor('W027').check(snapshot);
assert.deepEqual(
diagnostics,
[],
'a worktree that is an ancestor of the active cwd must also be excluded',
);
});
test('a DIFFERENT stale worktree (not the active cwd, not an ancestor of it) still fires', (t) => {
const cwd = createTempDir('gsd-3309-w027-5-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const otherStalePath = path.join(cwd, 'wt-other-stale');
fs.mkdirSync(otherStalePath, { recursive: true });
fs.utimesSync(otherStalePath, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000));
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', otherStalePath]));
const snapshot = buildPlanningSnapshot(cwd);
const diagnostics = ruleFor('W027').check(snapshot);
assert.equal(diagnostics.length, 1, 'a stale worktree distinct from the active cwd must still be flagged');
assert.equal(diagnostics[0].code, 'W027');
});
});

View File

@@ -0,0 +1,389 @@
'use strict';
/**
* Tests for `src/health-diagnostic.cts` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5).
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
* Test matrix: .gsd/phase/refactor-3309-health-diagnostic-rule-table/50-test-matrix.md
*
* Covers test-matrix section 2 (rows 9-16) against the FULLY WIRED rule
* table (`RULES` now carries all 31 rules — see the "RULES" describe block
* below for the exact count and why it is 31, not 32 — extracted from
* `cmdValidateHealth`, `src/verify.cts:1616-2577`). Rows 15-16 (the
* DESTRUCTIVE-refusal proof and the NONE-risk apply proof) run against REAL
* diagnostics emitted by REAL rules over a REAL `buildPlanningSnapshot`
* projection of a temp fixture, not hand-constructed fakes — the
* hand-constructed-fake coverage (rows 11-12 below) is kept alongside it
* since it exercises `applyRepairs`'s gating logic in isolation from any
* particular rule's shape.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const healthDiagnostic = require('../gsd-core/bin/lib/health-diagnostic.cjs');
const { buildPlanningSnapshot } = require('../gsd-core/bin/lib/planning-snapshot.cjs');
const { createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
const {
SEVERITY,
REMEDY_ACTION,
REMEDY_RISK,
RULES,
evaluateRules,
evaluateRuleTable,
applyRepairs,
} = healthDiagnostic;
// ─── Shared fixture helpers (mirror tests/orphan-worktree-detection.test.cjs's
// setupHealthyProject, the proven-healthy recipe for the pre-migration
// cmdValidateHealth) ────────────────────────────────────────────────────────
function writeMinimalProjectMd(tmpDir) {
const sections = ['## What This Is', '## Core Value', '## Requirements'];
const content = sections.map((s) => `${s}\n\nContent here.\n`).join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), `# Project\n\n${content}`);
}
function writeMinimalRoadmap(tmpDir) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 1: Setup\n');
}
function writeMinimalStateMd(tmpDir) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Session State\n\n## Current Position\n\nPhase: 1\n',
);
}
function writeValidConfigJson(tmpDir) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify(
{
model_profile: 'balanced',
commit_docs: true,
workflow: { nyquist_validation: true, ai_integration_phase: true },
},
null,
2,
),
);
}
function setupHealthyProject(tmpDir) {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir);
writeMinimalStateMd(tmpDir);
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true });
}
// ─── Row 9 — REMEDY_ACTION locks exactly 7 members ─────────────────────────
describe('REMEDY_ACTION', () => {
test('row 9: locks exactly 7 members (6 real repair actions + ADVISE)', () => {
assert.deepEqual(Object.keys(REMEDY_ACTION).sort(), [
'ADD_AI_INTEGRATION_PHASE_KEY',
'ADD_NYQUIST_KEY',
'ADVISE',
'BACKFILL_MILESTONES',
'CREATE_CONFIG',
'REGENERATE_STATE',
'RESET_CONFIG',
]);
assert.deepEqual(
Object.values(REMEDY_ACTION).sort(),
[
'addAiIntegrationPhaseKey',
'addNyquistKey',
'advise',
'backfillMilestones',
'createConfig',
'regenerateState',
'resetConfig',
],
);
});
test('is frozen', () => {
assert.equal(Object.isFrozen(REMEDY_ACTION), true);
});
});
// ─── Row 10 — REMEDY_RISK locks exactly 2 members ──────────────────────────
describe('REMEDY_RISK', () => {
test('row 10: locks exactly 2 members (NONE, DESTRUCTIVE)', () => {
assert.deepEqual(Object.keys(REMEDY_RISK).sort(), ['DESTRUCTIVE', 'NONE']);
assert.deepEqual(Object.values(REMEDY_RISK).sort(), ['destructive', 'none']);
});
test('is frozen', () => {
assert.equal(Object.isFrozen(REMEDY_RISK), true);
});
});
describe('SEVERITY', () => {
test('locks exactly 3 members (ERROR, WARNING, INFO)', () => {
assert.deepEqual(Object.keys(SEVERITY).sort(), ['ERROR', 'INFO', 'WARNING']);
assert.deepEqual(Object.values(SEVERITY).sort(), ['error', 'info', 'warning']);
});
test('is frozen', () => {
assert.equal(Object.isFrozen(SEVERITY), true);
});
});
// ─── Rows 11-12 — applyRepairs risk-gating, hand-constructed diagnostics ───
//
// No real rule exists yet to emit these remedies (RULES is empty in this
// skeleton). These diagnostics are hand-built using the risk harvested from
// health.md's published table (design doc, "Risk assignment" section):
// resetConfig/regenerateState are DESTRUCTIVE; every other real action is
// NONE. This proves applyRepairs's gating logic is correct independent of
// whether any real rule exists to produce these shapes yet.
function fakeDiagnostic(code, action, risk) {
return {
code,
severity: SEVERITY.WARNING,
message: `fake diagnostic for ${code}`,
remedy: { action, risk, args: {} },
};
}
describe('applyRepairs — risk gating (hand-constructed diagnostics)', () => {
test('row 11: resetConfig/regenerateState (DESTRUCTIVE) are refused, never applied, when --repair is requested', () => {
const diagnostics = [
fakeDiagnostic('E005', REMEDY_ACTION.RESET_CONFIG, REMEDY_RISK.DESTRUCTIVE),
fakeDiagnostic('E004', REMEDY_ACTION.REGENERATE_STATE, REMEDY_RISK.DESTRUCTIVE),
];
const result = applyRepairs('/fake/cwd', diagnostics, true, false);
assert.deepEqual(result.applied, []);
assert.deepEqual(result.refused.sort(), ['E004', 'E005']);
});
test('row 12: every other real action (NONE risk) is applied, not refused, when --repair is requested', (t) => {
// Unlike the other tests in this block, these four codes now dispatch to
// REAL handlers (runRepairAction, src/health-diagnostic.cts) that perform
// real filesystem I/O — createConfig writes config.json, addNyquistKey /
// addAiIntegrationPhaseKey read-then-patch it (throwing if absent). A
// literal '/fake/cwd' makes every one of those genuinely fail (ENOENT),
// which applyRepairs correctly reports as NOT applied. A real temp
// project with a real, valid config.json already in place is required so
// the read-then-patch handlers have something to read.
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
writeValidConfigJson(tmpDir);
const diagnostics = [
fakeDiagnostic('W003', REMEDY_ACTION.CREATE_CONFIG, REMEDY_RISK.NONE),
fakeDiagnostic('W008', REMEDY_ACTION.ADD_NYQUIST_KEY, REMEDY_RISK.NONE),
fakeDiagnostic('W016', REMEDY_ACTION.ADD_AI_INTEGRATION_PHASE_KEY, REMEDY_RISK.NONE),
fakeDiagnostic('W018', REMEDY_ACTION.BACKFILL_MILESTONES, REMEDY_RISK.NONE),
];
const result = applyRepairs(tmpDir, diagnostics, true, false);
assert.deepEqual(result.applied.sort(), ['W003', 'W008', 'W016', 'W018']);
assert.deepEqual(result.refused, []);
});
test('ADVISE-action diagnostics are never applied nor refused, regardless of --repair', () => {
const diagnostics = [fakeDiagnostic('W001', REMEDY_ACTION.ADVISE, REMEDY_RISK.NONE)];
const result = applyRepairs('/fake/cwd', diagnostics, true, true);
assert.deepEqual(result.applied, []);
assert.deepEqual(result.refused, []);
});
test('non-backfillMilestones NONE-risk diagnostics are skipped (not applied) when --repair is not requested', () => {
const diagnostics = [fakeDiagnostic('W003', REMEDY_ACTION.CREATE_CONFIG, REMEDY_RISK.NONE)];
const result = applyRepairs('/fake/cwd', diagnostics, false, false);
assert.deepEqual(result.applied, []);
assert.deepEqual(result.refused, []);
});
test('DESTRUCTIVE-risk diagnostics are skipped (not refused) when --repair is not requested — refusal only fires when actually requested', () => {
const diagnostics = [fakeDiagnostic('E005', REMEDY_ACTION.RESET_CONFIG, REMEDY_RISK.DESTRUCTIVE)];
const result = applyRepairs('/fake/cwd', diagnostics, false, false);
assert.deepEqual(result.applied, []);
assert.deepEqual(result.refused, []);
});
test('backfillMilestones applies on --backfill alone, without --repair (mirrors verify.cts:2504 intent)', () => {
const diagnostics = [fakeDiagnostic('W018', REMEDY_ACTION.BACKFILL_MILESTONES, REMEDY_RISK.NONE)];
const result = applyRepairs('/fake/cwd', diagnostics, false, true);
assert.deepEqual(result.applied, ['W018']);
assert.deepEqual(result.refused, []);
});
test('backfillMilestones is skipped when neither --repair nor --backfill is set', () => {
const diagnostics = [fakeDiagnostic('W018', REMEDY_ACTION.BACKFILL_MILESTONES, REMEDY_RISK.NONE)];
const result = applyRepairs('/fake/cwd', diagnostics, false, false);
assert.deepEqual(result.applied, []);
assert.deepEqual(result.refused, []);
});
});
// ─── Row 13 — duplicate-code detection, LOCAL fake rule array ──────────────
//
// Proven against a small, locally-constructed fake rule array — independent
// of the real `RULES` table's own (already-unique, see the "RULES" describe
// block below) codes, so this guard's logic is covered in isolation.
describe('evaluateRuleTable — duplicate-code guard (row 13)', () => {
test('throws when two rules share the same code', () => {
const fakeRules = [
{ code: 'W999', severity: SEVERITY.WARNING, check: () => [] },
{ code: 'W999', severity: SEVERITY.WARNING, check: () => [] },
];
assert.throws(() => evaluateRuleTable(fakeRules, {}), /W999/);
});
test('does not throw, and flattens all diagnostics, when codes are unique', () => {
const fakeRules = [
{
code: 'W997',
severity: SEVERITY.WARNING,
check: () => [
{ code: 'W997', severity: SEVERITY.WARNING, message: 'a', remedy: { action: REMEDY_ACTION.ADVISE, risk: REMEDY_RISK.NONE, args: {} } },
],
},
{
code: 'W998',
severity: SEVERITY.WARNING,
check: () => [
{ code: 'W998', severity: SEVERITY.WARNING, message: 'b', remedy: { action: REMEDY_ACTION.ADVISE, risk: REMEDY_RISK.NONE, args: {} } },
{ code: 'W998', severity: SEVERITY.WARNING, message: 'c', remedy: { action: REMEDY_ACTION.ADVISE, risk: REMEDY_RISK.NONE, args: {} } },
],
},
];
const diagnostics = evaluateRuleTable(fakeRules, {});
assert.equal(diagnostics.length, 3);
assert.deepEqual(diagnostics.map((d) => d.message), ['a', 'b', 'c']);
});
test('empty rule array never throws and returns []', () => {
assert.deepEqual(evaluateRuleTable([], {}), []);
});
});
// ─── RULES — the fully wired table ──────────────────────────────────────────
//
// 31 rule entries, not the design doc's own prose figure of "32" (that doc's
// "Rule table organization" section already flags its own count as
// inconsistent between its table and prose — see this repo's design doc,
// same section). Counted directly from each rule-group file's own exported
// `RULES` array: root-existence (4: E002/E003/E004/W001) + state-consistency
// (5: W024/W002/W011/W021/W026) + config-validation (10: W003/E005/W004/
// W008/W016/W012/W013/W014/W015/W022) + phase-structure (4: W005/W023/I001/
// W009) + agent-install (1: W010) + roadmap-disk-consistency (2: W006/W007)
// + worktree-health (3: W020/W017/W027) + milestone-archive-hygiene (2:
// W018/W019) = 31. E001 and the home-directory guard (E010/I010) are
// deliberately NOT rows (design doc, "Two guards that stay OUTSIDE the rule
// table entirely").
describe('RULES', () => {
test('is the full, frozen 31-rule table with every code unique', () => {
assert.equal(Array.isArray(RULES), true);
assert.equal(RULES.length, 31);
const codes = RULES.map((r) => r.code);
assert.equal(new Set(codes).size, codes.length, 'every rule code must be unique');
});
test('every rule carries a code, severity, and check function', () => {
for (const rule of RULES) {
assert.equal(typeof rule.code, 'string');
assert.ok(Object.values(SEVERITY).includes(rule.severity), `${rule.code}: unknown severity ${rule.severity}`);
assert.equal(typeof rule.check, 'function');
}
});
});
// ─── Row 14 — evaluator against an all-clean REAL snapshot ────────────────
describe('evaluateRules (row 14)', () => {
test('evaluateRules(buildPlanningSnapshot(healthyProject)) returns []', (t) => {
const tmpDir = createTempGitProject();
t.after(() => cleanup(tmpDir));
setupHealthyProject(tmpDir);
const snapshot = buildPlanningSnapshot(tmpDir);
const diagnostics = evaluateRules(snapshot);
assert.deepEqual(diagnostics, [], `expected zero diagnostics for a healthy project, got: ${JSON.stringify(diagnostics)}`);
});
});
// ─── Rows 15-16 — applyRepairs against REAL diagnostics from REAL rules ────
describe('applyRepairs — REAL diagnostics (rows 15-16)', () => {
test('row 15: --repair given a real DESTRUCTIVE E004 finding (STATE.md missing) refuses regenerateState; STATE.md stays absent', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
setupHealthyProject(tmpDir);
fs.unlinkSync(path.join(tmpDir, '.planning', 'STATE.md'));
const snapshot = buildPlanningSnapshot(tmpDir);
const diagnostics = evaluateRules(snapshot);
const e004 = diagnostics.find((d) => d.code === 'E004');
assert.ok(e004, `expected E004 when STATE.md is missing, got: ${JSON.stringify(diagnostics)}`);
assert.equal(e004.remedy.action, REMEDY_ACTION.REGENERATE_STATE);
assert.equal(e004.remedy.risk, REMEDY_RISK.DESTRUCTIVE);
const result = applyRepairs(tmpDir, diagnostics, true, false);
assert.ok(!result.applied.includes('E004'), 'E004 must not be applied');
assert.ok(result.refused.includes('E004'), 'E004 must be refused');
assert.equal(
fs.existsSync(path.join(tmpDir, '.planning', 'STATE.md')),
false,
'STATE.md must remain absent — the DESTRUCTIVE remedy is refused, not silently applied',
);
});
test('row 16: --repair given a real NONE-risk W003 finding (config.json missing) applies createConfig, exactly as pre-migration', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
setupHealthyProject(tmpDir);
fs.unlinkSync(path.join(tmpDir, '.planning', 'config.json'));
const snapshot = buildPlanningSnapshot(tmpDir);
const diagnostics = evaluateRules(snapshot);
const w003 = diagnostics.find((d) => d.code === 'W003');
assert.ok(w003, `expected W003 when config.json is missing, got: ${JSON.stringify(diagnostics)}`);
assert.equal(w003.remedy.action, REMEDY_ACTION.CREATE_CONFIG);
assert.equal(w003.remedy.risk, REMEDY_RISK.NONE);
const result = applyRepairs(tmpDir, diagnostics, true, false);
assert.ok(result.applied.includes('W003'), 'W003 must be applied');
assert.ok(!result.refused.includes('W003'), 'W003 must not be refused');
const configPath = path.join(tmpDir, '.planning', 'config.json');
assert.ok(fs.existsSync(configPath), 'config.json should now exist on disk');
const diskConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(diskConfig.model_profile, 'balanced');
});
// Regression: a repair handler that THROWS (caught by applyRepairs's own
// try/catch) must be recorded in `details` with `success: false` and must
// NOT land in `applied` — `applied` means "succeeded", not "attempted".
// Forced here via ADD_NYQUIST_KEY against a config.json that is genuinely
// absent: `runRepairAction`'s `fs.readFileSync(configPath, ...)` throws
// ENOENT.
test('regression: a repair handler that throws is recorded in details with success:false and is NOT pushed to applied', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
setupHealthyProject(tmpDir);
fs.unlinkSync(path.join(tmpDir, '.planning', 'config.json'));
assert.equal(fs.existsSync(path.join(tmpDir, '.planning', 'config.json')), false);
const diagnostics = [fakeDiagnostic('W008', REMEDY_ACTION.ADD_NYQUIST_KEY, REMEDY_RISK.NONE)];
const result = applyRepairs(tmpDir, diagnostics, true, false);
assert.ok(!result.applied.includes('W008'), 'W008 must not be applied — the handler threw');
assert.ok(!result.refused.includes('W008'), 'a thrown handler is not a DESTRUCTIVE-risk refusal either');
const detail = result.details.find((d) => d.code === 'W008');
assert.ok(detail, 'a details row must still be recorded for the failed attempt');
assert.equal(detail.success, false);
assert.ok(detail.error, 'the details row must carry the thrown error message');
});
});

View File

@@ -1075,7 +1075,12 @@ describe('W023 — colliding phase directories (issue #2408)', () => {
fs.mkdirSync(realDir, { recursive: true });
fs.writeFileSync(path.join(realDir, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(realDir, '01-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(realDir, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
// Real convention is `*-VERIFICATION.md` (gsd-core/workflows/verify-work.md:573's
// `ls "${PHASE_DIR}"/*-VERIFICATION.md` glob; verification.cts:425's
// `readVerificationStatus` matches the same `-VERIFICATION.md` suffix) — a bare
// `VERIFICATION.md` with no prefix is never produced by /gsd-verify-work and is
// invisible to the canonical reader `isPhaseComplete` now sources this rule from.
fs.writeFileSync(path.join(realDir, '05-real-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
// 05-real-stray/ — empty → Not Started
fs.mkdirSync(path.join(phasesDir, '05-real-stray'), { recursive: true });

View File

@@ -20,7 +20,7 @@ const MANIFEST_PATH = path.join(ROOT, 'docs', 'INVENTORY-MANIFEST.json');
// a family added to the generator but not here left this test silently verifying a
// subset while still reporting green. Importing makes divergence impossible rather than
// merely detectable.
const { FAMILIES, NESTED_FAMILIES, collectNested } = require('../scripts/gen-inventory-manifest.cjs');
const { FAMILIES, NESTED_FAMILIES, collectNested, collectOneLevelSubdirs } = require('../scripts/gen-inventory-manifest.cjs');
test('docs/INVENTORY-MANIFEST.json matches the filesystem', () => {
const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
@@ -28,11 +28,14 @@ test('docs/INVENTORY-MANIFEST.json matches the filesystem', () => {
const removals = [];
for (const { name, dir, filter, toName } of FAMILIES) {
const live = new Set(
fs.readdirSync(dir)
.filter((f) => fs.statSync(path.join(dir, f)).isFile() && filter(f))
.map(toName),
);
const flat = fs.readdirSync(dir)
.filter((f) => fs.statSync(path.join(dir, f)).isFile() && filter(f))
.map(toName);
// `cli_modules` also ships one level of subdirectory modules (#3309); mirror
// buildManifest's special-case merge exactly, or this test would report every
// subdirectory file as a phantom removal.
const nested = name === 'cli_modules' ? collectOneLevelSubdirs({ dir, filter }) : [];
const live = new Set([...flat, ...nested]);
const recorded = new Set((committed.families || {})[name] || []);
for (const entry of live) {

View File

@@ -18,10 +18,11 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { collectNested } = require('../scripts/gen-inventory-manifest.cjs');
const { collectNested, collectOneLevelSubdirs } = require('../scripts/gen-inventory-manifest.cjs');
const { cleanup } = require('./helpers.cjs');
const MD_ONLY = (f) => f.endsWith('.md');
const CJS_ONLY = (f) => f.endsWith('.cjs');
/** Build a fixture tree: {parentName: {subdirName: [fileNames]}}. */
function buildTree(spec) {
@@ -174,3 +175,104 @@ test('collectNested is deterministic and sorted', (t) => {
assert.deepStrictEqual(first, second);
assert.deepStrictEqual(first, ['alpha/steps/c.md', 'zeta/steps/a.md', 'zeta/steps/b.md']);
});
// ─── collectOneLevelSubdirs — cli_modules one-level subdir scan (#3309) ───────
//
// Unlike collectNested's FIXED subdir name (many parents, one shared child-dir
// name like `steps`), this helper's `dir` IS the parent, and every one of ITS
// child directories is the thing being collected — e.g. `bin/lib/health-diagnostic-rules/`.
/** Build a fixture tree directly under `dir`: {subdirName: [fileNames]} \| flat file list. */
function buildSubdirTree(dir, spec) {
for (const [subdir, files] of Object.entries(spec)) {
const subdirPath = path.join(dir, subdir);
fs.mkdirSync(subdirPath, { recursive: true });
for (const f of files) fs.writeFileSync(path.join(subdirPath, f), '// fixture\n');
}
}
test('collectOneLevelSubdirs picks up a file inside a subdirectory', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-'));
t.after(() => cleanup(dir));
buildSubdirTree(dir, { 'health-diagnostic-rules': ['root-existence.cjs'] });
assert.deepStrictEqual(
collectOneLevelSubdirs({ dir, filter: CJS_ONLY }),
['health-diagnostic-rules/root-existence.cjs'],
);
});
test('collectOneLevelSubdirs merges multiple subdirectories, sorted', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-'));
t.after(() => cleanup(dir));
buildSubdirTree(dir, {
observability: ['redaction.cjs', 'event.cjs'],
'installer-migrations': ['001-x.cjs'],
});
assert.deepStrictEqual(
collectOneLevelSubdirs({ dir, filter: CJS_ONLY }),
['installer-migrations/001-x.cjs', 'observability/event.cjs', 'observability/redaction.cjs'],
);
});
test('collectOneLevelSubdirs contributes nothing for an empty subdirectory', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-'));
t.after(() => cleanup(dir));
fs.mkdirSync(path.join(dir, 'empty-subdir'), { recursive: true });
assert.deepStrictEqual(collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), []);
});
test('collectOneLevelSubdirs ignores files directly in dir (flat scan owns those)', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-'));
t.after(() => cleanup(dir));
fs.writeFileSync(path.join(dir, 'top-level.cjs'), '// fixture\n');
buildSubdirTree(dir, { subdir: ['nested.cjs'] });
assert.deepStrictEqual(collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), ['subdir/nested.cjs']);
});
test('collectOneLevelSubdirs applies the filter and ignores non-matching files', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-'));
t.after(() => cleanup(dir));
buildSubdirTree(dir, { subdir: ['keep.cjs', 'skip.md', 'skip.json'] });
assert.deepStrictEqual(collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), ['subdir/keep.cjs']);
});
test('collectOneLevelSubdirs does not recurse past one level', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-'));
t.after(() => cleanup(dir));
buildSubdirTree(dir, { subdir: ['top.cjs'] });
const deep = path.join(dir, 'subdir', 'deeper');
fs.mkdirSync(deep, { recursive: true });
fs.writeFileSync(path.join(deep, 'too-deep.cjs'), '// fixture\n');
assert.deepStrictEqual(collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), ['subdir/top.cjs']);
});
test('collectOneLevelSubdirs on a missing dir contributes nothing rather than throwing', () => {
assert.deepStrictEqual(
collectOneLevelSubdirs({ dir: path.join(os.tmpdir(), 'gsd-3309-does-not-exist'), filter: CJS_ONLY }),
[],
);
});
test('collectOneLevelSubdirs skips a dangling symlink without crashing the walk', (t) => {
if (process.platform === 'win32') {
t.skip('symlink creation requires elevation on Windows; the unstattable-entry path is asserted on macOS + Linux');
return;
}
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-'));
t.after(() => cleanup(dir));
buildSubdirTree(dir, { subdir: ['real.cjs'] });
fs.symlinkSync(path.join(dir, 'subdir', 'nope.cjs'), path.join(dir, 'subdir', 'dangling.cjs'));
assert.deepStrictEqual(
collectOneLevelSubdirs({ dir, filter: CJS_ONLY }),
['subdir/real.cjs'],
'one unreadable entry must not take down the whole scan',
);
});

View File

@@ -0,0 +1,234 @@
'use strict';
/**
* Tests for `scripts/lint-health-diagnostic-rule-table.cjs` — the guard
* enforcing ADR-3180 §8.2's 1:1 rule-code invariant and §8.5's fixture-proof
* invariant for `src/health-diagnostic.cts`'s RULES table (Phase 11, #3309).
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
* ("The lint guard (§8.2 1:1 invariant + §8.5 fixture proof)").
*/
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 guard = require('../scripts/lint-health-diagnostic-rule-table.cjs');
const {
checkOneToOneInvariant,
checkFixtureProofInvariant,
findHealthDiagnosticTestFiles,
PERMANENTLY_INERT_CODES,
} = guard;
const FAKE_SEVERITY = Object.freeze({ ERROR: 'error', WARNING: 'warning', INFO: 'info' });
function writeTempTestFile(dir, name, content) {
const full = path.join(dir, name);
fs.writeFileSync(full, content);
return full;
}
// ─── Check 1 — §8.2 rule 1: 1:1 code invariant ─────────────────────────────
describe('checkOneToOneInvariant (§8.2 rule 1)', () => {
test('flags a duplicated code', () => {
const rules = [
{ code: 'W001', severity: FAKE_SEVERITY.WARNING },
{ code: 'W002', severity: FAKE_SEVERITY.WARNING },
{ code: 'W001', severity: FAKE_SEVERITY.WARNING },
];
const { duplicates, badSeverities } = checkOneToOneInvariant(rules, FAKE_SEVERITY);
assert.deepEqual(duplicates, [{ code: 'W001', count: 2 }]);
assert.deepEqual(badSeverities, []);
});
test('passes when every code is unique', () => {
const rules = [
{ code: 'W001', severity: FAKE_SEVERITY.WARNING },
{ code: 'W002', severity: FAKE_SEVERITY.ERROR },
{ code: 'W003', severity: FAKE_SEVERITY.INFO },
];
const { duplicates, badSeverities } = checkOneToOneInvariant(rules, FAKE_SEVERITY);
assert.deepEqual(duplicates, []);
assert.deepEqual(badSeverities, []);
});
test('flags a severity that is not a member of SEVERITY (hand-edited artifact)', () => {
const rules = [
{ code: 'W001', severity: 'critical' },
{ code: 'W002', severity: FAKE_SEVERITY.WARNING },
];
const { duplicates, badSeverities } = checkOneToOneInvariant(rules, FAKE_SEVERITY);
assert.deepEqual(duplicates, []);
assert.deepEqual(badSeverities, [{ code: 'W001', severity: 'critical' }]);
});
});
// ─── Check 2 — §8.5: fixture-proof invariant ───────────────────────────────
describe('checkFixtureProofInvariant (§8.5)', () => {
test('flags a code with zero mentions anywhere in the scanned test files', (t) => {
const dir = createTempDir('gsd-lint-hd-rt-nomention-');
t.after(() => cleanup(dir));
const file = writeTempTestFile(
dir,
'fake.test.cjs',
"describe('W001 — something', () => { test('fires', () => {}); });\n",
);
const rules = [{ code: 'W001' }, { code: 'W999' }];
const { uncovered } = checkFixtureProofInvariant(rules, [file]);
assert.deepEqual(uncovered, ['W999']);
});
test('flags a code mentioned only in a comment/string outside any describe/test title', (t) => {
const dir = createTempDir('gsd-lint-hd-rt-comment-only-');
t.after(() => cleanup(dir));
const file = writeTempTestFile(
dir,
'fake.test.cjs',
[
"// W002 is handled elsewhere, see notes",
"const message = 'refers to W002 in a plain string, not a block title';",
"describe('unrelated block', () => { test('does something', () => {}); });",
'',
].join('\n'),
);
const rules = [{ code: 'W002' }];
const { uncovered } = checkFixtureProofInvariant(rules, [file]);
assert.deepEqual(uncovered, ['W002']);
});
test('passes a code named in a describe() block title', (t) => {
const dir = createTempDir('gsd-lint-hd-rt-titled-');
t.after(() => cleanup(dir));
const file = writeTempTestFile(
dir,
'fake.test.cjs',
"describe('W003 — some finding', () => { test('fires when absent', () => {}); });\n",
);
const rules = [{ code: 'W003' }];
const { uncovered } = checkFixtureProofInvariant(rules, [file]);
assert.deepEqual(uncovered, []);
});
test('passes a code named in a test()-only title (no wrapping describe)', (t) => {
const dir = createTempDir('gsd-lint-hd-rt-test-only-');
t.after(() => cleanup(dir));
const file = writeTempTestFile(
dir,
'fake.test.cjs',
"test('W010 fires on incomplete agent install', () => {});\n",
);
const rules = [{ code: 'W010' }];
const { uncovered } = checkFixtureProofInvariant(rules, [file]);
assert.deepEqual(uncovered, []);
});
test('passes for a real code (W001) against the real tests/ tree', () => {
const testFiles = findHealthDiagnosticTestFiles();
assert.ok(testFiles.length > 0, 'expected at least one health-diagnostic test file on disk');
const { uncovered } = checkFixtureProofInvariant([{ code: 'W001' }], testFiles);
assert.deepEqual(uncovered, []);
});
});
// ─── Check 2b — §8.5 EXCEPTION: PERMANENTLY_INERT_CODES ────────────────────
//
// A code whose `check` is a documented permanent no-op (W024 — see
// `scripts/lint-health-diagnostic-rule-table.cjs`'s own `PERMANENTLY_INERT_CODES`
// comment) can never satisfy a real fixture-proof. It must be reported as
// `exempted`, separately from genuinely-covered codes, and must NEVER land in
// `uncovered` — regardless of whether any test file happens to mention it.
describe('checkFixtureProofInvariant — PERMANENTLY_INERT_CODES exemption (§8.5 exception)', () => {
test('an exempted code with ZERO test coverage anywhere still passes (not uncovered), and is reported as exempted', (t) => {
const dir = createTempDir('gsd-lint-hd-rt-exempt-nomention-');
t.after(() => cleanup(dir));
const file = writeTempTestFile(dir, 'fake.test.cjs', "describe('unrelated', () => {});\n");
const rules = [{ code: 'W024' }];
const inertCodes = new Map([['W024', 'permanent no-op, real check lives outside the rule table']]);
const { uncovered, exempted } = checkFixtureProofInvariant(rules, [file], inertCodes);
assert.deepEqual(uncovered, [], 'an exempted code must never be reported as uncovered');
assert.deepEqual(exempted, ['W024']);
});
test('a code NOT in the exemption map, with zero test coverage, still fails as uncovered', (t) => {
const dir = createTempDir('gsd-lint-hd-rt-not-exempt-');
t.after(() => cleanup(dir));
const file = writeTempTestFile(dir, 'fake.test.cjs', "describe('unrelated', () => {});\n");
const rules = [{ code: 'W998' }];
const inertCodes = new Map([['W024', 'permanent no-op']]); // W998 is NOT in this map
const { uncovered, exempted } = checkFixtureProofInvariant(rules, [file], inertCodes);
assert.deepEqual(uncovered, ['W998'], 'a non-exempted, uncovered code must still fail the guard');
assert.deepEqual(exempted, []);
});
test('an exempted code is reported as exempted even when a test file DOES happen to mention it in a titled block', (t) => {
const dir = createTempDir('gsd-lint-hd-rt-exempt-mentioned-');
t.after(() => cleanup(dir));
const file = writeTempTestFile(
dir,
'fake.test.cjs',
"test('exports exactly 1 rule: W024', () => {});\n",
);
const rules = [{ code: 'W024' }];
const inertCodes = new Map([['W024', 'permanent no-op']]);
const { uncovered, exempted } = checkFixtureProofInvariant(rules, [file], inertCodes);
assert.deepEqual(uncovered, []);
assert.deepEqual(exempted, ['W024'], 'must be classified as exempted, not folded into ordinary coverage');
});
test('W024 is exempted (not uncovered, not silently "covered") against the real tests/ tree and the real PERMANENTLY_INERT_CODES map', () => {
const testFiles = findHealthDiagnosticTestFiles();
const { uncovered, exempted } = checkFixtureProofInvariant([{ code: 'W024' }], testFiles);
assert.deepEqual(uncovered, []);
assert.deepEqual(exempted, ['W024']);
});
test('PERMANENTLY_INERT_CODES locks exactly W024 with a non-empty, auditable reason', () => {
assert.deepEqual([...PERMANENTLY_INERT_CODES.keys()], ['W024']);
const reason = PERMANENTLY_INERT_CODES.get('W024');
assert.equal(typeof reason, 'string');
assert.ok(reason.length > 0);
assert.ok(/ambient I\/O|§8\.1/i.test(reason), 'reason should explain the §8.1 rule 1 constraint');
});
});
// ─── findHealthDiagnosticTestFiles ─────────────────────────────────────────
describe('findHealthDiagnosticTestFiles', () => {
test('finds every *.test.cjs under tests/health-diagnostic-rules/ plus tests/health-diagnostic.test.cjs', () => {
const files = findHealthDiagnosticTestFiles();
assert.ok(files.some((f) => f.endsWith('root-existence.test.cjs')));
assert.ok(files.some((f) => f.endsWith('state-consistency.test.cjs')));
assert.ok(files.some((f) => f.endsWith(path.join('tests', 'health-diagnostic.test.cjs'))));
});
});

View File

@@ -439,14 +439,17 @@ describe('#2528 consumer parity — the eight sites migrated to matchPhaseDirs',
});
test(`${name} — roadmap-driven consumers`, () => {
// 5. validate health, W021: STATE must claim the milestone is done for
// the roadmap-vs-disk consistency check to run at all.
// 5. validate health, W026 (Phase 11, #3309 — split off the
// pre-migration 'W021' site for this exact subject; the OTHER W021
// subject, phase_id_convention mismatch, kept its code): STATE must
// claim the milestone is done for the roadmap-vs-disk consistency
// check to run at all.
const health = json('validate health', project(dirs, query, 'milestone complete'));
const w021 = health.warnings.filter((w) => w.code === 'W021');
const w026 = health.warnings.filter((w) => w.code === 'W026');
assert.strictEqual(
w021.length > 0,
w026.length > 0,
!resolves,
`W021 disagreed on whether Phase ${query} is started: ${JSON.stringify(w021)}`,
`W026 disagreed on whether Phase ${query} is started: ${JSON.stringify(w026)}`,
);
const tmpDir = project(dirs, query);

View File

@@ -26,6 +26,7 @@ const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const childProcess = require('node:child_process');
const fc = require('fast-check');
const { createTempDir, cleanup } = require('./helpers.cjs');
@@ -43,6 +44,12 @@ const { worstScope } = planningSnapshotLib;
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
const { _unusableInputEmissionCountForTests } = require('../gsd-core/bin/lib/unusable-input.cjs');
// Phase 11 (#3309) additions — agent-install fixture helper mirrors
// tests/agent-install-check.test.cjs's own EXPECTED_AGENTS/createCompleteAgents
// (design doc's "subject-surface gap" §, reused per its provenance rule).
const { MODEL_PROFILES } = require('../gsd-core/bin/lib/model-profiles.cjs');
const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES);
// ─── Fixture helpers (mirrors tests/completion-ratio-scope-withholding.test.cjs) ─
function planningDirOf(cwd) {
@@ -495,3 +502,591 @@ describe('worstScope — pure unit coverage', () => {
);
});
});
// ═════════════════════════════════════════════════════════════════════════
// Phase 11 (#3309) additions — config / agentInstall / worktreeHealth
//
// Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
// ("The subject-surface gap: config.json, agent-install, git-worktree-list")
// Test matrix: .gsd/phase/refactor-3309-health-diagnostic-rule-table/50-test-matrix.md
// section 1, rows 1-8
//
// These three new fields wrap the SAME owner calls `cmdValidateHealth`
// (src/verify.cts) already makes (`checkAgentsInstalled`, `inspectWorktreeHealth`,
// a raw config.json read), so a later phase step can migrate the caller onto
// this snapshot without a shape mismatch.
// ═════════════════════════════════════════════════════════════════════════
function writeConfig(cwd, obj) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'config.json'), JSON.stringify(obj));
}
function writeRawConfig(cwd, rawText) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'config.json'), rawText);
}
// Env isolation for GSD_AGENTS_DIR — mirrors tests/agent-install-check.test.cjs's
// beforeEach/afterEach save-restore idiom, inlined per-test via t.after since this
// describe block does not otherwise need beforeEach/afterEach hooks.
function withAgentsDirOverride(t, agentsDir) {
const saved = process.env['GSD_AGENTS_DIR'];
process.env['GSD_AGENTS_DIR'] = agentsDir;
t.after(() => {
if (saved === undefined) delete process.env['GSD_AGENTS_DIR'];
else process.env['GSD_AGENTS_DIR'] = saved;
});
}
function createCompleteAgentsDir(agentsDir) {
fs.mkdirSync(agentsDir, { recursive: true });
for (const agent of EXPECTED_AGENTS) {
fs.writeFileSync(path.join(agentsDir, `${agent}.toml`), `name = "${agent}"\n`);
}
}
// Simulates a successful `git worktree list --porcelain` at the spawnSync seam —
// mirrors tests/worktree-safety.test.cjs's "execGitDefault (real spawn seam)"
// section, the repo's convention for driving the real execGit rather than a
// hand-set deps.execGit stub (this module accepts no deps parameter to inject).
function mockGitWorktreeListOk(t, porcelain) {
t.mock.method(childProcess, 'spawnSync', () => ({
status: 0,
stdout: porcelain,
stderr: '',
signal: null,
error: null,
}));
}
// Simulates a timed-out `git worktree list --porcelain` (ETIMEDOUT), the same
// shape shell-command-projection.cjs's execGit / isSpawnTimeout recognize.
function mockGitWorktreeListTimeout(t) {
t.mock.method(childProcess, 'spawnSync', () => ({
status: null,
stdout: '',
stderr: '',
signal: null,
error: Object.assign(new Error('spawnSync git ETIMEDOUT'), { code: 'ETIMEDOUT' }),
}));
}
describe('config field (Phase 11, #3309, matrix rows 1-3)', () => {
test('row 1: well-formed config.json parses to {value, scope: COMPLETE}', (t) => {
const cwd = createTempDir('gsd-3309-cfg1-');
t.after(() => cleanup(cwd));
writeConfig(cwd, { model_profile: 'balanced' });
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.config, { value: { model_profile: 'balanced' }, scope: SCOPE.COMPLETE, exists: true });
});
test('row 2: absent config.json is a real non-answer — {value: null, scope: UNREADABLE, exists: false}', (t) => {
const cwd = createTempDir('gsd-3309-cfg2-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
// No config.json written at all.
const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd));
assert.deepStrictEqual(snap.config, { value: null, scope: SCOPE.UNREADABLE, exists: false });
assert.strictEqual(emitted, 0, 'absence is not corruption — no diagnostic');
});
test('row 3: present-but-unparseable config.json degrades without throwing — {value: null, scope: UNREADABLE, exists: true}, emits CONFIG_UNREADABLE exactly once', (t) => {
const cwd = createTempDir('gsd-3309-cfg3-');
t.after(() => cleanup(cwd));
writeRawConfig(cwd, '{ not valid json');
let snap;
let emitted;
assert.doesNotThrow(() => {
[snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd));
});
assert.deepStrictEqual(snap.config, { value: null, scope: SCOPE.UNREADABLE, exists: true });
assert.strictEqual(emitted, 1, 'present-but-unparseable config.json is corruption — exactly one CONFIG_UNREADABLE diagnostic');
});
});
describe('agentInstall field (Phase 11, #3309, matrix rows 4-5)', () => {
test('row 4: all agents present reports zero missing/incomplete, scope COMPLETE', (t) => {
const cwd = createTempDir('gsd-3309-agt4-');
t.after(() => cleanup(cwd));
const agentsDir = path.join(cwd, 'agents-complete');
createCompleteAgentsDir(agentsDir);
withAgentsDirOverride(t, agentsDir);
const snap = buildPlanningSnapshot(cwd);
assert.strictEqual(snap.agentInstall.scope, SCOPE.COMPLETE);
assert.strictEqual(snap.agentInstall.value.agents_installed, true);
assert.deepStrictEqual(snap.agentInstall.value.missing_agents, []);
assert.deepStrictEqual(snap.agentInstall.value.incomplete_agents, []);
});
test('row 5: missing agents dir reports the full missing set, scope COMPLETE (the scan itself succeeded)', (t) => {
const cwd = createTempDir('gsd-3309-agt5-');
t.after(() => cleanup(cwd));
const agentsDir = path.join(cwd, 'agents-absent');
withAgentsDirOverride(t, agentsDir);
// agentsDir deliberately never created.
const snap = buildPlanningSnapshot(cwd);
assert.strictEqual(snap.agentInstall.scope, SCOPE.COMPLETE);
assert.strictEqual(snap.agentInstall.value.agents_installed, false);
assert.deepStrictEqual(snap.agentInstall.value.missing_agents.slice().sort(), EXPECTED_AGENTS.slice().sort());
});
});
describe('worktreeHealth field (Phase 11, #3309, matrix rows 6-7)', () => {
test('row 6: git worktree list succeeds — value is the parsed findings array, scope COMPLETE', (t) => {
const cwd = createTempDir('gsd-3309-wt6-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
mockGitWorktreeListOk(t, 'worktree /repo\nHEAD 0000000000000000000000000000000000000000\nbranch refs/heads/main\n\n');
const snap = buildPlanningSnapshot(cwd);
assert.strictEqual(snap.worktreeHealth.scope, SCOPE.COMPLETE);
assert.ok(Array.isArray(snap.worktreeHealth.value));
});
test('row 7: git worktree list times out — scope reflects degradation (mirrors W020)', (t) => {
const cwd = createTempDir('gsd-3309-wt7-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
mockGitWorktreeListTimeout(t);
const snap = buildPlanningSnapshot(cwd);
assert.notStrictEqual(snap.worktreeHealth.scope, SCOPE.COMPLETE);
assert.strictEqual(snap.worktreeHealth.scope, SCOPE.UNREADABLE);
assert.deepStrictEqual(snap.worktreeHealth.value, []);
});
});
describe('Phase-10 fields unchanged by the Phase-11 extension (matrix row 8)', () => {
test('the four original fields keep their exact pre-extension values on the same fixture', (t) => {
const cwd = createTempDir('gsd-3309-reg8-');
t.after(() => cleanup(cwd));
buildHealthyTwoPhaseFixture(cwd);
const snap = buildPlanningSnapshot(cwd);
assert.strictEqual(snap.milestone.scope, SCOPE.COMPLETE);
assert.strictEqual(snap.phaseDirs.scope, SCOPE.COMPLETE);
assert.strictEqual(snap.phases.scope, SCOPE.COMPLETE);
assert.strictEqual(snap.phases.value.length, 2);
for (const p of snap.phases.value) {
assert.strictEqual(p.complete, true);
assert.strictEqual(p.scope, SCOPE.COMPLETE);
assert.strictEqual(p.verificationStatus, 'passed');
assert.strictEqual(p.planCount, 1);
assert.strictEqual(p.summaryCount, 1);
}
// The extension is additive — the new fields must be present alongside
// the untouched originals, not in place of them.
assert.ok('config' in snap);
assert.ok('agentInstall' in snap);
assert.ok('worktreeHealth' in snap);
});
});
// ═════════════════════════════════════════════════════════════════════════
// Phase 11 (#3309) — "Rule table organization" batch, 7 more fields
//
// Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
// ("Rule table organization" table)
//
// Each field relocates (not reinvents) an existing verify.cts derivation —
// see the JSDoc above each builder in src/planning-snapshot.cts for the
// exact source lines. Fixture helpers below mirror the existing
// writeRoadmap/writeState/writeFile idiom.
// ═════════════════════════════════════════════════════════════════════════
function writeProject(cwd, content) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'PROJECT.md'), content);
}
function writeMilestoneArchiveRoadmap(cwd, version, content) {
const archiveDir = path.join(planningDirOf(cwd), 'milestones');
fs.mkdirSync(archiveDir, { recursive: true });
fs.writeFileSync(path.join(archiveDir, `${version}-ROADMAP.md`), content);
}
function writeMilestonesRegistry(cwd, content) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'MILESTONES.md'), content);
}
describe('projectSections field (Phase 11, #3309)', () => {
test('happy: returns every ## heading actually present, unfiltered against any required list', (t) => {
const cwd = createTempDir('gsd-3309-ps1-');
t.after(() => cleanup(cwd));
writeProject(cwd, [
'# My Project',
'',
'## What This Is',
'',
'text',
'',
'## Custom Section',
'',
'### Not a top-level heading',
].join('\n'));
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.projectSections, {
value: ['What This Is', 'Custom Section'],
scope: SCOPE.COMPLETE,
exists: true,
});
});
test('absence: no PROJECT.md is a real non-answer, not corruption', (t) => {
const cwd = createTempDir('gsd-3309-ps2-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd));
assert.deepStrictEqual(snap.projectSections, { value: null, scope: SCOPE.UNREADABLE, exists: false });
assert.strictEqual(emitted, 0);
});
test('hostile: present-but-unreadable PROJECT.md degrades without throwing, emits PROJECT_UNREADABLE exactly once', (t) => {
const cwd = createTempDir('gsd-3309-ps3-');
t.after(() => cleanup(cwd));
makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'PROJECT.md'));
const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd));
assert.deepStrictEqual(snap.projectSections, { value: null, scope: SCOPE.UNREADABLE, exists: true });
assert.strictEqual(emitted, 1, 'present-but-unreadable PROJECT.md is corruption — exactly one PROJECT_UNREADABLE diagnostic');
});
});
describe('statePhaseTokens field (Phase 11, #3309)', () => {
test('happy: every phase-number-shaped token anywhere in STATE.md text, in appearance order', (t) => {
const cwd = createTempDir('gsd-3309-spt1-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
// The `Phase:`-field syntax ("Phase: 3 of 8") does NOT match this regex —
// it requires `[Pp]hase\s+<digits>` (whitespace, not a colon, right
// after "Phase"), exactly like verify.cts's own W002 relocation target.
// Only prose-style "Phase N" references match, e.g. bracketed decision
// annotations and free-text mentions.
appendToState(cwd, [
'',
'## Current Position',
'',
'Phase: 3 of 8 (User Auth)',
'',
'### Decisions',
'- [Phase 5]: revisit after Phase 2 wraps',
].join('\n'));
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.statePhaseTokens, { value: ['5', '2'], scope: SCOPE.COMPLETE });
});
test('absence: no STATE.md yields an empty token list, non-answer scope, no diagnostic', (t) => {
const cwd = createTempDir('gsd-3309-spt2-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd));
assert.deepStrictEqual(snap.statePhaseTokens, { value: [], scope: SCOPE.UNREADABLE });
assert.strictEqual(emitted, 0);
});
test('hostile: unreadable-but-present STATE.md degrades statePhaseTokens together with currentPhaseLabel from ONE diagnostic', (t) => {
const cwd = createTempDir('gsd-3309-spt3-');
t.after(() => cleanup(cwd));
makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'STATE.md'));
const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd));
assert.deepStrictEqual(snap.statePhaseTokens, { value: [], scope: SCOPE.UNREADABLE });
assert.deepStrictEqual(snap.currentPhaseLabel, { value: null, scope: SCOPE.UNREADABLE });
assert.strictEqual(emitted, 1, 'the shared STATE.md read must not double-emit across fields');
});
});
describe('stateStatus field (Phase 11, #3309)', () => {
test('happy: Status field under Current Position is extracted verbatim, mirroring currentPhaseLabel', (t) => {
const cwd = createTempDir('gsd-3309-ss1-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
appendToState(cwd, [
'',
'## Current Position',
'',
'Phase: 3 of 8 (User Auth)',
'Status: In progress',
'',
].join('\n'));
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.stateStatus, { value: 'In progress', scope: SCOPE.COMPLETE });
});
test('boundary: missing Current Position section still resolves status from frontmatter, scope TRUNCATED', (t) => {
const cwd = createTempDir('gsd-3309-ss2-');
t.after(() => cleanup(cwd));
writeState(cwd, { status: 'planning' });
const snap = buildPlanningSnapshot(cwd);
assert.strictEqual(snap.stateStatus.value, 'planning');
assert.strictEqual(snap.stateStatus.scope, SCOPE.TRUNCATED);
});
test('absence: no STATE.md yields a non-answer, no diagnostic', (t) => {
const cwd = createTempDir('gsd-3309-ss3-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd));
assert.deepStrictEqual(snap.stateStatus, { value: null, scope: SCOPE.UNREADABLE });
assert.strictEqual(emitted, 0);
});
});
describe('roadmapDeclaredPhases field (Phase 11, #3309)', () => {
test('happy: every declared phase id paired with the milestone section it was found under', (t) => {
const cwd = createTempDir('gsd-3309-rdp1-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n'));
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.roadmapDeclaredPhases, {
value: [
{ phaseId: '1', milestone: 'v1.0' },
{ phaseId: '2', milestone: 'v1.0' },
],
scope: SCOPE.COMPLETE,
});
});
test('boundary: a phase declared before any version heading gets milestone: null', (t) => {
const cwd = createTempDir('gsd-3309-rdp2-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['### Phase 9: Prelude', '', '## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
const snap = buildPlanningSnapshot(cwd);
const prelude = snap.roadmapDeclaredPhases.value.find((p) => p.phaseId === '9');
const foo = snap.roadmapDeclaredPhases.value.find((p) => p.phaseId === '1');
assert.deepStrictEqual(prelude, { phaseId: '9', milestone: null });
assert.deepStrictEqual(foo, { phaseId: '1', milestone: 'v1.0' });
});
test('absence: no ROADMAP.md is a non-answer', (t) => {
const cwd = createTempDir('gsd-3309-rdp3-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.roadmapDeclaredPhases, { value: [], scope: SCOPE.UNREADABLE });
});
test('hostile: unreadable ROADMAP.md degrades to an empty list, scope UNREADABLE', (t) => {
const cwd = createTempDir('gsd-3309-rdp4-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'ROADMAP.md'));
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.roadmapDeclaredPhases, { value: [], scope: SCOPE.UNREADABLE });
});
});
describe('roadmapPhaseCheckboxes field (Phase 11, #3309)', () => {
test('happy: [x]/[ ] checkbox state parsed per phase id', (t) => {
const cwd = createTempDir('gsd-3309-rpc1-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## Progress', '', '- [x] Phase 1: Foo', '- [ ] Phase 2: Bar'].join('\n'));
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.roadmapPhaseCheckboxes, { value: { '1': true, '2': false }, scope: SCOPE.COMPLETE });
});
test('boundary: no checklist lines present is a real empty answer, not a non-answer', (t) => {
const cwd = createTempDir('gsd-3309-rpc2-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.roadmapPhaseCheckboxes, { value: {}, scope: SCOPE.COMPLETE });
});
test('hostile: unreadable ROADMAP.md degrades to an empty map, scope UNREADABLE', (t) => {
const cwd = createTempDir('gsd-3309-rpc3-');
t.after(() => cleanup(cwd));
makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'ROADMAP.md'));
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.roadmapPhaseCheckboxes, { value: {}, scope: SCOPE.UNREADABLE });
});
});
describe('researchValidationStatus field (Phase 11, #3309)', () => {
test('happy: RESEARCH.md carries the Validation Architecture heading and a VALIDATION.md exists', (t) => {
const cwd = createTempDir('gsd-3309-rvs1-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
writeFile(cwd, '.planning/phases/01-foo/01-RESEARCH.md', '# Research\n\n## Validation Architecture\n\ntext\n');
writeFile(cwd, '.planning/phases/01-foo/01-VALIDATION.md', '# Validation\n');
const snap = buildPlanningSnapshot(cwd);
const entry = snap.researchValidationStatus.value.find((r) => r.dir === '01-foo');
assert.deepStrictEqual(entry, { dir: '01-foo', hasValidationArchitecture: true, hasValidationMd: true });
assert.strictEqual(snap.researchValidationStatus.scope, SCOPE.COMPLETE);
});
test('negative: RESEARCH.md without the heading and no VALIDATION.md reports both false', (t) => {
const cwd = createTempDir('gsd-3309-rvs2-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
writeFile(cwd, '.planning/phases/01-foo/01-RESEARCH.md', '# Research\n\nno special section\n');
const snap = buildPlanningSnapshot(cwd);
const entry = snap.researchValidationStatus.value.find((r) => r.dir === '01-foo');
assert.deepStrictEqual(entry, { dir: '01-foo', hasValidationArchitecture: false, hasValidationMd: false });
});
test('hostile: an unreadable phase directory degrades that entry to false/false without throwing', (t) => {
const cwd = createTempDir('gsd-3309-rvs3-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
const phaseDir = path.join(planningDirOf(cwd), 'phases', '01-foo');
fs.mkdirSync(phaseDir, { recursive: true });
injectPhaseDirFault(t, phaseDir);
const snap = buildPlanningSnapshot(cwd);
const entry = snap.researchValidationStatus.value.find((r) => r.dir === '01-foo');
assert.deepStrictEqual(entry, { dir: '01-foo', hasValidationArchitecture: false, hasValidationMd: false });
});
});
describe('milestoneArchiveStatus field (Phase 11, #3309)', () => {
test('happy: archived ROADMAP snapshot present and its version documented in MILESTONES.md', (t) => {
const cwd = createTempDir('gsd-3309-mas1-');
t.after(() => cleanup(cwd));
writeMilestoneArchiveRoadmap(cwd, 'v1.0', '# v1.0 archive\n');
writeMilestonesRegistry(cwd, '## v1.0\n\nShipped.\n');
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.milestoneArchiveStatus, {
value: { archivedVersions: ['v1.0'], documentedVersions: ['v1.0'] },
scope: SCOPE.COMPLETE,
});
});
test('negative: no milestones/ dir and no MILESTONES.md is a real empty answer, not a non-answer', (t) => {
const cwd = createTempDir('gsd-3309-mas2-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.milestoneArchiveStatus, {
value: { archivedVersions: [], documentedVersions: [] },
scope: SCOPE.COMPLETE,
});
});
test('boundary: an archived version missing from the registry is reported, not silently dropped', (t) => {
const cwd = createTempDir('gsd-3309-mas3-');
t.after(() => cleanup(cwd));
writeMilestoneArchiveRoadmap(cwd, 'v1.0', '# v1.0 archive\n');
// No MILESTONES.md at all.
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.milestoneArchiveStatus.value.archivedVersions, ['v1.0']);
assert.deepStrictEqual(snap.milestoneArchiveStatus.value.documentedVersions, []);
});
test('hostile: an unreadable milestones/ dir degrades to scope UNREADABLE without throwing', (t) => {
const cwd = createTempDir('gsd-3309-mas4-');
t.after(() => cleanup(cwd));
// Directory-vs-file swap: milestones/ is a regular FILE, so
// fs.existsSync is true but readdirSync throws ENOTDIR.
makeDirUnreadableAsFile(path.join(planningDirOf(cwd), 'milestones'));
const snap = buildPlanningSnapshot(cwd);
assert.strictEqual(snap.milestoneArchiveStatus.scope, SCOPE.UNREADABLE);
});
});
describe('planningRootFiles field (Phase 11, #3309)', () => {
test('happy: lists files (not directories) directly under .planning/ root', (t) => {
const cwd = createTempDir('gsd-3309-prf1-');
t.after(() => cleanup(cwd));
writeState(cwd, { milestone: 'v1.0' });
writeRoadmap(cwd, ['## v1.0 Current 🚧', ''].join('\n'));
writeFile(cwd, '.planning/NOTES.md', 'stray file\n');
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases'), { recursive: true }); // a directory — must be excluded
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.planningRootFiles.value.slice().sort(), ['NOTES.md', 'ROADMAP.md', 'STATE.md']);
assert.strictEqual(snap.planningRootFiles.scope, SCOPE.COMPLETE);
});
test('absence: no .planning/ directory at all degrades to an empty list, scope UNREADABLE', (t) => {
const cwd = createTempDir('gsd-3309-prf2-');
t.after(() => cleanup(cwd));
// .planning/ deliberately never created.
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.planningRootFiles, { value: [], scope: SCOPE.UNREADABLE });
});
test('hostile: an unreadable .planning/ root degrades without throwing', (t) => {
const cwd = createTempDir('gsd-3309-prf3-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
injectPhaseDirFault(t, planningDirOf(cwd));
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.planningRootFiles, { value: [], scope: SCOPE.UNREADABLE });
});
});
describe('allPhaseDirNames field (Phase 11, #3309 — health-diagnostic-rules/roadmap-disk-consistency batch)', () => {
// Found while implementing W007 (`src/health-diagnostic-rules/
// roadmap-disk-consistency.cts`): `phaseDirs` is windowed to directories
// the ROADMAP already declares, so it can never expose a genuine orphan
// directory. `allPhaseDirNames` is the unwindowed twin.
test('happy: lists every directory under phases/, including one NOT declared anywhere in ROADMAP.md', (t) => {
const cwd = createTempDir('gsd-3309-apdn1-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases', '01-foo'), { recursive: true });
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases', '04-extra'), { recursive: true }); // undeclared
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.allPhaseDirNames.value.slice().sort(), ['01-foo', '04-extra']);
assert.strictEqual(snap.allPhaseDirNames.scope, SCOPE.COMPLETE);
// Sanity: `phaseDirs` (windowed) must NOT include the undeclared dir —
// this is the exact gap `allPhaseDirNames` exists to close.
assert.ok(!snap.phaseDirs.value.includes('04-extra'));
});
test('absence: no phases/ directory at all is a real empty, not a failure', (t) => {
const cwd = createTempDir('gsd-3309-apdn2-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', ''].join('\n'));
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.allPhaseDirNames, { value: [], scope: SCOPE.COMPLETE });
});
test('hostile: an unreadable phases/ directory degrades to an empty list, scope UNREADABLE, without throwing', (t) => {
const cwd = createTempDir('gsd-3309-apdn3-');
t.after(() => cleanup(cwd));
const phasesDir = path.join(planningDirOf(cwd), 'phases');
fs.mkdirSync(phasesDir, { recursive: true });
injectPhaseDirFault(t, phasesDir);
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.allPhaseDirNames, { value: [], scope: SCOPE.UNREADABLE });
});
});

View File

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

View File

@@ -1954,11 +1954,19 @@ test('manager.md and autonomous.md no longer contain old "not claude" background
test('W025 is documented consistently across health.md and both config references', () => {
// The rename W020 -> W025 landed in health.md only; the two docs kept
// saying W020, which collides with a code src/verify.cts already emits.
// #3309: health.md's generated `<error_codes>` table now carries a real,
// UNRELATED W020 row of its own (`Worktree health scan degraded` —
// git-worktree-list-inventory failure, #3384/#3652 territory), whose
// description legitimately contains the bare word "worktree" right next
// to "W020". A bare `worktree` probe can no longer tell that apart from
// the stale isolation-check naming this guard exists for, so it narrows
// to the literal config key (`use_worktrees`) the isolation warning is
// actually about — the real W020 row's text never mentions that key.
for (const rel of ['gsd-core/workflows/health.md', 'docs/CONFIGURATION.md', 'gsd-core/references/planning-config.md']) {
const text = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
assert.ok(text.includes('W025'), `${rel}: must document the worktrees warning as W025`);
assert.ok(
!/\bW020\b[^)]{0,80}worktree/i.test(text),
!/\bW020\b[^)]{0,120}use_worktrees/i.test(text),
`${rel}: stale W020 reference for the worktrees warning`,
);
}
@@ -1974,16 +1982,25 @@ test('manager.md and autonomous.md no longer contain old "not claude" background
test('the health.md error-codes table is not broken by the namespace note', () => {
// The note was inserted BETWEEN two rows, which terminates the GFM table
// and orphans the I001 row into literal pipe-delimited text.
// and orphans the trailing row(s) into literal pipe-delimited text.
// #3309: the hand-written "Note: the `W0NN` warning-code namespace..."
// paragraph (and the `W025` row it sat under) is gone — `gen-health-docs.cjs`
// now GENERATES this table from `RULES`, and deliberately excludes W025
// (a workflow-layer diagnostic emitted by this file's own bash step, never
// by `cmdValidateHealth`/`RULES` — see the generator's module header and its
// `FOOTNOTE_PARAGRAPH`, which still names W025 for cross-reference). The
// table's actual last row is now I010, not I001, and the footnote's own
// opening sentence replaces the old namespace note. The hazard this test
// guards — a footnote landing mid-table — still applies to the new content.
const src = readWorkflow('health.md');
const w025 = src.indexOf('| W025 |');
const i001 = src.indexOf('| I001 |');
const note = src.indexOf('Note: the `W0NN` warning-code namespace');
assert.ok(w025 > -1 && i001 > -1 && note > -1, 'health.md: expected W025, I001 and the namespace note');
assert.ok(i001 > w025, 'health.md: I001 row must follow the W025 row');
const i010 = src.indexOf('| I010 |');
const note = src.indexOf('Note: this table is **generated**');
assert.ok(i001 > -1 && i010 > -1 && note > -1, 'health.md: expected I001, I010 and the generated-table note');
assert.ok(i010 > i001, 'health.md: I010 row must follow the I001 row');
assert.ok(
note > i001,
'health.md: the namespace note must come AFTER the final table row — placing it between rows ends the table and orphans I001',
note > i010,
'health.md: the generated-table note must come AFTER the final table row — placing it between rows ends the table and orphans trailing rows',
);
});
});

View File

@@ -72,7 +72,7 @@ describe('UNUSABLE_REASON', () => {
// (enum + call site + this assertion) instead of a silent widening.
assert.deepStrictEqual(
Object.keys(UNUSABLE_REASON).sort(),
['FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'ROADMAP_UNREADABLE', 'STATE_UNREADABLE'],
['CONFIG_UNREADABLE', 'FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'PROJECT_UNREADABLE', 'ROADMAP_UNREADABLE', 'STATE_UNREADABLE'],
);
assert.strictEqual(UNUSABLE_REASON.FRONTMATTER_UNTERMINATED, 'frontmatter_unterminated');
});
@@ -134,6 +134,100 @@ describe('STATE_UNREADABLE', () => {
});
});
// ─── CONFIG_UNREADABLE: a config.json that exists but could not be read/parsed ─
describe('CONFIG_UNREADABLE', () => {
test('a genuinely unreadable config.json produces exactly one diagnostic', () => {
_resetUnusableInputWarningsForTests();
const emitted = emissionsDuring(() => {
const wrote = warnUnusableInput({
reason: UNUSABLE_REASON.CONFIG_UNREADABLE,
source: '/u/config-unreadable.json',
});
assert.strictEqual(wrote, true);
});
assert.strictEqual(emitted, 1);
});
test('the same config.json path reported twice yields one diagnostic', () => {
_resetUnusableInputWarningsForTests();
const source = '/u/config-unreadable-dedup/config.json';
const emitted = emissionsDuring(() => {
const first = warnUnusableInput({ reason: UNUSABLE_REASON.CONFIG_UNREADABLE, source });
const repeat = warnUnusableInput({ reason: UNUSABLE_REASON.CONFIG_UNREADABLE, source });
assert.strictEqual(first, true);
assert.strictEqual(repeat, false, 'same (path, cause) must dedup');
});
assert.strictEqual(emitted, 1);
});
test('two different config.json paths are never suppressed as one', () => {
_resetUnusableInputWarningsForTests();
const emitted = emissionsDuring(() => {
warnUnusableInput({
reason: UNUSABLE_REASON.CONFIG_UNREADABLE,
source: '/u/config-unreadable-a/config.json',
});
warnUnusableInput({
reason: UNUSABLE_REASON.CONFIG_UNREADABLE,
source: '/u/config-unreadable-b/config.json',
});
});
assert.strictEqual(emitted, 2, 'keying too coarsely would hide a real second fault');
});
test('the reason value is the frozen string "config_unreadable"', () => {
assert.strictEqual(UNUSABLE_REASON.CONFIG_UNREADABLE, 'config_unreadable');
});
});
// ─── PROJECT_UNREADABLE: a PROJECT.md that exists but could not be read ──────
describe('PROJECT_UNREADABLE', () => {
test('a genuinely unreadable PROJECT.md produces exactly one diagnostic', () => {
_resetUnusableInputWarningsForTests();
const emitted = emissionsDuring(() => {
const wrote = warnUnusableInput({
reason: UNUSABLE_REASON.PROJECT_UNREADABLE,
source: '/u/project-unreadable.md',
});
assert.strictEqual(wrote, true);
});
assert.strictEqual(emitted, 1);
});
test('the same PROJECT.md path reported twice yields one diagnostic', () => {
_resetUnusableInputWarningsForTests();
const source = '/u/project-unreadable-dedup/PROJECT.md';
const emitted = emissionsDuring(() => {
const first = warnUnusableInput({ reason: UNUSABLE_REASON.PROJECT_UNREADABLE, source });
const repeat = warnUnusableInput({ reason: UNUSABLE_REASON.PROJECT_UNREADABLE, source });
assert.strictEqual(first, true);
assert.strictEqual(repeat, false, 'same (path, cause) must dedup');
});
assert.strictEqual(emitted, 1);
});
test('two different PROJECT.md paths are never suppressed as one', () => {
_resetUnusableInputWarningsForTests();
const emitted = emissionsDuring(() => {
warnUnusableInput({
reason: UNUSABLE_REASON.PROJECT_UNREADABLE,
source: '/u/project-unreadable-a/PROJECT.md',
});
warnUnusableInput({
reason: UNUSABLE_REASON.PROJECT_UNREADABLE,
source: '/u/project-unreadable-b/PROJECT.md',
});
});
assert.strictEqual(emitted, 2, 'keying too coarsely would hide a real second fault');
});
test('the reason value is the frozen string "project_unreadable"', () => {
assert.strictEqual(UNUSABLE_REASON.PROJECT_UNREADABLE, 'project_unreadable');
});
});
// ─── The discriminator: truncated vs. everything that merely looks like it ───
describe('extractFrontmatter — flags a genuinely truncated frontmatter', () => {

View File

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

View File

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

View File

@@ -4526,13 +4526,15 @@ describe('bug #3384: adjacent worktree data-loss guards', () => {
});
test('validate health warns when worktree inventory cannot be listed', () => {
const source = read('gsd-core/bin/lib/verify.cjs');
// Accept both hand-written dot access and the tsc-compiled bracket form
// (ADR-457: verify.cjs is now emitted from src/verify.cts):
// hand-written: worktreeHealth.reason === 'git_list_failed'
// tsc-compiled: worktreeHealth['reason'] === 'git_list_failed'
const failureBranch = source.search(/worktreeHealth(?:\.reason|\['reason'\]) === 'git_list_failed'/);
const warning = source.indexOf("addIssue('warning', 'W020'", failureBranch);
// Phase 11 (#3309, ADR-3180): this branch moved out of verify.cts into
// the W020 rule (src/health-diagnostic-rules/worktree-health.cts),
// compiled to gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs.
// Accept both hand-written dot access and the tsc-compiled bracket form:
// hand-written: reason === 'git_list_failed'
// tsc-compiled: reason === 'git_list_failed' (unchanged shape either way)
const source = read('gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs');
const failureBranch = source.search(/reason === 'git_list_failed'/);
const warning = source.indexOf("code: 'W020'", failureBranch);
assert.ok(failureBranch > 0, 'verify health should branch on git_list_failed');
assert.ok(warning > failureBranch, 'git_list_failed should emit W020 degraded-health warning');