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

@@ -40,6 +40,12 @@ import phaseIdModule = require('./phase-id.cjs');
import planningWorkspace = require('./planning-workspace.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import shellCommandProjection = require('./shell-command-projection.cjs');
// planning-scope.cjs is a leaf module (no imports of its own — see its file
// header), so importing it here directly cannot introduce a cycle.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
type Scope = planningScopeMod.Scope;
// ─── Line-ending normalization ─────────────────────────────────────────────────
@@ -250,14 +256,14 @@ interface PhaseFileStats {
hasContext: boolean;
hasVerification: boolean;
hasReviews: boolean;
scope: string;
scope: Scope;
}
// Minimal shape this module needs from plan-scan.cjs's scanPhasePlans result.
interface PlanScanResultShape {
planFiles: string[];
summaryFiles: string[];
scope: string;
scope: Scope;
}
/**
@@ -291,16 +297,40 @@ interface PlanScanResultShape {
* Degrades on an unreadable directory instead of throwing: empty arrays,
* every flag false, scope UNREADABLE (mirroring scanPhasePlans's own
* degrade path).
*
* #4014 (epic #3473 B4-unreadable): this function's OWN readdirSync used to
* catch its own failure and then return `scope: scan.scope` — the scope of
* the UNRELATED, already-successful scanPhasePlans call — silently dropping
* that THIS read failed. The own-readdirSync is now routed through
* findContextMdIn's directory-string form (which never throws and reports
* its own SCOPE.UNREADABLE), and the two independently-scoped answers are
* combined into the worse of the two below — never letting a successful
* scan.scope mask this function's own failed read.
*
* The combination is written out as a two-value comparison rather than
* calling planning-snapshot.cts's `worstScope` directly: that module
* requires core-utils.cjs (for `findOrphanSummaries`) at its own top level,
* so importing it back here would create a tight, direct
* core-utils.cjs <-> planning-snapshot.cjs cycle — a materially different
* (and riskier) shape than this file's existing, documented lazy-access
* cyclic partners (planning-workspace.cjs, phase-id.cjs), which
* planning-snapshot.cts sits *above* in the dependency graph, not beside.
* `findContextMdIn`'s directory-string form can only ever report
* SCOPE.COMPLETE or SCOPE.UNREADABLE (a single readdirSync is binary — see
* its own doc comment), so per planning-snapshot.cts's SCOPE_SEVERITY
* ordering (UNREADABLE is maximal), `worstScope(scan.scope, ownScope)` is
* exactly `ownScope === UNREADABLE ? UNREADABLE : scan.scope` — the
* expression below is that identity, not a re-derivation of the ordering.
*/
function getPhaseFileStats(phaseDir: string): PhaseFileStats {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const scanPhasePlans: (dir: string) => PlanScanResultShape = require('./plan-scan.cjs');
const scan = scanPhasePlans(phaseDir);
let files: string[];
try {
files = fs.readdirSync(phaseDir);
} catch {
const { files, scope: ownScope } = planningWorkspace.findContextMdIn(phaseDir);
const scope: Scope = ownScope === SCOPE.UNREADABLE ? SCOPE.UNREADABLE : scan.scope;
if (ownScope === SCOPE.UNREADABLE) {
return {
plans: scan.planFiles,
summaries: scan.summaryFiles,
@@ -308,7 +338,7 @@ function getPhaseFileStats(phaseDir: string): PhaseFileStats {
hasContext: false,
hasVerification: false,
hasReviews: false,
scope: scan.scope,
scope,
};
}
@@ -321,7 +351,7 @@ function getPhaseFileStats(phaseDir: string): PhaseFileStats {
hasContext: planningWorkspace.findContextMdIn(scopedFiles) !== null,
hasVerification: scopedFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
hasReviews: scopedFiles.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
scope: scan.scope,
scope,
};
}