Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
dff2b78
feat(review): content-anchored incremental rounds for the local revie…
wenshao Aug 21, 2026
ee54f8b
fix(review): close nine Criticals, most of them my own last round's
wenshao Aug 21, 2026
0810d06
fix(review): close round 2 — the cache rename's fallout, and the stop…
wenshao Aug 22, 2026
91d93cb
fix(review): close round 3 — the cache records the gate's identity, a…
github-actions[bot] Aug 22, 2026
87b769b
fix(review): close round 4 — the named-path flow's six dead ends
github-actions[bot] Aug 22, 2026
c661806
fix(review): close round 5 — including a gate of mine that could neve…
wenshao Aug 22, 2026
feb55b5
fix(review): close round 6 — the five Criticals the re-read of HEAD s…
github-actions[bot] Aug 22, 2026
b295232
fix(review): close round 7 — and stop enumerating the fail-closed list
wenshao Aug 23, 2026
ecf5fea
merge: keep both sets of SKILL guards
wenshao Aug 23, 2026
af708af
fix(review): close round 8 — the four shapes the last fixes left open
wenshao Aug 23, 2026
575d136
fix(review): close round 9 — date a stop's blockers; key the local ca…
wenshao Aug 23, 2026
8192762
fix(review): close round 10 — date each stop blocker against its own …
wenshao Aug 23, 2026
df533ff
fix(review): close round 11 — date blockers on bytes; certify vanishe…
wenshao Aug 23, 2026
3b916d6
fix(review): close round 12 — attribute landed fixes, date by regime,…
github-actions[bot] Aug 24, 2026
4142065
Merge origin/main into review-incremental-anchor
wenshao Aug 24, 2026
e6aa683
fix(review): close round 13 — pay the isolation test's import cost at…
github-actions[bot] Aug 24, 2026
99fff42
Merge branch 'review/local-content-anchor' of https://github.com/Qwen…
qwen-code-dev-bot Aug 24, 2026
1f0fb94
refactor(review): drop the stop-round blocker-dating chain
wenshao Aug 24, 2026
7774eb3
Merge remote round-12/13 autofix commits, keeping the chain deleted
wenshao Aug 24, 2026
b13eb13
Merge remote-tracking branch 'origin/main' into p1
wenshao Aug 24, 2026
4a24ca5
fix(review): close round 14 — certify what git diff cannot see, split…
github-actions[bot] Aug 24, 2026
5dba419
fix(review): close round 15 — withhold the candidate under visibility…
github-actions[bot] Aug 25, 2026
dc74e83
fix(review): close round 15 review — bracket the visibility oracle, g…
wenshao Aug 25, 2026
c723d5f
fix(review): close round 16 — withhold the incremental stops under --…
github-actions[bot] Aug 25, 2026
af521c5
fix(review): gate decided stops on a composed re-rule verdict (#9941)
wenshao Aug 25, 2026
0d11241
Revert "fix(review): gate decided stops on a composed re-rule verdict…
wenshao Aug 25, 2026
2f050a6
Merge remote-tracking branch 'origin/main' into p1
wenshao Aug 25, 2026
0cf1b07
fix(cli): drop duplicate getCurrentCustomTitle key that breaks npm ci
wenshao Aug 25, 2026
8b81762
fix(cli): drop the second duplicate key too (getSessionDisplayName)
wenshao Aug 25, 2026
2e03ecd
Merge branch 'main' into review/local-content-anchor
qwen-code-dev-bot Aug 25, 2026
9443811
test(review): pin the visibility oracle's sample count
wenshao Aug 25, 2026
49c2db3
Merge remote-tracking branch 'origin/review/local-content-anchor' int…
wenshao Aug 25, 2026
79dfd58
fix(review): close round 17 — restore the merged-away fake, publish t…
wenshao Aug 26, 2026
eef3c34
fix(review): close round 18 — ask git for the cone, make the cap idem…
wenshao Aug 26, 2026
0f70270
fix(review): close round 19 — only ENOENT is absence, undecodable is …
wenshao Aug 26, 2026
518d26f
fix(review): close round 20 — bool-typed fileMode, no directory in th…
wenshao Aug 26, 2026
146ab25
fix(review): close round 21 — measure the pointer, ride the skipped l…
wenshao Aug 27, 2026
60bb3ce
fix(review): close round 22 — a gitlink's identity is real, on both s…
wenshao Aug 27, 2026
e94892e
Merge remote-tracking branch 'origin/main' into p1
wenshao Aug 27, 2026
f1d9c62
fix(review): close round 23 — withhold over the dropped-out set, excl…
wenshao Aug 27, 2026
f425a8c
fix(review): keep this run's stop sidecar across its own cleanup
wenshao Aug 27, 2026
917e1d4
Merge remote-tracking branch 'origin/main' into p1
wenshao Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,456 changes: 1,456 additions & 0 deletions packages/cli/src/commands/review/capture-local.incremental.test.ts

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions packages/cli/src/commands/review/capture-local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { DEADLINE_ENV } from './lib/deadline.js';

const captureMock = vi.hoisted(() => vi.fn());
const settingsMock = vi.hoisted(() => vi.fn(() => ({ merged: {} })));
const visibilityMock = vi.hoisted(() => vi.fn(() => [] as string[]));
vi.mock('../../config/settings.js', async (orig) => ({
...(await orig<Record<string, unknown>>()),
loadSettings: settingsMock,
Expand All @@ -27,6 +28,15 @@ vi.mock('./lib/local-diff.js', async (orig) => ({
...(await orig<Record<string, unknown>>()),
captureLocalDiff: captureMock,
}));
// The git layer is tested elsewhere (the integration suites run a real
// repository); the scratch directory here is not one. The visibility-bit
// oracle answers "no tracked path carries a bit" — the shape a clean tree
// has — because without an answer the stops must fail closed, and the
// tests below pin the clean claim.
vi.mock('./lib/local-anchor.js', async (orig) => ({
...(await orig<Record<string, unknown>>()),
invisibleTrackedPaths: visibilityMock,
}));

const { captureLocalCommand } = await import('./capture-local.js');

Expand Down Expand Up @@ -69,6 +79,8 @@ beforeEach(() => {
cwd = process.cwd();
process.chdir(dir);
errs = [];
visibilityMock.mockReset();
visibilityMock.mockReturnValue([] as string[]);
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
errs.push(String(chunk));
return true;
Expand Down
311 changes: 311 additions & 0 deletions packages/cli/src/commands/review/capture-local.toctou.test.ts
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' });

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.

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's reviewModelId — and the stale <target>.json naming prose) plus six Suggestions. This finding is acknowledged and queued; no code was changed for it in this round. The toctou test's model argv key is dead and the env identity is unset there.

中文说明

延迟到下一轮——本轮批次有上限:本轮实现了两个仍未关闭的 Critical(PR 缓存记录的身份——{{model}} → fetch 报告的 reviewModelId——以及过时的 <target>.json 命名文案),另加六条 Suggestion。该发现已确认并排队;本轮未对其改动代码。

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.

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 model argument remains. It stays queued for the next round.

中文说明

延迟到下一轮处理。本轮批次上限为六个 round-3 Critical,已在 87b769b 中全部修复(逗号驱动名折叠、目录形 plumbing 目标、ignore 规则豁免、目录目标的 tracked 剔除、限额先于解析、文件评审措辞)。R1-5:toctou 套件中未使用的 model 参数仍在。该条保留在队列中,下一轮处理。

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.

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 model argument remains. It stays queued for the next round.

中文说明

延迟到下一轮处理。本轮批次上限为六个 round-3 Critical,已在 87b769b 中全部修复(逗号驱动名折叠、目录形 plumbing 目标、ignore 规则豁免、目录目标的 tracked 剔除、限额先于解析、文件评审措辞)。R1-5:toctou 套件中未使用的 model 参数仍在。该条保留在队列中,下一轮处理。

Comment on lines +181 to +182

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.

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) model: 'model-a' in run()'s argv is dead input” — stays open and is queued for the next round. It was not declined, only sequenced.

中文说明

延迟到下一轮处理。本轮的批次上限是第 5 轮对 HEAD 重新审查提出的 5 个新 Critical(Critical 优先);本条发现——“R1-5: (carried from round 1, re-confirmed at HEAD) model: 'model-a' in run()'s argv is dead input”——保持打开状态并排入下一轮队列。它没有被拒绝,只是排序靠后。

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 });

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.

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 是同一发现——与它一起延迟到下一轮;具体情况见该线程。

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.

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 model argument remains. It stays queued for the next round.

中文说明

延迟到下一轮处理。本轮批次上限为六个 round-3 Critical,已在 87b769b 中全部修复(逗号驱动名折叠、目录形 plumbing 目标、ignore 规则豁免、目录目标的 tracked 剔除、限额先于解析、文件评审措辞)。R1-5:toctou 套件中未使用的 model 参数仍在。该条保留在队列中,下一轮处理。


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');
});
});
Loading
Loading