* 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:
@@ -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