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

@@ -33,6 +33,10 @@ const { scanPhasePlans } = planScanMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { scopeToPhase } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
type Scope = planningScopeMod.Scope;
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -73,8 +77,16 @@ interface GapResult {
* attempted) or was read successfully. A message naming the phase directory
* when it EXISTS but `readdirSync` failed (EACCES/EIO/...) — never
* collapsed to the same `[]` an absent directory produces.
*
* #4014 (epic #3473 B4): kept additive — `phase_dir_scope` below is the
* new, typed sibling signal; this field is now derived from it rather than
* owning its own readdirSync.
*/
phase_dir_read_error: string | null;
/** #4014 (epic #3473 B4): the SCOPE this phase dir's listing resolved to —
* SCOPE.UNREADABLE distinguishes a real read failure from a genuinely
* empty/absent phase dir (SCOPE.COMPLETE). */
phase_dir_scope: Scope;
}
interface RunGapAnalysisOptions {
@@ -347,6 +359,7 @@ function runGapAnalysis(cwd: string, phaseDir: string, options: RunGapAnalysisOp
summary: 'workflow.post_planning_gaps disabled — skipping post-planning gap analysis',
counts: { total: 0, covered: 0, uncovered: 0 },
phase_dir_read_error: null,
phase_dir_scope: SCOPE.COMPLETE,
};
}
@@ -372,17 +385,20 @@ function runGapAnalysis(cwd: string, phaseDir: string, options: RunGapAnalysisOp
// Read the phase directory once; reuse the listing for both context detection
// and plan-file enumeration (avoids redundant readdirSync calls).
let phaseDirFiles: string[] = [];
// #3885 (ADR-3473 §8.5): the existsSync guard above already means a catch
// here is NEVER "genuinely absent" (ENOENT) — this directory exists, so any
// failure to list it is a real read error (EACCES/EIO/...) and must be
// named, not folded into the same `[]` an absent directory produces.
let phaseDirReadError: string | null = null;
try {
if (fs.existsSync(absPhaseDir)) phaseDirFiles = fs.readdirSync(absPhaseDir);
} catch (err) {
phaseDirReadError = `Could not read phase directory ${formatDiagnosticToken(absPhaseDir)}: ${formatDiagnosticToken((err as Error)?.message ?? String(err))}`;
}
//
// #4014 (epic #3473 B4): findContextMdIn's directory-string form now owns
// the listing + ENOENT-vs-other discrimination, retiring the local
// existsSync-guarded readdirSync try/catch — ENOENT (genuinely absent)
// and a successful-but-empty read both resolve to SCOPE.COMPLETE with an
// empty listing, exactly like the existsSync guard's short-circuit did.
const { files: phaseDirFiles, scope: phaseDirScope } = findContextMdIn(absPhaseDir);
// #3885 (ADR-3473 §8.5): `phaseDirReadError` stays additive for the
// shipped `phase_dir_read_error` JSON field — derived from `phaseDirScope`
// rather than from its own caught error, since findContextMdIn's
// directory-string form reports SCOPE, not the raw errno message.
const phaseDirReadError = phaseDirScope === SCOPE.UNREADABLE
? `Could not read phase directory ${formatDiagnosticToken(absPhaseDir)}`
: null;
// #3511-class: scope the raw listing to this phase dir before the
// phase-numbered -CONTEXT.md predicate. `phaseDirFiles` itself stays raw —
@@ -450,6 +466,7 @@ function runGapAnalysis(cwd: string, phaseDir: string, options: RunGapAnalysisOp
summary: coverageSummary + '; extracted 0 of N — possible format mismatch',
counts: { total: rows.length, covered, uncovered },
phase_dir_read_error: phaseDirReadError,
phase_dir_scope: phaseDirScope,
};
}
return {
@@ -459,6 +476,7 @@ function runGapAnalysis(cwd: string, phaseDir: string, options: RunGapAnalysisOp
summary: 'extracted 0 of N — possible format mismatch',
counts: { total: 0, covered: 0, uncovered: 0 },
phase_dir_read_error: phaseDirReadError,
phase_dir_scope: phaseDirScope,
};
}
@@ -477,6 +495,7 @@ function runGapAnalysis(cwd: string, phaseDir: string, options: RunGapAnalysisOp
summary: 'no requirements or decisions to check',
counts: { total: 0, covered: 0, uncovered: 0 },
phase_dir_read_error: phaseDirReadError,
phase_dir_scope: phaseDirScope,
};
}
@@ -498,6 +517,7 @@ function runGapAnalysis(cwd: string, phaseDir: string, options: RunGapAnalysisOp
summary,
counts: { total: rows.length, covered, uncovered },
phase_dir_read_error: phaseDirReadError,
phase_dir_scope: phaseDirScope,
};
}