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

@@ -31,6 +31,10 @@ import phaseId = require('./phase-id.cjs');
import worktreeSafety = require('./worktree-safety.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
import planningWorkspace = require('./planning-workspace.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
type Scope = planningScopeMod.Scope;
import { maskIfSecret } from './secrets.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
import scanPhasePlans = require('./plan-scan.cjs');
@@ -1131,6 +1135,11 @@ function cmdInitPlanPhase(
has_research: phaseInfo?.['has_research'] || false,
has_context: phaseInfo?.['has_context'] || false,
// #4014 (epic #3473 B4-unreadable): additive scope signal adjacent to
// has_context — SCOPE.COMPLETE by default (no phase directory to read is
// a genuine, not-unreadable answer), overwritten below to whatever
// findContextMdIn(phaseDirFull) reports once a directory is known.
context_scope: SCOPE.COMPLETE,
has_reviews: phaseInfo?.['has_reviews'] || false,
has_plans: ((phaseInfo?.['plans'] as unknown[] | undefined)?.length || 0) > 0,
plan_count: (phaseInfo?.['plans'] as unknown[] | undefined)?.length || 0,
@@ -1149,6 +1158,13 @@ function cmdInitPlanPhase(
if (phaseInfo?.['directory']) {
const phaseDirFull = path.join(cwd, phaseInfo['directory'] as string);
// #4014 (epic #3473 B4-unreadable): findContextMdIn's directory-string
// form never throws, so this can record the real scope BEFORE the
// pre-existing `fs.readdirSync(phaseDirFull)` immediately below
// (unchanged) throws on the same unreadable directory and is caught
// exactly as before — additive only, the failure control-flow for
// context_path/research_path/etc. is untouched.
result['context_scope'] = findContextMdIn(phaseDirFull).scope;
try {
const files = fs.readdirSync(phaseDirFull);
const phaseDirName = path.basename(phaseDirFull);
@@ -2038,6 +2054,9 @@ function cmdInitPhaseOp(cwd: string, phase: string, raw: boolean): void {
has_research: phaseInfo?.['has_research'] || false,
has_context: phaseInfo?.['has_context'] || false,
// #4014 (epic #3473 B4-unreadable): see the parallel field in
// cmdInitPlanPhase — additive scope signal adjacent to has_context.
context_scope: SCOPE.COMPLETE,
has_plans: ((phaseInfo?.['plans'] as unknown[] | undefined)?.length || 0) > 0,
has_verification: phaseInfo?.['has_verification'] || false,
has_reviews: phaseInfo?.['has_reviews'] || false,
@@ -2055,6 +2074,9 @@ function cmdInitPhaseOp(cwd: string, phase: string, raw: boolean): void {
if (phaseInfo?.['directory']) {
const phaseDirFull = path.join(cwd, phaseInfo['directory'] as string);
// #4014 (epic #3473 B4-unreadable): see the parallel site in
// cmdInitPlanPhase — additive only, failure control-flow below unchanged.
result['context_scope'] = findContextMdIn(phaseDirFull).scope;
try {
const files = fs.readdirSync(phaseDirFull);
const phaseDirName = path.basename(phaseDirFull);
@@ -2414,6 +2436,9 @@ function cmdInitManager(cwd: string, raw: boolean): void {
let hasResearch = false;
let lastActivity: string | null = null;
let isActive = false;
// #4014 (epic #3473 B4-unreadable): default COMPLETE — no directory at
// all (dirMatch not found) is a genuine, not-unreadable answer.
let contextScope: Scope = SCOPE.COMPLETE;
let completion = buildPhaseCompletionProjection(
cwd,
phaseNum,
@@ -2433,6 +2458,17 @@ function cmdInitManager(cwd: string, raw: boolean): void {
if (dirMatch) {
const fullDir = path.join(phasesDir, dirMatch);
const phaseDirRel = toPosixPath(path.relative(cwd, fullDir));
// #4014 (epic #3473 B4-unreadable): this whole block used to swallow
// ANY readdirSync failure below into the bare `catch { /* empty */ }`
// at the bottom — an unreadable phase directory reported the exact
// same `has_context: false` / `disk_status: 'no_directory'` as a
// directory that never existed. findContextMdIn's directory-string
// form never throws, so it can record the real scope (COMPLETE vs
// UNREADABLE) here, BEFORE the pre-existing `fs.readdirSync(fullDir)`
// immediately below (unchanged) throws on the exact same unreadable
// directory and is caught exactly as before — this line is additive
// only, the failure control-flow is untouched.
contextScope = findContextMdIn(fullDir).scope;
const phaseFiles = fs.readdirSync(fullDir);
planCount = listPhasePlanFiles(fullDir).length;
summaryCount = listPhaseSummaryFiles(fullDir).length;
@@ -2510,6 +2546,7 @@ function cmdInitManager(cwd: string, raw: boolean): void {
...completion,
last_activity: lastActivity,
is_active: isActive,
context_scope: contextScope,
});
}