* fix(#4685): a directory artifact fails its own entry instead of aborting the check `must_haves.artifacts` entries are read with `safeReadFile`, which rethrows every errno except ENOENT. A listed path that is a directory therefore threw EISDIR out of the per-artifact loop: `query verify.artifacts` printed Error: EISDIR: illegal operation on a directory, read and reported NOTHING — not the offending entry, and not the plan's other, perfectly checkable artifacts. One directory entry disabled the whole plan's check. Reproduced against a real plan before the fix, and after. A directory is now reported as that entry's own failure, with an issue distinct from `File not found` (the path did resolve; it simply is not the thing an artifact entry can be checked against), and every other artifact in the plan is still checked and reported independently. Anything else the stat or read throws becomes that entry's failure too, carrying its errno, rather than discarding the run — a check that disappears is worse than one that fails, because a failure is visible. Verifying directories properly — matching `contains:`/`min_lines:`/`exports:` across the files inside one — is a feature decision and deliberately not made here, per the issue's stated scope. Authoring-time rejection of a directory path is likewise left alone: the brief raises it as a separate question, and the runtime fix does not depend on it. Also, found in pre-PR review and pre-existing: `safeReadFile(...) || ''` turned a post-stat ENOENT into empty content, so an artifact declaring only `path`/`provides` had no criterion left to fail and passed, having checked nothing. A null read now reports that instead of inheriting a pass. It can only turn a false pass into a failure. Verified: reverting src/verify.cts to the merge-base turns both new rows red with the exact EISDIR message; lint:ci exit 0; full suite 24/24 chunks, 37,280 tests, 0 failures; tests/verify.test.cjs 219/219. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4mJZpSNwoyVtRVUQoijfL * chore(#4685): backfill changeset PR number to 4735 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4mJZpSNwoyVtRVUQoijfL * test(#4685): pin the injected-I/O branches, and narrow the guard's comment Review findings from #4735. Major — the two error branches this PR adds were untested, and the PR body claimed the mid-check ENOENT window was "not deterministically reproducible through the CLI seam these tests drive." That was wrong: ADR-3574 records this repo's convention for exactly this — inject filesystem failures by monkeypatching the fs method, never by chmod or mode-bit tricks, which root bypasses and yields a test that passes with zero coverage in root Docker and CI. Both branches are now pinned that way. The injection runs in the CHILD via NODE_OPTIONS=--require, because `output()` writes fd 1 directly (`writeAllSync(1, …)`, io.cjs) rather than through console.log, so an in-process call cannot have its JSON captured. The preload patches the child's own module objects, which the compiled code reads at call time. - a file that disappears between stat and read now fails as that entry rather than passing on empty content - a non-ENOENT errno (EACCES) is reported as that entry's failure, carrying its code, so an operator can tell a permissions problem from an I/O one The injection matches the target by path SUFFIX, not string equality: the first cut compared absolute paths, and a /tmp vs /private/tmp prefix difference silently disarmed it — the test passed while asserting nothing. A disarmed injection test is worse than no test, so the reason is recorded at the call site. Nit — the comment above the try block said the guard "covers what the stat and read below actually throw", which reads as if the min_lines/contains/exports checks inside the same block were deliberately guarded too. They are pure string operations and cannot throw; the comment now says so rather than implying a guarantee it does not make. Verified: reverting src/verify.cts to the merge-base turns all three #4685 rows red — the directory row and both new ones; lint:ci exit 0; full suite 24/24 chunks, 37,389 tests, 0 failures; tests/verify.test.cjs 221/221. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4mJZpSNwoyVtRVUQoijfL --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
committed by
GitHub
parent
ecc508139a
commit
febe6c9885
@@ -1695,6 +1695,168 @@ describe('verify artifacts command', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// #4685: a directory-valued artifact path used to abort the WHOLE command.
|
||||
// `safeReadFile`/`platformReadSync` rethrows every errno except ENOENT, so
|
||||
// `fs.readFileSync` on a directory threw EISDIR out of the per-artifact loop and
|
||||
// the command printed `Error: EISDIR: illegal operation on a directory, read`
|
||||
// with no results at all — not for the directory entry, and not for the plan's
|
||||
// other, perfectly checkable artifacts. Reproduced against a real plan before
|
||||
// the fix; these rows are the contract that replaced it.
|
||||
test('#4685: a directory artifact fails as its own entry and does not abort the others', () => {
|
||||
fs.mkdirSync(path.join(tmpDir, 'src', 'snapshots'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'snapshots', 'a.snap'), 'snap\n');
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'hello world\n');
|
||||
writePlanWithArtifacts(tmpDir, [
|
||||
'- path: src/snapshots',
|
||||
' provides: "a directory of snapshots"',
|
||||
'- path: src/app.js',
|
||||
' contains: "hello"',
|
||||
]);
|
||||
|
||||
const result = runGsdTools('verify artifacts .planning/phases/01-test/01-01-PLAN.md', tmpDir);
|
||||
assert.ok(result.success, `Command crashed instead of reporting: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.total, 2, `both artifacts must be checked: ${JSON.stringify(output)}`);
|
||||
assert.strictEqual(output.passed, 1, `the file artifact must still pass: ${JSON.stringify(output)}`);
|
||||
assert.strictEqual(output.all_passed, false);
|
||||
|
||||
const dirCheck = output.artifacts.find((a) => a.path === 'src/snapshots');
|
||||
assert.ok(dirCheck, 'the directory entry must be reported, not swallowed');
|
||||
assert.strictEqual(dirCheck.passed, false);
|
||||
assert.strictEqual(dirCheck.exists, true, 'the path does resolve — this is not "not found"');
|
||||
assert.ok(
|
||||
dirCheck.issues.some((i) => /directory/i.test(i)),
|
||||
`the directory entry needs its own distinct issue, not "File not found": ${JSON.stringify(dirCheck.issues)}`
|
||||
);
|
||||
assert.equal(
|
||||
dirCheck.issues.some((i) => /not found/i.test(i)), false,
|
||||
'a directory that exists must not be reported as missing'
|
||||
);
|
||||
|
||||
// The point of the fix: the OTHER artifact is still independently checked.
|
||||
const fileCheck = output.artifacts.find((a) => a.path === 'src/app.js');
|
||||
assert.ok(fileCheck, 'the file artifact must still be reported');
|
||||
assert.strictEqual(fileCheck.passed, true, `the file artifact is fine and must say so: ${JSON.stringify(fileCheck)}`);
|
||||
assert.deepStrictEqual(fileCheck.issues, []);
|
||||
});
|
||||
|
||||
// The degenerate shape: nothing else in the plan can carry the result, so a
|
||||
// crash here would leave the caller with no verdict at all.
|
||||
test('#4685: a plan whose only artifact is a directory still returns a structured verdict', () => {
|
||||
fs.mkdirSync(path.join(tmpDir, 'src', 'snapshots'), { recursive: true });
|
||||
writePlanWithArtifacts(tmpDir, [
|
||||
'- path: src/snapshots',
|
||||
' provides: "a directory of snapshots"',
|
||||
]);
|
||||
|
||||
const result = runGsdTools('verify artifacts .planning/phases/01-test/01-01-PLAN.md', tmpDir);
|
||||
assert.ok(result.success, `Command crashed instead of reporting: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.total, 1);
|
||||
assert.strictEqual(output.passed, 0);
|
||||
assert.strictEqual(output.all_passed, false, 'a directory-only block must never read as a pass');
|
||||
});
|
||||
|
||||
// #4685 review follow-up: the two error branches this PR ADDS are reachable and
|
||||
// must be pinned deterministically. Per ADR-3574, filesystem failures are injected
|
||||
// by monkeypatching the fs method and restoring after — never by chmod or mode-bit
|
||||
// tricks, which root bypasses (yielding a test that passes with zero coverage in
|
||||
// root Docker and CI).
|
||||
//
|
||||
// The injection runs in the CHILD via `NODE_OPTIONS=--require`, because
|
||||
// `output()` writes fd 1 directly (`writeAllSync(1, …)`, io.cjs) rather than
|
||||
// through console.log, so an in-process call cannot have its JSON captured. The
|
||||
// preload patches the child's own module objects, which the compiled code reads at
|
||||
// call time (`shell_command_projection_cjs_1.platformReadSync(…)`,
|
||||
// `node_fs_1.default.statSync(…)`), and the process exits at the end of the case,
|
||||
// so no restore is needed beyond its lifetime.
|
||||
describe('#4685: injected I/O failures on one artifact (ADR-3574 monkeypatching)', () => {
|
||||
const LIB = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
|
||||
|
||||
function withInjection(mode, targetPath) {
|
||||
const preload = path.join(tmpDir, `inject-${mode}.cjs`);
|
||||
fs.writeFileSync(preload, `
|
||||
const target = ${JSON.stringify(targetPath)};
|
||||
if (${JSON.stringify(mode)} === 'enoent-read') {
|
||||
const sp = require(${JSON.stringify(path.join(LIB, 'shell-command-projection.cjs'))});
|
||||
const orig = sp.platformReadSync;
|
||||
// Match on suffix, not string equality: the child resolves the artifact path
|
||||
// itself, and a /tmp vs /private/tmp prefix difference would silently disarm the
|
||||
// injection and leave the test asserting nothing.
|
||||
sp.platformReadSync = (p, o) => (String(p).endsWith(target) ? null : orig(p, o));
|
||||
} else {
|
||||
const nodeFs = require('node:fs');
|
||||
const orig = nodeFs.statSync;
|
||||
nodeFs.statSync = (p, o) => {
|
||||
if (String(p).endsWith(target)) throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
|
||||
return orig(p, o);
|
||||
};
|
||||
}
|
||||
`);
|
||||
return { NODE_OPTIONS: `--require ${preload}` };
|
||||
}
|
||||
|
||||
function writeFixture() {
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'hello world\n');
|
||||
writePlanWithArtifacts(tmpDir, [
|
||||
'- path: src/app.js',
|
||||
' provides: "a real file, no criteria declared"',
|
||||
]);
|
||||
return path.join('src', 'app.js');
|
||||
}
|
||||
|
||||
test('a file that disappears between stat and read fails instead of passing empty', () => {
|
||||
// The latent bug this PR also fixes: `safeReadFile(...) || ''` turned a
|
||||
// post-stat ENOENT into empty content, and an entry declaring only
|
||||
// `path`/`provides` then had NO criterion left to fail — so it passed, having
|
||||
// checked nothing. platformReadSync returns null on ENOENT, so returning null
|
||||
// reproduces exactly that window.
|
||||
const target = writeFixture();
|
||||
const result = runGsdTools(
|
||||
'verify artifacts .planning/phases/01-test/01-01-PLAN.md',
|
||||
tmpDir,
|
||||
withInjection('enoent-read', target),
|
||||
);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
const check = output.artifacts[0];
|
||||
assert.strictEqual(
|
||||
check.passed, false,
|
||||
`an artifact whose content could not be read must not pass: ${JSON.stringify(check)}`
|
||||
);
|
||||
assert.ok(
|
||||
check.issues.some((i) => /disappeared during check/i.test(i)),
|
||||
`expected the mid-check disappearance to be named: ${JSON.stringify(check.issues)}`
|
||||
);
|
||||
assert.strictEqual(output.all_passed, false);
|
||||
});
|
||||
|
||||
test('a non-ENOENT errno is reported as that entry\'s failure, carrying its code', () => {
|
||||
// The generic catch branch. EACCES is the realistic case (an unreadable parent
|
||||
// directory); the assertion pins that the errno reaches the operator rather
|
||||
// than a generic message, because EACCES and EIO call for different responses.
|
||||
const target = writeFixture();
|
||||
const result = runGsdTools(
|
||||
'verify artifacts .planning/phases/01-test/01-01-PLAN.md',
|
||||
tmpDir,
|
||||
withInjection('eacces-stat', target),
|
||||
);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
const check = output.artifacts[0];
|
||||
assert.strictEqual(check.passed, false);
|
||||
assert.ok(
|
||||
check.issues.some((i) => i.includes('EACCES')),
|
||||
`the errno must reach the operator: ${JSON.stringify(check.issues)}`
|
||||
);
|
||||
assert.strictEqual(output.all_passed, false);
|
||||
});
|
||||
});
|
||||
|
||||
// A MIXED artifacts block (one bare-string prose bullet + one well-formed
|
||||
// `path:` entry) must not be disturbed by the positive-evidence floor: the
|
||||
// string is item-skipped, the real entry is checked, results.length === 1 > 0,
|
||||
|
||||
Reference in New Issue
Block a user