From 6aa378b2619b5b1896f7c5376802af70d5110b6c Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 00:41:39 -0400 Subject: [PATCH] refactor(#3309): extend planning-snapshot.cts with config/agentInstall/worktreeHealth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 11 of epic #3180 (ADR-3180 §8.2/§8.3/§8.5) foundation. Extends the already-merged Phase-10 PlanningSnapshot additively with three fields the upcoming health-diagnostic rule table needs and Phase 10 never required: - config: {value, scope, exists} — parsed .planning/config.json. `exists` distinguishes absent (no diagnostic, non-answer) from present-but-invalid (CONFIG_UNREADABLE diagnostic, corruption) — both collapse to scope UNREADABLE, so a rule needs the extra bit to tell "not configured yet" apart from "config.json is broken." - agentInstall / worktreeHealth — not .planning/-sourced, wrap the existing checkAgentsInstalled/inspectWorktreeHealth owners with the same arguments cmdValidateHealth already passes them, so a later migration step reads these fields instead of calling the owners itself. Adds CONFIG_UNREADABLE to src/unusable-input.cts's UNUSABLE_REASON (eighth #1879 site), mirroring STATE_UNREADABLE's exact shape from Phase 10. Additive only — the four Phase-10 fields and worstScope/buildPhaseSnapshot are unchanged; existing tests for them are untouched. --- src/planning-snapshot.cts | 125 +++++++++++++++++++- src/unusable-input.cts | 9 ++ tests/planning-snapshot.test.cjs | 193 +++++++++++++++++++++++++++++++ tests/unusable-input.test.cjs | 49 +++++++- 4 files changed, 370 insertions(+), 6 deletions(-) diff --git a/src/planning-snapshot.cts b/src/planning-snapshot.cts index b1fcfd7dc..836d6812c 100644 --- a/src/planning-snapshot.cts +++ b/src/planning-snapshot.cts @@ -19,6 +19,7 @@ * 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'); @@ -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,13 @@ 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; // ─── worstScope — the one new piece of coordination logic ─────────────────── @@ -89,6 +97,16 @@ interface PlanningSnapshot { phaseDirs: ReturnType; 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 | null; scope: Scope; exists: boolean }; + agentInstall: { value: ReturnType; scope: Scope }; + worktreeHealth: { value: ReturnType['findings']; scope: Scope }; } /** @@ -153,9 +171,103 @@ function buildCurrentPhaseLabel(statePath: string): { value: string | null; scop } /** - * 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//` instead) — see verify.cts's own + * rootBase-vs-wsBase split at cmdValidateHealth's top. + */ +function buildConfigField(cwd: string): { value: Record | 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; + 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; 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. + */ +function buildWorktreeHealthField(cwd: string): { value: ReturnType['findings']; scope: Scope } { + try { + const result = inspectWorktreeHealth( + cwd, + { staleAfterMs: 60 * 60 * 1000 }, + { execGit, existsSync: fs.existsSync, statSync: fs.statSync }, + ); + if (!result.ok) { + return { value: [], scope: SCOPE.UNREADABLE }; + } + return { value: result.findings, scope: SCOPE.COMPLETE }; + } catch { + return { value: [], scope: SCOPE.UNREADABLE }; + } +} + +/** + * 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 { @@ -173,6 +285,9 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot { scope: worstScope(phaseDirs.scope, ...phasesValue.map((p) => p.scope)), }, currentPhaseLabel: buildCurrentPhaseLabel(paths.state), + config: buildConfigField(cwd), + agentInstall: buildAgentInstallField(cwd), + worktreeHealth: buildWorktreeHealthField(cwd), }; } diff --git a/src/unusable-input.cts b/src/unusable-input.cts index cd51e1880..f91bb2244 100644 --- a/src/unusable-input.cts +++ b/src/unusable-input.cts @@ -64,6 +64,13 @@ 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', } as const); type UnusableReason = (typeof UNUSABLE_REASON)[keyof typeof UNUSABLE_REASON]; @@ -78,6 +85,8 @@ const REASON_PROSE: Readonly> = 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', }); // ─── Dedup state ────────────────────────────────────────────────────────────── diff --git a/tests/planning-snapshot.test.cjs b/tests/planning-snapshot.test.cjs index 604fdcaef..83d5eedd8 100644 --- a/tests/planning-snapshot.test.cjs +++ b/tests/planning-snapshot.test.cjs @@ -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,189 @@ 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); + }); +}); diff --git a/tests/unusable-input.test.cjs b/tests/unusable-input.test.cjs index 043bf7f14..8452197ab 100644 --- a/tests/unusable-input.test.cjs +++ b/tests/unusable-input.test.cjs @@ -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', 'ROADMAP_UNREADABLE', 'STATE_UNREADABLE'], ); assert.strictEqual(UNUSABLE_REASON.FRONTMATTER_UNTERMINATED, 'frontmatter_unterminated'); }); @@ -134,6 +134,53 @@ 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'); + }); +}); + // ─── The discriminator: truncated vs. everything that merely looks like it ─── describe('extractFrontmatter — flags a genuinely truncated frontmatter', () => {