* 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:
@@ -556,6 +556,55 @@ describe('getPhaseFileStats', () => {
|
||||
assert.strictEqual(stats.hasReviews, false);
|
||||
});
|
||||
|
||||
// ─── #4014 (epic #3473 B4-unreadable) matrix rows 10-11 ───────────────────
|
||||
//
|
||||
// getPhaseFileStats used to catch its OWN readdirSync failure and return
|
||||
// `scope: scan.scope` — the scope of the UNRELATED, already-successful
|
||||
// scanPhasePlans call — silently dropping that THIS function's own read
|
||||
// failed. Both scanPhasePlans and getPhaseFileStats's own read call
|
||||
// `fs.readdirSync(phaseDir)` on the identical path, in that order (scan
|
||||
// first) — so the failure is injected on the SECOND call to that exact
|
||||
// path only, letting scanPhasePlans succeed (scope complete) while
|
||||
// getPhaseFileStats's own read fails, reproducing the exact swallowing bug
|
||||
// named in the issue. No chmod 0o000 — root bypasses mode bits (silent
|
||||
// zero coverage in root CI); restored via t.mock's auto-restore.
|
||||
test('#4014 matrix row 10: own readdirSync failure is not masked by an already-successful scan.scope', (t) => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-'));
|
||||
fs.writeFileSync(path.join(tmpDir, '01-PLAN.md'), '# Plan\n');
|
||||
const target = path.resolve(tmpDir);
|
||||
const origReaddirSync = fs.readdirSync.bind(fs);
|
||||
let callsOnTarget = 0;
|
||||
t.mock.method(fs, 'readdirSync', (p, ...rest) => {
|
||||
if (path.resolve(String(p)) === target) {
|
||||
callsOnTarget += 1;
|
||||
// 1st call = scanPhasePlans's own read: let it succeed (scope complete).
|
||||
if (callsOnTarget === 1) return origReaddirSync(p, ...rest);
|
||||
// 2nd call = getPhaseFileStats's own read (via findContextMdIn): fail it.
|
||||
const err = new Error(`EACCES: simulated failure, scandir '${p}'`);
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return origReaddirSync(p, ...rest);
|
||||
});
|
||||
|
||||
const stats = coreUtils.getPhaseFileStats(tmpDir);
|
||||
assert.strictEqual(stats.scope, SCOPE.UNREADABLE,
|
||||
`own readdirSync failure must win over the unrelated already-successful scan.scope; got: ${stats.scope}`);
|
||||
// #3183's pre-existing plans/summaries-untouched contract: unaffected
|
||||
// by this function's own (unrelated) read outcome.
|
||||
assert.deepEqual(stats.plans, ['01-PLAN.md']);
|
||||
});
|
||||
|
||||
test('#4014 matrix row 11: both reads succeed — scope complete, flags computed as before (must not regress)', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-'));
|
||||
fs.writeFileSync(path.join(tmpDir, '01-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(tmpDir, 'CONTEXT.md'), '# context\n');
|
||||
const stats = coreUtils.getPhaseFileStats(tmpDir);
|
||||
assert.strictEqual(stats.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(stats.hasContext, true);
|
||||
assert.deepEqual(stats.plans, ['01-PLAN.md']);
|
||||
});
|
||||
|
||||
test('#3183 row 7 regression: nested plans/PLAN-01.md ONLY is reported (used to report 0)', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-test-'));
|
||||
fs.mkdirSync(path.join(tmpDir, 'plans'));
|
||||
@@ -777,6 +826,28 @@ describe('extractCanonicalPlanId', () => {
|
||||
assert.strictEqual(coreUtils.extractCanonicalPlanId('100-01-extra-slug-PLAN.md'), '100-01');
|
||||
assert.strictEqual(coreUtils.extractCanonicalPlanId('01-02-PLAN.md'), '01-02');
|
||||
});
|
||||
|
||||
// ─── #4014 mutation-gap: the three chained suffix strips are each anchored
|
||||
// ($) to the true end of the filename. An unanchored variant would instead
|
||||
// strip the FIRST mid-string occurrence of the pattern, corrupting a
|
||||
// filename that merely CONTAINS one of these substrings earlier on.
|
||||
|
||||
test('mutation-gap: mid-string ".md" is left alone — only the trailing .md is stripped', () => {
|
||||
// "foo.mdx-01-PLAN.md": the trailing "-PLAN.md" is stripped by the first
|
||||
// regex; the base "foo.mdx-01" does NOT end in ".md" (it ends in "mdx"
|
||||
// before "-01"), so the third (bare .md$) regex must NOT touch the ".md"
|
||||
// that happens to sit inside "foo.mdx". An unanchored /\.md/i would strip
|
||||
// it there instead, corrupting the base to "foox-01".
|
||||
assert.strictEqual(coreUtils.extractCanonicalPlanId('foo.mdx-01-PLAN.md'), 'foo.mdx-01');
|
||||
});
|
||||
|
||||
test('mutation-gap: mid-string "-SUMMARY.md" is left alone — only the trailing form is stripped', () => {
|
||||
// "foo-SUMMARY.md-01-PLAN.md": trailing "-PLAN.md" is stripped by the
|
||||
// first regex, leaving "foo-SUMMARY.md-01" — which does NOT end in
|
||||
// "-SUMMARY.md". An unanchored /-SUMMARY\.md/i would still strip the
|
||||
// mid-string occurrence, corrupting the base to "foo-01".
|
||||
assert.strictEqual(coreUtils.extractCanonicalPlanId('foo-SUMMARY.md-01-PLAN.md'), 'foo-SUMMARY.md-01');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── countMatchedSummaries (#1988) ───────────────────────────────────────────
|
||||
@@ -843,6 +914,71 @@ describe('countMatchedSummaries — stray non-plan summaries excluded (#1988)',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #4014 mutation-gap: summaryCandidates / findUnsummarizedPlans /
|
||||
// findOrphanSummaries — same "Stryker core-utils shard runs only THIS file"
|
||||
// reasoning as countMatchedSummaries above. These three share the private
|
||||
// summaryCandidates helper but, unlike countMatchedSummaries, had no direct
|
||||
// coverage inside core-utils.test.cjs itself (their existing coverage lives
|
||||
// in plan-count-single-owner.test.cjs / summary-status-blocked-3345.test.cjs,
|
||||
// neither of which the core-utils Stryker shard executes).
|
||||
|
||||
describe('summaryCandidates / findUnsummarizedPlans / findOrphanSummaries — #4014 mutation-gap', () => {
|
||||
const { countMatchedSummaries, findUnsummarizedPlans, findOrphanSummaries } = coreUtils;
|
||||
|
||||
test('mutation-gap: mid-string ".md" in the plan filename does not corrupt the base', () => {
|
||||
// "my.mdx-file-PLAN.md": summaryCandidates' own `base` strip is anchored
|
||||
// (/\.md$/i) so only the true trailing ".md" is removed, leaving
|
||||
// "my.mdx-file-PLAN" — the marker-swap candidate is then
|
||||
// "my.mdx-file-SUMMARY.md". An unanchored strip would instead corrupt the
|
||||
// base by stripping the ".md" that sits inside "my.mdx" first.
|
||||
assert.strictEqual(
|
||||
countMatchedSummaries(['my.mdx-file-PLAN.md'], ['my.mdx-file-SUMMARY.md']),
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(findUnsummarizedPlans(['my.mdx-file-PLAN.md'], ['my.mdx-file-SUMMARY.md']), []);
|
||||
});
|
||||
|
||||
test('mutation-gap: legacy extended form <n>-PLAN-<m> matches ONLY via the <n>-<m>-SUMMARY candidate', () => {
|
||||
// "14-PLAN-01.md" also generates a marker-swap candidate
|
||||
// ("14-SUMMARY-01.md") and a stem-suffix candidate
|
||||
// ("14-PLAN-01-SUMMARY.md"), but the summary file used here
|
||||
// ("14-01-SUMMARY.md") matches NEITHER of those — it can only match via
|
||||
// the `extended` candidate on the summaryCandidates line that special-
|
||||
// cases `^(\d+)-PLAN-(\d+)`. Isolates that candidate from the other two.
|
||||
assert.strictEqual(countMatchedSummaries(['14-PLAN-01.md'], ['14-01-SUMMARY.md']), 1);
|
||||
assert.deepEqual(findUnsummarizedPlans(['14-PLAN-01.md'], ['14-01-SUMMARY.md']), []);
|
||||
});
|
||||
|
||||
test('mutation-gap: #3183 canonical-id candidate fires only when it differs from the PLAN-stripped stem', () => {
|
||||
// "68-01-scaffolding-PLAN.md": extractCanonicalPlanId pairs "68"+"01" into
|
||||
// "68-01", which differs from the plain PLAN-stripped stem
|
||||
// "68-01-scaffolding" — so the `canonicalId !== planStem` guard fires and
|
||||
// pushes the "68-01-SUMMARY.md" candidate. The summary here matches ONLY
|
||||
// through that candidate (not the marker-swap or stem-suffix forms), so
|
||||
// flipping the guard's equality in either direction breaks this match.
|
||||
assert.strictEqual(
|
||||
countMatchedSummaries(['68-01-scaffolding-PLAN.md'], ['68-01-SUMMARY.md']),
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(findUnsummarizedPlans(['68-01-scaffolding-PLAN.md'], ['68-01-SUMMARY.md']), []);
|
||||
});
|
||||
|
||||
test('mutation-gap: findUnsummarizedPlans on a MIXED set returns exactly the unsummarized subset', () => {
|
||||
// An all-matched or all-unmatched fixture can't distinguish a `.filter()`
|
||||
// that was mutated to always-true/always-false/identity from a correctly
|
||||
// behaving one — only a mixed set, checked by exact array identity, can.
|
||||
const plans = ['01-01-PLAN.md', '01-02-PLAN.md', '01-03-PLAN.md'];
|
||||
const summaries = ['01-01-SUMMARY.md', '01-03-SUMMARY.md']; // 01-02 has none
|
||||
assert.deepEqual(findUnsummarizedPlans(plans, summaries), ['01-02-PLAN.md']);
|
||||
});
|
||||
|
||||
test('mutation-gap: findOrphanSummaries on a MIXED set returns exactly the unclaimed subset', () => {
|
||||
const plans = ['01-01-PLAN.md', '01-02-PLAN.md'];
|
||||
const summaries = ['01-01-SUMMARY.md', '01-02-SUMMARY.md', '01-STRAY-SUMMARY.md'];
|
||||
assert.deepEqual(findOrphanSummaries(plans, summaries), ['01-STRAY-SUMMARY.md']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #3883 (ADR-3473 §8.3) — one slug implementation per rule
|
||||
//
|
||||
|
||||
@@ -901,5 +901,136 @@ describe('#3885 (ADR-3473 §8.5): runGapAnalysis distinguishes unreadable from a
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// #4014 (epic #3473 B4-unreadable) matrix row 12: runGapAnalysis now
|
||||
// consumes `scope` from findContextMdIn(phaseDir) and surfaces it as the
|
||||
// additive `phase_dir_scope` field — the typed SCOPE-enum sibling of the
|
||||
// pre-existing `phase_dir_read_error` string field asserted above.
|
||||
test('#4014 matrix row 12: unreadable phase dir reports phase_dir_scope:unreadable', (t) => {
|
||||
setup();
|
||||
try {
|
||||
injectReaddirFailure(t, phaseDir, 'EACCES');
|
||||
const result = runGapAnalysis(tmpDir, phaseDir);
|
||||
assert.strictEqual(result.phase_dir_scope, 'unreadable',
|
||||
`an unreadable phase directory must report phase_dir_scope 'unreadable'; got: ${result.phase_dir_scope}`);
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('#4014: readable phase dir reports phase_dir_scope:complete (must not regress)', () => {
|
||||
setup();
|
||||
try {
|
||||
const result = runGapAnalysis(tmpDir, phaseDir);
|
||||
assert.strictEqual(result.phase_dir_scope, 'complete');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('#4014: genuinely absent phase dir reports phase_dir_scope:complete (boundary — not unreadable)', () => {
|
||||
tmpDir = createTempProject();
|
||||
const missingPhaseDir = path.join(tmpDir, '.planning', 'phases', '09-nonexistent');
|
||||
try {
|
||||
const result = runGapAnalysis(tmpDir, missingPhaseDir);
|
||||
assert.strictEqual(result.phase_dir_scope, 'complete',
|
||||
`a genuinely absent phase directory must report phase_dir_scope 'complete', not 'unreadable'; got: ${result.phase_dir_scope}`);
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #4014 (epic #3473 B4-unreadable) matrix row 15 — independence: the SAME
|
||||
// injected-unreadable phase directory must be reported distinguishably from
|
||||
// an empty one, using the SAME SCOPE.UNREADABLE value, across all three CLI
|
||||
// surfaces this issue touches: `roadmap analyze` (roadmap.cts), gap-checker
|
||||
// (gap-checker.cts), and one `init` bundle (init.cts). All three surfaces are
|
||||
// exercised in-process (each module's exported function called directly,
|
||||
// mirroring the identical direct-call style already used above and in
|
||||
// init.test.cjs's #3885 block) against one shared fixture, so a single
|
||||
// `fs.readdirSync` monkeypatch on the same phase directory drives all three.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('#4014 matrix row 15: unreadable-vs-empty identity is consistent across roadmap/gap-checker/init', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
const { runGapAnalysis } = require('../gsd-core/bin/lib/gap-checker.cjs');
|
||||
const roadmapLib = require('../gsd-core/bin/lib/roadmap.cjs');
|
||||
const initMod = require('../gsd-core/bin/lib/init.cjs');
|
||||
|
||||
function injectReaddirFailure(t, targetPath, code) {
|
||||
const resolved = path.resolve(targetPath);
|
||||
const origReaddirSync = fs.readdirSync.bind(fs);
|
||||
t.mock.method(fs, 'readdirSync', (p, ...rest) => {
|
||||
if (path.resolve(String(p)) === resolved) {
|
||||
const err = new Error(`${code}: simulated failure, scandir '${p}'`);
|
||||
err.code = code;
|
||||
throw err;
|
||||
}
|
||||
return origReaddirSync(p, ...rest);
|
||||
});
|
||||
}
|
||||
|
||||
// `output()` writes via `fs.writeSync(1, ...)`, bypassing console.log — see
|
||||
// init.test.cjs's captureFd1 for the identical rationale/pattern.
|
||||
function captureFd1(run) {
|
||||
const chunks = [];
|
||||
const origWriteSync = fs.writeSync;
|
||||
fs.writeSync = function patchedWriteSync(fd, data, offset, length) {
|
||||
if (fd !== 1) return origWriteSync.apply(fs, arguments);
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
const start = offset ?? 0;
|
||||
const len = length ?? (buf.length - start);
|
||||
chunks.push(Buffer.from(buf.subarray(start, start + len)));
|
||||
return len;
|
||||
};
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
fs.writeSync = origWriteSync;
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
}
|
||||
|
||||
test('the same unreadable phase directory reports SCOPE.UNREADABLE consistently on all three surfaces', (t) => {
|
||||
const tmpDir = createTempProject();
|
||||
try {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n',
|
||||
);
|
||||
|
||||
injectReaddirFailure(t, phaseDir, 'EACCES');
|
||||
|
||||
// Surface 1: gap-checker.
|
||||
const gapResult = runGapAnalysis(tmpDir, phaseDir);
|
||||
assert.strictEqual(gapResult.phase_dir_scope, 'unreadable',
|
||||
`gap-checker surface: got phase_dir_scope=${gapResult.phase_dir_scope}`);
|
||||
|
||||
// Surface 2: roadmap analyze.
|
||||
const analyzeOutput = captureFd1(() => roadmapLib.cmdRoadmapAnalyze(tmpDir, false));
|
||||
const phase3 = analyzeOutput.phases.find((p) => p.number === '3');
|
||||
assert.ok(phase3, `phase 3 must appear in analyze output; got: ${JSON.stringify(analyzeOutput.phases)}`);
|
||||
assert.strictEqual(phase3.context_scope, 'unreadable',
|
||||
`roadmap analyze surface: got context_scope=${phase3.context_scope}`);
|
||||
|
||||
// Surface 3: one init bundle (cmdInitPlanPhase).
|
||||
const initOutput = captureFd1(() => initMod.cmdInitPlanPhase(tmpDir, '03', false));
|
||||
assert.strictEqual(initOutput.context_scope, 'unreadable',
|
||||
`init surface: got context_scope=${initOutput.context_scope}`);
|
||||
|
||||
// All three used the SAME typed SCOPE.UNREADABLE value — no ad-hoc
|
||||
// per-surface string vocabulary.
|
||||
assert.strictEqual(gapResult.phase_dir_scope, phase3.context_scope);
|
||||
assert.strictEqual(phase3.context_scope, initOutput.context_scope);
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3413,11 +3413,17 @@ describe('#3885 (ADR-3473 §8.5): init callers distinguish unreadable from absen
|
||||
const phaseDirAbs = () => path.join(projectDir, '.planning', 'phases', '03-api');
|
||||
|
||||
describe('cmdInitPlanPhase', () => {
|
||||
// #4014 (epic #3473 B4-unreadable) matrix row 14: a readable, genuinely
|
||||
// context-less phase dir reports context_scope 'complete' — the
|
||||
// additive scope signal adjacent to has_context.
|
||||
test('readablePhaseDirReportsNoReadError (MUST STAY GREEN)', (t) => {
|
||||
const output = captureFd1(t, () => initMod.cmdInitPlanPhase(projectDir, '03', false));
|
||||
assert.strictEqual(output.context_read_error ?? null, null);
|
||||
assert.strictEqual(output.context_scope, 'complete');
|
||||
});
|
||||
|
||||
// #4014 matrix row 13: has_context stays false AND context_scope
|
||||
// distinguishes this from genuine absence.
|
||||
test('unreadablePhaseDirIsNotReportedAsAbsent', (t) => {
|
||||
injectReaddirFailure(t, phaseDirAbs(), 'EACCES');
|
||||
const output = captureFd1(t, () => initMod.cmdInitPlanPhase(projectDir, '03', false));
|
||||
@@ -3425,6 +3431,9 @@ describe('#3885 (ADR-3473 §8.5): init callers distinguish unreadable from absen
|
||||
`an unreadable phase directory must be reported, not silently absent; got: ${JSON.stringify(output.context_read_error)}`);
|
||||
assert.ok(output.context_read_error.includes('03-api'),
|
||||
`the reported error must name the discarded input (the phase directory); got: ${output.context_read_error}`);
|
||||
assert.strictEqual(output.has_context, false);
|
||||
assert.strictEqual(output.context_scope, 'unreadable',
|
||||
`an unreadable phase directory must report context_scope 'unreadable', distinct from a genuinely empty one; got: ${output.context_scope}`);
|
||||
});
|
||||
|
||||
test('raceConditionEnoentStaysAGenuineSilentDegrade (MUST STAY GREEN)', (t) => {
|
||||
@@ -3436,11 +3445,14 @@ describe('#3885 (ADR-3473 §8.5): init callers distinguish unreadable from absen
|
||||
});
|
||||
|
||||
describe('cmdInitPhaseOp', () => {
|
||||
// #4014 matrix row 14 (second surface).
|
||||
test('readablePhaseDirReportsNoReadError (MUST STAY GREEN)', (t) => {
|
||||
const output = captureFd1(t, () => initMod.cmdInitPhaseOp(projectDir, '03', false));
|
||||
assert.strictEqual(output.context_read_error ?? null, null);
|
||||
assert.strictEqual(output.context_scope, 'complete');
|
||||
});
|
||||
|
||||
// #4014 matrix row 13 (second surface).
|
||||
test('unreadablePhaseDirIsNotReportedAsAbsent', (t) => {
|
||||
injectReaddirFailure(t, phaseDirAbs(), 'EACCES');
|
||||
const output = captureFd1(t, () => initMod.cmdInitPhaseOp(projectDir, '03', false));
|
||||
@@ -3448,6 +3460,9 @@ describe('#3885 (ADR-3473 §8.5): init callers distinguish unreadable from absen
|
||||
`an unreadable phase directory must be reported, not silently absent; got: ${JSON.stringify(output.context_read_error)}`);
|
||||
assert.ok(output.context_read_error.includes('03-api'),
|
||||
`the reported error must name the discarded input (the phase directory); got: ${output.context_read_error}`);
|
||||
assert.strictEqual(output.has_context, false);
|
||||
assert.strictEqual(output.context_scope, 'unreadable',
|
||||
`an unreadable phase directory must report context_scope 'unreadable', distinct from a genuinely empty one; got: ${output.context_scope}`);
|
||||
});
|
||||
|
||||
test('raceConditionEnoentStaysAGenuineSilentDegrade (MUST STAY GREEN)', (t) => {
|
||||
|
||||
@@ -499,32 +499,42 @@ describe('bug #3739 — gap-analysis padded-prefix CONTEXT.md', () => {
|
||||
});
|
||||
|
||||
// ── Test 5: findContextMdIn helper unit test ─────────────────────────────
|
||||
//
|
||||
// #4014 (epic #3473 B4-unreadable) matrix rows 1-3: the directory-string
|
||||
// form now returns `{ file, files, scope }` instead of a bare string/null
|
||||
// — `.file` carries what these tests used to assert directly on the
|
||||
// return value.
|
||||
|
||||
test('findContextMdIn helper returns padded filename when present', () => {
|
||||
test('findContextMdIn helper returns padded filename when present (matrix row 2)', () => {
|
||||
const { findContextMdIn } = require('../gsd-core/bin/lib/planning-workspace.cjs');
|
||||
// Write 01-CONTEXT.md into the phase dir (already created in beforeEach)
|
||||
fs.writeFileSync(path.join(phaseDir, '01-CONTEXT.md'), '# context\n');
|
||||
|
||||
const found = findContextMdIn(phaseDir);
|
||||
assert.strictEqual(found, '01-CONTEXT.md',
|
||||
const result = findContextMdIn(phaseDir);
|
||||
assert.strictEqual(result.file, '01-CONTEXT.md',
|
||||
'findContextMdIn must return the padded-prefix filename');
|
||||
assert.ok(result.files.includes('01-CONTEXT.md'), 'files must include the raw listing');
|
||||
assert.strictEqual(result.scope, 'complete');
|
||||
});
|
||||
|
||||
test('findContextMdIn helper returns bare filename when only bare form exists', () => {
|
||||
test('findContextMdIn helper returns bare filename when only bare form exists (matrix row 1)', () => {
|
||||
const { findContextMdIn } = require('../gsd-core/bin/lib/planning-workspace.cjs');
|
||||
fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), '# context\n');
|
||||
|
||||
const found = findContextMdIn(phaseDir);
|
||||
assert.strictEqual(found, 'CONTEXT.md',
|
||||
const result = findContextMdIn(phaseDir);
|
||||
assert.strictEqual(result.file, 'CONTEXT.md',
|
||||
'findContextMdIn must return CONTEXT.md for bare form');
|
||||
assert.strictEqual(result.scope, 'complete');
|
||||
});
|
||||
|
||||
test('findContextMdIn helper returns null when no CONTEXT.md exists', () => {
|
||||
test('findContextMdIn helper returns null file when no CONTEXT.md exists (matrix row 3)', () => {
|
||||
const { findContextMdIn } = require('../gsd-core/bin/lib/planning-workspace.cjs');
|
||||
// phaseDir exists but is empty (no CONTEXT.md)
|
||||
const found = findContextMdIn(phaseDir);
|
||||
assert.strictEqual(found, null,
|
||||
'findContextMdIn must return null when no CONTEXT.md exists');
|
||||
const result = findContextMdIn(phaseDir);
|
||||
assert.strictEqual(result.file, null,
|
||||
'findContextMdIn must return a null file when no CONTEXT.md exists');
|
||||
assert.strictEqual(result.scope, 'complete',
|
||||
'a successfully-read, genuinely context-less directory is scope complete, not unreadable');
|
||||
});
|
||||
|
||||
// ── Test 5b: findContextMdIn accepts pre-read files array (avoids double readdirSync) ──
|
||||
@@ -551,8 +561,8 @@ describe('bug #3739 — gap-analysis padded-prefix CONTEXT.md', () => {
|
||||
fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), '# bare context\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '01-CONTEXT.md'), '# padded context\n');
|
||||
|
||||
const found = findContextMdIn(phaseDir);
|
||||
assert.strictEqual(found, 'CONTEXT.md',
|
||||
const result = findContextMdIn(phaseDir);
|
||||
assert.strictEqual(result.file, 'CONTEXT.md',
|
||||
'findContextMdIn must return bare CONTEXT.md when both forms exist — matches pre-refactor gap-checker behavior');
|
||||
});
|
||||
|
||||
@@ -583,13 +593,25 @@ describe('bug #3739 — gap-analysis padded-prefix CONTEXT.md', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── bug #1883: findContextMdIn must not swallow permission/I-O errors ───────
|
||||
// A catch-all `catch { return null }` conflated a genuine ENOENT ("nothing there")
|
||||
// with an EACCES/EIO failure ("can't read this"), so an unreadable phase dir was
|
||||
// silently reported as "no CONTEXT.md" — discuss/plan gates then wrongly believed
|
||||
// context had never been gathered. The narrowed catch re-throws every non-ENOENT
|
||||
// error and keeps returning null only for genuine absence.
|
||||
describe('bug #1883 — findContextMdIn distinguishes a permission error from emptiness', () => {
|
||||
// ─── bug #1883 / #4014: findContextMdIn distinguishes unreadable from absent ──
|
||||
// A catch-all `catch { return null }` originally conflated a genuine ENOENT
|
||||
// ("nothing there") with an EACCES/EIO failure ("can't read this"), so an
|
||||
// unreadable phase dir was silently reported as "no CONTEXT.md" — discuss/
|
||||
// plan gates then wrongly believed context had never been gathered. #1883's
|
||||
// fix re-threw every non-ENOENT error to surface the distinction to a caller
|
||||
// via try/catch.
|
||||
//
|
||||
// #4014 (epic #3473 B4-unreadable) changes the SHAPE of that fix: throwing
|
||||
// pushed every one of findContextMdIn's callers into hand-rolling their own
|
||||
// try/catch (five call sites, independently — the exact duplication this
|
||||
// epic's B4 item closes). The directory-string form now never throws —
|
||||
// ENOENT and a successful read both resolve to `scope: SCOPE.COMPLETE`
|
||||
// (ENOENT is a genuine "nothing there yet" answer, not a failure — ADR-3180's
|
||||
// existing COMPLETE-with-zero-items contract), and every other read error
|
||||
// resolves to `scope: SCOPE.UNREADABLE` instead of propagating an exception.
|
||||
// Matrix rows 4 and 5 below are the identity pair this item exists to keep
|
||||
// distinguishable: both share `file: null`, but only row 5's `scope` differs.
|
||||
describe('bug #1883 / #4014 — findContextMdIn distinguishes unreadable from absent', () => {
|
||||
const { findContextMdIn } = require('../gsd-core/bin/lib/planning-workspace.cjs');
|
||||
|
||||
// Helper: build a Node-style error with a `code`, matching what fs.readdirSync throws.
|
||||
@@ -601,48 +623,56 @@ describe('bug #1883 — findContextMdIn distinguishes a permission error from em
|
||||
return err;
|
||||
}
|
||||
|
||||
test('findContextMdIn re-throws a permission (EACCES) error instead of swallowing it as null', (t) => {
|
||||
// Matrix row 5 — negative, the defect this issue fixes: a directory that
|
||||
// exists but cannot be read must be reported as scope 'unreadable', not
|
||||
// silently collapsed to the same shape row 4 (genuine absence) produces.
|
||||
test('findContextMdIn reports scope unreadable on a permission (EACCES) error instead of throwing (matrix row 5)', (t) => {
|
||||
// No chmod 0o000 — root bypasses mode bits (silent zero coverage in root CI).
|
||||
// t.mock auto-restores after the test.
|
||||
t.mock.method(fs, 'readdirSync', () => { throw fsError('EACCES'); });
|
||||
assert.throws(
|
||||
() => findContextMdIn('/denied/phase-dir'),
|
||||
(err) => err.code === 'EACCES',
|
||||
'an unreadable dir must propagate EACCES, not return null as if empty',
|
||||
);
|
||||
const result = findContextMdIn('/denied/phase-dir');
|
||||
assert.deepStrictEqual(result, { file: null, files: [], scope: 'unreadable' },
|
||||
'an unreadable dir must report scope unreadable, not throw and not look like an absent dir');
|
||||
});
|
||||
|
||||
test('findContextMdIn re-throws any non-ENOENT error (EIO)', (t) => {
|
||||
test('findContextMdIn reports scope unreadable on any non-ENOENT error (EIO)', (t) => {
|
||||
t.mock.method(fs, 'readdirSync', () => { throw fsError('EIO'); });
|
||||
assert.throws(
|
||||
() => findContextMdIn('/io-failure/phase-dir'),
|
||||
(err) => err.code === 'EIO',
|
||||
'every non-ENOENT error must propagate — the narrowed catch only keeps ENOENT',
|
||||
);
|
||||
const result = findContextMdIn('/io-failure/phase-dir');
|
||||
assert.deepStrictEqual(result, { file: null, files: [], scope: 'unreadable' },
|
||||
'every non-ENOENT error must resolve to scope unreadable — the narrowed catch only keeps ENOENT as scope complete');
|
||||
});
|
||||
|
||||
test('findContextMdIn returns null for an absent dir (ENOENT) — empty path unchanged', () => {
|
||||
// Matrix row 4 — boundary, the "real empty" case: a directory that
|
||||
// genuinely does not exist must NOT become unreadable.
|
||||
test('findContextMdIn reports scope complete with a null file for an absent dir (ENOENT) (matrix row 4)', () => {
|
||||
// A path that genuinely does not exist yields ENOENT from the real OS call.
|
||||
const absent = path.join(os.tmpdir(), 'gsd-1883-does-not-exist-' + process.pid);
|
||||
assert.strictEqual(findContextMdIn(absent), null,
|
||||
'an absent dir (ENOENT) must still return null — Hyrum: empty path unchanged');
|
||||
assert.deepStrictEqual(findContextMdIn(absent), { file: null, files: [], scope: 'complete' },
|
||||
'an absent dir (ENOENT) must report scope complete with a null file — Hyrum: the pre-#4014 null-on-ENOENT contract is unchanged in substance, just re-shaped');
|
||||
});
|
||||
|
||||
test('findContextMdIn finds bare CONTEXT.md', (t) => {
|
||||
test('findContextMdIn finds bare CONTEXT.md (matrix row 1)', (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1883-bare-'));
|
||||
t.after(() => { cleanup(tmp); });
|
||||
fs.writeFileSync(path.join(tmp, 'CONTEXT.md'), '# bare\n');
|
||||
assert.strictEqual(findContextMdIn(tmp), 'CONTEXT.md');
|
||||
const result = findContextMdIn(tmp);
|
||||
assert.strictEqual(result.file, 'CONTEXT.md');
|
||||
assert.strictEqual(result.scope, 'complete');
|
||||
});
|
||||
|
||||
test('findContextMdIn finds padded NN-CONTEXT.md', (t) => {
|
||||
test('findContextMdIn finds padded NN-CONTEXT.md (matrix row 2)', (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1883-padded-'));
|
||||
t.after(() => { cleanup(tmp); });
|
||||
fs.writeFileSync(path.join(tmp, '01-CONTEXT.md'), '# padded\n');
|
||||
assert.strictEqual(findContextMdIn(tmp), '01-CONTEXT.md');
|
||||
const result = findContextMdIn(tmp);
|
||||
assert.strictEqual(result.file, '01-CONTEXT.md');
|
||||
assert.strictEqual(result.scope, 'complete');
|
||||
});
|
||||
|
||||
test('findContextMdIn matches against a pre-read files array without touching fs', (t) => {
|
||||
// Matrix row 6 — the array-input form must not regress: unchanged
|
||||
// string|null return, no object wrapping, and it must never touch fs (the
|
||||
// whole point of accepting a pre-read listing).
|
||||
test('findContextMdIn matches against a pre-read files array without touching fs (matrix row 6)', (t) => {
|
||||
// The array path never calls readdirSync — exercised by getPhaseFileStats,
|
||||
// countPhasePlansAndSummaries, runGapAnalysis. Must keep working untouched.
|
||||
t.mock.method(fs, 'readdirSync', () => { throw new Error('array path must not call fs'); });
|
||||
@@ -651,7 +681,7 @@ describe('bug #1883 — findContextMdIn distinguishes a permission error from em
|
||||
assert.strictEqual(findContextMdIn(['02-CONTEXT.md']), '02-CONTEXT.md',
|
||||
'array path matches padded form');
|
||||
assert.strictEqual(findContextMdIn(['PLAN.md']), null,
|
||||
'array path returns null when no match');
|
||||
'array path returns null when no match — no object wrapping for the array form');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4312,6 +4312,53 @@ describe('#3885 (ADR-3473 §8.5): countPhasePlansAndSummaries distinguishes unre
|
||||
`ENOENT must be treated as genuinely absent, not reported as an error; got: ${phase.context_read_error}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ─── #4014 (epic #3473 B4-unreadable) matrix rows 7-9 ───────────────────
|
||||
//
|
||||
// `countPhasePlansAndSummaries` (not exported — driven through
|
||||
// cmdRoadmapAnalyze, the same style as T61/T62/T64 above) gains a
|
||||
// `context_scope` field on its result, surfaced on `AnalyzePhase` as
|
||||
// `context_scope` — the typed SCOPE-enum sibling of the existing
|
||||
// `context_read_error` string field.
|
||||
|
||||
test('#4014 matrix row 7: readable phase dir with CONTEXT.md reports has_context:true, context_scope:complete', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), '# context\n');
|
||||
|
||||
const output = runAnalyzeCapturingStdout(tmpDir);
|
||||
const phase = findPhase3(output);
|
||||
assert.strictEqual(phase.has_context, true);
|
||||
assert.strictEqual(phase.context_scope, 'complete');
|
||||
assert.strictEqual(phase.context_read_error ?? null, null);
|
||||
});
|
||||
|
||||
test('#4014 matrix row 8: unreadable phase dir reports context_scope:unreadable, distinct from a genuinely empty phase dir', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n## Task 1\n');
|
||||
|
||||
const restore = injectReaddirFailure(phaseDir, 'EACCES');
|
||||
let output;
|
||||
try {
|
||||
output = runAnalyzeCapturingStdout(tmpDir);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
const phase = findPhase3(output);
|
||||
assert.strictEqual(phase.has_context, false);
|
||||
assert.strictEqual(phase.context_scope, 'unreadable',
|
||||
`an unreadable phase directory must report context_scope 'unreadable', distinct from a genuinely empty one; got: ${phase.context_scope}`);
|
||||
});
|
||||
|
||||
test('#4014 matrix row 9: genuinely absent phase dir reports context_scope:complete (boundary — not unreadable)', () => {
|
||||
// No phase directory created at all — matches T64's ENOENT-shaped absence.
|
||||
const output = runAnalyzeCapturingStdout(tmpDir);
|
||||
const phase = findPhase3(output);
|
||||
assert.strictEqual(phase.has_context, false);
|
||||
assert.strictEqual(phase.context_scope, 'complete',
|
||||
`a genuinely absent phase directory must report context_scope 'complete', not 'unreadable'; got: ${phase.context_scope}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('findSlugDerivationDrift — T3-T5: sanctioned sites are exempted BY th
|
||||
|
||||
describe('findSlugDerivationDrift — MAJOR-1: allowlist exemption is scoped to the REAL function body, not "until the next top-level function"', () => {
|
||||
const sanctionedRealEndLines = [
|
||||
{ file: path.join('src', 'core-utils.cts'), fn: 'generateSlugInternal', realEndLine: 193 },
|
||||
{ file: path.join('src', 'core-utils.cts'), fn: 'generateSlugInternal', realEndLine: 199 },
|
||||
{ file: path.join('src', 'gsd2-import.cts'), fn: 'slugify', realEndLine: 103 },
|
||||
{ file: path.join('src', 'runtime-artifact-conversion.cts'), fn: 'normalizeKimiSkillName', realEndLine: 616 },
|
||||
{ file: path.join('scripts', 'generate-package-identity.cjs'), fn: 'slugifyPackageName', realEndLine: 42 },
|
||||
|
||||
Reference in New Issue
Block a user