Skip to content

test(review): add hard-link witness test to save-artifact overwrite guard (#12578) - #12581

Merged
yiliang114 merged 2 commits into
QwenLM:mainfrom
holny:fix/save-artifact-hardlink-witness
Sep 24, 2026
Merged

yiliang114 merged 2 commits into
QwenLM:mainfrom
holny:fix/save-artifact-hardlink-witness

Conversation

@holny

@holny holny commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Adds one hard-link witness test to packages/cli/src/commands/review/save-artifact.test.ts, placed beside the existing refuses to overwrite the %s input it.each: an output path hard-linked to the findings input must make saveReviewArtifact throw must not overwrite the findings input.

Why it's needed

Issue #12578 notes that of the three consumers of the shared isSameFile comparator named in #11848's triage, findings and repo-context each keep a hard-link witness that goes red if the comparator ever loses inode sight, while save-artifact had none — every overwrite shape it covered (same path, symlink traversal, case-insensitive alias) is also caught by a string-compare fallback, so a regression to that shape would ship green.

Since #12568 landed, isSameFile stats with { bigint: true }, so a 64-bit NTFS file id above 2^53 arrives exact and the guard works there; the new test uses the same narrow inode <= 0 gate as the two sibling witnesses (only an unusable inode skips).

Reviewer Test Plan

How to verify

  • npx vitest run packages/cli/src/commands/review/save-artifact.test.ts — 69/69 pass, including refuses to overwrite the findings input when the output is hardlinked to it (#12578).
  • Mutation check (per the maintainer review): reduce isSameFile(outputPath, inputPath) to a plain === and the new case goes red; make the comparator inode-blind (always realpathSync) and it is the only red row in this suite.
  • Gate form matches findings.test.ts and repo-context.test.ts: statSync(path).ino; if (inode <= 0) ctx.skip() inside a plain it(name, (ctx) => …).

Evidence (Before & After)

Before: no hard-link case in this suite; the same-path it.each at line ~832 pins the identity guard but not the dev/ino comparison under a second name.

After: a hard-linked output is refused with must not overwrite the findings input, and the input file's bytes are unchanged.

Tested on

  • Local dev runner: 69/69 save-artifact.test.ts pass; tsc --noEmit clean for this file (baseline errors in unrelated acp-integration / config files unchanged); prettier --check and eslint clean.

Risk & Scope

  • Test-only change. One new it case; no production code touched.
  • The three guard labels (findings, composed, report) share one isSameFile call shape in a single loop, so the findings witness pins the comparison for all three — one case is sufficient (as scoped in save-artifact's isSameFile overwrite guard has no hard-link witness (follow-up from #11848) #12578's thread).
  • Placement beside the overwrite it.each so a reader auditing overwrite coverage sees both the same-path and hard-link shapes together.

Linked Issues

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking this up — the witness mechanism itself is right. I verified it locally against both mutants that matter: with isSameFile(outputPath, inputPath) reduced to outputPath === inputPath the new cases go red, and with an inode-blind isSameFile that always falls through to realpathSync they are the only red rows in the suite. So the discriminating power is there.

A few things need to align with #12578 and the sibling witnesses before this lands:

1. The gate is the pre-#12568 form (the one must-fix). #12568 merged today and converted isSameFile to stat(..., { bigint: true }), so a 64-bit NTFS file id above 2^53 now arrives exact and the guard is no longer inert there. Both sibling witnesses (findings.test.ts, repo-context.test.ts) moved to the narrow gate:

const inode = statSync(path).ino;
if (inode <= 0) {
  ctx.skip();
  return;
}

The !Number.isSafeInteger(inode) || inode <= 0 form here over-skips on exactly the high-id NTFS volumes this series (#11848) is about — the guard works there now, so the witness should run, not skip. It also contradicts this PR's own comment, which already describes the post-#12568 bigint behavior.

2. One case, not three. #12578 scopes this to a single case beside refuses to overwrite the %s input, and the triage thread on the issue explains why: the three labels share one isSameFile call in a single loop, so a hard-link witness on findings pins dev/ino sight for all three. Could you collapse the it.each to one it on findings and tighten the assertion to /must not overwrite the findings input/?

3. Placement and cleanup. The case belongs beside the existing overwrite it.each, not the traversal-guard block — that's where someone auditing overwrite coverage will look. The rmSync(alias) calls can go: afterEach already removes the temp root recursively, and neither sibling witness cleans up its link.

On CI: the six failures all died in Install dependencies / Build cli — infrastructure, not this change. A rebase onto current main should pick up a healthy pipeline, and is needed anyway for the gate in point 1.

…ard (QwenLM#12578)

Issue QwenLM#12578: of the three consumers of isSameFile named in QwenLM#11848's
triage, two (findings, repo-context) keep a hard-link witness that
catches the fail-open case when the shared comparator's
`stat(..., { bigint: true })` post-QwenLM#12568 regresses to a string-identity
check. save-artifact's overwrite guard at save-artifact.ts:649-657 had
no such witness — every overwrite shape it covered was already caught
by the pre-fix realpathSync.native fallback.

Add a 3-row it.each mirroring the findings.test.ts / repo-context.test.ts
hard-link gate (`inode <= 0 / !Number.isSafeInteger(inode) → skip`), one
per consumer (findings, composed, report). The existing same-path
test at line 874 covers the no-link case; this row covers the
hard-link case where the output is a different path string but the
same inode as one of the three inputs.

Fixes QwenLM#12578. No production code changes.
…te, plain it ctx

Reviewer findings on QwenLM#12581 (two rounds of CHANGES_REQUESTED):

- it.each does not receive a TestContext in vitest 3.2.7, so ctx was
  undefined and ctx.skip() a runtime TypeError on the ino<=0 volumes
  the gate exists for (and a tsc error). Rewrite as a plain
  it(name, (ctx) => ...) like findings.test.ts:1448.
- Narrow the gate to inode <= 0: QwenLM#12568 merged and isSameFile now
  stats with { bigint: true }, so 64-bit ids above 2^53 are exact and
  the guard works there — the old !Number.isSafeInteger form
  over-skips exactly the volumes this series (QwenLM#11848) targets.
- Collapse the three it.each rows to one case on the findings input:
  the three labels share one isSameFile call in a single loop, so one
  witness pins all three (per QwenLM#12578's thread).
- Move the case beside the existing overwrite it.each instead of the
  traversal-guard block; drop manual rmSync (afterEach removes root).
- Rebase onto current main (picks up QwenLM#12568 and a healthy pipeline).
@holny
holny force-pushed the fix/save-artifact-hardlink-witness branch from cf14710 to d92c3fd Compare September 24, 2026 06:55
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the revision addresses all three points from the previous round: a single hard-link witness on the findings input placed beside the overwrite it.each, the narrow inode <= 0 gate matching the post-#12568 sibling witnesses, and the cleanup boilerplate is gone. Full matrix green on d92c3fd (ubuntu Test, Lint & Static, no-AK Integration, TUI parity, OpenTUI no-flicker, web-shell E2E all pass). Thanks for the quick turnaround.

@yiliang114
yiliang114 dismissed a stale review September 24, 2026 13:58

Stale: this CHANGES_REQUESTED targeted the pre-revision head (7279391). The revision d92c3fd addressed all points, and the bot's own re-review on the new head passed (review-pr success).

@yiliang114 yiliang114 changed the title fix(review): add hard-link witness test to save-artifact overwrite guard (#12578) test(review): add hard-link witness test to save-artifact overwrite guard (#12578) Sep 24, 2026

@qwen-code-review-bot qwen-code-review-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at d92c3fdf. Independent second pass on a test-only diff.

The part worth confirming was whether the discriminating power survives collapsing three cases into one, so I checked that by reading rather than rerunning the mutants. A string-identity isSameFile lets alias !== paths.findings through, the write lands, and both the toThrow(/must not overwrite the findings input/) and the unchanged-content assertion go red. An inode-blind one that falls through to realpathSync also goes red, because realpathSync resolves symlinks but not hard links, so the two names stay distinct path strings. Both mutants from the earlier round are still killed by the single case.

Placement and gate match the sibling witnesses: the case sits directly after the it.each(['findings', 'composed', 'report']) overwrite block, which is where someone auditing overwrite coverage looks, and the inode <= 0 skip is the post-#12568 narrow form rather than the !Number.isSafeInteger(inode) one that would over-skip on exactly the high-id NTFS volumes this series is about.

18 checks pass, 8 skipped, none failing at this head; no unresolved threads.

@yiliang114
yiliang114 added this pull request to the merge queue Sep 24, 2026
Merged via the queue into QwenLM:main with commit 3e304ab Sep 24, 2026
82 of 84 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

save-artifact's isSameFile overwrite guard has no hard-link witness (follow-up from #11848)

3 participants