What happened?
#11848's triage comment names three consumers of isSameFile whose --out alias guard failed open on a volume with file ids above 2^53, and asks that the fix "be validated against all three":
packages/cli/src/commands/review/findings.ts (--to-anchors)
packages/cli/src/commands/review/repo-context.ts (--out vs --plan)
packages/cli/src/commands/review/save-artifact.ts:654 — if (isSameFile(outputPath, inputPath)) { throw new Error(...) }, run for the findings input, the composed input and the Markdown report.
#12568 converts the shared comparator (review/lib/same-file.ts tryStat) to stat(..., { bigint: true }), so in production all three guards get the exact 64-bit id at once. Two of the three keep a hard-link witness:
findings.test.ts — refuses a --to-anchors hardlinked to a sibling file, gated if (inode <= 0) { ctx.skip(); return; }
repo-context.test.ts — rejects plan/out aliases and preserves the plan on artifact failure, same gate
save-artifact.test.ts has no hard-link case at all. Its overwrite coverage is the same-path it.each (refuses to overwrite the %s input), the symlinked-absent-input case (refuses an absent output spelled through a symlink onto an absent input) and the case-insensitive alias — all shapes the pre-fix realpathSync.native fallback already caught.
Why it matters
Nothing in the save-artifact suite goes red if isSameFile loses hard-link sight again. Measured during #12568's review round with the mutant isSameFile(outputPath, inputPath) → outputPath === inputPath:
save-artifact.test.ts 67 passed | 1 skipped GREEN <- the guard is inert here
findings.test.ts 'refuses a --to-anchors hardlinked to a sibling file' RED
repo-context.test.ts 'rejects plan/out aliases ...' RED
So a future weakening of the shared comparator is caught by two suites and silently missed by the third — the artifact overwrite it protects ships broken with CI green.
Production behaviour is not broken today: this is a regression-witness gap, not a live fail-open.
Suggested fix
One case beside refuses to overwrite the %s input in save-artifact.test.ts:
- build the fixture, then
linkSync(paths.findings, paths.out);
- assert
saveReviewArtifact({ ...paths, target: 'local', effort: 'medium' }) throws /must not overwrite the findings input/;
- assert
readFileSync(paths.findings, 'utf8') is unchanged (the refusal precedes every write).
Gate it exactly like the two sibling witnesses — const inode = statSync(paths.findings).ino; if (inode <= 0) { ctx.skip(); return; } — because on an ino-0 volume (FAT/exFAT/SMB) a hard link is undetectable by design and an ungated case would red there for a non-defect.
Acceptance: the new case must go red when isSameFile(outputPath, inputPath) at save-artifact.ts:654 is replaced with outputPath === inputPath, while every existing save-artifact alias case stays green.
Why this is tracked separately
#12568 is scoped to the two comparators it converts, and the remaining guard-by-guard work is being split one issue at a time: #11877 (conversation-workspace + acpAgent comparators) and #12574 / #12577 (repo-context's two plan-identity guards). Source: the R1-1 review thread on #12568.
What happened?
#11848's triage comment names three consumers ofisSameFilewhose--outalias guard failed open on a volume with file ids above 2^53, and asks that the fix "be validated against all three":packages/cli/src/commands/review/findings.ts(--to-anchors)packages/cli/src/commands/review/repo-context.ts(--outvs--plan)packages/cli/src/commands/review/save-artifact.ts:654—if (isSameFile(outputPath, inputPath)) { throw new Error(...) }, run for the findings input, the composed input and the Markdown report.#12568converts the shared comparator (review/lib/same-file.tstryStat) tostat(..., { bigint: true }), so in production all three guards get the exact 64-bit id at once. Two of the three keep a hard-link witness:findings.test.ts—refuses a --to-anchors hardlinked to a sibling file, gatedif (inode <= 0) { ctx.skip(); return; }repo-context.test.ts—rejects plan/out aliases and preserves the plan on artifact failure, same gatesave-artifact.test.tshas no hard-link case at all. Its overwrite coverage is the same-pathit.each(refuses to overwrite the %s input), the symlinked-absent-input case (refuses an absent output spelled through a symlink onto an absent input) and the case-insensitive alias — all shapes the pre-fixrealpathSync.nativefallback already caught.Why it matters
Nothing in the
save-artifactsuite goes red ifisSameFileloses hard-link sight again. Measured during#12568's review round with the mutantisSameFile(outputPath, inputPath)→outputPath === inputPath:So a future weakening of the shared comparator is caught by two suites and silently missed by the third — the artifact overwrite it protects ships broken with CI green.
Production behaviour is not broken today: this is a regression-witness gap, not a live fail-open.
Suggested fix
One case beside
refuses to overwrite the %s inputinsave-artifact.test.ts:linkSync(paths.findings, paths.out);saveReviewArtifact({ ...paths, target: 'local', effort: 'medium' })throws/must not overwrite the findings input/;readFileSync(paths.findings, 'utf8')is unchanged (the refusal precedes every write).Gate it exactly like the two sibling witnesses —
const inode = statSync(paths.findings).ino; if (inode <= 0) { ctx.skip(); return; }— because on an ino-0 volume (FAT/exFAT/SMB) a hard link is undetectable by design and an ungated case would red there for a non-defect.Acceptance: the new case must go red when
isSameFile(outputPath, inputPath)atsave-artifact.ts:654is replaced withoutputPath === inputPath, while every existingsave-artifactalias case stays green.Why this is tracked separately
#12568is scoped to the two comparators it converts, and the remaining guard-by-guard work is being split one issue at a time:#11877(conversation-workspace + acpAgent comparators) and#12574/#12577(repo-context's two plan-identity guards). Source: the R1-1 review thread on#12568.