-
Notifications
You must be signed in to change notification settings - Fork 3k
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 4 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,284 @@ | ||
| /** | ||
| * @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 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. [Suggestion] R1-5 (carried): 中文说明R1-5(沿用):经 — qwen3.8-max via Qwen Code /review (v0.21.15)
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. [Suggestion] R1-5: (carried from round 1, re-confirmed): model: 'model-a' passed through run()'s argv is dead input — CaptureLocalArgs has no model field; round identity comes exclusively from roundModelIdFrom(process.env), which the suite never sets. When it bites: certifierMatchesRound('model-a', '') is false by construction, so the cached anchor is NOT 'otherwise valid' as the comment claims — the model gate would refuse it too; the test passes only because anchorRefusalReason checks !treeHeldStill before the model clause. A maintainer copying run({ model: ... }) into a test where the model gate must PASS gets a silent refusal; the inaccurate comment hides why the fixture never established the isolation it advertises. The sibling incremental harness routes model into the env correctly. Suggested fix: Route model into QWEN_CODE_MODEL_IDENTITY in run() like the sibling harness, or drop the inert key and reword the comment to 'the guard's clause is checked first, so the emitted refusal can only be the guard's'. 中文说明[Suggestion] R1-5: (沿用第 1 轮编号,本轮重新确认) — qwen3.8-max via Qwen Code /review (v0.21.15)
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. [Suggestion] R1-5: (carried from round 1, re-confirmed at HEAD) Fix: mirror the sibling harness — translate model into QWEN_CODE_MODEL_IDENTITY in run() with a try/finally restore. 中文说明(自第 1 轮携带,已在 HEAD 重新确认)run() argv 中的 model: 'model-a' 是死输入——CaptureLocalArgs 没有 model 字段,身份只来自 roundModelIdFrom(process.env)。与姊妹的 incremental 测试支架不同,本文件的 run() 从不设置 QWEN_CODE_MODEL_IDENTITY,因此测试的隔离前提(「锚点在其他方面有效,拒绝只能来自守卫」)并不成立:模型门同样会拒绝(certifierMatchesRound('model-a', '') 按构造即失配),守卫之所以胜出仅因 treeHeldStill 是 anchorRefusalReason 的第一子句。修复:对齐姊妹支架——在 run() 中把 model 转设为 QWEN_CODE_MODEL_IDENTITY 并在 try/finally 中恢复。 — qwen3.8-max via Qwen Code /review (v0.21.15)
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. [Suggestion] This test's stated isolation — "the anchor is otherwise valid and the refusal can only come from the guard" — is not achieved: 中文说明[Suggestion] 该测试声明的隔离性——「锚点在其他方面有效,拒绝只能来自守卫」——并未成立:argv 里的 — qwen3.8-max via Qwen Code /review (v0.21.15)
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.
[Suggestion] R1-5: Still stands —
modelin argv remains a dead fixture argument:CaptureLocalArgshas nomodelfield, the yargs builder defines none, and identity comes exclusively fromroundModelIdFrom(process.env)— this file never setsQWEN_CODE_MODEL_IDENTITY, so the comment's isolation claim ("the anchor is otherwise valid and the refusal can only come from the guard") is false: the same-model gate also refuses this anchor. Witness (mutation arm): reordering the gates (model beforetreeHeldStill, guard intact) makes this test fail with a model-mismatch refusal instead of the TOCTOU refusal — a false regression signal pointing at the guard. The sibling harness solves this explicitly (stripsmodelfrom argv, sets the env identity). Fix: mirror it here, or drop the no-op keys and the claim.中文说明
[Suggestion] 仍然成立 —— argv 里的
model依旧是死夹具参数:CaptureLocalArgs没有model字段,yargs builder 也未定义,身份完全来自roundModelIdFrom(process.env)—— 本文件又从不设置QWEN_CODE_MODEL_IDENTITY,因此注释的隔离声明(「锚点在其他方面有效,拒绝只能来自守卫」)不成立:同模型门同样会拒绝这个锚点。实测(变异臂):调整门顺序(model 提到treeHeldStill之前、守卫不动)会让本测试以模型不匹配拒绝而非 TOCTOU 拒绝失败 —— 一个把矛头指向守卫的假回归信号。兄弟夹具已明确解决(从 argv 剥掉model、设置环境身份)。修复:在此照做,或删掉无效键与该声明。— qwen3.8-max via Qwen Code /review (v0.21.15)
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参数仍在。该条保留在队列中,下一轮处理。