Repository navigation
feat(review): content-anchored incremental rounds for the local review-fix loop #9659
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 21 commits
dff2b78
ee54f8b
0810d06
91d93cb
87b769b
c661806
feb55b5
b295232
ecf5fea
af708af
575d136
8192762
df533ff
3b916d6
4142065
e6aa683
99fff42
1f0fb94
7774eb3
b13eb13
4a24ca5
5dba419
dc74e83
c723d5f
af521c5
0d11241
2f050a6
0cf1b07
8b81762
2e03ecd
9443811
49c2db3
79dfd58
eef3c34
0f70270
518d26f
146ab25
60bb3ce
e94892e
f1d9c62
f425a8c
917e1d4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,311 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| // The TOCTOU withhold branch, in isolation: when the re-capture after hashing | ||
| // returns different bytes, the candidate must NOT be written and the refusal | ||
| // must be said out loud — the one uncertainty in the anchor module that used | ||
| // to fail open. The capture layer is mocked with a stateful fake so the two | ||
| // captures can disagree deterministically; everything downstream is real. | ||
|
|
||
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | ||
| import { | ||
| mkdtempSync, | ||
| rmSync, | ||
| writeFileSync, | ||
| readFileSync, | ||
| existsSync, | ||
| realpathSync, | ||
| } from 'node:fs'; | ||
| import { execFileSync } from 'node:child_process'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
|
|
||
| const stderrLines: string[] = []; | ||
| vi.mock('../../utils/stdioHelpers.js', () => ({ | ||
| writeStdoutLine: vi.fn(), | ||
| writeStderrLine: vi.fn((line: string) => { | ||
| stderrLines.push(line); | ||
| }), | ||
| writeStderrLineSafe: vi.fn(), | ||
| })); | ||
|
|
||
| const captures: Array<{ diff: Buffer }> = []; | ||
| /** | ||
| * Successive `hashWorktreeFiles` answers, when a test needs the passes to | ||
| * disagree. Empty means "use the real one" — every other test in this file | ||
| * hashes for real. A list shorter than the number of passes REPEATS its last | ||
| * entry, which reads as "the tree stopped moving": a fixture says what it is | ||
| * about and the guard's extra samples see a settled tree. | ||
| */ | ||
| const hashPasses: Array<Record<string, string>> = []; | ||
| vi.mock('./lib/local-anchor.js', async (importOriginal) => { | ||
| const real = await importOriginal<typeof import('./lib/local-anchor.js')>(); | ||
| return { | ||
| ...real, | ||
| hashWorktreeFiles: (...args: Parameters<typeof real.hashWorktreeFiles>) => | ||
| hashPasses.length > 0 | ||
| ? ((hashPasses.length > 1 | ||
| ? hashPasses.shift() | ||
| : hashPasses[0]) as Record<string, string>) | ||
| : real.hashWorktreeFiles(...args), | ||
| }; | ||
| }); | ||
| vi.mock('./lib/local-diff.js', async (importOriginal) => { | ||
| const real = await importOriginal<typeof import('./lib/local-diff.js')>(); | ||
| return { | ||
| ...real, | ||
| captureLocalDiff: vi.fn(() => { | ||
| const next = captures.length > 1 ? captures.shift() : captures[0]; | ||
| if (!next) throw new Error('fixture exhausted'); | ||
| return { | ||
| diff: next.diff, | ||
| untracked: [], | ||
| skipped: [], | ||
| unbornHead: false, | ||
| repoRoot: repo, | ||
| }; | ||
| }), | ||
| }; | ||
| }); | ||
|
|
||
| import { captureLocalCommand } from './capture-local.js'; | ||
| import { isolateHostGitConfig } from './lib/test-utils.js'; | ||
|
|
||
| let repo: string; | ||
| let cwd: string; | ||
| let gitIsolation: ReturnType<typeof isolateHostGitConfig>; | ||
|
|
||
| beforeEach(() => { | ||
| stderrLines.length = 0; | ||
| captures.length = 0; | ||
| hashPasses.length = 0; | ||
| repo = realpathSync(mkdtempSync(join(tmpdir(), 'review-toctou-'))); | ||
| cwd = process.cwd(); | ||
| process.chdir(repo); | ||
| gitIsolation = isolateHostGitConfig(); | ||
| const git = (...args: string[]) => | ||
| execFileSync('git', args, { cwd: repo, encoding: 'utf8' }); | ||
| git('init', '-q', '--template=', '.'); | ||
| git('config', 'user.email', 'a@b'); | ||
| git('config', 'user.name', 'a'); | ||
| git('config', 'commit.gpgsign', 'false'); | ||
| writeFileSync(join(repo, 'a.ts'), 'export const a = 1;\n'); | ||
| git('add', '-A'); | ||
| git('commit', '-q', '--no-verify', '-m', 'base'); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| process.chdir(cwd); | ||
| rmSync(repo, { recursive: true, force: true }); | ||
| gitIsolation.dispose(); | ||
| }); | ||
|
|
||
| const DIFF_A = Buffer.from( | ||
| 'diff --git a/a.ts b/a.ts\nindex 000..111 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1,1 +1,1 @@\n-export const a = 1;\n+export const a = 2;\n', | ||
| 'utf8', | ||
| ); | ||
|
|
||
| function run(extra: Record<string, unknown> = {}): void { | ||
| (captureLocalCommand.handler as (argv: unknown) => void)({ | ||
| out: join(repo, 'plan.json'), | ||
| target: 'local', | ||
| untracked: true, | ||
| ...extra, | ||
| }); | ||
| } | ||
|
|
||
| /** Read the plan report `run()` just wrote. */ | ||
| function report(): { incremental?: unknown; diffPath: string } { | ||
| return JSON.parse(readFileSync(join(repo, 'plan.json'), 'utf8')) as { | ||
| incremental?: unknown; | ||
| diffPath: string; | ||
| }; | ||
| } | ||
|
|
||
| describe('capture-local — TOCTOU candidate withholding', () => { | ||
| it('a tree that moved between capture and hash withholds the candidate, out loud', () => { | ||
| captures.push( | ||
| { diff: DIFF_A }, | ||
| { diff: Buffer.from('changed mid-hash\n') }, | ||
| ); | ||
| run(); | ||
| expect( | ||
| existsSync( | ||
| join(repo, '.qwen/tmp/qwen-review-local-cache-candidate.json'), | ||
| ), | ||
| ).toBe(false); | ||
| expect(stderrLines.join('\n')).toContain( | ||
| 'working tree changed while the capture was being hashed', | ||
| ); | ||
| }); | ||
|
|
||
| it('a withhold REMOVES an earlier candidate left at the stable path', () => { | ||
| // The candidate path is stable per target: round A's candidate still | ||
| // sits there when round B withholds, and round B's plan still publishes | ||
| // `cacheCandidatePath` — so Step 8 read the stale file and promoted | ||
| // round A's anchor merged with round B's ledger. The withhold must | ||
| // leave the published path actually ABSENT. | ||
| captures.push({ diff: DIFF_A }, { diff: Buffer.from(DIFF_A) }); | ||
| run(); | ||
| expect( | ||
| existsSync( | ||
| join(repo, '.qwen/tmp/qwen-review-local-cache-candidate.json'), | ||
| ), | ||
| ).toBe(true); | ||
|
|
||
| // Round B: the tree moves under the hash pass — the withhold path. | ||
| captures.push( | ||
| { diff: DIFF_A }, | ||
| { diff: Buffer.from('changed mid-hash\n') }, | ||
| ); | ||
| run(); | ||
| expect( | ||
| existsSync( | ||
| join(repo, '.qwen/tmp/qwen-review-local-cache-candidate.json'), | ||
| ), | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it('a moved tree refuses THIS round\u2019s scoping too, not just the candidate', () => { | ||
| // Withholding only the candidate protects the NEXT round and leaves this | ||
| // one wrong: the scoping compares the very hashes the guard just proved | ||
| // may not describe the capture under review. A file edited during the | ||
| // hash pass and reverted before it is hashed reads as unchanged, | ||
| // `changedSince` reports nothing, and its diff section is sliced out — | ||
| // the round then says "nothing to re-review" over a capture no agent | ||
| // read. Promote a real candidate first, so the anchor is otherwise | ||
| // valid and the refusal can only come from the guard. | ||
| captures.push({ diff: DIFF_A }, { diff: Buffer.from(DIFF_A) }); | ||
| run({ model: 'model-a' }); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred to the next round. This round's batch was capped at the six round-3 Criticals, all fixed in 87b769b (comma-driver fold, directory plumbing targets, ignore-rules exemption, directory-target tracked drop, cap-before-parse, file-review prose). R1-5 the toctou suite's unused 中文说明延迟到下一轮处理。本轮批次上限为六个 round-3 Critical,已在 87b769b 中全部修复(逗号驱动名折叠、目录形 plumbing 目标、ignore 规则豁免、目录目标的 tracked 剔除、限额先于解析、文件评审措辞)。R1-5:toctou 套件中未使用的
Comment on lines
+181
to
+182
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred to the next round. This round's batch is capped at the 5 fresh Criticals the round-5 re-read of HEAD raised (Criticals first); this finding — “R1-5: (carried from round 1, re-confirmed at HEAD) 中文说明延迟到下一轮处理。本轮的批次上限是第 5 轮对 HEAD 重新审查提出的 5 个新 Critical(Critical 优先);本条发现——“R1-5: (carried from round 1, re-confirmed at HEAD) |
||
| const cachePath = join(repo, 'cache.json'); | ||
| const promoted = JSON.parse( | ||
| readFileSync( | ||
| join(repo, '.qwen/tmp/qwen-review-local-cache-candidate.json'), | ||
| 'utf8', | ||
| ), | ||
| ) as Record<string, unknown>; | ||
| writeFileSync( | ||
| cachePath, | ||
| JSON.stringify({ ...promoted, lastModelId: 'model-a' }), | ||
| ); | ||
|
|
||
| // Round 2: same anchor, but the tree moves under the hash pass. | ||
| stderrLines.length = 0; | ||
| captures.push( | ||
| { diff: DIFF_A }, | ||
| { diff: Buffer.from('changed mid-hash\n') }, | ||
| ); | ||
| run({ model: 'model-a', cache: cachePath }); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same finding as the round-2 re-post rc:3833436424 — deferred to the next round together with it; see that thread for the specifics. 中文说明与第 2 轮重发的 rc:3833436424 是同一发现——与它一起延迟到下一轮;具体情况见该线程。
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred to the next round. This round's batch was capped at the six round-3 Criticals, all fixed in 87b769b (comma-driver fold, directory plumbing targets, ignore-rules exemption, directory-target tracked drop, cap-before-parse, file-review prose). R1-5 the toctou suite's unused 中文说明延迟到下一轮处理。本轮批次上限为六个 round-3 Critical,已在 87b769b 中全部修复(逗号驱动名折叠、目录形 plumbing 目标、ignore 规则豁免、目录目标的 tracked 剔除、限额先于解析、文件评审措辞)。R1-5:toctou 套件中未使用的 |
||
|
|
||
| expect(report().incremental).toBeUndefined(); | ||
| expect(stderrLines.join('\n')).toContain( | ||
| 'Incremental anchor not used — the working tree changed while the ' + | ||
| 'capture was being hashed', | ||
| ); | ||
| // The full capture is what the plan reviews. | ||
| expect(readFileSync(report().diffPath).equals(DIFF_A)).toBe(true); | ||
| }); | ||
|
|
||
| it('does not call a MOVED tree clean, even with an empty capture', () => { | ||
| // The two stops contradicted each other. A review starting on an empty | ||
| // tree, with an autosave landing inside the capture window, withholds the | ||
| // candidate and refuses the anchor — and then wrote `clean-tree` anyway, | ||
| // because capture 0's diff is empty. stderr printed both lines back to | ||
| // back, the round stopped on the second, and the just-written change went | ||
| // unreviewed while the run was recorded as clean. | ||
| captures.push( | ||
| { diff: Buffer.from('') }, | ||
| { diff: DIFF_A }, | ||
| { diff: DIFF_A }, | ||
| ); | ||
| run(); | ||
| const plan = JSON.parse( | ||
| readFileSync(join(repo, 'plan.json'), 'utf8'), | ||
| ) as Record<string, unknown>; | ||
| expect(plan['nothingToReview']).toBeUndefined(); | ||
| const err = stderrLines.join('\n'); | ||
| expect(err).toContain( | ||
| 'working tree changed while the capture was being hashed', | ||
| ); | ||
| // …and the PROSE must not contradict it either. The orchestrator branches | ||
| // on these sentences, and the round printed "the working tree is clean" | ||
| // right after the line above until this was gated too. | ||
| expect(err).not.toContain('the working tree is clean'); | ||
| expect(err).toContain('this is NOT a clean tree'); | ||
| }); | ||
|
|
||
| it('catches a PHASE-ALIGNED write the pairwise guard let through', () => { | ||
| // Two samples of each kind, compared pairwise, never tied a capture to | ||
| // the hashes recorded beside it. Three timed writes defeat it: X→Y | ||
| // before the hash pass, Y→X before the re-capture, X→Y after it. The two | ||
| // captures agree (X, X) and the two hash passes agree (Y, Y), so | ||
| // `treeHeldStill` is true — and the candidate certifies Y's identity for | ||
| // a round that reviewed X. Promoted, the next round compares cache Y | ||
| // against tree Y, finds no delta and says "No changes" over bytes no | ||
| // round ever read. | ||
| // | ||
| // Interleaving a third sample of each kind means the write pattern has | ||
| // to keep alternating; this one stops, and the third hash pass reads | ||
| // what the captures did. | ||
| captures.push( | ||
| { diff: DIFF_A }, | ||
| { diff: Buffer.from(DIFF_A) }, | ||
| { diff: Buffer.from(DIFF_A) }, | ||
| ); | ||
| hashPasses.push( | ||
| { 'a.ts': '100644:oid-Y' }, | ||
| { 'a.ts': '100644:oid-Y' }, | ||
| { 'a.ts': '100644:oid-X' }, | ||
| ); | ||
| run(); | ||
| expect( | ||
| existsSync( | ||
| join(repo, '.qwen/tmp/qwen-review-local-cache-candidate.json'), | ||
| ), | ||
| ).toBe(false); | ||
| expect(stderrLines.join('\n')).toContain( | ||
| 'working tree changed while the capture was being hashed', | ||
| ); | ||
| }); | ||
|
|
||
| it('catches a same-bytes revert that STRADDLES the hash pass', () => { | ||
| // The hash pass sits BETWEEN the two diff snapshots, so a write that | ||
| // straddles it is invisible to the diffs alone: capture B0 → autosave | ||
| // writes B1 → the hashes read B1 → undo restores B0 → the re-capture | ||
| // reads B0. Both diffs agree and the candidate certifies B1's identity | ||
| // for a round that reviewed B0. | ||
| // | ||
| // The note here used to call that shape harmless and a | ||
| // different-bytes revert the uncatchable one — backwards: a | ||
| // different-bytes revert moves the endpoints and IS caught by the | ||
| // diffs. Re-hashing after the re-capture is what sees this one. | ||
| captures.push({ diff: DIFF_A }, { diff: Buffer.from(DIFF_A) }); | ||
| // The two diffs AGREE — that is the point. What disagrees is the pair of | ||
| // hash passes that bracket the re-capture: the first read B1, the second | ||
| // reads B0. | ||
| hashPasses.push({ 'a.ts': '100644:oid-B1' }, { 'a.ts': '100644:oid-B0' }); | ||
| run(); | ||
| expect( | ||
| existsSync( | ||
| join(repo, '.qwen/tmp/qwen-review-local-cache-candidate.json'), | ||
| ), | ||
| ).toBe(false); | ||
| expect(stderrLines.join('\n')).toContain( | ||
| 'working tree changed while the capture was being hashed', | ||
| ); | ||
| }); | ||
|
|
||
| it('a tree that held still writes the candidate and no warning', () => { | ||
| captures.push({ diff: DIFF_A }, { diff: Buffer.from(DIFF_A) }); | ||
| run(); | ||
| expect( | ||
| existsSync( | ||
| join(repo, '.qwen/tmp/qwen-review-local-cache-candidate.json'), | ||
| ), | ||
| ).toBe(true); | ||
| expect(stderrLines.join('\n')).not.toContain('candidate is withheld'); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deferred to the next round — this round's batch was bounded: it implemented the two still-open Criticals (the PR cache's recorded identity —
{{model}}→ the fetch report'sreviewModelId— and the stale<target>.jsonnaming prose) plus six Suggestions. This finding is acknowledged and queued; no code was changed for it in this round. The toctou test'smodelargv key is dead and the env identity is unset there.中文说明
延迟到下一轮——本轮批次有上限:本轮实现了两个仍未关闭的 Critical(PR 缓存记录的身份——
{{model}}→ fetch 报告的reviewModelId——以及过时的<target>.json命名文案),另加六条 Suggestion。该发现已确认并排队;本轮未对其改动代码。There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deferred to the next round. This round's batch was capped at the six round-3 Criticals, all fixed in 87b769b (comma-driver fold, directory plumbing targets, ignore-rules exemption, directory-target tracked drop, cap-before-parse, file-review prose). R1-5 the toctou suite's unused
modelargument remains. It stays queued for the next round.中文说明
延迟到下一轮处理。本轮批次上限为六个 round-3 Critical,已在 87b769b 中全部修复(逗号驱动名折叠、目录形 plumbing 目标、ignore 规则豁免、目录目标的 tracked 剔除、限额先于解析、文件评审措辞)。R1-5:toctou 套件中未使用的
model参数仍在。该条保留在队列中,下一轮处理。