enhance(#4014): an unreadable directory must not report as an empty one (#4163)

* test(#4014): add failing-first coverage for unreadable-vs-empty directory scope (epic #3473 B4)

* fix(#4014): an unreadable directory must not report as an empty one (epic #3473 B4)

* test(#4014): update hardcoded generateSlugInternal closing-brace line after import shift

src/core-utils.cts's new #4014 import block shifted every subsequent line by
6, moving generateSlugInternal's real closing brace from line 193 to 199.
tests/slug-derivation-drift-guard.test.cjs's MAJOR-1 fixture hardcodes that
line number to plant a synthetic violation immediately after the function's
real body; the guard script itself locates the boundary dynamically via
brace-matching and needed no change.

* docs(#4014): document the unreadable-directory scope signal and add changeset

* docs(#4014): backfill changeset PR number to #4163

* test(#4014): kill pre-existing core-utils.cjs mutation-score gap, unrelated to this issue's diff

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-02 08:12:57 -04:00
committed by GitHub
parent 2131fe13f3
commit 77dcdda534
14 changed files with 693 additions and 93 deletions

View File

@@ -0,0 +1,47 @@
---
id: 4014
title: Unreadable-Directory Scope Signal
group: v1.7.0 Features
---
**Purpose:** ADR-3473 §8.4 ("failure is a value") applies to filesystem
listings, not only command argv. `#3885` (B5) gave `roadmap analyze`,
`gap-checker`, and `init`'s JSON bundles a `context_read_error` /
`phase_dir_read_error` string naming an unreadable phase directory — but the
underlying `has_context` / `hasContext` boolean stayed `false` either way,
so a consumer branching on that boolean alone still cannot tell "genuinely
no context file" from "could not read the directory at all." This closes
that gap with a typed signal, reusing ADR-3180's existing frozen `SCOPE`
enum rather than a new vocabulary.
**`findContextMdIn` (`src/planning-workspace.cts`) now reports its own
scope.** Called with a directory path, it returns `{ file, files, scope }`
instead of a bare filename-or-null, and never throws — an unreadable
directory reports `scope: 'unreadable'` (previously it threw, forcing every
caller to hand-roll its own `try`/`catch`); a genuinely absent directory
(`ENOENT`) reports `scope: 'complete'`, the same "real empty" answer as
today. The array-input call form (an already-read listing) is unchanged.
**Five downstream call sites gain an additive `scope` field, none renamed
or removed:** `roadmap analyze`'s `AnalyzePhase.context_scope`,
`gap-checker`'s `phase_dir_scope`, and `init`'s `context_scope` on all three
JSON bundles (`init plan-phase`, `init phase-op`, `init manager`) —
including `cmdInitManager`, whose own read failure previously vanished into
a bare empty `catch {}` with no signal of any kind. `getPhaseFileStats`
(`src/core-utils.cts`) — the shared listing owner behind `roadmap analyze`
and `init`'s `has_context` — no longer lets its own failed read get masked
by an unrelated, already-successful `scanPhasePlans` scope on the same
phase directory.
**Known limits:**
- `context_read_error` / `phase_dir_read_error`'s message text is now a
fixed "Could not read phase directory `<path>`" rather than embedding the
underlying OS errno text — `findContextMdIn`'s directory-string form
reports only the `SCOPE` discriminator, not the raw caught error. The
field's presence and type are unchanged; only its message detail is
coarser than before #4014.
- `init.cts`'s three call sites call `findContextMdIn` for the scope signal
and then still run their own, pre-existing `fs.readdirSync` on the same
path for the rest of their output — an intentional, additive-only choice
to avoid altering already-complex failure control-flow at those sites,
not a performance optimization.