* 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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
37
src/init.cts
37
src/init.cts
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ import { platformEnsureDir, retryRenameSync } from './shell-command-projection.c
|
||||
import { realClock } from './clock.cjs';
|
||||
import type { Clock } from './clock.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningScopeMod = require('./planning-scope.cjs');
|
||||
const { SCOPE } = planningScopeMod;
|
||||
type Scope = planningScopeMod.Scope;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import activeWorkstreamStore = require('./active-workstream-store.cjs');
|
||||
const {
|
||||
createSharedPointerAdapter,
|
||||
@@ -569,32 +573,56 @@ function describeUnresolvedWorkstreamReason(reason: 'invalid_name' | 'missing_wo
|
||||
* form (`CONTEXT.md`) and the padded-prefix convention (`NN-CONTEXT.md`,
|
||||
* `NN.N-CONTEXT.md`, etc.) used by gsd-discuss-phase output.
|
||||
*
|
||||
* Returns the filename (not the full path) of the first match, or null if
|
||||
* no CONTEXT.md exists in the directory.
|
||||
*
|
||||
* Canonical dual-form predicate extracted here to eliminate the 5-site
|
||||
* duplication that previously existed across init.cjs, roadmap.cjs,
|
||||
* core.cjs, gap-checker.cjs (#3739).
|
||||
*
|
||||
* @param absDirOrFiles - Absolute path to the phase directory,
|
||||
* OR an already-read files array (avoids a redundant readdirSync at call sites
|
||||
* that already hold a directory listing).
|
||||
* Two call shapes, two return shapes (#4014, epic #3473 B4-unreadable):
|
||||
*
|
||||
* - Array-input form (`files: string[]`, an already-read directory listing —
|
||||
* avoids a redundant readdirSync at call sites that already hold one, and
|
||||
* lets a caller pass an already phase-scoped listing): UNCHANGED —
|
||||
* returns the matched filename or `null`, never throws (there is no I/O
|
||||
* to fail on an in-memory array).
|
||||
* - Directory-string form (`absDir: string`): performs the `readdirSync`
|
||||
* itself and returns `{ file, files, scope }` — `file`/`files` are the
|
||||
* match and the raw listing, `scope` is `SCOPE.COMPLETE` on a successful
|
||||
* read (including ENOENT, which is a genuine "nothing there yet" answer,
|
||||
* not a failure) or `SCOPE.UNREADABLE` on any other read error
|
||||
* (EACCES/EIO/…). This form never throws — a caller that used to see an
|
||||
* exception on an unreadable directory now sees `scope: SCOPE.UNREADABLE`
|
||||
* instead, so an unreadable phase dir is reported distinctly from a
|
||||
* genuinely empty one rather than being silently indistinguishable from
|
||||
* it (#1883's original defect this closes at the source).
|
||||
*/
|
||||
function findContextMdIn(absDirOrFiles: string | string[]): string | null {
|
||||
try {
|
||||
const files = Array.isArray(absDirOrFiles)
|
||||
? absDirOrFiles
|
||||
: fs.readdirSync(absDirOrFiles);
|
||||
function findContextMdIn(files: string[]): string | null;
|
||||
function findContextMdIn(absDir: string): { file: string | null; files: string[]; scope: Scope };
|
||||
function findContextMdIn(
|
||||
absDirOrFiles: string | string[],
|
||||
): string | null | { file: string | null; files: string[]; scope: Scope } {
|
||||
const matchIn = (files: string[]): string | null => {
|
||||
if (files.includes('CONTEXT.md')) return 'CONTEXT.md';
|
||||
return files.find((f: string) => f.endsWith('-CONTEXT.md')) ?? null;
|
||||
};
|
||||
|
||||
if (Array.isArray(absDirOrFiles)) {
|
||||
return matchIn(absDirOrFiles);
|
||||
}
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(absDirOrFiles);
|
||||
return { file: matchIn(files), files, scope: SCOPE.COMPLETE };
|
||||
} catch (err) {
|
||||
// #1883: distinguish genuine absence from a permission/I-O failure. ENOENT
|
||||
// ("nothing there") keeps the long-standing null contract the callers rely
|
||||
// on; every other error (EACCES, EIO, …) is a real read failure that must
|
||||
// propagate — otherwise an unreadable phase dir is silently reported as
|
||||
// "no CONTEXT.md" and the discuss/plan gates wrongly skip context.
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw err;
|
||||
// #1883 / #4014: distinguish genuine absence from a permission/I-O
|
||||
// failure. ENOENT ("nothing there") keeps the long-standing "real empty"
|
||||
// contract callers rely on; every other error (EACCES, EIO, …) is a real
|
||||
// read failure — reported as SCOPE.UNREADABLE rather than thrown, so a
|
||||
// caller no longer needs its own try/catch to keep an unreadable phase
|
||||
// dir from being silently reported the same as "no CONTEXT.md".
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { file: null, files: [], scope: SCOPE.COMPLETE };
|
||||
}
|
||||
return { file: null, files: [], scope: SCOPE.UNREADABLE };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,8 +65,19 @@ interface PhasePlansAndSummaries {
|
||||
* readdirSync failed for any other reason (EACCES/EIO/...), so an
|
||||
* unreadable directory is never silently reported the same as one that was
|
||||
* successfully read and genuinely has no CONTEXT.md.
|
||||
*
|
||||
* #4014 (epic #3473 B4): kept — `AnalyzePhase.context_read_error` (the
|
||||
* shipped, tested `roadmap analyze` JSON field this feeds) is an existing
|
||||
* consumer, so this field stays additive rather than being retired. `scope`
|
||||
* below is the new, typed sibling signal; this field is now derived from
|
||||
* it rather than owning its own readdirSync.
|
||||
*/
|
||||
contextReadError: 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`), which
|
||||
* `contextReadError`/`hasContext` alone cannot. */
|
||||
scope: Scope;
|
||||
}
|
||||
|
||||
interface PhaseSearchResult {
|
||||
@@ -126,21 +137,20 @@ function countPhasePlansAndSummaries(phaseDir: string): PhasePlansAndSummaries {
|
||||
const { planCount, summaryCount } = scanPhasePlans(phaseDir);
|
||||
// hasContext and hasResearch are not plan-scan concerns — read the directory
|
||||
// once and share the listing for all non-plan metadata that cmdRoadmapAnalyze needs.
|
||||
let phaseFiles: string[] = [];
|
||||
// #3885 (ADR-3473 §8.5): distinguish "genuinely absent" (ENOENT) from
|
||||
// "could not read" (EACCES/EIO/...) — the collapse of both to an empty
|
||||
// listing is exactly the defect class this item closes. Mirrors
|
||||
// core-utils.cts's getPhaseFileStats / phase-locator.cts's
|
||||
// listMilestonePhaseDirs SCOPE.UNREADABLE discriminator.
|
||||
let contextReadError: string | null = null;
|
||||
try {
|
||||
phaseFiles = fs.readdirSync(phaseDir);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException)?.code;
|
||||
if (code !== 'ENOENT') {
|
||||
contextReadError = `Could not read phase directory ${formatDiagnosticToken(phaseDir)}: ${formatDiagnosticToken((err as Error)?.message ?? String(err))}`;
|
||||
}
|
||||
}
|
||||
//
|
||||
// #4014 (epic #3473 B4): the listing + unreadable-vs-empty discrimination
|
||||
// is now owned by findContextMdIn's directory-string form, retiring this
|
||||
// function's own readdirSync try/catch (mirrors core-utils.cts's
|
||||
// getPhaseFileStats / phase-locator.cts's listMilestonePhaseDirs
|
||||
// SCOPE.UNREADABLE discriminator).
|
||||
const { files: phaseFiles, scope } = findContextMdIn(phaseDir);
|
||||
// #3885 (ADR-3473 §8.5): `contextReadError` stays additive for the shipped
|
||||
// `AnalyzePhase.context_read_error` JSON field — derived from `scope`
|
||||
// rather than from its own caught error, since findContextMdIn's
|
||||
// directory-string form reports SCOPE, not the raw errno message.
|
||||
const contextReadError = scope === SCOPE.UNREADABLE
|
||||
? `Could not read phase directory ${formatDiagnosticToken(phaseDir)}`
|
||||
: null;
|
||||
// #3511: scope the raw listing to this phase dir before the
|
||||
// phase-numbered-artifact predicates (hasContext/hasResearch) — planCount/
|
||||
// summaryCount above stay on scanPhasePlans's own unscoped listing since a
|
||||
@@ -153,6 +163,7 @@ function countPhasePlansAndSummaries(phaseDir: string): PhasePlansAndSummaries {
|
||||
hasContext: findContextMdIn(scopedFiles) !== null,
|
||||
hasResearch: scopedFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
|
||||
contextReadError,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -393,6 +404,11 @@ type AnalyzePhase = {
|
||||
roadmap_complete: boolean;
|
||||
/** #3885 (ADR-3473 §8.5): see PhasePlansAndSummaries.contextReadError. */
|
||||
context_read_error: string | null;
|
||||
/** #4014 (epic #3473 B4): see PhasePlansAndSummaries.scope. Additive
|
||||
* sibling of context_read_error — SCOPE.UNREADABLE for the same read
|
||||
* failure context_read_error names, SCOPE.COMPLETE otherwise (including a
|
||||
* genuinely absent/no_directory phase). */
|
||||
context_scope: Scope;
|
||||
};
|
||||
|
||||
type AnalyzePhaseCollection = {
|
||||
@@ -496,6 +512,10 @@ function collectAnalyzePhases(
|
||||
// hit a non-ENOENT error — no directory at all is `disk_status:
|
||||
// 'no_directory'`, a real (if uninteresting) answer, not a read error.
|
||||
let contextReadError: string | null = null;
|
||||
// #4014 (epic #3473 B4): additive sibling — SCOPE.COMPLETE by default
|
||||
// (no directory at all is a genuine, not-unreadable answer), overwritten
|
||||
// below only when dirMatch resolves.
|
||||
let contextScope: Scope = SCOPE.COMPLETE;
|
||||
|
||||
// DEAD catch removed (#2245 audit): matchPhaseDirs(...) is a pure
|
||||
// array lookup on an already-resolved string array, and
|
||||
@@ -528,6 +548,7 @@ function collectAnalyzePhases(
|
||||
hasContext = counts.hasContext;
|
||||
hasResearch = counts.hasResearch;
|
||||
contextReadError = counts.contextReadError;
|
||||
contextScope = counts.scope;
|
||||
|
||||
// ADR-3180 §7.4 (issue #3186, disk-strict, #3168 fix): route "is this
|
||||
// phase complete" through the canonical owner (`isPhaseComplete`),
|
||||
@@ -575,6 +596,7 @@ function collectAnalyzePhases(
|
||||
disk_status: diskStatus,
|
||||
roadmap_complete: roadmapComplete,
|
||||
context_read_error: contextReadError,
|
||||
context_scope: contextScope,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -595,6 +617,9 @@ function collectAnalyzePhases(
|
||||
let tHasContext = false;
|
||||
let tHasResearch = false;
|
||||
let tContextReadError: string | null = null;
|
||||
// #4014 (epic #3473 B4): additive sibling, same default rule as the
|
||||
// heading-declared branch above.
|
||||
let tContextScope: Scope = SCOPE.COMPLETE;
|
||||
if (dirMatchA) {
|
||||
const counts = countPhasePlansAndSummaries(path.join(phasesDir, dirMatchA));
|
||||
tPlanCount = counts.planCount;
|
||||
@@ -606,6 +631,7 @@ function collectAnalyzePhases(
|
||||
// existsSync (which cannot itself distinguish EACCES from absent), but
|
||||
// an unreadable phase directory is still surfaced via the sibling call.
|
||||
tContextReadError = counts.contextReadError;
|
||||
tContextScope = counts.scope;
|
||||
}
|
||||
phases.push({
|
||||
number: tr.id,
|
||||
@@ -620,6 +646,7 @@ function collectAnalyzePhases(
|
||||
disk_status: dirMatchA ? 'ok' : 'no_directory',
|
||||
roadmap_complete: false,
|
||||
context_read_error: tContextReadError,
|
||||
context_scope: tContextScope,
|
||||
});
|
||||
}
|
||||
return { phases, detailKeys };
|
||||
|
||||
Reference in New Issue
Block a user