feat(review): transfer per-file content verdicts across rebases - #9661
feat(review): transfer per-file content verdicts across rebases#9661wenshao wants to merge 73 commits into
Conversation
…w-fix loop
A local or file-path review at high effort can now skip what it already
reviewed, anchored on CONTENT rather than on a commit.
The reviewed state is a dirty working tree: it has no commit to anchor on, and
`local-diff.ts`'s standing constraint — nothing on the capture path writes to
the index, the worktree, or any ref — rules out snapshot commits and stashes.
So the anchor is the hashed per-file state of exactly what the plan covered,
plus the HEAD the diff was measured against. `git hash-object` without `-w`
computes the blob id git WOULD store and writes nothing.
The identity is `<mode>:<blob>`, not the blob alone: an exec-bit flip or a
file/symlink typechange is its own diff lines, so identical bytes under a
different mode are not an identical change. Symlinks hash their link text at
120000, exactly what `git diff` renders. Whatever cannot be captured
faithfully is UNHASHABLE — which never equals itself, and is therefore
re-reviewed every round rather than silently certified.
Rendering attributes ride in the identity too, from `git check-attr` rather
than a hand-derivation: `.gitattributes` at any level, `.git/info/attributes`,
the commondir's copy in a linked worktree, `core.attributesFile`, and the
config-side `diff.<driver>.binary` that flips a section between readable hunks
and "Binary files differ" with the bytes standing still.
The scope is the same slicing the PR flow uses — the round's own files plus one
import hop — and every refusal falls back to the full capture with its reason
on stderr.
Hardening from review, each mutation-checked:
- The same-model gate is ruled over the provider-qualified identity the runtime
publishes, recorded by the capture itself. `{{model}}` interpolates the bare
model id, so two provider configurations exposing one model name passed each
other's gate.
- The cache is keyed by the SOURCE path, not the flattened token. `safeTarget`
is not injective — `src/foo.ts` and `src_foo.ts` collide — and two different
files were accepting each other's anchor and erasing each other's findings.
- The target stem is capped with a digest suffix. Unbounded, a legal deep path
made every write for that target throw ENAMETOOLONG.
- Either side of a rename keeps its section, so a staged `git mv` no longer
publishes an empty slice and spins the loop until HEAD moves.
- The review's own plumbing is excluded by path segment at any depth, and on
the TRACKED half too, so a round cannot review its own cache and never
converge.
- `check-attr`'s NUL protocol is read raw; the trimming wrapper stole the first
record from any path beginning with whitespace, and failed open on exactly
the driver lookup the identity exists for.
- The mid-capture tree guard samples three interleaved states, so a
phase-aligned write can no longer certify bytes no round reviewed.
- The decided stops carry a machine-readable `nothingToReview`, so
`qwen review run` stops reporting "Review did not complete" over a round that
decided something.
Replaces #9190, which cannot be merged or repaired: its base is the head of the
closed #9188, GitHub counts it as a stack, and stack validation fails on the
closed root while base changes are refused for stack members. The review is
there — 20 reviews, 166 inline comments.
A local round's anchor is per-file content, so a rebase that leaves a file's
bytes untouched should not cost that file a re-review — but the commit anchor
moves and takes every file with it. This carries the per-file verdicts across,
so only the files a rebase actually rewrote come back into scope.
The cache write is one command for both flows now — `cache-commit` merges the
capture's deterministic candidate with the round's small ledger file and writes
atomically, candidate fields winning every collision, instead of the
orchestrator hand-copying a per-file map through its own output where a dropped
or mangled pair reads downstream as a verdict it is not.
`lastModelId` is one of those candidate-owned fields. Left to the ledger it was
the bare `{{model}}` an orchestrator can type, which two provider
configurations exposing one model name share — so the token that decides the
same-model contract is the provider-qualified one both captures now record, and
`fetch-pr`'s candidate carries it too.
Replaces #9191, which inherited #9190's unmergeable stack. The review is there
— 158 inline comments.
|
Thanks for the PR — this is the second half of the split #9659's review asked for, and the stack lands the way that run requested: #9659 back to one commit on Template: the body is prose rather than the template — there are no formal Problem: real, not theoretical. A rebase moves the commit half of the anchor and takes every file's verdict with it — including files whose bytes never changed — and the cache write used to route a per-file map through the model's own output, a copy job that fails silently (a dropped or mangled pair reads downstream as a verdict it is not). It's a Direction: aligned — core Size: 769 production lines / 819 test lines / 9 docs lines, 19 files. Production code is all inside Approach: the shape is right — content-addressed per-file identity, one mechanical cache write with candidate-wins precedence, fail-closed gates, symlink-chain guards on all three deterministic writers. One substantive flag going into code review: the title and first section read as though rebase survival has landed, but at this commit the PR-flow transfer is producer-side only — Risk: no elevated-revert-path signals — the change stays inside Moving on to code review. 🔍 中文说明感谢贡献!这是 #9659 评审要求的拆分的下半部分,栈的形态也正是那次运行所要求的:#9659 回到 main 上的单 commit,本 PR 在其上承载第二个 feature commit。 模板: 正文是叙述体,没有模板的 问题: 真实存在,不是理论性的。rebase 会移动锚点的 commit 那一半,把所有文件的裁决一起带走——包括字节从未变过的文件——而缓存写入过去要把每文件映射经由模型输出手工誊写,这种抄写会静默失败。这是 方向: 对齐—— 规模: 769 行生产 / 819 行测试 / 9 行文档,共 19 个文件。生产代码全部在 方案: 形态正确——内容寻址的按文件身份、candidate 优先的机械化单次缓存写入、fail-closed 闸门、三处确定性写入都加上符号链接链防护。进入代码审查前有一个实质性提醒:标题与第一节读起来像 rebase 存活已经生效,但在此 commit 上 PR 流程的转移只有生产端—— 风险: 无高回滚风险路径信号——改动都在 进入代码审查。🔍 — Qwen Code · qwen3.8-max Reviewed at |
|
Code review (at The parts that earn a second read are all sound on inspection: the merge's delete branch scrubs allowlist keys a ledger tries to smuggle, target binding refuses cross-target promotion, and Three non-blocking notes:
sequenceDiagram
participant P1 as capture local
participant P2 as fetch pr
participant P3 as orchestrator model
participant P4 as cache commit
participant P5 as review cache
P1->>P3: plan + candidate (hashed state, HEAD)
P2->>P3: plan + candidate (blob pairs, commit anchor)
P3->>P4: small ledger (round, verdict, findings)
P4->>P4: validate (model id, target binding, control chars)
P4->>P5: atomic merged write, candidate wins
Files changed (19 of 19 shown)
Testing evidence — unattended CI run: nothing was built or executed here; the evidence below is the PR's own CI on the reviewed commit, read via the API. The one suite that covers this change, CI on reviewed commit
What the suite pins: the merge-precedence tests fail if candidate/ledger precedence flips, the symlink and parent-chain tests fail if the guards come out, the TOCTOU tests fail if a withheld candidate is still announced, and the 中文说明代码审查(于 合并的 delete 分支会清掉 ledger 试图夹带的 allowlist 键、target 绑定拒绝跨目标晋升、 三条非阻塞意见:一、正文需按 Stage 1 的要求补一句分期说明(本 commit 上 PR 流程转移只有生产端);二、 时序图展示了新的缓存写入流:两条捕获各自在计划旁写出确定性 candidate,模型只写小 ledger, 测试证据——无人值守 CI 运行:此处未构建或执行任何 PR 代码,以下证据是通过 API 读取的该 commit 自身 CI 结果。覆盖本改动的唯一套件 套件钉住了改动:合并优先级翻转、防护移除、弃权 candidate 仍被通告、blobPairs/changedPairs 的真实 git 场景(mode 翻转、仅属性变化、历史重写、rename 漏洞)都会使相应测试失败——绿色是有承载力的。未验证:端到端 rebase 存活——本 commit 尚无消费者可驱动,无法 A/B。消费者落地后可用沙箱验证收尾: — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 4/5 — genuinely strong code, green CI at the reviewed commit, and every process blocker from the previous run resolved; the missing point is two hygiene items and the follow-up consumer the series now owes. Stepping back: my independent proposal was content-addressed per-file identity with a candidate-wins atomic merge and fail-closed degradation, and the PR matches that shape, then closes holes I would not have listed first — decode aliasing, the absent-base rename pair, governing All four deferral reasons from the #9659 run are resolved by this split: CI was in flight then and is green now on this exact commit; the scope has been split (#9659 is back to the single commit its body describes, this PR is the second feature commit); the head has settled; and #9190 is closed. The honest reservations, none blocking:
Stack note, not a defect: this merges only after #9659 does, since its base is that branch. Both PRs are consistently described now, so the ordering is mechanical. Approving, pinned to the reviewed commit. ✅ 中文说明置信度:4/5 —— 代码确实扎实,受审 commit 的 CI 为绿,上一轮运行的所有流程阻塞均已解除;差的 1 分是两个卫生项,以及本系列现在欠下的后续消费者。 退一步看:我的独立方案是内容寻址的按文件身份 + candidate 优先的原子合并 + fail-closed 降级,PR 与这个形态吻合,并且补上了我自己不会第一时间列出的洞——解码歧义、absent-base 的 rename pair、随 pair 记录的各级 #9659 那轮暂缓的四个理由,都已被这次拆分解决:当时 CI 未决,现在该 commit 上已绿;范围已拆分(#9659 回到正文描述的单 commit,本 PR 是第二个 feature commit);head 已稳定;#9190 已关闭。 诚实的保留意见,均不阻塞:一、正文仍缺那句分期说明——本 commit 上 PR 流程转移只有生产端,代码与 DESIGN.md 都写明了,正文补一句即可;二、 栈说明(非缺陷):本 PR 的 base 是 #9659 的分支,因此只能在 #9659 之后合并。两个 PR 现在描述一致,合并顺序是机械的。 批准,锚定在受审 commit 上。✅ — Qwen Code · qwen3.8-max Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: reverse audit — stopped at the 5-round cap without two consecutive dry rounds (every round surfaced findings; round 5 still reported 3).
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Not reviewed: test-efficacy probe — all 8 probes inconclusive: the probe tree cannot satisfy this repo's #9149 vitest guard prerequisites (files this PR does not touch), so no mutants or hunk reverts were executed; mutation claims in this review were instead run by the verifiers' own probes.
中文说明
仅完成部分审查,审查缺口已披露。
未审查:reverse audit — stopped at the 5-round cap without two consecutive dry rounds (every round surfaced findings; round 5 still reported 3)。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
未审查:test-efficacy probe — all 8 probes inconclusive: the probe tree cannot satisfy this repo's #9149 vitest guard prerequisites (files this PR does not touch), so no mutants or hunk reverts were executed; mutation claims in this review were instead run by the verifiers' own probes。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| * end (via `cache-commit`). Absent when the capture had no diff to | ||
| * describe. | ||
| * | ||
| * PRODUCER SIDE ONLY at this commit, and the distinction matters because |
There was a problem hiding this comment.
[Suggestion] R1-3: The PR title and description certify end-to-end rebase-survival behaviour — "transfer per-file content verdicts across rebases", "The per-file verdicts now carry across, so only the files a rebase actually rewrote come back into scope", "Part 2 of 2" — that this diff does not deliver: changedPairs/readFileVerdicts have zero production callers at this commit (verified by grep), so a rebase still degrades to a full review exactly as before. The diff's own added text is honest about this (this comment, DESIGN.md's "the CONSUMER is not landed", file-verdicts.ts's groundwork framing) — but the repo squash-merges, so the title becomes the permanent commit subject on main for a change that ships only the record, and the description says "Part 2 of 2" while the diff announces a still-required consumer follow-up. Anyone bisecting rebase-survival behaviour or writing a changelog from history is told the feature landed. This regresses the R10-1 resolution from the reviewed predecessor (#9191), where it was agreed the docs must say plainly that this is producer-side only, not rebase survival today.
Fix: align the title/description with the delivered scope (e.g. "record per-file content verdicts and unify the cache write into cache-commit; the --since-path transfer consumer is a follow-up"), or land the consumer in this PR.
中文说明
[Suggestion] R1-3:PR 标题与描述承诺了端到端的 rebase 存活行为——"transfer per-file content verdicts across rebases"、"per-file 裁决现在可以传递,只有 rebase 真正改写过的文件才重新进入范围"、"Part 2 of 2"——但本 diff 并未交付:在本提交上 changedPairs/readFileVerdicts 没有任何生产调用方(已用 grep 验证),rebase 之后依旧退化为全量评审。diff 自己新增的文本对此是诚实的(本注释、DESIGN.md 的 "the CONSUMER is not landed"、file-verdicts.ts 的 groundwork 表述)——但本仓库是 squash 合并,标题会成为 main 上的永久提交主题,而描述写着 "Part 2 of 2",diff 里却又声明还需要一个 consumer 后续 PR。任何对 rebase 存活行为做 bisect 或依据历史写 changelog 的人都会被告知该特性已落地。这回退了前身 #9191 评审中达成的 R1-10 决议(当时已约定文档必须明说这只是生产端、并非 rebase 存活)。
修复:让标题/描述与交付范围一致(例如 "record per-file content verdicts and unify the cache write into cache-commit; the --since-path transfer consumer is a follow-up"),或在本 PR 中一并落地 consumer。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| // paths against git's cwd, and a fetch started from a subdirectory would | ||
| // otherwise record every pair as (absent, absent) — a candidate that | ||
| // later transfers clean verdicts over anything. | ||
| const pairs = blobPairs( |
There was a problem hiding this comment.
[Suggestion] R1-4: This cache-candidate producer — the PR-flow half of the feature (~80 new lines: pair computation, candidate JSON shape, symlink guard, degrade-to-warning, report field) — has zero test coverage; the consumer-side tests pair only with hand-written fixtures, not producer output. Verified green mutants at this commit: deleting the --show-toplevel pin (subdirectory fetches then record all-NO_BLOB pairs) leaves fetch-pr.test.ts 148/148 green; misspelling lastModelId in the candidate JSON leaves the fetch-pr + cache-commit suites 164/164 green. Producer/consumer field-name drift is caught only at runtime — fail-closed, but invisible in CI, and exactly the split the CANDIDATE_FIELDS comment warns about.
Fix: add a fetch-pr test asserting the candidate is written with the exact field set cache-commit's allowlist accepts, that cacheCandidatePath rides the report only when written, and ideally a round-trip feeding actual producer output through cacheCommitCommand.handler.
中文说明
[Suggestion] R1-4:这个缓存 candidate 生产端——本特性的 PR 流程半边(约 80 行新增:pair 计算、candidate JSON 形状、符号链接守卫、降级告警、report 字段)——零测试覆盖;消费端测试只与手写 fixture 配对,不与生产端产物配对。在本提交上验证过的绿色变异:删掉 --show-toplevel 钉扎(子目录 fetch 会记录全 NO_BLOB 对),fetch-pr.test.ts 仍 148/148 全绿;把 candidate JSON 里的 lastModelId 拼错,fetch-pr + cache-commit 套件仍 164/164 全绿。生产/消费字段名漂移只能在运行时被发现——虽然失败安全,但 CI 看不见,正是 CANDIDATE_FIELDS 注释警告的那种裂缝。
修复:新增 fetch-pr 测试,断言 candidate 以 cache-commit 白名单恰好接受的字段集写出、cacheCandidatePath 仅在写出成功时随 report 出现,最好再做一个把真实生产端产物喂给 cacheCommitCommand.handler 的往返测试。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| fetchedSha, | ||
| plan.files.map((f) => f.path), | ||
| ); | ||
| if (pairs !== null) { |
There was a problem hiding this comment.
[Suggestion] R1-5: When blobPairs returns null — a transient ls-tree I/O failure, or the deterministic U+FFFD decode-aliasing refusal (any repo git holds with a non-decodable filename) — the candidate is withheld silently: no stderr word, while the sibling write-failure catch three lines down warns loudly, and the field doc says "Absent when the capture had no diff to describe", not when the listing failed. A recurring probe failure is then indistinguishable from a no-diff PR, with nothing to grep for; the command's own posture (the adjacent catch branch) is to say withholdings out loud.
Fix: add an else branch mirroring the catch — warn on stderr that the blob-pair listing failed and the candidate is withheld (review unaffected) — and extend the field doc to name the failure case.
中文说明
[Suggestion] R1-5:当 blobPairs 返回 null 时——瞬时的 ls-tree I/O 失败,或确定性的 U+FFFD 解码别名拒绝(git 仓库里存在无法解码的文件名即触发)——candidate 被静默扣下:stderr 一个字都没有;而三行之下的写入失败 catch 分支会大声告警,字段文档也只说"当捕获没有 diff 可描述时缺省",没提列表失败的情况。于是反复发生的探测失败与"没有 diff 的 PR"无从区分,也没有任何可 grep 的痕迹;命令自身的姿态(相邻 catch 分支)是把扣留大声说出来。
修复:加一个与 catch 对称的 else 分支——在 stderr 上告警 blob-pair 列表失败、candidate 被扣留(评审本身不受影响)——并在字段文档里补上这一失败情形。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| // to the user, so a forged line reaches the model's context too — or emit | ||
| // OSC/CSI sequences at the operator's terminal. | ||
| // | ||
| // `capture-local` has escaped filenames this way since its own review found |
There was a problem hiding this comment.
[Suggestion] R1-16: The "one escaper for every workspace-controlled string this command family prints" extraction leaves capture-local.ts's inline display() copy in place (capture-local.ts:115-118: CONTROL.test(path) ? JSON.stringify(path) : path), which lacks this module's own DEL fix — JSON.stringify passes U+007F through verbatim (verified: JSON.stringify('a\u007fb') keeps the raw DEL byte). Probe through the live sink: driving capture-local with a DEL-carrying skipped-file path, the "was NOT reviewed" warning emits stderr contains RAW DEL byte: true — a filename containing DEL still reaches the operator's terminal as a raw control byte through the very sink this module's header cites, so the header's universal claim ("extracted, so the newer sinks cannot each re-derive it — and re-forget it") is false at the commit that makes it. (display() itself is pre-existing; the divergence is what this diff's new claim makes newly inconsistent.)
Fix: migrate capture-local.ts's display() to inertText (drop the local CONTROL copy), or narrow this module's header claim to the sinks actually migrated.
中文说明
[Suggestion] R1-16:这次"命令族打印的每个工作区可控字符串共用一个转义器"的抽取,把 capture-local.ts 里联用的 display() 副本留在了原地(capture-local.ts:115-118:CONTROL.test(path) ? JSON.stringify(path) : path),而该副本缺少本模块自己的 DEL 修复——JSON.stringify 会原样透过 U+007F(已验证:JSON.stringify('a\u007fb') 保留裸 DEL 字节)。经活输出端探测:给 capture-local 一个带 DEL 的未审查文件路径,"was NOT reviewed" 警告的输出 stderr contains RAW DEL byte: true——含 DEL 的文件名仍以裸控制字节抵达操作者终端,走的正是本模块头部引用的那个输出端;所以头部的全称断言("抽取出来,让更新的输出端不必各自重新推导——也就不会各自重新忘掉")在做出该断言的这个提交上就是假的。(display() 本身是既有的;不一致是本 diff 新增断言造成的。)
修复:把 capture-local.ts 的 display() 迁移到 inertText(删掉本地 CONTROL 副本),或把本模块头部断言收窄到实际迁移过的输出端。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| }); | ||
| }); | ||
|
|
||
| describe('changedPairs', () => { |
There was a problem hiding this comment.
[Suggestion] R1-17: The changedPairs suite never exercises the one-sided-key case attributesMoved exists for — a .gitattributes entry present in only ONE of recorded/current — so the branch's return true ("Present on one side only is a move", file-verdicts.ts:222-224) has zero coverage. Verified green mutation: flipping if (!rec || !cur) return true; to return false leaves all 15 tests green — the four unit cases carry no .gitattributes keys (attributePaths → empty set → .some never invokes the callback), and the git-backed attribute test calls blobPairs with the SAME plan list on both sides, so both maps materialize identical key sets. changedPairs has no other test consumer and no production consumer yet, so this suite is the branch's only witness. The real trigger once the consumer lands: a plan-set shift between rounds (current gains a dir/.gitattributes key the record lacks) reads as "not moved" instead of retiring every verdict — a clean verdict transfers over an attribute change no round ever read.
it('an attribute path present on only one side retires every verdict', () => {
const pair = { base: 'b1', head: 'h1' };
expect(
changedPairs(
{ 'a.ts': pair },
{ 'a.ts': pair, 'pkg/.gitattributes': { base: NO_BLOB, head: 'h2' } },
['a.ts'],
),
).toEqual(['a.ts']);
});中文说明
[Suggestion] R1-17:changedPairs 套件从未测试 attributesMoved 存在的那个单边键场景——.gitattributes 条目只出现在 recorded/current 的其中一边——因此该分支的 return true("只在一边存在即视为移动",file-verdicts.ts:222-224)零覆盖。已验证的绿色变异:把 if (!rec || !cur) return true; 翻成 return false,全部 15 个测试仍绿——四个单元用例不含任何 .gitattributes 键(attributePaths → 空集 → .some 根本不会调用回调),git 实测的属性测试两边用相同的 plan 列表调用 blobPairs,两个 map 的键集必然一致。changedPairs 目前既无其他测试消费方也无生产消费方,本套件是该分支的唯一见证。consumer 落地后的真实触发:轮次之间 plan 集合变化(current 多出记录里没有的 dir/.gitattributes 键)会被读成"未移动",而不是让所有裁决失效——一个干净的裁决就被传递到没有任何轮次读过的属性变更之上。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| expect(changedPairs(rec, cur, ['gone.ts'])).toEqual([]); | ||
| }); | ||
|
|
||
| it('a path named __proto__ compares as an ordinary key', () => { |
There was a problem hiding this comment.
[Suggestion] R1-18: The __proto__ test only exercises the COMPARE side (a JSON.parse-built record + a plain {} current map) and never produces a map through blobsAt/blobPairs for a file literally named __proto__. Verified green regression: replacing nullProtoMap()'s Object.create(null) with {} literals leaves the entire shipped suite green — under naive indexing the test's {}['__proto__'] reads Object.prototype (truthy), so changedPairs still flags the path, while producer-side out['__proto__'] = … on a plain object is a silent no-op (Object.hasOwn false after assignment). The shipped regression drops a __proto__ file's tree-entry identity at capture; the cache's JSON round-trip masks the worst outcome today (serialization drops the key → fail-closed re-review), so this is a test-strength gap, not a live bug — the code at this commit is correct.
Fix: add a producer-side case — write('__proto__', 'A\n'), commit, then expect(blobsAt(repo, sha, ['__proto__'])!['__proto__']).toMatch(/^100644 [0-9a-f]{40,64}$/) (and/or assert blobPairs(...) carries the real pair) — so reverting nullProtoMap to plain literals fails the suite.
中文说明
[Suggestion] R1-18:__proto__ 测试只测了比较侧(JSON.parse 构造的记录 + 普通 {} 当前 map),从未让一个真实命名为 __proto__ 的文件经过 blobsAt/blobPairs 产出 map。已验证的绿色回归:把 nullProtoMap() 的 Object.create(null) 换成 {} 字面量,整套件依旧全绿——裸索引下测试里的 {}['__proto__'] 读到 Object.prototype(真值),changedPairs 仍会标记该路径;而生产端在普通对象上执行 out['__proto__'] = … 是静默空操作(赋值后 Object.hasOwn 为 false)。该回归落地后会在捕获时丢掉 __proto__ 文件的树条目身份;目前缓存的 JSON 往返会掩盖最坏结果(序列化丢掉该键 → 失败安全地重审),所以这是测试强度缺口而非活 bug——本提交上的代码是正确的。
修复:补一个生产端用例——write('__proto__', 'A\n')、提交,然后 expect(blobsAt(repo, sha, ['__proto__'])!['__proto__']).toMatch(/^100644 [0-9a-f]{40,64}$/)(和/或断言 blobPairs(...) 携带真实 pair)——使 nullProtoMap 退化为普通字面量时套件变红。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| * Render an incremental anchor for humans: truncate only sha-shaped labels. | ||
| * The label space holds 40-64-hex commit shas AND the literal | ||
| * `content-verdicts`; a blind 12-char slice printed `content-verd` into the | ||
| * summary line and every brief. One copy, because its two renderers |
There was a problem hiding this comment.
[Suggestion] R1-19: This new doc comment names rescope as one of displayAnchor's two renderers ("rescope's summary, agent-prompt's frames"), but rescope does not exist at this commit — the function's only consumers are agent-prompt.ts:737 and agent-prompt.ts:1107 (verified: git ls-tree -r HEAD has no rescope module; the only occurrences are prose mentions of its removal). The DESIGN.md paragraph this same PR adds states "rescope is gone — its scoping moved into fetch-pr --since". A maintainer changing the incremental-frame wording will grep for the named second renderer, find nothing; the comment's stated drift rationale points at a module that isn't there — the drift risk it warns about is actually between agent-prompt's two call sites.
Fix: rename the renderers to what exists — e.g. "One copy, because both renderers — agent-prompt's summary line and its chunk frames — must never drift".
中文说明
[Suggestion] R1-19:这条新增文档注释把 rescope 说成 displayAnchor 的两个渲染器之一("rescope's summary, agent-prompt's frames"),但 rescope 在本提交上并不存在——该函数仅有的消费方是 agent-prompt.ts:737 与 agent-prompt.ts:1107(已验证:git ls-tree -r HEAD 中没有 rescope 模块,仅剩提及它被移除的散文)。本 PR 自己新增的 DESIGN.md 段落写着 "rescope is gone — its scoping moved into fetch-pr --since"。维护者修改增量 frame 措辞时会按注释去找第二个渲染器,一无所获;注释给出的"防漂移"理由指向一个不存在的模块——它警告的漂移风险实际存在于 agent-prompt 的两个调用点之间。
修复:把渲染器改成实际存在的——例如 "One copy, because both renderers — agent-prompt's summary line and its chunk frames — must never drift"。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to `.qwen/review-cache/local.json` (file-path review: `.qwen/review-cache/<target>.json`). The same fail-closed rule as the PR cache applies unchanged — **and a non-empty `skippedFiles` in the capture is fail-closed for this write**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare `{{model}}`, which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out .qwen/review-cache/<target>.json` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows unchanged: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) |
There was a problem hiding this comment.
[Suggestion] R1-20: Step 8 contradicts itself for the tree-moved mid-capture case: the new fallback sentence routes "a local round whose capture withheld the candidate because the tree moved mid-capture" to hand-writing the template below, while the retained parenthetical at the same paragraph's end says of that exact case "then there is nothing to promote". Pre-PR, the local flow wrote its cache only from the candidate, so withheld → skip was unambiguous; this diff introduced the contradiction. The fallback numbered item's template is PR-shaped only — pr-<number>.json with lastCommitSha, no stateId/files/headSha — while a tree-moved withholding is a local-only case. Traced outcome of the hand-write reading: readLocalCache requires v === 1, string target/stateId, object files — a PR-shaped local.json returns null, so the next round does a full capture; the hand-write branch produces an unusable artifact and contradicts the withholding's stated purpose ("so the next round cannot anchor on bytes this round never reviewed"), while the parenthetical reading skips the write — two different actions for one fail-closed state, decided per-run by the model.
Fix: pick one rule and state it once — if a tree-moved capture stays fail-closed (its refusal reason says the anchor is unusable), delete "or a local round whose capture withheld the candidate because the tree moved mid-capture" from the fallback sentence so the parenthetical stands; otherwise delete the parenthetical and give the local fallback its own target name and shape.
中文说明
[Suggestion] R1-20:Step 8 在"捕获中途树被改动"的场景下自相矛盾:新的回退句把"因捕获中途树被改动而扣留 candidate 的 local 轮次"引导到手写下方模板,而同一段末尾保留的括号句却对同一场景说"then there is nothing to promote"。PR 之前,local 流程只从 candidate 写缓存,扣留 → 跳过是唯一解读;本 diff 引入了这个矛盾。回退编号项里的模板只有 PR 形态——pr-<number>.json 带 lastCommitSha,没有 stateId/files/headSha——而树被改动的扣留只发生在 local 场景。按手写解读追溯结果:readLocalCache 要求 v === 1、字符串 target/stateId、对象 files——PR 形态的 local.json 返回 null,下一轮照样全量捕获;手写分支产出一个不可用的产物,且与扣留声明的目的("让下一轮不能锚定到本轮从未审查过的字节")相矛盾;而按括号句解读则跳过写入——同一个失败安全状态对应两种不同动作,由模型每次临场决定。
修复:二选一并只说一次——若树被改动的捕获保持失败安全(其拒绝理由已说明锚点不可用),从回退句中删掉 "or a local round whose capture withheld the candidate because the tree moved mid-capture",让括号句成立;否则删掉括号句,并为 local 回退给出专属的 target 名与形态。
— qwen3.8-max via Qwen Code /review (v0.21.15)
Round 1 on this PR found nine, and the majority are regressions the
previous round's fixes introduced. Taken in that order:
**The PR cache lost its only writer.** The last round removed
`lastModelId: "{{model}}"` from Step 8's template because the LOCAL
capture had started recording the identity itself — but the PR flow has
no such writer in this PR (that producer is the follow-up's), so every
PR cache written after it carried no certifier, Step 1 omitted
`--since-model`, and `fetch-pr` refused every anchor as
`cross-model-anchor` for ever. The template line is back; the
"do not hand-carry a model" instruction stays where it belongs, on the
local bullet whose command does derive one.
**Two stops contradicted each other.** The `clean-tree` decided stop was
written without consulting `treeHeldStill`, so a capture whose own guard
had just proved the tree moved mid-capture still ended the round as
"nothing to review": stderr printed both lines back to back and the
just-written change went unreviewed while the run recorded clean. It is
gated now, like the skipped-content case beside it.
**A third decided shape had no stop at all.** A cached path that vanished
is a change by design, so the unchanged-since stop cannot fire, and the
clean-tree stop is gated on `!incremental` — leaving `chunks: []` with an
`incremental` block and no field: `agent-prompt --roster` threw and the
parent reported "Review did not complete". That shape is
`scope-emptied`, and only when no more specific stop already fired — the
first cut of this overwrote `unchanged-since-last-round`, which the
existing test caught.
**The attribute probe was buffer-bound.** `gitWithInputRaw` inherited
`execFileSync`'s 1 MB default while `check-attr --stdin -z` emits ~3
records per path — ~1.16 MB at this repo's file count. Past it the call
threw, the blanket catch answered an empty map, every identity became
UNHASHABLE, and since UNHASHABLE never equals itself the whole target
was silently re-reviewed every round with a stable stateId and no
refusal. Same 512 MiB ceiling `gitRaw` takes.
**The parent polled a name the skill lets the model choose.** The stop
was published inside the plan, whose `--out` is the orchestrator's to
pick — necessarily, since the target token does not exist at Step 1 — so
every file review's decided stop was invisible to `qwen review run`. It
is a sidecar now, named from the same target the parent derives.
**A decided stop passed `--fail-on request-changes`.** Both stop
branches open by rendering the cache's still-open findings, and the
common shape is a user who committed without fixing a Critical. Reported
with no verdict, the gate returned 0 over a blocker the round itself
called standing — passing the moment the author stopped touching the
tree. The sidecar carries the ledger's open-blocker count and the parent
maps a non-zero one to `REQUEST_CHANGES`.
**The ledger reads named a token that does not exist yet.** Step 1's
reads and Step 8's write all spelled `<target>.json` before the command
that derives `target` had run, and `safeTarget` is not hand-reproducible
past 64 characters or through a symlink. The capture publishes the
resolved `cachePath` and every reader takes it from there.
**Step 8's local paragraph pointed at a rule that cannot apply.** It
extended "the same fail-closed rule as the PR cache" to local rounds,
but that rule keys on a `sha` inside a posted review's marker, and a
local round posts nothing — read literally it skipped the cache write on
every round. The local conditions are stated in full instead.
**The cache key did not discriminate the subject.** The anchor gate's
`source` check is the second layer, not the first: it can only refuse a
cache the round already opened, leaving the ledger — read and written by
the orchestrator — sharing one file. `src/foo.ts` and `src_foo.ts`
erased each other's findings, a root file named `local` produced the
whole-tree key byte for byte, and one named `pr-<n>` produced PR <n>'s.
File reviews get their own namespace and a digest of the source path.
Safe to respell because nothing predicts the name any more.
Every fix is mutation-checked, and the ordering bug in the third one is
the reason: the fix that closes a finding is exactly as capable of
opening one.
R1-1 and R1-2, plus two integration breaks the fixes themselves exposed. **`source` never survived promotion.** The allowlist that makes this command mechanical is exactly what dropped it: the local capture writes `source` and the next round's anchor gate compares it — `safeTarget` is not injective, so the token alone cannot tell two files apart — and the hand-merge this command replaced spread the whole candidate, so the field survived there and in no promoted cache. Every file-path review lost its anchor permanently: refused as "an unrecorded path", full review for ever. It is a candidate field now, which also brings it under the control-character sweep. **The sweep missed C1.** U+0080–U+009F passed into a cache that sits at a deterministic in-repo path this command's own threat model calls tamperable, is read back next round, and is printed on a refusal through escapers sharing the same blind spot — so an 8-bit CSI or OSC reaches the operator's terminal intact. House convention already sweeps C1. Then the end-to-end test found what neither unit test could: **The cross-target guard did not know the file-review cache form.** The base branch namespaces a file review's cache as `file-<token>-<digest of source>.json`, because the flattened token alone does not discriminate the subject; this command derived the target from the whole basename and refused every such promotion. It now parses the form and checks BOTH halves — token against `target`, digest against `source` — which also refuses promoting one file's candidate into another file's cache even when their tokens collide. **The directory form of `--cache` still looked for the old name**, so a round reported "the cache is missing or unreadable" for a cache sitting right there. One function spells that path now, and both the write and the lookup go through it. Both of those were invisible to the unit tests on either side of the seam — and to this suite's `promoteCandidate` helper, which spreads the candidate instead of running the command, so the dropped field survived in every test and in no real round. The new test drives the real `cache-commit` between two real captures.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: reverse audit — stopped at the 5-round cap without two consecutive dry rounds (every round surfaced findings; round 5 still reported 2).
Not reviewed: test-efficacy probe — all 9 probes inconclusive: the probe tree cannot satisfy this repo's vitest globalSetup build prerequisites (files this PR does not touch), so no mutants or hunk reverts were executed; mutation claims in this review were instead run by the verifiers' own probes.
Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:
packages/cli/src/commands/review/lib/inert-text.test.ts:1 — [review] raw control bytes make git classify the escaper's only test as binary — unauditable in any diff viewpackages/cli/src/commands/review/cache-commit.ts:52 — [probe] inertText wrap around the parse-error message untested; Node's SyntaxError embeds raw control bytes; deleting the wrap leaves 18/18 greenpackages/cli/src/commands/review/capture-local.ts:494 — [probe] withhold rmSync deletes through an unguarded parent chain; probe deleted the victim file through a planted .qwen/tmp symlinkpackages/cli/src/commands/review/cache-commit.ts:149 — [probe] control sweep never touches the persisted ledger ({...ledger} carries verdict/findings unswept); probe persisted C1 bytespackages/cli/src/commands/review/lib/paths.test.ts:193 — [review] no test plants a symlink above the immediate parent though the guard's doc claims ANYWHERE in the chainpackages/cli/src/commands/review/lib/inert-text.ts:45 — [probe] inertText double-escapes every control character (\\uXXXX instead of \uXXXX); inertness preserved, rendering wrongpackages/cli/src/commands/review/capture-local.ts:486 — [probe] the guarded-write refusal branch never rmSyncs the stale candidate; probe: stale candidate survives exactly when the field is absentpackages/cli/src/commands/review/capture-local.ts:211 — [probe] four doc sites misspell the cache name (<dir>/<target>.json vs cachePathFor's namespaced form; --out help omits the file-review form)packages/cli/src/commands/review/lib/file-verdicts.test.ts:242 — [probe] readFileVerdicts' Array.isArray branch and null-proto safety unpinned; both mutants live at 15/15 greenpackages/cli/src/commands/review/lib/file-verdicts.test.ts:218 — [review] the U+FFFD-refusal test's win32 skip is undocumented and not justified by fixture impossibilitypackages/cli/src/commands/review/agent-prompt.ts:737 — [probe] displayAnchor removed the 12-char cap; inertPath has no length cap; a 3MB non-hex anchor from a corruptible plan floods every brief
中文说明
仅完成部分审查,审查缺口已披露。
未审查:reverse audit — stopped at the 5-round cap without two consecutive dry rounds (every round surfaced findings; round 5 still reported 2)。
未审查:test-efficacy probe — all 9 probes inconclusive: the probe tree cannot satisfy this repo's vitest globalSetup build prerequisites (files this PR does not touch), so no mutants or hunk reverts were executed; mutation claims in this review were instead run by the verifiers' own probes。
收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 11 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| * end (via `cache-commit`). Absent when the capture had no diff to | ||
| * describe. | ||
| * | ||
| * PRODUCER SIDE ONLY at this commit, and the distinction matters because |
There was a problem hiding this comment.
[Suggestion] R1-3: Still stands from round 1 — the PR title and description certify end-to-end rebase-survival behaviour ("transfer per-file content verdicts across rebases", "The per-file verdicts now carry across, so only the files a rebase actually rewrote come back into scope"), but readFileVerdicts/changedPairs have zero production call sites at this commit — the pairs are produced and promoted, nothing consumes them. This field doc and DESIGN.md disclose it ("a rebase still degrades to a full review today"); the title/description still do not.
Failure scenario: a maintainer merging on the description's certification ships a feature whose headline behaviour does not exist at the merge commit; the consumer lands later (or not), and the rebase-survival claim in the merged history points at this PR.
Fix: scope the title/description to what lands ("record and promote per-file verdicts; consumer follows"), or land the consumer in this PR.
中文说明
[Suggestion] R1-3:第 1 轮已提出,仍然存在——PR 标题与描述承诺了端到端的 rebase 存活行为("transfer per-file content verdicts across rebases"、"The per-file verdicts now carry across, so only the files a rebase actually rewrote come back into scope"),但在本提交上 readFileVerdicts/changedPairs 没有任何生产调用点:裁决对被生产、被晋升,却没有消费者。本字段文档与 DESIGN.md 已如实披露("a rebase still degrades to a full review today"),但标题/描述仍未同步。
失败场景:维护者基于描述的承诺合入,得到的却是合并提交上并不存在的头牌行为;消费者日后再落地(或不落地),而合并历史里的 rebase 存活承诺会指向本 PR。
修复:把标题/描述收窄到实际落地的内容("记录并晋升 per-file 裁决;消费者随后"),或在本 PR 中一并落地消费者。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| const pairs = blobPairs( | ||
| gitOpt('rev-parse', '--show-toplevel') ?? '.', |
There was a problem hiding this comment.
[Suggestion] R1-5: Still stands from round 1 — when blobPairs returns null (a transient ls-tree I/O failure, or the deterministic U+FFFD decode-aliasing refusal — any repo git holds with a non-decodable filename colliding with a literal-U+FFFD sibling), the candidate is withheld silently: the pairs === null arm has no else and no stderr line, unlike the write-failure branch just below it and capture-local's two withholding announcements.
Failure scenario: the round completes and the report simply lacks cacheCandidatePath — indistinguishable from the documented "no diff to describe" case — so after a rebase the next round degrades to a full review, and neither operator nor orchestrator can find, in this round's output, any reason the anchor was not produced. This module family's stated discipline is that every anchor loss is "said out loud" (anchorRefusalReason's header).
Fix: add an else writing one stderr line, e.g. WARNING: could not list the reviewed blob pairs — no cache candidate this round; the next round cannot anchor on it, but the review itself is unaffected.
中文说明
[Suggestion] R1-5:第 1 轮已提出,仍然存在——当 blobPairs 返回 null(瞬时的 ls-tree I/O 失败,或确定性的 U+FFFD 解码歧义拒绝——任何含有不可解码文件名且与字面 U+FFFD 兄弟文件冲突的仓库都会触发)时,candidate 被静默扣下:pairs === null 分支既没有 else 也没有 stderr 输出,而紧挨着的写失败分支和 capture-local 的两处扣留都会公告。
失败场景:轮次正常结束,报告只是缺少 cacheCandidatePath——与文档中"没有 diff 可描述"的情形无从区分——于是 rebase 之后下一轮退化为全量评审,而操作者和 orchestrator 在本轮输出里找不到任何锚点未生成的原因。本命令族声明的纪律是:每一次锚点丢失都要"说出来"(anchorRefusalReason 的头部注释)。
修复:补一个 else,写一行 stderr,例如 WARNING: could not list the reviewed blob pairs — no cache candidate this round; the next round cannot anchor on it, but the review itself is unaffected.
— qwen3.8-max via Qwen Code /review (v0.21.15)
| '', | ||
| `**This is an INCREMENTAL round** — the diff holds only what changed since the ` + | ||
| `previous clean review round (anchor \`${inertPath(incremental.anchor.slice(0, 12))}\`), ` + | ||
| `previous clean review round (anchor \`${inertPath(displayAnchor(incremental.anchor))}\`), ` + |
There was a problem hiding this comment.
[Suggestion] R1-6: Still stands from round 1 — the pairing between displayAnchor and its two agent-prompt call sites (here and ~line 1107) is vacuous: the incremental fixtures use 20/16-char anchors and assert only toContain('abc1234def56'), which passes identically under the old .slice(0, 12) and the new displayAnchor; report.test.ts pins the function, not its use.
Failure scenario: reverting either call site to incremental.anchor.slice(0, 12) keeps the whole suite green — the exact regression displayAnchor exists to fix (a non-sha anchor rendered as content-verd in every chunk brief and reading frame) can silently return.
Witness (probe at this commit):
BASELINE: Test Files 1 passed, Tests 285 passed (285)
MUTANT (both call sites reverted to .slice(0, 12)): Tests 285 passed (285)
Fix: run one incremental plan with a non-sha anchor (e.g. content-verdicts) through buildChunkAgentPrompt/buildRoleBrief and assert the full label appears — that assertion is red under the old slice.
中文说明
[Suggestion] R1-6:第 1 轮已提出,仍然存在——displayAnchor 与其在 agent-prompt 的两处调用点(此处与约 1107 行)之间的配对是空洞的:增量测试夹具使用 20/16 字符锚点,且只断言 toContain('abc1234def56')——旧的 .slice(0, 12) 与新的 displayAnchor 都能通过;report.test.ts 钉住的是函数本身,不是其使用。
失败场景:把任一调用点还原为 incremental.anchor.slice(0, 12),整个测试套件仍然全绿——displayAnchor 本要修复的回归(非 sha 锚点在每个分块 brief 与阅读框架里被渲染成 content-verd)可以悄悄复现。
证据(在本提交上探测):基线 285/285 通过;把两处调用点还原为 .slice(0, 12) 的变异体仍 285/285 通过。
修复:用一个非 sha 锚点(如 content-verdicts)的增量计划跑一遍 buildChunkAgentPrompt/buildRoleBrief,断言完整标签出现——该断言在旧 slice 下会失败。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| !('lastModelId' in candidate) | ||
| ? { ...candidate, lastModelId: 'candidate-model@aaaaaaaa' } |
There was a problem hiding this comment.
[Suggestion] R1-7: Still stands from round 1 (and subsumes R1-8's source-object-contract arm): seed()'s default injection makes the model-id gate's non-string/missing arm unreachable by any fixture — both gate tests pin only the empty-string arm, despite titles claiming "without"/"not just a missing one". A weak candidate combined with a strong ledger is unreachable for the same reason.
Failure scenario: probed mutant — rewriting the gate as if (candidateModel === '') keeps 18/18 green; candidates carrying lastModelId: 123, an object, or a ledger-only model id then pass the gate and the control sweep (strings only) and are persisted — every later round silently fails the same-model gate and degrades to full review, blamed on a stale cache.
Witness (probe at this commit):
MUTANT if (candidateModel === '') → suite 18/18 green
probes (lastModelId 123 / absent / object + strong ledger): 3/3 failed under mutant, 3/3 passed restored
Fix: add tests that bypass seed()'s injection (write the candidate file directly): one with no lastModelId key at all, one with lastModelId: 123, one combining a weak candidate with a ledger-carried model id; all must throw /lastModelId/.
中文说明
[Suggestion] R1-7:第 1 轮已提出,仍然存在(并涵盖 R1-8 的"源对象契约"分支):seed() 的默认注入让模型 id 门的"非字符串/缺失"分支对任何夹具都不可达——两个门测试只钉住了空字符串分支,尽管标题写着 "without"/"not just a missing one";弱 candidate 加强 ledger 的组合同样因注入而不可达。
失败场景:探测变异体——把门改写为 if (candidateModel === '') 后 18/18 仍然全绿;携带 lastModelId: 123、对象、或仅由 ledger 携带模型 id 的 candidate 都能通过该门与控制字符扫描(只查字符串)并被持久化——此后每一轮都会静默地未通过同模型门、退化为全量评审,却被归咎于缓存过期。
证据(在本提交上探测):变异体下套件 18/18 绿;三个探针在变异体下 3/3 失败,还原后 3/3 通过。
修复:增加绕开 seed() 注入的测试(直接写 candidate 文件):一个完全不含 lastModelId 键,一个 lastModelId: 123,一个弱 candidate 配强 ledger;均应抛出 /lastModelId/。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| if (expected !== fileForm[2]) { | ||
| throw new Error( |
There was a problem hiding this comment.
[Suggestion] R2-4: The fileForm digest-verification branch — refusing promotion when --out names a different source path's namespaced cache — is never exercised by any test; deleting the whole if (fileForm) block leaves every test in this diff green, since the only file-form promotion (the capture-local incremental test) has a matching digest and passes via the plain target check anyway.
Failure scenario: the collision this PR exists for — src/foo.ts and src_foo.ts flatten to one token. An orchestrator pairing one file's candidate with the other file's cachePath (a misread or stale plan field) is refused today only by this branch; if it regresses, promotion silently merges file A's anchor onto file B's ledger and the next round anchors on a pair describing the wrong file. The local flow's second-layer source re-compare contains it — which is exactly why the first layer needs a test.
Witness (probe at this commit):
BASELINE: cache-commit.test.ts 18/18, capture-local.incremental.test.ts 43/43
MUTANT (digest block deleted): Tests 61 passed (61)
probe: PR throws 'refusing to promote across file targets'; MUTANT writes file A's anchor into file B's cache
Fix: add a cache-commit test: seed a candidate {target: 'src_foo.ts', source: 'src/foo.ts'} and pass --out ending in file-src_foo.ts-<digest-of-a-different-source>.json; expect the cross-file-targets refusal and no cache file.
中文说明
[Suggestion] R2-4:fileForm 摘要校验分支——当 --out 指向另一个源路径的命名空间缓存时拒绝晋升——没有任何测试覆盖;删掉整个 if (fileForm) 块,本 diff 的所有测试仍然全绿:唯一的 file 形态晋升(capture-local 增量测试)摘要恰好匹配,且本来就靠普通 target 检查通过。
失败场景:本 PR 正是为这个碰撞而生——src/foo.ts 与 src_foo.ts 拍扁成同一个 token。orchestrator 若把一个文件的 candidate 配上另一个文件的 cachePath(读错或过期的计划字段),今天只有这个分支在拦;一旦回归,晋升会静默地把文件 A 的锚点并进文件 B 的 ledger,下一轮就锚定在描述错误文件的配对上。本地流程第二层的 source 复查能兜住——这恰恰说明第一层需要一个测试。
证据(在本提交上探测):基线 cache-commit.test.ts 18/18、capture-local.incremental.test.ts 43/43;变异体(删除摘要块)61/61 全过;探针:PR 抛出 'refusing to promote across file targets',变异体把文件 A 的锚点写进了文件 B 的缓存。
修复:新增 cache-commit 测试:种入 candidate {target: 'src_foo.ts', source: 'src/foo.ts'},--out 以 file-src_foo.ts-<另一个源的摘要>.json 结尾;断言跨文件 target 拒绝且不产生缓存文件。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| const stem = basename(args.out).replace(/\.json$/, ''); | ||
| const fileForm = /^file-(.*)-([0-9a-f]{8})$/.exec(stem); |
There was a problem hiding this comment.
[Suggestion] R2-7: The target binding is driven by the --out stem's SPELLING instead of the candidate's shape — three probed arms: (a) it never requires the file-<token>-<digest>.json form for a candidate carrying source, so a file-review candidate promotes into a plain <token>.json name; (b) it never requires --out to end in .json at all, so an extensionless name passes every check; (c) it parses file-form off the stem for source-LESS candidates too, so a plain candidate whose target merely resembles the reserved file-<token>-<8hex> shape — capture-local --target file-foo-1234abcd is legal free-form input — can NEVER be promoted: the digest arm demands a source a plain round never records, and no --out spelling survives.
Failure scenario (all probed end-to-end at this commit): (a) a file candidate promoted into a plain name erased a seeded whole-tree cache — the exact hazard cachePathFor's comment warns about ("a root file literally named local produced the whole-tree key byte for byte"); (b) --out <dir>/pr-7 prints "Committed review cache" and writes a cache no reader names — every later round full-reviews, nothing tells the operator the anchor is dead; (c) capture-local --target file-foo-1234abcd publishes cachePath file-foo-1234abcd.json and cache-commit --out with exactly that path throws "refusing to promote across file targets" on every spelling — promotion permanently impossible for a legal target, fail-closed every round with an error blaming a cross-file promotion that is not happening.
Witness (probe):
(a) file candidate → plain name: threw = null; seeded whole-tree cache replaced
(b) --out pr-7 (no extension): handler returned without error; reader-named pr-7.json exists: false
(c) real capture-local --target file-foo-1234abcd + the published cachePath: throws /refusing to promote across file targets/
flip (mandate file-form iff the candidate carries source): all three arms refuse correctly
Fix: drive the form from the candidate, not the stem — when typeof candidate['source'] === 'string', require a file-form --out and check both halves; otherwise treat the stem as a plain target, refuse reserved-shape stems, and refuse extensionless outs (e.g. if (!basename(args.out).endsWith('.json')) throw).
中文说明
[Suggestion] R2-7:target 绑定由 --out 主干的"拼写"驱动,而不是 candidate 的形状——已探测的三个分支:(a) 对携带 source 的 candidate,从不要求 file-<token>-<digest>.json 形态,于是文件评审的 candidate 可以被晋升进普通 <token>.json 名字;(b) 从不要求 --out 以 .json 结尾,无扩展名的名字能通过所有检查;(c) 对不带 source 的 candidate 也按主干解析 file 形态,于是 target 恰好形似保留形态 file-<token>-<8hex> 的普通 candidate——capture-local --target file-foo-1234abcd 是合法的自由输入——永远无法被晋升:摘要分支索要普通轮次从不记录的 source,且没有任何 --out 拼写能通过。
失败场景(均在本提交上端到端探测):(a) 文件 candidate 晋升进普通名字,抹掉了预先种入的整树缓存——正是 cachePathFor 注释警告的灾害("名为 local 的根文件会逐字节地产出整树键");(b) --out <dir>/pr-7 打印 "Committed review cache" 并写出一个没有任何读取者命名的缓存——此后每轮全量评审,操作者却得不到锚点已死的任何提示;(c) capture-local --target file-foo-1234abcd 发布 cachePath file-foo-1234abcd.json,用这个路径原样调用 cache-commit --out 在任何拼写下都抛 "refusing to promote across file targets"——合法 target 的晋升被永久封死,每轮失败关闭,错误信息却归咎于一次并未发生的跨文件晋升。
证据(探测):(a) 文件 candidate → 普通名字:不抛错,种入的整树缓存被替换;(b) --out pr-7(无扩展名):handler 正常返回,读取者命名的 pr-7.json 不存在;(c) 真实运行后按发布的 cachePath 调用:抛 /refusing to promote across file targets/;修复翻转(当且仅当 candidate 携带 source 时强制 file 形态):三个分支全部正确拒绝。
修复:用 candidate 而非主干驱动形态——typeof candidate['source'] === 'string' 时要求 file 形态 --out 并校验两半;否则视主干为普通 target,拒绝保留形态的主干与无扩展名输出(如 if (!basename(args.out).endsWith('.json')) throw)。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are the local ones, stated in full — do NOT go looking for the PR cache's marker rule below.** That rule keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to check; read literally it would skip this write on every round and the feature would never persist. For a local or file-path round the conditions are: **a non-empty `skippedFiles` in the capture is fail-closed for this write**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare `{{model}}`, which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the plan's `cachePath`>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) |
There was a problem hiding this comment.
[Suggestion] R2-21: The rewrite replaced the old completeness framing ("The fail-closed conditions here are the local ones, stated in full") with "the sentence that follows and nothing else", while two sentences later a bolded gate asserts "A non-empty skippedFiles in the capture is fail-closed for this write, both flows" — the paragraph is self-contradictory about the LOCAL round's full gate set, and the literal reading drops the local skippedFiles gate. cache-commit has no skippedFiles input, so the contradiction sits on the only enforcement.
Failure scenario: a high-effort local review of a tree containing a file the capture skips (over-size/binary/unhashable → non-empty skippedFiles): an orchestrator reading the completeness claim literally — the paragraph's own history ("read literally it would skip this write on every round") is evidence orchestrators read it literally — passes the scope rule, runs cache-commit, and promotes the candidate over skipped content; the next round then anchors its "no changes" scope past work no round read — the exact harm the skippedFiles sentence exists to prevent.
Fix: restore the completeness framing — "and for a LOCAL round the fail-closed conditions are the ones in this paragraph and no others — the sentence that follows, the effort gate, and the skippedFiles rule below" (drop or qualify "nothing else").
中文说明
[Suggestion] R2-21:重写把旧的完备性表述("此处的失败关闭条件就是本地那些,已完整陈述")换成了"紧随其后的那一句,别无其他",而两句之后一个加粗门又断言"捕获中非空的 skippedFiles 对这次写入是失败关闭的,两条流程都是"——段落对本地轮次的完整门集合自相矛盾,字面解读会丢掉本地 skippedFiles 门。cache-commit 没有 skippedFiles 输入,因此矛盾恰好落在唯一的执行点上。
失败场景:对一个含有被捕获跳过文件(过大/二进制/不可哈希 → 非空 skippedFiles)的树做高效本地评审:按字面理解完备性断言的 orchestrator——段落自己的历史("按字面读会在每一轮跳过这次写入")证明 orchestrator 确实会按字面读——通过范围规则、运行 cache-commit、把 candidate 晋升在被跳过内容之上;下一轮就把"无变化"范围锚定在任何轮次都没读过的工作之后——正是 skippedFiles 这句要防止的灾害。
修复:恢复完备性表述——"对本地轮次,失败关闭条件就是本段落中的这些,别无其他——紧随其后的那一句、努力级别门、以及下方的 skippedFiles 规则"(删除或限定"别无其他")。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are the local ones, stated in full — do NOT go looking for the PR cache's marker rule below.** That rule keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to check; read literally it would skip this write on every round and the feature would never persist. For a local or file-path round the conditions are: **a non-empty `skippedFiles` in the capture is fail-closed for this write**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare `{{model}}`, which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the plan's `cachePath`>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) |
There was a problem hiding this comment.
[Suggestion] R2-22: This PR adds a CLI writer for the review cache and moves lastModelId out of what the model types at Step 8, but leaves DESIGN.md:537 (same file this PR edits elsewhere) asserting the opposite: "the cache path's lastModelId remains what the model types at Step 8 (no CLI writer exists for that file), so a forged cache id can defeat the gate without exceeding the pre-change baseline". The passage now also contradicts, inside the same record, the paragraph this PR added to DESIGN.md ("cache-commit promotes them mechanically") and this SKILL.md sentence; SKILL.md cites DESIGN.md as its audit narrative 62 times.
Failure scenario: a maintainer reasoning about the model-identity forgery boundary — the exact boundary this paragraph's No-lastModelId rule hardens — consults DESIGN.md and concludes the cache's lastModelId is still model-typed and forgeable, when cache-commit now refuses a missing candidate lastModelId and the allowlist deletes any ledger-supplied one; the stale passage's security conclusion is argued from a surface this diff removed.
Fix: update DESIGN.md:537's cache-path clause — cache-commit is now the cache's CLI writer and lastModelId is candidate-owned (recorded provider-qualified at capture); re-derive the forgery analysis for candidate tampering instead of model-typed ledger keys.
中文说明
[Suggestion] R2-22:本 PR 为评审缓存添加了 CLI 写入者,并把 lastModelId 从"模型在 Step 8 手打的内容"中移出,却让 DESIGN.md:537(本 PR 在同一文件的别处有修改)继续断言相反内容:"缓存路径的 lastModelId 仍然是模型在 Step 8 打出的(该文件没有 CLI 写入者),因此伪造缓存 id 可以在不超出改动前基线的情况下绕过门"。该段落如今在同一份记录里也与本 PR 新增的 DESIGN.md 段落("cache-commit 机械地晋升它们")以及本 SKILL.md 句子矛盾;SKILL.md 有 62 处把 DESIGN.md 当作审计叙事引用。
失败场景:维护者推敲模型身份伪造边界——正是本段 No-lastModelId 规则要加固的边界——去查 DESIGN.md,得出"缓存的 lastModelId 仍是模型手打、可以伪造"的结论,而 cache-commit 现在会拒绝缺失的 candidate lastModelId、白名单会删除任何来自 ledger 的值;过期段落的安全结论建立在本 diff 已经移除的表面之上。
修复:更新 DESIGN.md:537 的缓存路径子句——cache-commit 现在是缓存的 CLI 写入者,lastModelId 归 candidate 所有(捕获时记录、provider 限定);把伪造分析从"模型手打的 ledger 键"改推到"candidate 篡改"上。
— qwen3.8-max via Qwen Code /review (v0.21.15)
|
|
||
| 1. Create `.qwen/review-cache/` directory if it doesn't exist | ||
| 2. Write `.qwen/review-cache/pr-<number>.json` with: | ||
| 1. Write the ledger file and run `cache-commit` as described above. **Fallback only** — when the plan carries no `cacheCandidatePath` — create `.qwen/review-cache/` and hand-write `.qwen/review-cache/pr-<number>.json` with: |
There was a problem hiding this comment.
[Suggestion] R2-23b: The fallback route this diff adds sends degraded LOCAL and file-path rounds into a template that is PR-only in both name and shape: item 1 names only pr-<number>.json (a local round has no number — cachePathFor names its slots local.json / file-<token>-<digest>.json), and the template carries lastCommitSha + bare {{model}} but none of the headSha/files/stateId/source fields the local anchor gate reads. The deleted prose had no local fallback at all — the routing is created by this diff.
Failure scenario: a clean high-effort whole-tree local round whose candidate write fails (capture-local's catch branch — atomic write EACCES/ENOSPC, or an assertUnredirectedParent refusal) omits cacheCandidatePath; the orchestrator follows the fallback literally: there is no pr-<number> name to write, so it stalls or improvises. Probed: readLocalCache(template-shape) ⇒ null — if it improvises local.json from the template, the next round's gate refuses it unconditionally, silently degrading to a full review after the terminal summary claimed the cache was persisted. A file review's fallback is doubly unexecutable: its cache name embeds a sha256 digest of the source path that capture-local documents as not hand-reproducible.
Witness (probe at this commit):
readLocalCache(the exact SKILL.md template shape written to disk) => null
Fix: scope the hand-write fallback to PR rounds (name and template); for a local/file-path round with no cacheCandidatePath say to skip the write and say so — the template cannot express a local anchor.
中文说明
[Suggestion] R2-23b:本 diff 新增的兜底路线会把降级的本地轮次与文件路径轮次送进一个名字和形状都只属于 PR 的模板:第 1 条只写了 pr-<number>.json(本地轮次没有编号——cachePathFor 命名的槽位是 local.json / file-<token>-<digest>.json),而模板携带 lastCommitSha + 裸 {{model}},却没有本地锚点门要读的 headSha/files/stateId/source 任何一个字段。被删除的旧文本根本没有本地兜底——这条路线是本 diff 新造的。
失败场景:一次干净的整树高效本地评审,若 candidate 写入失败(capture-local 的 catch 分支——原子写 EACCES/ENOSPC,或 assertUnredirectedParent 拒绝)就会缺省 cacheCandidatePath;orchestrator 照字面走兜底:没有 pr-<number> 名字可写,于是卡住或即兴发挥。已探测:把 SKILL.md 模板形状原样写盘后 readLocalCache ⇒ null——若即兴用模板写出 local.json,下一轮的门会无条件拒绝,在终端摘要声称缓存已持久化之后静默退化为全量评审。文件评审的兜底更加不可执行:其缓存名内嵌源路径的 sha256 摘要,capture-local 文档明说不可手工复现。
证据(在本提交上探测):readLocalCache(SKILL.md 模板形状原样写盘) ⇒ null。
修复:把手写兜底限定于 PR 轮次(名字与模板);对没有 cacheCandidatePath 的本地/文件轮次,明示跳过写入并说明原因——模板表达不了本地锚点。
— qwen3.8-max via Qwen Code /review (v0.21.15)
…'s gaps Nine Criticals, and the shape is the same as last round: the fixes were what opened them. **The cache rename was split-brain (R2-1).** `cachePathFor` namespaces a file review's cache by source path, and the directory resolver still probed the old `<dir>/<target>.json` — so a file review reported "the cache is missing or unreadable" over a cache sitting right there. The resolver goes through the same speller now. (The follow-up PR already carried this fix; it belonged here, with the rename. Second time a fix and its dependency landed in different halves of this stack.) Step 6's prose named the old spelling too. **The stderr clean-tree warning was not gated (R2-2).** The field-level stop got `treeHeldStill` last round; the sentence beside it did not, so the round still printed "the working tree changed while the capture was being hashed" and "the working tree is clean" back to back — and the orchestrator branches on prose. It now says the tree is NOT clean and to re-run. **Three stops, two branches (R2-3, R2-12).** `scope-emptied` appeared nowhere in SKILL.md, so the reason existed with nothing to act on it; and the skipped-only shape — no chunks, non-empty `skippedFiles`, field deliberately withheld — had no executable continuation at all. Both have branches now, the second saying explicitly that it is NOT a stop. **The stop sidecar was collidable (R2-13).** It decides `completed` and can carry a REQUEST_CHANGES event, while its name is the flattened target token, which is not injective — and the epoch fence separates earlier runs, not concurrent ones. `run` stamps its child with a nonce and accepts only a sidecar stamped back. **A file review of an unmodified file read as a clean stop (R2-15).** An empty diff is not decided for a file target — SKILL.md's no-diff branch owes it a whole-file review — and marking it decided turned "Review did not complete" into a passing gate over a file nobody read. **One fixed plan name for every file review (R2-8).** File reviews take no lease and the plan is re-read all round, so two concurrent ones overwrote each other's central artifact mid-run: the second reviewed the first's file and merged its findings into the wrong ledger. The name must be unique to the run; it does not have to match anything. **The file-path ledger was write-only (R2-20).** Every ledger, incremental and stop bullet sat under the `local` branch; the `file` branch had none, so round 2 of a file review presented zero blockers over a Critical round 1 had recorded open. Mutation-checked: reverting the file-review exclusion, the sidecar stamp, or the stderr gate each turns exactly one test red.
R2-1. The module header calls itself `capture-local`'s filename escaper
"extracted, so the newer sinks cannot each re-derive it (and re-forget
it)" — and the extraction forgot three of the four classes the original
`inertPath` rule handles.
`\p{Cc}` covers the ECMA-48 C1 range U+0080–U+009F as well as C0 and
DEL, and a terminal acts on an 8-bit CSI or OSC exactly as it acts on
`ESC [`. `\p{Cf}` is the invisible formatting class: bidi overrides can
reverse the rendering of the rest of the line, and zero-width joiners
and the BOM hide characters inside a value the operator is being asked
to judge. `\p{Zl}`/`\p{Zp}` open a new line wherever the text renders,
so a forged second line needs no `\n`.
All three passed verbatim and unquoted, through the sinks this module
was extracted to protect: `cache-commit`'s refusals over a candidate its
own intake comment calls tamperable, `capture-local`'s warnings, and the
symlink guards.
Also merges the base branch, whose round-2 fixes include the resolver
half of the file-review cache rename — that half had been sitting here
instead of beside the rename, which is the second time in this stack a
fix and its dependency landed in different halves.
Mutation-checked: narrowing back to C0+DEL turns the new test red on the
8-bit CSI case.
…nd the naming prose follows the writer
Two Criticals and six Suggestions.
The PR cache wrote `lastModelId` from `{{model}}`, which interpolates the
BARE model id — but the same-model gate inside `fetch-pr` compares
whole-string against the provider-qualified identity it samples from the
runtime. A bare-id cache is refused as `cross-model-anchor` on every later
round and never heals (each clean round rewrites it bare again), so
cache-path incremental scoping and the `upToDate` stop were dead for every
PR, and a run that does not post lost the anchor entirely. Step 8 now copies
the fetch report's `reviewModelId` — the CLI-published qualified identity,
the gate's kind of string — verbatim, omitting the field when the runtime
published none (the gate then fails closed to a full review, the designed
state for an unrecorded identity). Pinned in SKILL.test.ts.
The round-2 commit renamed the file-review cache to
`file-<target>-<digest>.json` but left three texts spelling the old
`<target>.json`: the Step-6 ledger-source parenthetical (the finding's
anchor), the Step-1 incremental bullet, and `capture-local`'s own
docblock/help. All four now point at the plan's published `cachePath`, and
DESIGN.md's identity-channel section names what Step 8 actually types.
The six Suggestions: the garbled comment restored to English; the
model-refusal message's cached-side fallback aligned to `||` so a
legitimately-empty recorded identity prints "an unrecorded model" instead of
a blank name (regression test added); the source-path gate's stderr sentence
pinned plus a hostile-source escaping variant; the malformed-cache refusal
added as the third leg its test title always promised; the driver-binary
fold matched segment-exactly so a driver whose name is a prefix of another
(`md` / `mdbook`) no longer folds its config into the other's paths
(two-driver fixture added); and the shared `IncrementalScope.fullDiffPath`
declared optional, matching the PR producer that never emits it.
Every guard mutation-probed: each one removed makes its test fail, each one
restored makes the suite green (packages/cli review suites 4363 passed,
packages/core SKILL.test.ts 24 passed; build, typecheck, lint clean).
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
2 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- R2-2 stale
rescopereference in file-verdicts.ts header — already reported (file-verdicts.ts:9, comment by qwen-code-ci-bot: '[Suggestion] R1-19 (second occurrence): this new module's header attributes the stale-sha refusal to rescope') - R2-5 fallback route sends degraded local rounds into a PR-only template — already reported (SKILL.md:1359, comment by qwen-code-ci-bot: '[Suggestion] R2-23b: The fallback route this diff adds sends degraded LOCAL and file-path rounds into a…
Not reviewed: reverse audit — stopped at the 5-round cap without two consecutive dry rounds (every round surfaced findings; round 5 still reported 3).
Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:
packages/core/src/skills/bundled/review/SKILL.md:1355 — [probe] nested single backticks split the Step-8 command code span (cachePath drops out of code formatting)packages/cli/src/commands/review/fetch-pr.ts:1586 — [review] no test drives fetch-pr's new candidate write (guard, degrade-to-warning, announcement can regress without a red test)packages/cli/src/commands/review/cache-commit.test.ts:280 — [review] test title claims a ledger-model control-char arm no fixture exercises and the implementation lackspackages/cli/src/commands/review/lib/report.ts:307 — [review] displayAnchor's content-verdicts case guards a phantom input no producer suppliespackages/core/src/skills/bundled/review/SKILL.md:1355 — [review] fallback template's {{model}} can never pass the same-model gate — fallback writes are dead anchorspackages/cli/src/commands/review/lib/paths.ts:343 — [probe] assertUnredirectedParent spuriously refuses over symlinked ancestors above the repo (fail-safe, misleading message)packages/cli/src/commands/review/capture-local.ts:497 — [probe] the tree-moved rmSync follows a planted .qwen/tmp symlink (removal half of the guarded threat)packages/core/src/skills/bundled/review/SKILL.md:1355 — [review] local fail-closed rule omits the depth-only unreviewed-dimension exception the PR marker rule grantspackages/cli/src/commands/review/lib/file-verdicts.test.ts:239 — [probe] 'rejects every malformation' but the Array.isArray arm has zero fixtures — mutant survives 15/15packages/cli/src/commands/review/lib/paths.test.ts:194 — [probe] symlink fixtures only at the immediate parent — anywhere-in-the-chain claim unpinned (lstat mutant passes 17/17)packages/cli/src/commands/review/cache-commit.ts:191 — [probe] the target-mismatch refusal hint names a --target option cache-commit does not declare (strict mode rejects it)
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 2 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查:reverse audit — stopped at the 5-round cap without two consecutive dry rounds (every round surfaced findings; round 5 still reported 3)。
收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 11 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| * end (via `cache-commit`). Absent when the capture had no diff to | ||
| * describe. | ||
| * | ||
| * PRODUCER SIDE ONLY at this commit, and the distinction matters because |
There was a problem hiding this comment.
[Suggestion] R1-3: Still stands — the PR title and description certify end-to-end rebase-survival behaviour ("transfer per-file content verdicts across rebases", "only the files a rebase actually rewrote come back into scope"), while the diff is candidly PRODUCER SIDE ONLY — this comment itself says "Nothing reads fileVerdicts back yet … a rebase still degrades to a full review today". A maintainer merging on the title's promise gets groundwork, not rebase survival. Re-scope the title/description to the producer half ("record per-file content verdicts for post-rebase transfer"), or land the consumer in this PR.
中文说明
[Suggestion] R1-3:仍然存在——PR 标题与描述承诺了端到端的 rebase 存活行为("transfer per-file content verdicts across rebases"、"只有 rebase 真正改写过的文件才重新进入范围"),而 diff 坦率地只是生产端——本注释自己就写着"目前没有任何代码读回 fileVerdicts……rebase 仍然退化为全量评审"。维护者若按标题的承诺合并,得到的是地基而非 rebase 存活。建议把标题/描述收窄到生产端("为 rebase 后的传递记录每文件内容裁决"),或在本 PR 中落地消费端。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| // otherwise record every pair as (absent, absent) — a candidate that | ||
| // later transfers clean verdicts over anything. | ||
| const pairs = blobPairs( | ||
| gitOpt('rev-parse', '--show-toplevel') ?? '.', |
There was a problem hiding this comment.
[Suggestion] R1-5: Still stands — when blobPairs returns null (a transient ls-tree I/O failure, or the deterministic U+FFFD decode-aliasing refusal — any repo git holds with a non-decodable filename), the candidate is withheld silently: if (pairs !== null) simply skips the write with no diagnostic, unlike the write-failure catch directly below it which does warn. Absence-by-failure becomes indistinguishable from the documented benign absence; a round that could have anchored rebase survival silently does not, and nothing in the round's output says why. Add a stderr warning on the pairs-null branch, matching the wording of the write-failure catch below it.
中文说明
[Suggestion] R1-5:仍然存在——当 blobPairs 返回 null(瞬时的 ls-tree I/O 失败,或确定性的 U+FFFD 解码别名拒绝——任何含有不可解码文件名的仓库都会触发)时,candidate 被静默保留不写:if (pairs !== null) 只是跳过写入、没有任何诊断输出,与紧随其后、会打警告的写入失败 catch 形成对比。因失败而缺失与文档中的良性缺失变得无法区分;本可以为 rebase 存活定锚的轮次静默地没有定锚,而轮次输出中没有任何说明。建议在 pairs 为 null 的分支加一条 stderr 警告,措辞与下方的写入失败 catch 一致。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| '', | ||
| `**This is an INCREMENTAL round** — the diff holds only what changed since the ` + | ||
| `previous clean review round (anchor \`${inertPath(incremental.anchor.slice(0, 12))}\`), ` + | ||
| `previous clean review round (anchor \`${inertPath(displayAnchor(incremental.anchor))}\`), ` + |
There was a problem hiding this comment.
[Suggestion] R1-6: Still stands — the displayAnchor↔agent-prompt pairing is vacuous: the incremental fixtures in agent-prompt.test.ts use 20/16-char anchors ('abc1234def5678900000', 'abc1234def567890' — neither 40-64 hex, so displayAnchor passes them whole) and assert only toContain('abc1234def56'), which passes for both the deleted .slice(0, 12) and the new passthrough. Mutation-verified at this commit: reverting both call sites to incremental.anchor.slice(0, 12) leaves agent-prompt.test.ts 285/285 green — the mutant survives; with the assertion strengthened to the full anchor the mutant fails. Add a fixture with a real 40-hex anchor asserting the 12-char truncation, and one with the content-verdicts literal asserting full passthrough.
中文说明
[Suggestion] R1-6:仍然存在——displayAnchor 与 agent-prompt 的配对是空洞的:agent-prompt.test.ts 的增量 fixture 使用 20/16 字符的 anchor('abc1234def5678900000'、'abc1234def567890'——都不是 40-64 位十六进制,因此 displayAnchor 会原样透传),且只断言 toContain('abc1234def56'),该断言对已删除的 .slice(0, 12) 和新的透传实现都通过。已在本提交上做变异验证:把两个调用点还原为 incremental.anchor.slice(0, 12),agent-prompt.test.ts 仍 285/285 全绿——变异存活;把断言加强为完整 anchor 后变异失败。建议增加一个使用真实 40 位十六进制 anchor 的 fixture 断言 12 字符截断,再加一个使用 content-verdicts 字面量的 fixture 断言完整透传。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are the local ones, stated in full — do NOT go looking for the PR cache's marker rule below.** That rule keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to check; read literally it would skip this write on every round and the feature would never persist. For a local or file-path round the conditions are: **a non-empty `skippedFiles` in the capture is fail-closed for this write**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare `{{model}}`, which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the plan's `cachePath`>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) |
There was a problem hiding this comment.
[Suggestion] R2-3: Still stands — the rewritten Step 8 paragraph says --out <the plan's cachePath> "for PR and local alike", but fetch-pr's plan report has no cachePath field — zero occurrences in fetch-pr.ts at HEAD; FetchPrResult carries only cacheCandidatePath?, and PlanReport has no cache path either. Only capture-local's report publishes cachePath. The instruction is literally un-followable on the PR flow — the orchestrator must hand-derive .qwen/review-cache/pr-<n>.json, against the paragraph's own inherited posture ("read that field, do not compute the name"). State the PR side explicitly: --out .qwen/review-cache/<target>.json (the PR flow's plan carries cacheCandidatePath only).
中文说明
[Suggestion] R2-3:仍然存在——重写后的 Step 8 段落写着 --out <the plan's cachePath>,且称"PR 与本地皆然",但 fetch-pr 的计划报告没有 cachePath 字段——HEAD 上的 fetch-pr.ts 零次出现;FetchPrResult 只携带 cacheCandidatePath?,PlanReport 也没有任何缓存路径。只有 capture-local 的报告发布 cachePath。该指令在 PR 流程上字面上不可执行——orchestrator 只能手工推导 .qwen/review-cache/pr-<n>.json,违背段落自己承继的姿态("读那个字段,不要计算名字")。建议把 PR 一侧写明:--out .qwen/review-cache/<target>.json(PR 流程的计划只携带 cacheCandidatePath)。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| typeof source === 'string' | ||
| ? createHash('sha256').update(source).digest('hex').slice(0, 8) | ||
| : null; | ||
| if (expected !== fileForm[2]) { |
There was a problem hiding this comment.
[Suggestion] R2-4: Still stands — the fileForm digest-verification branch (refusing promotion when --out names a different source path's namespaced cache) has no test on its REFUSAL side: no fixture constructs a file-<token>-<digest> out name with a wrong digest or a missing source. Mutation-proven at this commit: deleting the whole if (fileForm) block leaves cache-commit.test.ts + capture-local.incremental.test.ts 63/63 green, while a comparator mutation corrupting the token extraction fails the E2E — the suite exercises the branch's success side only. If the check regressed, a tampered or mis-typed promotion of one file's candidate into a token-colliding file's cache (src/foo.ts and src_foo.ts flatten to one token — precisely the collision this branch polices) would overwrite that file's review state uncaught. Add two cases: candidate {target:'src_foo.ts', source:'src/foo.ts'} with a wrong-digest out → throws /across file targets/ and writes nothing; same out with a candidate carrying no source → same refusal.
中文说明
[Suggestion] R2-4:仍然存在——fileForm 摘要校验分支(当 --out 指向另一个源路径的命名空间缓存时拒绝晋升)的拒绝侧没有任何测试:没有 fixture 构造错误摘要或缺 source 的 file-<token>-<digest> 输出名。已在本提交上做变异验证:删除整个 if (fileForm) 块,cache-commit.test.ts + capture-local.incremental.test.ts 仍 63/63 全绿;而破坏 token 提取的对照变异会让 E2E 失败——套件只覆盖了该分支的成功侧。若该检查回归,被篡改或手误的晋升会把一个文件的 candidate 写入 token 冲突的另一文件的缓存(src/foo.ts 与 src_foo.ts 压平为同一 token——恰是本分支要防的冲突),并不被察觉地覆盖该文件的评审状态。建议补两个用例:candidate {target:'src_foo.ts', source:'src/foo.ts'} 配错误摘要的 out → 抛 /across file targets/ 且不落盘;同一 out 配不带 source 的 candidate → 同样拒绝。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| // refused even when their tokens collide. | ||
| const stem = basename(args.out).replace(/\.json$/, ''); | ||
| const fileForm = /^file-(.*)-([0-9a-f]{8})$/.exec(stem); | ||
| const outTarget = fileForm ? fileForm[1] : stem; |
There was a problem hiding this comment.
[Suggestion] R2-7: Still stands — the target binding is driven by the --out stem's SPELLING instead of the candidate's shape: the digest check runs only if (fileForm), so a file-review candidate (carrying source) promoted with a plain <token>.json out skips the digest check entirely. Probed at this commit through the real handler: candidate {target:'src_foo.ts', source:'src/foo.ts'} with --out …/src_foo.ts.json → "Committed review cache to …" (no throw; digest check skipped), while file-form outs behave (wrong digest refused, right digest accepted); patching in a shape-keyed guard flips the arm to refused. A token collision can land the misnamed file on another review's plain-token cache name (a file literally named local promoted to local.json clobbers the whole-tree round's cache — the anchor gate refuses next round, but the orchestrator-read ledger shares that file). Require the file-form name whenever the candidate carries source, instead of gating on the stem's spelling.
中文说明
[Suggestion] R2-7:仍然存在——target 绑定由 --out 主干的拼写驱动,而非 candidate 的形状:摘要检查只在 if (fileForm) 内运行,因此携带 source 的 file-review candidate 以普通 <token>.json 形式晋升时会完全跳过摘要检查。已在本提交上经真实 handler 探测:candidate {target:'src_foo.ts', source:'src/foo.ts'} 配 --out …/src_foo.ts.json → 打印"Committed review cache to …"(未抛错;摘要检查被跳过),而 file 形式的 out 行为正常(错误摘要被拒、正确摘要通过);打上按形状判定守卫的补丁后该分支翻转为拒绝。token 冲突可能让命名错误的文件落在另一个评审的普通 token 缓存名上(名为 local 的文件晋升到 local.json 会覆盖整树轮次的缓存——锚点门会在下一轮拒绝,但 orchestrator 读取的 ledger 共享该文件)。建议只要 candidate 携带 source 就强制要求 file 形式命名,而不是以主干拼写为门。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are the local ones, stated in full — do NOT go looking for the PR cache's marker rule below.** That rule keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to check; read literally it would skip this write on every round and the feature would never persist. For a local or file-path round the conditions are: **a non-empty `skippedFiles` in the capture is fail-closed for this write**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare `{{model}}`, which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the plan's `cachePath`>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) |
There was a problem hiding this comment.
[Suggestion] R2-21: Still stands — the rewrite says of the local fail-closed rule "it is the sentence that follows and nothing else", while two sentences later a bolded gate asserts "A non-empty skippedFiles in the capture is fail-closed for this write, both flows" — a second fail-closed condition that explicitly includes local rounds, so "nothing else" is false as written. The deleted text framed the list as plural and complete ("the local ones, stated in full"); the rewrite narrowed it while retaining the skippedFiles gate with expanded "both flows" scope. A literal-minded orchestrator resolving the contradiction in favour of "nothing else" could promote a local candidate over a capture with non-empty skippedFiles — anchoring the next round's "no changes" past work this round never read. Restore the completeness framing: name both local fail-closed conditions, or fold skippedFiles into the rule sentence.
中文说明
[Suggestion] R2-21:仍然存在——重写稿对本地 fail-closed 规则说"就是紧接着的那句话,别无其他",而两句之后一条加粗门检查断言"捕获中非空的 skippedFiles 对这次写入是 fail-closed 的,两条流程皆然"——第二个显式包含本地轮次的 fail-closed 条件,因此"别无其他"字面上为假。被删除的原文把条件列表表述为复数且完整("本地的那些,已完整陈述");重写稿在保留 skippedFiles 门(并扩大为"两条流程")的同时却把表述收窄为单数。照字面理解、按"别无其他"解决矛盾的 orchestrator,可能在 skippedFiles 非空的捕获之上晋升本地 candidate——让下一轮的"无变化"定锚在本轮从未读过的内容之上。建议恢复完整性表述:点名两个本地 fail-closed 条件,或把 skippedFiles 并入规则句。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are the local ones, stated in full — do NOT go looking for the PR cache's marker rule below.** That rule keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to check; read literally it would skip this write on every round and the feature would never persist. For a local or file-path round the conditions are: **a non-empty `skippedFiles` in the capture is fail-closed for this write**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare `{{model}}`, which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the plan's `cachePath`>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) |
There was a problem hiding this comment.
[Suggestion] R2-22: Still stands — this PR adds a CLI writer for the review cache (cache-commit) and moves lastModelId out of what the model types at Step 8 ("No lastModelId: the capture already recorded the identity…"), but leaves DESIGN.md:537 asserting the opposite: "the cache path's lastModelId remains what the model types at Step 8 (no CLI writer exists for that file)". Both halves are contradicted at HEAD: cache-commit writes the cache file, and both captures record lastModelId CLI-side (capture-local.ts:441, fetch-pr.ts:1604) — and the same diff's DESIGN.md paragraph (~line 560) documents the new flow, leaving line 537's opposite assertion untouched. The contradiction is internal to this PR, and the stale sentence carries a security-relevant rationale (the forged-cache-id baseline argument) that no longer describes the primary path. Update DESIGN.md:537: the primary path's lastModelId is candidate-owned and CLI-recorded; only the hand-write fallback still types {{model}}.
中文说明
[Suggestion] R2-22:仍然存在——本 PR 为评审缓存新增了 CLI 写入器(cache-commit),并把 lastModelId 移出模型在 Step 8 手打的内容("No lastModelId:capture 已记录身份……"),却让 DESIGN.md:537 继续断言相反内容:"缓存路径的 lastModelId 仍是模型在 Step 8 打的(该文件不存在 CLI 写入器)"。两半在 HEAD 上都被驳斥:cache-commit 会写缓存文件,两条捕获都在 CLI 侧记录 lastModelId(capture-local.ts:441、fetch-pr.ts:1604)——且同一 diff 中 DESIGN.md 约第 560 行的段落已记录新流程,唯独第 537 行的相反断言未被改动。矛盾内在于本 PR,且这句陈旧文字承载一个安全相关的论证(伪造缓存 id 的基线论证),而它已不再描述主路径。建议更新 DESIGN.md:537:主路径的 lastModelId 归 candidate 所有、由 CLI 记录;只有手写降级路径仍打 {{model}}。
— qwen3.8-max via Qwen Code /review (v0.21.15)
Six Criticals, all in the capture's named-path handling, all probed against the round-3 commit before fixing. **The driver fold re-parsed its own serialization (R3-1).** A driver NAME may contain a comma — `*.bin diff=a,b` is a legal gitattributes line — and the fold matched by splitting the comma-joined attribute string, which can never equal such a value. Its `binary` flag silently left the identity, so flipping the config changed how `git diff` rendered the same bytes while the identity stood still: the next round certified the previous verdict against a different rendering. The fold now matches a structured path→driver map recorded during parsing. **Directory-shaped plumbing targets reviewed nothing (R1-21, escalated).** `.qwen/reviews/` is the shape the named-path exemption was designed for, but the exemption matched `p === pathspec` — a child path never equals its parent directory — so every child was filtered with no skipped record. The exemption now keys on one named-plumbing predicate that covers the path and its children. **The same flow was dead under ignore rules (R3-2).** `.qwen/*` in `.gitignore` is the common configuration, and `--exclude-standard` applies to explicitly named pathspecs too, so the named file never reached the filter at all. A deliberately named plumbing path now wins over ignore rules; the new integration tests carry `.qwen/*` in `.gitignore`. **A directory target kept its tracked plumbing (R3-4).** The tracked drop skipped for ANY pathspec, but only a FILE-shaped pathspec is "what the user asked for": a `sub/` target kept every tracked plumbing section beneath it while the untracked half dropped plumbing descendants. The drop now runs unless the pathspec itself is plumbing, so both halves of the capture agree. **The cap gate ran after the parse it exists to skip (R1-20, escalated).** An oversized tracked diff was decoded and section-parsed before the 10 MB cap rejected it — and near `gitRaw`'s 512 MiB ceiling an all-ASCII diff decodes past Node's maximum string length, so the decode threw instead of producing the graceful skip record. The gate now runs before any decode; an over-cap diff is rejected whole. **A file review of an unchanged file was told the tree is clean (R3-3).** The field-level stop excludes `--file` captures; the stderr sentence beside it did not, so the orchestrator — which reads prose too — stopped on "the working tree is clean" over a pathspec-scoped capture that says nothing about the tree. The prose now carries the same exclusion and points at the no-diff branch's whole-file review. Every guard mutation-probed: removing it turns its witness red, restoring it turns the suite green (packages/cli review suites 4365 passed, 4 skipped; build, typecheck, lint clean).
R2-1 again and R3-1 — the previous commit fixed each of these halfway,
and a half-fix reads as done.
`inertText` had its CLASSIFIER widened to `\p{Cc}\p{Cf}\p{Zl}\p{Zp}`
while the replacement beside it still swept C0+DEL. So a value carrying
an 8-bit CSI, a bidi override or U+2028 was correctly judged dangerous
and correctly quoted — and still carried the raw bytes inside the
quotes, under a comment claiming every control character was replaced.
The replacement is built from the classifier's own source now, so the
two cannot drift again.
`cache-commit`'s intake predicate had C1 added and Cf and the line
separators left out, so `target: 'pr-8
FORGED LINE'` passed the
write-time sweep whose comment says every persisted string is checked,
landed raw in a cache at a deterministic in-repo path, and came back out
through the refusal message. It imports the one class rather than
carrying a third copy of the idea — two copies drifted twice in two
rounds, which is the argument.
Tests assert the bytes are absent from the OUTPUT, not merely that
quoting happened; the earlier test could not tell those apart.
Mutation-checked on both halves.
…r clear Five Criticals. Three are the same shape as the last three rounds: a rule applied to one branch and not its sibling. **The blocker gate could not be cleared (worst of the five, and mine).** Round 1's fix mapped the cache's open-Critical count to REQUEST_CHANGES so a stop round would not silently pass `--fail-on`. But the ledger is rewritten only by a round that WRITES the cache, and a stop round does not — so once the user fixes the blocker and commits, the ordinary workflow, every later round reads the same stale `open` entry and fails the gate over code that no longer contains the defect, with nothing the user can do to clear it. The CLI cannot tell that case from "committed without fixing": both leave a clean tree and a moved HEAD. A false failure no action clears is worse than a false pass sitting beside a rendered blocker list, so the synthesised event is gone. The count stays in the sidecar as reporting. The gate question's real answer is a composed verdict on the stop path — a verdict the model produces after re-ruling the ledger, not one this process invents from a file it cannot date against the code. **`scope-emptied` lacked the file-review exclusion** both sibling stops carry, so a file review whose anchored change was discarded completed as decided — while the identical tree WITHOUT a cache routed to the whole-file review SKILL.md owes a file target. Same tree, two answers. **The fail-closed list said "in full" and was not.** It omitted the two anchor-withholding classes the PR paragraph beside it names — a finding still `— [unverified]`, and an undecided blocker whose verifier never returned. Neither enters `findings[]` and neither reads as "unreviewed scope", so a local round promoted the anchor over a Critical nobody ruled on. Two that predate this PR's changes: **A decoded path is not a name.** Every invalid byte folds to U+FFFD, and beside a file literally named with one, two plan paths fold to a single key: `lstat` succeeds on the real file, the sibling inherits its identity, is never hashed, and compares unchanged for ever. The `lstat` guard cannot see it because the stat succeeds. Such paths are UNHASHABLE now — over-review, the affordable direction. **The repository root was classified as an escape.** `classifyRunTarget` accepts a directory target, so the root is reachable, and calling it an escape split the parent's pin from the child's derivation: the poll never matched and a review that had run reported no verdict, while `--file <root>` threw "resolves to <root>, which is outside the repository at <root>". Mutation-checked: each of the three code fixes turns exactly one test red when reverted.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
8 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- fetch-pr cache-candidate producer zero test coverage — already reported (R1-4, comment 3831106942)
- capture-local.test.ts:138 'Nothing was written through the link' overclaim + unguarded sibling diff writes — already reported (R1-15, comment 3831107014)
- SKILL.md:1359 fallback routes local/file rounds into the PR-only template — already reported (R2-23b, SKILL.md:1359)
- nested single backticks split the Step-8 command code span — disclosed in the round-3 deferral list
- assertUnredirectedParent over-refusal over symlinked ancestors above the repo root — disclosed in the round-3 deferral list (paths.ts:343)
- withhold-branch rmSync follows a planted .qwen/tmp parent symlink — disclosed in the round-2/3 deferral lists
- inertText wrap around cache-commit's parse-error message untested — disclosed in the round-2 deferral list (cache-commit.ts:52)
- inert-text.test.ts raw control bytes make the file binary in git — disclosed in the round-2/3 deferral lists
Not reviewed: build-and-test — build-test's test phase aborted on a pre-existing build failure in the unrelated vscode-ide-companion workspace; no suite ran under it (review agents ran the changed suites green instead: cache-commit 19/19, capture-local family, file-verdicts 15/15, inert-text 6/6).
Not reviewed: reverse audit — stopped at the 5-round cap without two consecutive dry rounds (every round surfaced findings; round 5 still reported 2).
Not explored to full depth (tool budget reached): chunk 5: execute file-verdicts.test.ts under vitest (no node_modules in worktree); "agent reverse-audit (round 4)": running packages/cli capture-local.toctou.test.ts and fetch-pr.test.ts to confirm the new tests pass and the untouched suites still tolerate the producer block.
Deferred under the convergence posture (round 4, not a blocker) — recorded, not requested in this round:
packages/cli/src/commands/review/lib/file-verdicts.test.ts:130 — [probe] nested .gitattributes move asserted only at recording level, never at the changedPairs ruling levelpackages/cli/src/commands/review/cache-commit.test.ts:169 — [probe] ledger-side readJsonObject refusal branches have zero coverage (mutant ships 19/19 green)packages/cli/src/commands/review/cache-commit.ts:221 — [probe] --out echoed raw on the success line and in uncaught mkdir/write errors, bypassing inertTextpackages/cli/src/commands/review/lib/paths.ts:331 — [probe] guard validates the lexical parent while the noFollow write uses the raw path (planted link + .. escapes)packages/cli/src/commands/review/cache-commit.ts:166 — [probe] --out's .json extension never enforced; extension-less caches are unfindable yet announced as committedpackages/cli/src/commands/review/lib/file-verdicts.ts:221 — [probe] attributesMoved retires every verdict when the plan's directory shape changes (NO_BLOB/NO_BLOB one-sided key)
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 8 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查:build-and-test — build-test's test phase aborted on a pre-existing build failure in the unrelated vscode-ide-companion workspace; no suite ran under it (review agents ran the changed suites green instead: cache-commit 19/19, capture-local family, file-verdicts 15/15, inert-text 6/6)。
未审查:reverse audit — stopped at the 5-round cap without two consecutive dry rounds (every round surfaced findings; round 5 still reported 2)。
未探索到全部深度(达到工具调用预算):chunk 5:execute file-verdicts.test.ts under vitest (no node_modules in worktree);"agent reverse-audit (round 4)":running packages/cli capture-local.toctou.test.ts and fetch-pr.test.ts to confirm the new tests pass and the untouched suites still tolerate the producer block。
收敛姿态下延后(第 4 轮,非阻断)——已记录,本轮不要求修改:共 6 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| const controlled = (v: unknown): boolean => | ||
| typeof v === 'string' && CONTROL.test(v); |
There was a problem hiding this comment.
[Suggestion] R4-2: The refuse-at-write sweep checks only top-level candidate fields — the whole ledger (verdict, findings[], any extra key) persists unswept through {...ledger}, while the comment above claims "Every persisted STRING is checked — the ledger's model id and each candidate-owned anchor field" (its "ledger's model id" half is vacuous: the candidate's mandatory lastModelId always wins the collision, so a ledger-carried one never persists). The test titled "refuses control characters in the ledger model AND in any candidate anchor field" taints only the candidate with ledger {}, so the ledger arm the title promises is never exercised. Witness (probe at this commit): a ledger verdict carrying U+009B and a findings entry carrying U+2028 both pass and persist raw (threw=false persisted=true), while the same characters in a candidate field are refused; a recursive ledger sweep flips both arms to refusal. No code sink prints the cache's ledger strings raw at this commit, so harm is latent today — the write-end guarantee the comment asserts is what's being fixed. Fix: sweep the ledger strings that survive the merge (recursively — findings[] nests them), and correct the comment and the test title (or add the ledger-tainted arm the title promises).
中文说明
[Suggestion] R4-2:写入端扫描只检查 candidate 的顶层字段——整个 ledger(verdict、findings[] 及任何额外键)经 {...ledger} 原样落盘、未经扫描,而上方的注释声称"每一个被持久化的字符串都会被检查——ledger 的 model id 以及每一个 candidate 所有的锚点字段"(其中"ledger 的 model id"一半是空话:candidate 强制携带的 lastModelId 总会赢下冲突,ledger 里的值永远无法持久化)。名为"拒绝 ledger model 与任何 candidate 锚点字段中的控制字符"的测试只污染了 candidate、ledger 传 {},标题承诺的 ledger 分支从未被执行。证据(在本提交上探测):携带 U+009B 的 ledger verdict 与携带 U+2028 的 findings 条目都能通过并被原样持久化(threw=false persisted=true),而同样字符放在 candidate 字段则被拒绝;对 ledger 做递归扫描后两个分支都翻转为拒绝。本提交没有代码出口会原样打印缓存中的 ledger 字符串,所以当下危害是潜在的——要修的是注释所宣称的写入端保证本身。修复:对合并后存活的 ledger 字符串做同样的扫描(需递归——findings[] 是嵌套的),并修正注释与测试标题(或补上标题承诺的、污染 ledger 的测试分支)。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| * PRODUCER SIDE ONLY at this commit, and the distinction matters because | ||
| * the docs used to read as though the feature had shipped. Nothing reads |
There was a problem hiding this comment.
[Suggestion] R1-3: Still stands — the PR title and description certify end-to-end rebase-survival behaviour ("transfer per-file content verdicts across rebases", "only the files a rebase actually rewrote come back into scope"), while nothing reads fileVerdicts back at this commit: readFileVerdicts/changedPairs have no callers outside tests, and this doc comment itself says "PRODUCER SIDE ONLY … Nothing reads fileVerdicts back yet". A reader trusting the title expects rebase survival from this change; every round still degrades to a full review until the consumer lands. Land the consumer on the fetch-pr --since path, or adjust the PR title/description to say the verdicts are recorded (not transferred) by this change.
中文说明
[Suggestion] R1-3:仍然存在——PR 标题与描述承诺了端到端的跨 rebase 存活行为("让 per-file 内容裁决跨 rebase 传递"、"只有 rebase 真正改写过的文件才重新进入范围"),但本提交没有任何代码读回 fileVerdicts:readFileVerdicts/changedPairs 除测试外没有调用者,这条文档注释自己也写着"PRODUCER SIDE ONLY……尚无代码读回 fileVerdicts"。按标题理解的读者会期待本改动就带来 rebase 存活;在消费者落地之前,每一轮仍会退化为全量评审。请在 fetch-pr --since 路径上落地消费者,或把 PR 标题/描述改为本改动只是"记录"(而非"传递")裁决。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| const pairs = blobPairs( | ||
| gitOpt('rev-parse', '--show-toplevel') ?? '.', |
There was a problem hiding this comment.
[Suggestion] R1-5: Still stands — when blobPairs returns null (a transient ls-tree I/O failure, or the deterministic U+FFFD decode-aliasing refusal — any repo git holds with a non-decodable filename), the candidate is withheld silently: no stderr warning, the field just disappears from the plan. The write-failure catch two lines below warns; the null-pairs path has no else-branch. On an affected repo every round silently loses the incremental anchor and degrades to a full review with no diagnostic — the invisible failure mode this command's announcement discipline exists to prevent. Add an else-branch warning on pairs === null, mirroring the write-failure catch.
中文说明
[Suggestion] R1-5:仍然存在——当 blobPairs 返回 null(ls-tree 的瞬时 I/O 失败,或确定性的 U+FFFD 解码别名拒绝——任何含有不可解码文件名的仓库都会触发)时,candidate 被静默扣下:没有 stderr 警告,字段直接从计划中消失。下方两行的写失败 catch 会发警告;null-pairs 路径没有 else 分支。在受影响的仓库里,每一轮都会静默丢失增量锚点并退化为全量评审,且没有任何诊断信息——这正是本命令的"披露纪律"本应杜绝的隐形失败。请在 pairs === null 时补一条与写失败 catch 对称的警告。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| '', | ||
| `**This is an INCREMENTAL round** — the diff holds only what changed since the ` + | ||
| `previous clean review round (anchor \`${inertPath(incremental.anchor.slice(0, 12))}\`), ` + | ||
| `previous clean review round (anchor \`${inertPath(displayAnchor(incremental.anchor))}\`), ` + |
There was a problem hiding this comment.
[Suggestion] R1-6: Still stands — the displayAnchor↔agent-prompt pairing is vacuous: the incremental fixtures use 20/16-char anchors ('abc1234def5678900000', 'abc1234def567890' — neither 40-64 hex, so displayAnchor is the identity on them) and assert only toContain, so reverting either call site to the old blind slice(0, 12) leaves the whole suite green. A regression reintroducing the blind slice (printing content-verd-style truncations) ships green — the exact defect displayAnchor was extracted to fix. Add an integration fixture with a real 40-hex anchor asserting the 12-char truncation, and a content-verdicts-shaped label asserting whole render.
中文说明
[Suggestion] R1-6:仍然存在——displayAnchor 与 agent-prompt 两个调用点之间的关联是空转的:增量测试用的锚点是 20/16 字符('abc1234def5678900000'、'abc1234def567890'——都不是 40-64 位十六进制,displayAnchor 对它们原样返回),且断言只有 toContain,把任一调用点还原成旧的盲目 slice(0, 12) 整个测试套件仍全绿。若回归重新引入盲目截断(打印出 content-verd 式的残截),也会全绿通过——这正是抽取 displayAnchor 要修的缺陷本身。请补一个用真实 40 位十六进制锚点断言 12 字符截断的集成用例,再加一个 content-verdicts 形态标签断言完整渲染。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are the local ones, stated in full — do NOT go looking for the PR cache's marker rule below.** That rule keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to check; read literally it would skip this write on every round and the feature would never persist. For a local or file-path round the conditions are: **a non-empty `skippedFiles` in the capture is fail-closed for this write**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare `{{model}}`, which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the plan's `cachePath`>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) |
There was a problem hiding this comment.
[Suggestion] R2-3: Still stands — Step 8 says --out <the plan's cachePath> "for PR and local alike", but fetch-pr's plan report has no cachePath field at this commit — zero occurrences in fetch-pr.ts; FetchPrResult carries cacheCandidatePath only. Only capture-local's plan publishes cachePath. On a clean high-effort PR round the orchestrator opens the fetch-pr plan, finds no cachePath, and improvises from the parenthetical naming hint; a wrong computed name is refused by cache-commit's target check — the cache write is lost and every later round pays for a full review. Add a cachePath field to the fetch-pr report (mirroring capture-local), or name the PR-flow path explicitly in the instruction.
中文说明
[Suggestion] R2-3:仍然存在——Step 8 说 --out <the plan's cachePath>、"PR 与本地皆然",但本提交中 fetch-pr 的计划报告没有 cachePath 字段——fetch-pr.ts 中零次出现;FetchPrResult 只携带 cacheCandidatePath。只有 capture-local 的计划会发布 cachePath。在干净的高强度 PR 轮次里,orchestrator 打开 fetch-pr 计划、找不到 cachePath,只能凭括号里的命名提示自行拼凑;拼错的名字会被 cache-commit 的目标检查拒绝——缓存写入丢失,之后每一轮都要付出一次全量评审。请给 fetch-pr 报告加上 cachePath 字段(与 capture-local 对齐),或在指令中明确写出 PR 流程的路径。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| if (fileForm) { | ||
| const source = candidate['source']; |
There was a problem hiding this comment.
[Suggestion] R2-4: Still stands — the fileForm digest-verification branch (refusing promotion when --out names a different source path's namespaced cache) has no test on its REFUSAL side: no fixture constructs a file-<token>-<digest> out name whose digest mismatches the candidate's source; deleting the whole if (fileForm) block leaves every test green. A regression breaking the digest comparison (wrong slice length, swapped operands, a permissive branch on missing source) lets src/foo.ts's candidate promote into the cache of a colliding src_foo.ts review — the exact cross-file overwrite the branch exists to refuse — and nothing turns red. Add a case: candidate target: 'src_foo.ts', source: 'src/foo.ts', --out named file-src_foo.ts-<digest-of-a-different-source>.json → expect /refusing to promote across file targets/ and no file written.
中文说明
[Suggestion] R2-4:仍然存在——fileForm 摘要校验分支(当 --out 指向另一个源路径的命名空间缓存时拒绝晋升)的拒绝侧没有任何测试:没有 fixture 构造过摘要与 candidate source 不匹配的 file-<token>-<digest> 输出名;删掉整个 if (fileForm) 块,所有测试仍全绿。若摘要比较回归(切片长度错、操作数互换、source 缺失时放行),src/foo.ts 的 candidate 就能晋升进与之碰撞的 src_foo.ts 评审的缓存——正是该分支要拒绝的跨文件覆盖——而没有任何测试变红。请补一个用例:candidate target: 'src_foo.ts', source: 'src/foo.ts',--out 命名为 file-src_foo.ts-<另一个源的摘要>.json → 断言 /refusing to promote across file targets/ 且无文件写出。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| // refused even when their tokens collide. | ||
| const stem = basename(args.out).replace(/\.json$/, ''); | ||
| const fileForm = /^file-(.*)-([0-9a-f]{8})$/.exec(stem); | ||
| const outTarget = fileForm ? fileForm[1] : stem; |
There was a problem hiding this comment.
[Suggestion] R2-7: Still stands — the target binding is driven by the --out stem's SPELLING instead of the candidate's shape: the digest check runs only if (fileForm), so a file-review candidate (carrying source) promoted with a plain <token>.json out name skips the digest check entirely and can resurrect the collisions the namespaced naming killed. Probed: a source-bearing candidate promoted to src_foo.ts.json succeeds, but readers resolve file-src_foo.ts-<digest>.json — the anchor and findings ledger are silently orphaned. Require the file-<token>-<digest> form whenever the candidate carries source (and say so in the refusal).
中文说明
[Suggestion] R2-7:仍然存在——目标绑定由 --out 主干的"拼写"驱动,而不是 candidate 的形状:摘要检查只在 if (fileForm) 时运行,因此携带 source 的 file 评审 candidate 用普通 <token>.json 输出名晋升时会完全跳过摘要检查,并可能复活命名空间命名本已消灭的碰撞。已探测:携带 source 的 candidate 晋升到 src_foo.ts.json 成功,但读取方解析的是 file-src_foo.ts-<digest>.json——锚点与发现台账被静默孤立。请在 candidate 携带 source 时强制要求 file-<token>-<digest> 形式(并在拒绝信息中说明)。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are the local ones, stated in full — do NOT go looking for the PR cache's marker rule below.** That rule keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to check; read literally it would skip this write on every round and the feature would never persist. For a local or file-path round the conditions are: **a non-empty `skippedFiles` in the capture is fail-closed for this write**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare `{{model}}`, which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the plan's `cachePath`>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) |
There was a problem hiding this comment.
[Suggestion] R2-21: Still stands — the rewrite says of the local fail-closed rule "it is the sentence that follows and nothing else", while two sentences later a bolded gate asserts "A non-empty skippedFiles in the capture is fail-closed for this write, both flows" — a second fail-closed sentence the "nothing else" clause denies. An orchestrator applying the paragraph literally skips the skippedFiles check for a local round (the text told it the next sentence was the whole rule) and promotes a candidate over skipped content — anchoring the next round's "no changes" past work this round could not read. Reconcile the two sentences: fold skippedFiles into "the sentence that follows", or drop the exclusivity claim.
中文说明
[Suggestion] R2-21:仍然存在——重写后的文本说本地失败关闭规则"就是紧随其后那句话、别无其他",而两句之后一个加粗门又断言"捕获中非空的 skippedFiles 对两条流程的这次写入都是失败关闭"——"别无其他"子句否定了第二个失败关闭句。照字面执行段落的 orchestrator 会在本地轮次跳过 skippedFiles 检查(文本告诉它下一句就是全部规则),把 candidate 晋升在被跳过内容之上——让下一轮的"无变化"定锚在本轮未能读到的工作之后。请调和这两句:把 skippedFiles 并入"紧随其后那句话",或删去排他性表述。
— qwen3.8-max via Qwen Code /review (v0.21.15)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are the local ones, stated in full — do NOT go looking for the PR cache's marker rule below.** That rule keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to check; read literally it would skip this write on every round and the feature would never persist. For a local or file-path round the conditions are: **a non-empty `skippedFiles` in the capture is fail-closed for this write**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare `{{model}}`, which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the plan's `cachePath`>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) |
There was a problem hiding this comment.
[Suggestion] R2-22: Still stands — this PR moves lastModelId out of what the model types at Step 8 ("No lastModelId: the capture already recorded the identity…"), but the fallback template below still lists lastModelId: {{model}} as a field to hand-write — and {{model}} interpolates the BARE model id, exactly the token the candidate-ownership change exists to eliminate: two provider configurations exposing one model name share it, so a fallback write would plant the bare token through the very path the main flow just closed. The local gate refuses it as a mismatch (the anchor is lost rather than forged), but the template's instruction is the inverse of the PR's stated posture. Drop lastModelId from the fallback template and state that fallback writes degrade to full review until a candidate round certifies.
中文说明
[Suggestion] R2-22:仍然存在——本 PR 把 lastModelId 从 Step 8 模型手写内容中移出("没有 lastModelId:捕获已记录了身份……"),但下方 fallback 模板仍把 lastModelId: {{model}} 列为手写字段——而 {{model}} 插值出的是裸模型 id,正是 candidate 所有权改动要消灭的那个 token:两个暴露同名模型的 provider 配置共享它,fallback 写入会把裸 token 种进主流程刚刚关闭的那条路径。本地门会以不匹配为由拒绝(锚点丢失而非伪造),但模板的指令与本 PR 声明的姿态恰好相反。请从 fallback 模板中删去 lastModelId,并说明 fallback 写入在有 candidate 轮次完成认证之前退化为全量评审。
— qwen3.8-max via Qwen Code /review (v0.21.15)
Two conflicts, both "each side added". `lib/paths.test.ts`: the base added a `repoRelativeOf` block (the repository root is not an escape), this branch has the `assertUnredirectedParent` one. Rebuilt from the two clean stages rather than spliced — the conflict boundaries do not line up with block boundaries, and a straight splice has now cut a `describe` open three times in this stack, each time surfacing as `TS1005` rather than a failing test. `SKILL.md`: this branch replaced the Step 8 paragraph with the `cache-commit` flow while the base completed that paragraph's fail-closed list — the two classes it named while claiming to be complete, an `— [unverified]` finding and an undecided blocker. The mechanism is this branch's; the list is the base's, and it applies to the command flow unchanged.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
10 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- R1-4 fetch-pr cache-candidate producer zero test coverage — already reported (comment 3831106942)
- R1-15 'Nothing was written through the link' overclaim + unguarded sibling diff writes — already reported (comment 3831107014)
- R2-23b fallback route sends degraded local/file rounds into the PR-only template — already reported (SKILL.md:1359, comment 3834450398)
- inert-text.test.ts raw control bytes make git classify the escaper's only test as binary — disclosed in the round-2/3/4 deferral lists
- cache-commit.ts:52 inertText wrap around the parse-error message untested — disclosed in the round-2 deferral list
- capture-local.ts:486 guarded-write refusal never rmSyncs the stale candidate — disclosed in the round-2 deferral list
- report.ts:307 displayAnchor's content-verdicts case guards a phantom input — disclosed in the round-3 deferral list
- paths.test.ts no test plants a symlink above the immediate parent — disclosed in the round-2/3 deferral lists
- cache-commit.ts:221 --out echoed raw on the success line — disclosed in the round-4 deferral list
- file-verdicts.test.ts:218 U+FFFD-refusal test's win32 skip undocumented — disclosed in the round-2 deferral list
Not reviewed: reverse audit — stopped at the 5-round cap without two consecutive dry rounds (every round surfaced findings; round 5 reported 2 candidates, 1 rejected by verification).
Not reviewed: test-efficacy probe — all 9 probes inconclusive: the probe tree cannot satisfy this repo's vitest globalSetup build prerequisites (files this PR does not touch), so no mutants or hunk reverts were executed; harnessValidated: null (the positive control produced no verdict — no green baseline in the probe tree).
Deferred under the convergence posture (round 5, not a blocker) — recorded, not requested in this round:
packages/cli/src/commands/review/fetch-pr.ts:1564 — [probe] fetch-pr's withholding paths leave a stale candidate that cache-commit will promotepackages/cli/src/commands/review/lib/inert-text.ts:69 — [probe] the \u escape is malformed for astral Cf — 5-hex-digit escapes decode to the wrong code pointpackages/cli/src/commands/review/cache-commit.test.ts:123 — [probe] no fixture pins the file-form stem split against a dash-bearing tokenpackages/cli/src/commands/review/cache-commit.test.ts:137 — [review] the 'whole class' refusal suite never exercises U+2029 (\p{Zp})
Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had none either, so the next review re-reads the whole diff and will keep doing so until a round's marker carries an anchor again. (Stated, not acted on — this changes nothing about what the round posts.)
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 10 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查:reverse audit — stopped at the 5-round cap without two consecutive dry rounds (every round surfaced findings; round 5 reported 2 candidates, 1 rejected by verification)。
未审查:test-efficacy probe — all 9 probes inconclusive: the probe tree cannot satisfy this repo's vitest globalSetup build prerequisites (files this PR does not touch), so no mutants or hunk reverts were executed; harnessValidated: null (the positive control produced no verdict — no green baseline in the probe tree)。
收敛姿态下延后(第 5 轮,非阻断)——已记录,本轮不要求修改:共 4 条(原文未翻译,列表见上方英文部分)。
机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有锚点,因此下一次评审将重读整个 diff——并会一直如此,直到某一轮的标记重新带上锚点。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)
— qwen3.8-max via Qwen Code /review (v0.22.0)
| // `inertText` on a refusal, so a gap in either sweep is a forged terminal | ||
| // line either way. | ||
| const controlled = (v: unknown): boolean => | ||
| typeof v === 'string' && CONTROL.test(v); |
There was a problem hiding this comment.
[Suggestion] R4-2: Still stands — the refuse-at-write sweep checks only top-level candidate fields: the whole ledger (verdict, findings[], any extra key) persists unswept through {...ledger}, while the comment above claims "Every persisted STRING is checked … refuse at the writing end, where a human is present". A tampered ledger file — the deterministic in-repo path this command's own header calls tamperable — carrying control characters in findings[].title is merged and persisted raw, and the next round reads it back through renderers that quote the values: the forged-terminal-line class this sweep exists to close. Fix: sweep the merged object recursively (every string in the persisted tree), or narrow the comment to the actual coverage.
中文说明
[Suggestion] R4-2:依然存在——写入端扫描只检查 candidate 的顶层字段:整个 ledger(verdict、findings[]、任何额外键)经 {...ledger} 未经扫描地原样持久化,而上方的注释声称"每一个被持久化的字符串都会被检查……在写入端拒绝,因为那时有人在"。被篡改的 ledger 文件——该命令头部注释自己点名的、位于仓库内确定性路径上的可篡改对象——若在 findings[].title 中携带控制字符,会被合并并原样落盘,下一轮读回时会经引用这些值的渲染器打印出来:正是这套扫描要封堵的伪造终端输出类。修复:对合并后的对象做递归扫描(持久化树中的每一个字符串),或把注释收窄到实际覆盖范围。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| * end (via `cache-commit`). Absent when the capture had no diff to | ||
| * describe. | ||
| * | ||
| * PRODUCER SIDE ONLY at this commit, and the distinction matters because |
There was a problem hiding this comment.
[Suggestion] R1-3: Still stands — the PR title and description certify end-to-end rebase-survival behaviour ("transfer per-file content verdicts across rebases", "only the files a rebase actually rewrote come back into scope"), while this diff's own comment states "a rebase still degrades to a full review today": readFileVerdicts/changedPairs have no production caller at this commit, so the PR-flow half is producer-only scaffolding. The local-flow half IS live end-to-end (the real-command promotion test proves it). Anyone verifying the central claim against this commit finds it false until the consumer lands on the --since path. Fix: land the consumer (or link its follow-up) before claiming rebase survival; until then keep the title/description at "record verdicts for future rebase survival + mechanical cache-commit".
中文说明
[Suggestion] R1-3:依然存在——PR 标题与描述认证了端到端的跨 rebase 存活行为("transfer per-file content verdicts across rebases"、"只有 rebase 真正改写过的文件才重新进入范围"),而 diff 自己的注释写着"今天,rebase 仍会退化为全量评审":本提交没有任何生产代码读回 readFileVerdicts/changedPairs,PR 流程这一半只是生产者侧的脚手架。本地流程那一半是端到端可用的(真实命令晋升测试已证明)。对着本提交验证该核心主张的人会发现它不成立,直到消费者落到 --since 路径。修复:先落地消费者(或链接后续工作)再宣称跨 rebase 存活;在此之前把标题/描述改为"为未来的跨 rebase 存活记录裁决 + 机械化 cache-commit"。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| fetchedSha, | ||
| plan.files.map((f) => f.path), | ||
| ); | ||
| if (pairs !== null) { |
There was a problem hiding this comment.
[Suggestion] R1-5: Still stands — when blobPairs returns null (a transient ls-tree I/O failure, or the deterministic U+FFFD decode-aliasing refusal — any repo git holds with a non-decodable filename), the candidate is withheld silently: no stderr line names the degradation. The write-failure catch below warns; the null-pairs path and the skipped outer condition do not. Repeated rounds on such a repo degrade to full reviews with no diagnostic to explain the missing anchor — exactly the rounds where an operator needs to know. Fix: emit a stderr warning naming the degradation on both silent paths, mirroring the write-failure catch.
中文说明
[Suggestion] R1-5:依然存在——当 blobPairs 返回 null(瞬时的 ls-tree I/O 故障,或确定性的 U+FFFD 解码别名拒绝——任何含有不可解码文件名的仓库都会命中),candidate 被静默保留不写:没有任何 stderr 行说明这次降级。下方写入失败的 catch 会告警;pairs === null 路径与被跳过的外层条件不会。此类仓库上的每一轮都静默退化为全量评审,没有任何诊断能解释锚点为何缺失——恰恰是最需要让操作者知道的场景。修复:在两条静默路径上输出点名降级原因的 stderr 告警,与写入失败 catch 对齐。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| '', | ||
| `**This is an INCREMENTAL round** — the diff holds only what changed since the ` + | ||
| `previous clean review round (anchor \`${inertPath(incremental.anchor.slice(0, 12))}\`), ` + | ||
| `previous clean review round (anchor \`${inertPath(displayAnchor(incremental.anchor))}\`), ` + |
There was a problem hiding this comment.
[Suggestion] R1-6: Still stands — the displayAnchor↔agent-prompt pairing is vacuous: the incremental fixtures in agent-prompt.test.ts use 20/16-char anchors (neither 40-64 hex, so displayAnchor is the identity on them), and reverting either call site leaves the whole suite green (verified in round 1: 299/299 pass with both reverted). The truncation the function exists for is pinned only by report.test.ts, not through these two renderers — a regression at either call site ships with zero red tests. Fix: drive one incremental fixture with a real 40-hex anchor and assert the rendered 12-char label in both frames.
中文说明
[Suggestion] R1-6:依然存在——displayAnchor 与 agent-prompt 的配对是空洞的:agent-prompt.test.ts 的增量 fixtures 使用 20/16 字符的 anchor(都不是 40-64 位十六进制,所以 displayAnchor 对它们是恒等映射),还原任意一个调用点整个测试套件依旧全绿(第 1 轮已验证:两个调用点都还原后 299/299 通过)。该函数赖以存在的截断行为只被 report.test.ts 钉住,并未经由这两个渲染器——任一调用点回归都不会有红灯。修复:用一个真实的 40 位十六进制 anchor 驱动一个增量 fixture,并在两个 frame 中断言渲染出的 12 字符标签。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are the local ones, stated in full — do NOT go looking for the PR cache's marker rule below.** That rule keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to check; read literally it would skip this write on every round and the feature would never persist. For a local or file-path round the conditions are, in full: **a non-empty `skippedFiles` in the capture is fail-closed for this write**; **a finding still marked `— [unverified]`** and **an undecided blocker** (a Critical whose verifier never returned — a deadline stop or a timed-out batch) are fail-closed too, for the reason the PR cache's own list gives: neither enters `findings[]`, so neither reads as "unreviewed scope", and a candidate promoted over one anchors the next round's skip past a claim nobody ruled on — the round after it stops decided over a Critical that was never verified: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare `{{model}}`, which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the plan's `cachePath`>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. **And these two, which complete the list**: a finding still marked `— [unverified]`, and an undecided blocker — a Critical whose verifier never returned, from a deadline stop or a timed-out batch. Neither enters `findings[]` (only confirmed findings do) and neither reads as "unreviewed scope", so without naming them a round promotes the anchor over a claim nobody ruled on and the round after it stops decided. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) |
There was a problem hiding this comment.
[Suggestion] R1-20: Still stands — the fallback sentence routes "a local round whose capture withheld the candidate because the tree moved mid-capture" to hand-writing the template below, while the same paragraph's closing parenthetical says of that exact case "then there is nothing to promote" (the old paragraph's only instruction for it was: skip the write). A high-effort local round whose tree moved mid-capture and that otherwise ends clean follows the fallback and hand-writes the PR-shaped template over local.json; readLocalCache rejects the shape (fail-safe — no false certification), but the write has already erased the previous round's valid cache, losing incremental scoping AND the cross-round findings ledger on exactly the rounds this sentence routes here. Fix: remove the local case from the fallback sentence — a local round whose candidate was withheld skips the write, leaving the previous cache in place.
中文说明
[Suggestion] R1-20:依然存在——回退句把"因捕获中途树变动而被保留 candidate 的本地轮次"路由到手工书写下方模板,而同一段落结尾的括号注释对同一情形说的是"那就没有什么可晋升的"(旧段落对它的唯一指示是:跳过写入)。一个捕获中途树变动、其余部分干净收尾的高强度本地轮次会照回退句手工把 PR 形态的模板写进 local.json;readLocalCache 会拒绝这个形状(方向安全——不会错误认证),但这次写入已经抹掉上一轮的有效缓存——恰恰在这句话路由来的轮次上,同时丢掉增量范围和跨轮发现台账。修复:把本地情形从回退句中移除——candidate 被保留的本地轮次跳过写入,保留上一轮缓存。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are the local ones, stated in full — do NOT go looking for the PR cache's marker rule below.** That rule keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to check; read literally it would skip this write on every round and the feature would never persist. For a local or file-path round the conditions are, in full: **a non-empty `skippedFiles` in the capture is fail-closed for this write**; **a finding still marked `— [unverified]`** and **an undecided blocker** (a Critical whose verifier never returned — a deadline stop or a timed-out batch) are fail-closed too, for the reason the PR cache's own list gives: neither enters `findings[]`, so neither reads as "unreviewed scope", and a candidate promoted over one anchors the next round's skip past a claim nobody ruled on — the round after it stops decided over a Critical that was never verified: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare `{{model}}`, which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the plan's `cachePath`>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. **And these two, which complete the list**: a finding still marked `— [unverified]`, and an undecided blocker — a Critical whose verifier never returned, from a deadline stop or a timed-out batch. Neither enters `findings[]` (only confirmed findings do) and neither reads as "unreviewed scope", so without naming them a round promotes the anchor over a claim nobody ruled on and the round after it stops decided. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) |
There was a problem hiding this comment.
[Suggestion] R2-3: Still stands — Step 8 says --out <the plan's cachePath> "for PR and local alike", but fetch-pr's plan report has no cachePath field at this commit (zero occurrences in fetch-pr.ts; FetchPrResult carries cacheCandidatePath only). On any high-effort PR review reaching Step 8, the orchestrator looks for cachePath in the fetch report and does not find it; it must improvise the name — workable, since cache-commit's target check catches a wrong name — but the skill's own design rule is "read that field, do not compute the name", and an improvising model can also conclude the field's absence means the degraded/fallback branch, silently losing the machine's incremental anchor for that PR. Fix: add cachePath to FetchPrResult (mirroring cacheCandidatePath), or split the sentence by flow.
中文说明
[Suggestion] R2-3:依然存在——Step 8 说 --out <the plan's cachePath>、"PR 与本地皆然",但本提交 fetch-pr 的计划报告里没有 cachePath 字段(fetch-pr.ts 中零次出现;FetchPrResult 只有 cacheCandidatePath)。任何走到 Step 8 的高强度 PR 评审,orchestrator 都会去 fetch 报告里找 cachePath 而找不到;它只能即兴拼名字——虽然可行(cache-commit 的 target 检查能拦住错名)——但 skill 自己的设计规则是"读那个字段,不要计算名字",而一个即兴的模型也可能把字段缺失解读为降级/回退分支,从而静默丢掉该 PR 的机器增量锚点。修复:给 FetchPrResult 加上 cachePath(与 cacheCandidatePath 对齐),或按流程拆开这句话。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| typeof source === 'string' | ||
| ? createHash('sha256').update(source).digest('hex').slice(0, 8) | ||
| : null; | ||
| if (expected !== fileForm[2]) { |
There was a problem hiding this comment.
[Suggestion] R2-4: Still stands — the fileForm digest-verification branch has no test on its REFUSAL side: the happy path is covered end-to-end (the new promotion test in capture-local.incremental.test.ts passes the real file-<token>-<digest> cache path), but nothing anywhere asserts /promote across file targets/. Since src/foo.ts and src_foo.ts flatten to the same token, this digest check is the only thing separating the two files' caches at promotion; a future edit that inverts the comparison or breaks the /^file-(.*)-([0-9a-f]{8})$/ parse ships with zero tests failing and silently reintroduces the cross-file overwrite the namespace was added to stop. Fix: seed a candidate with source: 'src/foo.ts' and call with --out naming file-src_foo.ts-<sha256('src/other.ts').slice(0,8)>.json; assert the refusal and that no cache is written.
中文说明
[Suggestion] R2-4:依然存在——fileForm 摘要校验分支的拒绝侧没有测试:幸福路径已被端到端覆盖(capture-local.incremental.test.ts 的新晋升测试传入真实的 file-<token>-<digest> 缓存路径),但任何地方都没有断言 /promote across file targets/。由于 src/foo.ts 与 src_foo.ts 扁平化为同一个 token,这道摘要检查是晋升时区分两个文件缓存的唯一屏障;未来若有改动反转比较或破坏 /^file-(.*)-([0-9a-f]{8})$/ 解析,将在零测试失败的情况下静默复活命名空间特地要阻止的跨文件覆盖。修复:种入 source: 'src/foo.ts' 的 candidate,用名为 file-src_foo.ts-<sha256('src/other.ts').slice(0,8)>.json 的 --out 调用,断言被拒绝且不落盘。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| // refused even when their tokens collide. | ||
| const stem = basename(args.out).replace(/\.json$/, ''); | ||
| const fileForm = /^file-(.*)-([0-9a-f]{8})$/.exec(stem); | ||
| const outTarget = fileForm ? fileForm[1] : stem; |
There was a problem hiding this comment.
[Suggestion] R2-7: Still stands — the target binding is driven by the --out stem's SPELLING instead of the candidate's shape: the digest check runs only if (fileForm), so (a) a file-review candidate (carrying source) promoted with a token-only --out skips the digest check entirely, and (b) a candidate for a file literally named pr-7 collides with PR-7's cache name — both degrade fail-safe today, but the binding the namespace was built on is not enforced. Fix: drive the binding off the candidate's shape — require the file-form stem whenever the candidate carries source, and refuse otherwise.
中文说明
[Suggestion] R2-7:依然存在——目标绑定由 --out 词干的拼写驱动,而不是 candidate 的形状:摘要检查只在 if (fileForm) 时运行,因此 (a) 携带 source 的 file 评审 candidate 用纯 token 的 --out 晋升时会完全跳过摘要检查;(b) 一个恰好名为 pr-7 的文件的 candidate 会与 PR-7 的缓存名相撞——今天两者都安全降级,但命名空间赖以建立的绑定并未被强制。修复:让绑定由 candidate 的形状驱动——凡 candidate 携带 source,必须要求 file 形态词干,否则拒绝。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are the local ones, stated in full — do NOT go looking for the PR cache's marker rule below.** That rule keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to check; read literally it would skip this write on every round and the feature would never persist. For a local or file-path round the conditions are, in full: **a non-empty `skippedFiles` in the capture is fail-closed for this write**; **a finding still marked `— [unverified]`** and **an undecided blocker** (a Critical whose verifier never returned — a deadline stop or a timed-out batch) are fail-closed too, for the reason the PR cache's own list gives: neither enters `findings[]`, so neither reads as "unreviewed scope", and a candidate promoted over one anchors the next round's skip past a claim nobody ruled on — the round after it stops decided over a Critical that was never verified: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare `{{model}}`, which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the plan's `cachePath`>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. **And these two, which complete the list**: a finding still marked `— [unverified]`, and an undecided blocker — a Critical whose verifier never returned, from a deadline stop or a timed-out batch. Neither enters `findings[]` (only confirmed findings do) and neither reads as "unreviewed scope", so without naming them a round promotes the anchor over a claim nobody ruled on and the round after it stops decided. (A capture that detected a mid-capture tree change withholds the candidate itself and says so — then there is nothing to promote.) |
There was a problem hiding this comment.
[Suggestion] R2-21: Still stands — the rewrite says of the local fail-closed rule "it is the sentence that follows and nothing else", while the same paragraph carries two further bolded fail-closed gates ("A non-empty skippedFiles in the capture is fail-closed for this write, both flows" and "And these two, which complete the list": unverified findings, undecided blockers). A model taking the exclusivity sentence literally skips the gates it is told not to look past — promoting an anchor over a claim nobody ruled on, the exact failure the gates exist to stop. Fix: restate as one completed list — name all four conditions together and drop the "nothing else" exclusivity claim.
中文说明
[Suggestion] R2-21:依然存在——改写后的文本说本地失败关闭规则"就是接下来那句,别无其他",而同一段落里还有两道加粗的失败关闭门("捕获中非空的 skippedFiles 对这次写入是失败关闭的,两条流程皆然",以及"还有这两项,它们补全了清单":未验证的发现、未决的 blocker)。一个照字面理解排他句的模型会跳过它被告知"不要去找"的那些门——把锚点晋升到无人裁决过的结论之上,恰是这些门要阻止的失败。修复:重述为一个完整的清单——把四个条件并列点名,删去"别无其他"的排他性说法。
— qwen3.8-max via Qwen Code /review (v0.22.0)
doudouOUC
left a comment
There was a problem hiding this comment.
Round 6 — Windows review pass
Re-reviewed the same commit 357db85f (no new commits since round 5). Ran the full PR test suite on Windows (git-bash, Node 22). All 20 previous Suggestion-level findings still stand at this HEAD. Severity floor is critical (round 6), so only Critical findings are posted.
C1 (Critical) — Windows source path uses backslash separators, breaking the cache digest
File: packages/cli/src/commands/review/lib/paths.ts:297 — repoRelativeOf returns rel from path.relative() which uses \ on Windows.
capture-local.ts:328 stores this value directly as the source field in the cache candidate without normalising to forward slashes. On Windows, the candidate carries "source": "src\foo.ts" instead of "source": "src/foo.ts".
This breaks the SHA-256 digest verification in cache-commit.ts:168:
const expected = typeof source === "string"
? createHash("sha256").update(source).digest("hex").slice(0, 8)
: null;The digest of "src\foo.ts" ≠ "src/foo.ts", so the file-form cache promotion always fails on Windows. Every file-path review run on Windows will reject its own cache as "belongs to a different source path" and degrade to a full review every round — the same silent full-review degradation the feature was designed to prevent.
Confirmed: 1 test failure on Windows at capture-local.incremental.test.ts:719:
AssertionError: expected src\foo.ts to be src/foo.ts // Object.is equality
Fix: Normalise rel to forward slashes in repoRelativeOf before returning, matching the existing pattern in local-diff.ts:215 (rel.split(sep).join("/")). The escapes check above it already uses sep and is unaffected by normalisation after the check.
Previous round status
- 20 Suggestion-level findings (round 5): All still stand at this HEAD. No new commits to address them.
- 4 deferred probes (round 5): Evaluated. None escalate to Critical:
- fetch-pr stale candidate:
tmpFileis unique per run, not reused. - inert-text astral Cf escape: Inside
JSON.stringify, the\uis literal text, not a JS escape — false positive. - Dash-bearing token file-form stem: Greedy regex handles it via backtracking; edge case is theoretically possible but astronomically unlikely.
- U+2029 test gap: Same code path as U+2028; test gap, not a defect.
- fetch-pr stale candidate:
- Reverse audit: Not run (round 6, no new commits to mutate).
- Test-efficacy probe: Skipped (Windows build prerequisite issue, same as round 5).
Verification
All 68 PR-related tests pass on Windows (cache-commit 19/2 skipped, file-verdicts 15/2 skipped, inert-text 6, report 14, paths 18, capture-local 13, run 52/2). The 1 failure in capture-local.incremental (44/1 failed/4 skipped) is the C1 bug above — the test correctly asserts forward-slash format.
— Qwen Code · qwen3.8-max via /review
Five Criticals, all judged against real git before changing anything. **The empty diff-driver name.** `*.dat diff=` is a legal attributes line, `check-attr --stdin -z` answers it with an empty value, and `git config diff..binary true` flips that section between readable hunks and "Binary files differ" with the mode and the blob standing still — verified against git 2.47.3 rather than taken on the report's word. The `value !== ''` clause dropped exactly that spelling, which was the last entrance of the family whose `set`/`unset`/`unspecified` siblings the previous round closed, under a comment claiming every answer was covered. **A driver name that did not survive the decode.** The record stream is utf8-decoded, so an invalid byte in a driver NAME folds to U+FFFD and the config probe asks for a key git never matches — nothing folds, and the flip moves the rendering with every identity component still. Such paths are UNHASHABLE, the same discipline this module already applies to a decoded PATH. That fix took two more passes before it was right, both caught here rather than in review: writing UNHASHABLE mid-loop let the path's later `binary`/`text` records append onto it, and leaving the path in the driver map let the config fold append onto it again. It is recorded in a set and applied once, after the stream. **A round that sees LESS cannot certify one that saw more.** With `--no-untracked` the untracked block never runs and records no `skipped` entries, so the skipped-content gate sees zero while a cached untracked path reads as vanished rather than out of scope: the slice keeps nothing and the round stops decided over bytes it never captured — and the stop does not advance the cache, so every later narrow round repeats it. The candidate records the capture's scope and the anchor is refused when this round's is narrower. **The plan's `--out` was still unbounded.** "Keep it short" is not a bound: a basename may be 255 bytes and the decoration adds 34. The guidance names a number now, and the guard pins the number. **The fail-closed list is gone, replaced by the rule.** This is the third round that "completed" it — cannot-tell was the shape that walked through the last completion. Write the cache only when every Critical carries a confirmed disposition; everything else withholds. The reason is one reason, which is why it is stated as one: the anchor's whole claim is "the next round may skip this", and a claim nobody ruled on is exactly what must not be skipped.
One conflict, in `SKILL.test.ts`: main added seven guards (the incident-replay carve-out, the Aone `--comment` contract, the subagent type, the census split, the fix-induced disposition, the fix-witness mandate, presubmit on Aone) while this branch added four (the plan `--out` bound, the capture-derived report naming, the never-derive rule, and the tree-moved stop bullet). Rebuilt from the two clean stages rather than spliced — main's file whole, with this branch's four blocks cut out by brace balance and appended. A straight splice of the conflict region has cut a block open three times in this stack, each time surfacing as a parse error rather than a failing test. 34 guards pass.
R4-1, filed twice. The per-file verdict record tracked the `.gitattributes` TREE blobs at base and head, but `git diff` renders under the LIVE attribute stack at the capture cwd — so a `.gitattributes` that is untracked in the reviewer's checkout governs the rendering while recording absent-on-both-sides for ever. Round 1 reads "Binary files … differ", round 2 (same trees, file since removed) full hunks, pairs byte-identical, and the clean verdict transfers over hunks no round ever read. The module comment argued away `.git/info/attributes` and the global file — neither travels with the PR — and the worktree copy is the class it did not consider. `check-attr` is the authority the local flow already asks, so the pair gains a third component: git's own answer for that path, which covers the worktree copy, `.git/info/attributes`, the global file, and the config-side drivers in one probe. Machine-local by nature, which is the safe direction — another checkout reads it as changed and re-reviews. Folding it was half the fix, and the test caught the other half: `changedPairs` compared only the two blobs, so the new component sat in the record doing nothing. A record written before the field has none on the recorded side and reads as changed once. Both halves mutation-checked. Also merges the base's round 7.
The base's R17-4 fix (publish cacheCandidateStateId beside the path) folds into this branch's guarded conditional write: the stateId rides exactly when the path does, and the promotion CHECK paragraph is ported into this branch's cache-commit rewrite of Step 8. The rest — the restored telemetry fake, supersededPaths, the unspecified-driver fold, the sparse-checkout exemption, the `~` cap joiner — merge clean.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: test-efficacy probe — all probes inconclusive: the probe runner cannot satisfy this repo's vitest globalSetup build guard (files this PR does not touch), so no mutants or hunk reverts were executed; harnessValidated: null (the positive control produced no verdict). Mutation claims this round were settled by the verifier's own probes..
Deferred under the convergence posture (round 14, not a blocker) — recorded, not requested in this round:
packages/cli/src/commands/review/capture-local.ts:1124 — [review] omitting cacheCandidatePath on withhold turns the planted visibility-bit regression assertion (capture-local.incremental.test.ts:1650) into existsSync(undefined) — a permanen…
Convergence: round 14 posted 5 inline comment(s), 1 of them reported for the first time; the previous round posted 6 (1 new). The rate of new findings is not falling. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)
Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had none either, so the next review re-reads the whole diff and will keep doing so until a round's marker carries an anchor again. (Stated, not acted on — this changes nothing about what the round posts.)
[Critical] R12-2 (still stands — re-checked at HEAD this round): the read side the anchor's trust lands on is unguarded — readLocalCache (lib/local-anchor.ts:600) is a plain readFileSync with shape-only validation and no lstat/symlink refusal (the file's lstat uses at :153/:569 are the worktree identity oracle, not the cache read), while this PR polices the WRITE side of the same deterministic path against exactly this threat. The gate the read lands on validates only the cache's self-consistency (stateId vs its own data), model, target and HEAD — all attacker-suppliable. A forged cache planted as .qwen/review-cache/local.json (a symlink — git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: the round skips scope the attacker's stateId certifies as unchanged. Prior-round probe at the same code: the forged cache anchored the round (anchor refused? false) and left a dirty change out of scope; an lstat symlink refusal flips it to a full review. Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side. Unanchorable inline (the defect line is outside this diff's hunks), hence in the body.
[Critical] R6-2 (still stands — claim carried from rounds 6-13; the defect lines in lib/paths.ts:297-317 are outside this diff's hunks, hence in the body): repoRelativeOf returns relative(repoRoot, abs) unnormalized — backslash separators on Windows (only the escapes check consults sep) — and capture-local stores it raw as sourcePath, persisted as the candidate source, which feeds the cache filename digest (file-<token>-<sha256(source)[:8]>.json in cache-commit) and the cross-machine source gate, both of which assume git's slash form. On Windows every file-path review rejects its own cache — the candidate carries source: "src\foo.ts", the digest is computed over the backslash form, and the next round's source comparison fails — permanently degrading file-path reviews to full reviews, the silent degradation the feature exists to prevent; cross-machine/WSL cache movement mismatches the same way. Witness: not run — the Windows surface cannot execute on this Linux host; triangulated via Node's win32 implementation (path.win32.relative('C:\repo','C:\repo\src\foo.ts') → src\foo.ts vs posix src/foo.ts), the HEAD trace showing zero normalization between that return (paths.ts:303) and the candidate write, and the round-6 Windows run's observed test failure AssertionError: expected src\foo.ts to be src/foo.ts (capture-local.incremental.test.ts pins the slash contract). Fix: normalise rel to forward slashes in repoRelativeOf before returning (rel.split(sep).join('/')); the escapes check above already uses sep and is unaffected.
[Critical] R10-3 (still stands — re-verified at this commit; the defect lines capture-local.ts:1130 and fetch-pr.ts:1791 are outside this diff's hunks, hence in the body): the plan report carrying the cacheCandidatePath field is itself written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds. The plan's cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself. With .qwen/tmp committed as a symlink (the PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — anyone with write access to the link target substitutes the machine-readable record that orchestrates the round (fabricating cacheCandidatePath even after the capture withheld the real candidate). Prior-round probe through the real capture-local handler (.qwen/tmp symlinked to a victim dir): full plan JSON landed in the victim directory while the guarded candidate write refused; guarding the plan write flipped it. Fix: route the plan write through assertUnredirectedParent(out, …) + atomicWriteFileSync(out, …, { noFollow: true }) in both captures.
中文说明
仅完成部分审查,审查缺口已披露。
未审查:test-efficacy probe — all probes inconclusive: the probe runner cannot satisfy this repo's vitest globalSetup build guard (files this PR does not touch), so no mutants or hunk reverts were executed; harnessValidated: null (the positive control produced no verdict). Mutation claims this round were settled by the verifier's own probes.。
收敛姿态下延后(第 14 轮,非阻断)——已记录,本轮不要求修改:共 1 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 14 轮发布了 5 条行内评论,其中 1 条是首次提出;上一轮发布了 6 条(其中 1 条首次提出)。新发现的产出速度没有下降。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)
机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有锚点,因此下一次评审将重读整个 diff——并会一直如此,直到某一轮的标记重新带上锚点。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)
[Critical] R12-2 (still stands — re-checked at HEAD this round): the read side the anchor's trust lands on is unguarded — readLocalCache (lib/local-anchor.ts:600) is a plain readFileSync with shape-only validation and no lstat/symlink refusal (the file's lstat uses at :153/:569 are the worktree identity oracle, not the cache read), while this PR polices the WRITE side of the same deterministic path against exactly this threat. The gate the read lands on validates only the cache's self-consistency (stateId vs its own data), model, target and HEAD — all attacker-suppliable. A forged cache planted as .qwen/review-cache/local.json (a symlink — git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: the round skips scope the attacker's stateId certifies as unchanged. Prior-round probe at the same code: the forged cache anchored the round (anchor refused? false) and left a dirty change out of scope; an lstat symlink refusal flips it to a full review. Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side. Unanchorable inline (the defect line is outside this diff's hunks), hence in the body.
[Critical] R6-2 (still stands — claim carried from rounds 6-13; the defect lines in lib/paths.ts:297-317 are outside this diff's hunks, hence in the body): repoRelativeOf returns relative(repoRoot, abs) unnormalized — backslash separators on Windows (only the escapes check consults sep) — and capture-local stores it raw as sourcePath, persisted as the candidate source, which feeds the cache filename digest (file-<token>-<sha256(source)[:8]>.json in cache-commit) and the cross-machine source gate, both of which assume git's slash form. On Windows every file-path review rejects its own cache — the candidate carries source: "src\foo.ts", the digest is computed over the backslash form, and the next round's source comparison fails — permanently degrading file-path reviews to full reviews, the silent degradation the feature exists to prevent; cross-machine/WSL cache movement mismatches the same way. Witness: not run — the Windows surface cannot execute on this Linux host; triangulated via Node's win32 implementation (path.win32.relative('C:\repo','C:\repo\src\foo.ts') → src\foo.ts vs posix src/foo.ts), the HEAD trace showing zero normalization between that return (paths.ts:303) and the candidate write, and the round-6 Windows run's observed test failure AssertionError: expected src\foo.ts to be src/foo.ts (capture-local.incremental.test.ts pins the slash contract). Fix: normalise rel to forward slashes in repoRelativeOf before returning (rel.split(sep).join('/')); the escapes check above already uses sep and is unaffected.
[Critical] R10-3 (still stands — re-verified at this commit; the defect lines capture-local.ts:1130 and fetch-pr.ts:1791 are outside this diff's hunks, hence in the body): the plan report carrying the cacheCandidatePath field is itself written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds. The plan's cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself. With .qwen/tmp committed as a symlink (the PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — anyone with write access to the link target substitutes the machine-readable record that orchestrates the round (fabricating cacheCandidatePath even after the capture withheld the real candidate). Prior-round probe through the real capture-local handler (.qwen/tmp symlinked to a victim dir): full plan JSON landed in the victim directory while the guarded candidate write refused; guarding the plan write flipped it. Fix: route the plan write through assertUnredirectedParent(out, …) + atomicWriteFileSync(out, …, { noFollow: true }) in both captures.
— qwen3.8-max via Qwen Code /review (v0.22.0)
| if ( | ||
| rec.attrs === UNANSWERED_ATTRS || | ||
| cur.attrs === UNANSWERED_ATTRS || | ||
| rec.attrs !== cur.attrs | ||
| ) { |
There was a problem hiding this comment.
[Critical] R8-1: Still stands — re-verified by probe at this commit. changedPairs compares a renderingAttributes answer of UNHASHABLE (the literal string 'unhashable') like any ordinary value: out[p].attrs = attrs[p] ?? UNANSWERED_ATTRS (line 203) catches only undefined, and only 'unanswered' gets never-equal treatment here — so 'unhashable' === 'unhashable' passes and a clean verdict transfers over hunks whose rendering changed. Legal, common gitattributes states produce UNHASHABLE: data.dat diff=unset (the state-vs-value ambiguity), an undecodable driver name, or diff.unspecified.binary configured. The failure bites between rounds: round 1 records attrs: 'unhashable' over readable hunks and caches the clean verdict; a git config diff.unset.binary true appearing on the reviewing machine in between (a reviewer action, or the reviewed repo's own test suite running git config) flips git diff to Binary files ... differ while the blobs and the .gitattributes blob stand still; round 2 computes identical pairs and transfers the verdict over hunks it never saw rendered — the fail-open the attrs component exists to close. The local-flow comparator already applies the correct rule (changedSince: UNHASHABLE never equals, not even itself). Witness (probe at this commit): fixture .gitattributes = data.dat diff=unset; git arm — readable hunks without config, Binary files a/data.dat and b/data.dat differ after git config diff.unset.binary true, ls-tree identities unchanged; code arm — PR changedPairs(round1, round2, ['data.dat']) returned [] where ['data.dat'] is required (AssertionError: expected [] to deeply equal [ 'data.dat' ]); adding the two UNHASHABLE arms below flips the probe to ['data.dat'] with all 18 existing tests still green. Fix: import UNHASHABLE from ./local-anchor.js and add the two arms.
| if ( | |
| rec.attrs === UNANSWERED_ATTRS || | |
| cur.attrs === UNANSWERED_ATTRS || | |
| rec.attrs !== cur.attrs | |
| ) { | |
| if ( | |
| rec.attrs === UNANSWERED_ATTRS || | |
| cur.attrs === UNANSWERED_ATTRS || | |
| rec.attrs === UNHASHABLE || | |
| cur.attrs === UNHASHABLE || | |
| rec.attrs !== cur.attrs | |
| ) { |
中文说明
[Critical] R8-1:依然存在——已在本提交上用探测重新验证。changedPairs 把 renderingAttributes 的 UNHASHABLE 回答(字面字符串 'unhashable')当普通值比较:第 203 行 attrs[p] ?? UNANSWERED_ATTRS 只捕获 undefined,这里也只有 'unanswered' 被当作永不相等——于是 'unhashable' === 'unhashable' 通过,干净裁决被转移到渲染已经改变的 hunk 上。合法且常见的 gitattributes 状态就会产生 UNHASHABLE:data.dat diff=unset(状态/取值歧义)、无法解码的 driver 名、或配置了 diff.unspecified.binary。缺陷在两轮之间发作:第 1 轮在可读 hunk 上记录 attrs: 'unhashable' 并缓存干净裁决;评审机器在两轮之间出现 git config diff.unset.binary true(评审者的操作,或被评审仓库自己的测试套件执行 git config)时,git diff 翻转为 Binary files ... differ,而 blob 与 .gitattributes blob 均未变;第 2 轮算出完全相同的配对,把裁决转移给它从未见过的渲染——正是 attrs 分量要堵的失败开放。本地流程的比较器已有正确规则(changedSince:UNHASHABLE 永不相等,连自身也不)。证据(本提交上探测):git 侧——无配置时可读 hunk,配置后同一棵树渲染为二进制而 ls-tree 身份不变;代码侧——本 PR 的 changedPairs 返回 [](应为 ['data.dat']);加上下方两个 UNHASHABLE 分支后探测翻转为 ['data.dat'],18 个既有测试仍绿。修复:从 ./local-anchor.js 导入 UNHASHABLE 并加上两个分支。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| const controlled = (v: unknown): boolean => | ||
| typeof v === 'string' && CONTROL.test(v); | ||
| for (const key of candidateFieldsOf(candidate)) { | ||
| if (controlled(candidate[key])) { |
There was a problem hiding this comment.
[Critical] R8-4: Still stands — re-verified at this commit. The refuse-at-write CONTROL sweep tests only candidate values — if (controlled(candidate[key])) — never the candidate's keys, and its own refusal interpolates the unchecked key raw into an error that reaches the terminal, while every other untrusted string in this file's error paths goes through inertText (readJsonObject wraps the path and the parse-error snippet; the target-mismatch and file-form refusals wrap their values). The guard's own error path emits the exact forgery class the guard exists to refuse: a tampered candidate at the deterministic .qwen/tmp path (the command's own named threat) whose KEY carries ESC/C1/Cf bytes triggers a refusal whose message renders them at the operator's terminal. A control-charactered key with a CLEAN value is worse: nothing checks keys at all, so no error is thrown and the raw key is persisted as a key of the promoted cache (JSON.stringify passes C1 bytes through verbatim). Fix: check keys in the same sweep — if (controlled(key) || controlled(candidate[key])) — and escape the key in the thrown message (${inertText(key)}).
| if (controlled(candidate[key])) { | |
| if (controlled(key) || controlled(candidate[key])) { |
中文说明
[Critical] R8-4:依然存在——已在本提交上重新验证。写入端 CONTROL 扫描只检查 candidate 的值——if (controlled(candidate[key]))——从不检查 candidate 的键,而它自己的拒绝信息把未检查的 key 原样插入直达终端的错误文本;同一文件里其他所有不可信字符串的错误路径都经过 inertText(readJsonObject 包裹路径与解析错误片段,target 不匹配与 file-form 拒绝都包裹其值)。守卫自身的错误路径吐出的正是它存在要拒绝的那类伪造:确定性 .qwen/tmp 路径上被篡改的 candidate(该命令自己点名的威胁),其键携带 ESC/C1/Cf 字节时,拒绝信息会把这些序列渲染到操作者终端。携带控制字符的键配干净的值则更糟:键根本无人检查,于是不抛错,裸键被原样持久化为晋升缓存的键(JSON.stringify 对 C1 字节原样透出)。修复:在同一扫描中检查键——if (controlled(key) || controlled(candidate[key]))——并在抛错信息中转义该键(${inertText(key)})。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| for (const key of LEDGER_FIELDS) { | ||
| if (key in ledger) merged[key] = ledger[key]; | ||
| } |
There was a problem hiding this comment.
[Critical] R12-1: Still stands — re-verified at this commit. The refuse-at-write sweep tests only TOP-LEVEL candidate scalar strings: controlled() is typeof v === 'string' && CONTROL.test(v) iterated over candidateFieldsOf(candidate) (lines 143-146), so map-valued candidate fields (files, fileVerdicts) fail the typeof check and strings nested inside them are never seen — and this ledger copy loop persists every ledger-owned string (verdict, findings[]) unswept as well, while the sweep's own comment claims "Every persisted STRING is checked". Git legally permits control bytes in filenames, and blobPairs carries such paths into fileVerdicts KEYS; a tampered candidate can likewise nest U+009B/U+202E inside fileVerdicts['a.ts'].base. Both pass this gate and persist raw into .qwen/review-cache/ at the deterministic path whose cleanliness the comment guarantees — armed for the consumer DESIGN.md announces, and reaching the orchestrator's context through the next round's cache read. Fix: recurse the sweep over all string leaves AND object keys of candidate-owned values, and sweep the ledger strings this loop copies, e.g. a recursive controlled that walks arrays and object entries (CONTROL.test(k) || controlled(v) per entry).
中文说明
[Critical] R12-1:依然存在——已在本提交上重新验证。写入端扫描只检查顶层 candidate 标量字符串:controlled() 是 typeof v === 'string' && CONTROL.test(v),遍历 candidateFieldsOf(candidate)(第 143-146 行),因此 map 类型的 candidate 字段(files、fileVerdicts)过不了 typeof 检查,其中嵌套的字符串完全不被检查——而这段 ledger 复制循环还把每一个 ledger 所有的字符串(verdict、findings[])未经扫描地持久化,而扫描自己的注释声称"每一个被持久化的字符串都会被检查"。git 合法允许文件名含控制字节,blobPairs 会把这类路径带入 fileVerdicts 的键;被篡改的 candidate 同样可以把 U+009B/U+202E 嵌进 fileVerdicts['a.ts'].base。两者都能通过这道门,原样落入 .qwen/review-cache/ 那个注释保证干净的确定性路径——为 DESIGN.md 宣布的消费者装填好,并经下一轮的缓存读取进入 orchestrator 上下文。修复:让扫描递归覆盖 candidate 所有值的全部字符串叶子与对象键,并扫描本循环复制的 ledger 字符串——例如递归版 controlled,遍历数组与对象条目(逐条 CONTROL.test(k) || controlled(v))。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| '-c', | ||
| 'diff.algorithm=myers', | ||
| '-c', | ||
| 'diff.indentHeuristic=true', |
There was a problem hiding this comment.
[Critical] R4-1: Still stands — carried as a CLASS finding under this family's round-4 id (ledger R9-3 diff.renameLimit and R9-4 driver-scoped diff.<driver>.algorithm entrances remain folded in as evidence, not re-filed). This round's diff pins diff.algorithm=myers and diff.indentHeuristic=true here — closing two entrances — but the pair identity enumerates git's rendering knobs one entrance at a time, and the surface keeps regenerating: diff.interHunkContext, diff.compactionHeuristic, diff.renameLimit, and driver-scoped diff.<driver>.algorithm are neither pinned here nor recorded in the pair identity (grep: zero occurrences across the review commands and the skill at HEAD). A reviewer whose config sets diff.interHunkContext=5 reads merged hunks — different hunk boundaries with identical blobs, mode and attrs — from the round that recorded a verdict; diff.renameLimit tripping renders every move as delete+add; the identity cannot see either, and a clean verdict transfers over hunks no round ever read — the exact hole the attrs component and these two new pins exist to close. Close the class structurally: pin the remaining hunk-shape config beside these two, and neutralize config-side drivers (fold the governing config state into the pair identity, or pin driver algorithms out of scope), so a rendering change moves the record whatever knob carries it.
中文说明
[Critical] R4-1:依然存在——作为类发现挂在本家族第 4 轮的 id 下继续跟踪(台账 R9-3 diff.renameLimit 与 R9-4 driver 作用域 diff.<driver>.algorithm 入口仍作为证据折叠在内,不再单独重报)。本轮 diff 在此处钉住 diff.algorithm=myers 与 diff.indentHeuristic=true——关闭了两个入口——但配对身份对 git 渲染旋钮是逐个入口枚举的,而这个表面不断再生:diff.interHunkContext、diff.compactionHeuristic、diff.renameLimit、driver 作用域的 diff.<driver>.algorithm 既未在此钉住、也未记入配对身份(HEAD 上 grep 整个 review 命令与技能:零匹配)。配置了 diff.interHunkContext=5 的评审者读到的是合并后的 hunk——blob、mode、attrs 全同而 hunk 边界不同——与记录裁决的那一轮不同;diff.renameLimit 触发时每个移动都渲染为删除+新增;身份两者都看不见,干净裁决于是被转移到没有任何一轮读过的 hunk 上——正是 attrs 分量与这两个新钉要堵的洞。请从结构上关闭该类:把其余 hunk 形状配置一并钉住,并中和配置侧 driver(把支配性配置状态折叠进配对身份,或钉死 driver 算法),使渲染变化无论由哪个旋钮承载都会移动记录。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it, and neither must a `--topology minimal` run at any effort — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. The reason is one reason, and it is why the rule is stated as a rule: the anchor's whole claim is "the next round may skip this", and a claim nobody ruled on is exactly what the next round must not skip — the round after it stops decided over a Critical that was never verified: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare model token (`YOUR_MODEL_ID`), which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the cache path for this target>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. **Where that path comes from differs by flow, and only one of them publishes it**: a LOCAL or file-path round reads it off the plan's `cachePath` — the capture derives the target and `safeTarget` is not hand-reproducible, so the name is not yours to spell — while the PR flow's plan carries no such field and the name is the one this section already gives, `.qwen/review-cache/pr-<n>.json`, which you already know from the PR number. Do not look for `cachePath` on a `fetch-pr` report; it has none, and treating its absence as a reason to skip the write would lose the PR cache entirely. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. **And these two, which complete the list**: a finding still marked `— [unverified]`, and an undecided blocker — a Critical whose verifier never returned, from a deadline stop or a timed-out batch. Neither enters `findings[]` (only confirmed findings do) and neither reads as "unreviewed scope", so without naming them a round promotes the anchor over a claim nobody ruled on and the round after it stops decided. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) |
There was a problem hiding this comment.
[Critical] R14-1 (new this round, verified): the rewritten Step 8 broadens a local-only pre-promotion CHECK to "for PR and local alike", but a fetch-pr plan structurally cannot pass it — fetch-pr publishes cacheCandidatePath only (the spread at fetch-pr.ts:1782 and FetchPrResult at :269 carry no cacheCandidateStateId; repo-wide, capture-local.ts:1125 is the field's only publisher) and its candidate carries no stateId. So on every clean high-effort PR round, Step 8 sees exactly the shape the clause names — "an absent cacheCandidateStateId field on a plan that published a path" — and skips the cache write: cache-commit never runs, this PR's headline feature (promoting fileVerdicts) is dead on arrival, and the pre-existing PR commit-anchor cache stops advancing too — every later round degrades to a full review, the silent degradation the feature exists to prevent. The paragraph shows the author knows this failure class: it warns, for cachePath, that "treating its absence as a reason to skip the write would lose the PR cache entirely" — yet leaves the identical trap armed for cacheCandidateStateId. SKILL.test.ts pins the clause into the skill body, so this is deliberate text, not a typo. Fix: restore the old scoping (apply the stateId comparison to LOCAL/file rounds only), or have fetch-pr publish a cacheCandidateStateId equivalent and reword the clause so a PR plan can satisfy it.
中文说明
[Critical] R14-1(本轮新发现,已验证):重写后的 Step 8 把原本仅限本地流程的晋升前检查扩成了"PR 与 local 一律适用",但 fetch-pr 的计划在结构上不可能通过它——fetch-pr 只发布 cacheCandidatePath(fetch-pr.ts:1782 的展开与 :269 的 FetchPrResult 均无 cacheCandidateStateId;全仓库内该字段唯一的发布者是 capture-local.ts:1125),其 candidate 也不含 stateId。于是每一个干净的 PR 高力度轮次,Step 8 看到的恰好是该条款点名的形态——"发布了路径却没有 cacheCandidateStateId 字段的计划"——从而跳过缓存写入:cache-commit 永不执行,本 PR 的头号特性(晋升 fileVerdicts)落地即死,连既有的 PR commit 锚点缓存也不再前进——此后每一轮都退化为全量评审,正是该特性要防止的静默退化。同一段落表明作者知道这类失败:对 cachePath 它警告"把其缺失当作跳过写入的理由会整个丢掉 PR 缓存"——却让同样的陷阱在 cacheCandidateStateId 上保持武装。SKILL.test.ts 把该条款钉进技能正文,说明这是有意文本而非笔误。修复:恢复旧的作用域(stateId 比对仅用于 LOCAL/文件轮次),或让 fetch-pr 发布等价的 cacheCandidateStateId 并改写条款使 PR 计划可以满足它。
— qwen3.8-max via Qwen Code /review (v0.22.0)
…potent, keep the stop evidence R18-1: tmpFile/tmpPrefix apply safeTarget a second time to tokens other callers already derived with it, and the second flatten rewrote the capped `~` joiner to `_` while run's pins interpolated the single-flatten spelling raw — every deep-path round wrote one name and polled another, resurrecting the never-matching-poll regression this PR exists to kill. safeTarget is idempotent over its own outputs now (the capped shape is recognized before the flatten); the pass-through that permits is no wider than the flatten's own collisions. R18-2/3/4: the sparse exemption re-derived git state by hand and got all three edges wrong — a raw --get missed legacy truthy spellings and read an inherited global; "absent + S" is not "out of cone" on gits that retain a manual bit inside the cone (2.43 does, 2.47 re-clears); and the combined-bit lowercase `s` spelling missed the S-only match. The oracle now asks git at every step: the flag reads --worktree --type=bool (sparse-checkout writes config.worktree via extensions.worktreeConfig), cone membership comes from `git sparse-checkout check-rules`, and the rule is uniform over every tag — exempt exactly the paths that are absent AND outside the rules; a failed check-rules exempts nothing. The version-dependent arms real git 2.47 cannot stage (it re-clears the manual bit and canonicalizes the bool even on read) are pinned by a scripted-git unit suite beside the real-git integration arms. R18-5: Step 9's file-review `-prompts` removal was unconditional, and it is that family's only remover — every unconverged file review destroyed its own budget-stop evidence on the way out, voiding the #9206 retention cleanup implements for swept families. The removal now keeps the plan and the record directory when the budget-stop marker is present, with cleanup's Kept-line wording. Mutation-checked where constructible: the exempt-all-absent mutant is killed by the scripted in-rules arm (proven live-red before restore), the joiner and idempotence mutants by paths.test, the flag-read mutant by the 2.43-class CI git via the raw-spelling arm.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: reverse audit — stopped at the 5-round cap without two consecutive dry rounds (rounds 1-4 each surfaced findings; round 5's sole candidate was a duplicate of the previously disclosed R11-12 and was dropped).
Not reviewed: build-and-test — the packages/cli test suite timed out twice (exit 143, infrastructure); no verdict for the PR's primary workspace this run (4 sibling suites green; packages/core and vscode-ide-companion red but measured pre-existing at the merge base via test-delta).
Not reviewed: test-efficacy probe — all probes inconclusive (the probe runner cannot satisfy this repo's vitest globalSetup build guard); harnessValidated: null (the positive control produced no verdict).
Deferred under the convergence posture (round 15, not a blocker) — recorded, not requested in this round:
packages/cli/src/commands/review/lib/file-verdicts.ts:131 — [probe] one legitimate U+FFFD-named file permanently kills verdict transfer for the whole PR (fix must stay per-key — a plain fatal-decoder swap reopens the wrong-pair hole)packages/core/src/skills/bundled/review/references/persistence.md:77 — [probe] the fallback routes withheld local rounds to a PR-shaped template, contradicting "nothing to promote" (harm contained by the fail-closed validator — degradation,…packages/core/src/skills/bundled/review/references/persistence.md:77 — [review] the stateId CHECK is model-side only — cache-commit re-reads the candidate unbound to it (check-then-use across the process boundary)packages/cli/src/commands/review/cache-commit.ts:208 — [probe] incremental promotion wholesale-replaces the cache — prior full-round verdicts silently dropped (probe: 40-pair cache + 2-pair candidate promotes to 2 entries)
Convergence: round 15 posted 6 inline comment(s), 1 of them reported for the first time; the previous round posted 5 (1 new). Findings keep coming back to the same files: packages/cli/src/commands/review/cache-commit.ts (findings in rounds 8, 12; 1 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)
Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had none either, so the next review re-reads the whole diff and will keep doing so until a round's marker carries an anchor again. (Stated, not acted on — this changes nothing about what the round posts.)
[Critical] R12-2 (still stands — re-checked at HEAD this round): the read side the anchor's trust lands on is unguarded — readLocalCache (lib/local-anchor.ts) is a plain readFileSync with shape-only validation and no lstat/symlink refusal, and resolveCachePath probes with following statSync/existsSync — while this PR polices the WRITE side of the same deterministic .qwen/review-cache path against exactly this threat. Verified this round: the post-round-14 base-branch merge added sparse-checkout exemptions only — no lstat/symlink refusal on the read path. A forged cache planted as .qwen/review-cache/local.json (a symlink — git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: the round skips scope the attacker's stateId certifies as unchanged. Gate-passing forgery requires state knowledge, but HEAD is known when reviewing an attacker branch checkout and the model id is guessable — validation is shape/token-deep, as the PR's own comments concede. Witness (probe at this commit): readLocalCache through the planted symlink returned a cache: true; round-2 capture anchored: true; the dirty file was certified by the symlinked cache and left out of the delta; with an lstat symlink refusal the round falls back to a full review (flip-check). Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side. Unanchorable inline (the defect line is outside this diff's hunks), hence in the body.
[Critical] R6-2 (still stands — claim carried from rounds 6-14, scope corrected by this round's verifier probe; the defect lines in lib/paths.ts:297-317 are outside this diff's hunks, hence in the body): repoRelativeOf returns relative(repoRoot, abs) unnormalized — backslash separators on Windows (only the escapes check consults sep) — and capture-local stores it raw as sourcePath, persisted as the candidate source, which feeds the cache filename digest (file-<token>-<sha256(source)[:8]>.json in cache-commit) and the cross-machine source gate, both of which assume git's slash form. Scope corrected this round: a same-machine Windows loop is self-consistent, but CROSS-MACHINE is permanently broken — a Windows-written cache (source: "src\foo.ts", digest 36da7ba9 vs the slash form's 0c92983c) consumed by a slash machine is refused through the real gate ("the cache belongs to source path src\foo.ts, not src/foo.ts" / digest-mismatched filename "missing or unreadable"), permanently degrading file-path reviews; WSL/machine cache movement mismatches the same way. The PR's own suite pins the slash contract (capture-local.incremental.test.ts, not win32-skipped) and the round-6 Windows run observed AssertionError: expected src\foo.ts to be src/foo.ts. Witness: probe driving the real handlers with the win32 form forced at the derivation site — baseline source: "src/foo.ts" accepted; cross-machine arms refused; the one-line slash normalisation flips refusal to acceptance ("Incremental scope since state 6ca2040a9324: 1 changed file(s)"). The post-round-14 merge touched utils/paths.ts (safeTarget idempotence) but no normalisation reached repoRelativeOf. Fix: normalise rel to forward slashes in repoRelativeOf before returning (rel.split(sep).join('/')), matching the existing pattern in local-diff.ts; the escapes check above already uses sep and is unaffected.
[Critical] R10-3 (still stands — re-verified by probe at this commit; the defect lines capture-local.ts:1130 and fetch-pr.ts:1791 are outside this diff's hunks, hence in the body; carried under the round-10 id — round 8 posted this claim as R8-5): the plan report carrying the cacheCandidatePath field is written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds. The plan's cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself, and the stateId CHECK compares two values both read off the plan — a substituted plan controls both sides. With .qwen/tmp committed as a symlink (the PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — anyone with write access to the link target substitutes the machine-readable record that orchestrates the round. Witness (probe at this commit, .qwen/tmp symlinked to a victim dir): the candidate refused ("a symlink in the path would redirect this write") with plan_cacheCandidatePath null, but the victim received qwen-review-local-plan.json; after substituting the victim's plan, re-reading through .qwen/tmp gave cacheCandidatePath, the stateId CHECK passed (both sides forged-plan-owned), and the real cache-commit promoted stateId "forged-state-id", lastModelId "forged-model@ffffffff", verdict "Approve"; guarding the plan write flips plan_landed_in_victim true → false. Fix: route the plan write through assertUnredirectedParent(out, 'plan report', …) + atomicWriteFileSync(out, …, { noFollow: true }) in both captures. Context for the same fix: the unguarded class extends to the sibling .qwen/tmp writers (diff.txt, stop.json, fullDiffPath).
中文说明
仅完成部分审查,审查缺口已披露。
未审查:reverse audit — stopped at the 5-round cap without two consecutive dry rounds (rounds 1-4 each surfaced findings; round 5's sole candidate was a duplicate of the previously disclosed R11-12 and was dropped)。
未审查:build-and-test — the packages/cli test suite timed out twice (exit 143, infrastructure); no verdict for the PR's primary workspace this run (4 sibling suites green; packages/core and vscode-ide-companion red but measured pre-existing at the merge base via test-delta)。
未审查:test-efficacy probe — all probes inconclusive (the probe runner cannot satisfy this repo's vitest globalSetup build guard); harnessValidated: null (the positive control produced no verdict)。
收敛姿态下延后(第 15 轮,非阻断)——已记录,本轮不要求修改:共 4 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 15 轮发布了 6 条行内评论,其中 1 条是首次提出;上一轮发布了 5 条(其中 1 条首次提出)。发现反复回到同一批文件:packages/cli/src/commands/review/cache-commit.ts(第 8、12 轮已出过发现,本轮又有 1 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)
机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有锚点,因此下一次评审将重读整个 diff——并会一直如此,直到某一轮的标记重新带上锚点。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)
[Critical] R12-2 (still stands — re-checked at HEAD this round): the read side the anchor's trust lands on is unguarded — readLocalCache (lib/local-anchor.ts) is a plain readFileSync with shape-only validation and no lstat/symlink refusal, and resolveCachePath probes with following statSync/existsSync — while this PR polices the WRITE side of the same deterministic .qwen/review-cache path against exactly this threat. Verified this round: the post-round-14 base-branch merge added sparse-checkout exemptions only — no lstat/symlink refusal on the read path. A forged cache planted as .qwen/review-cache/local.json (a symlink — git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: the round skips scope the attacker's stateId certifies as unchanged. Gate-passing forgery requires state knowledge, but HEAD is known when reviewing an attacker branch checkout and the model id is guessable — validation is shape/token-deep, as the PR's own comments concede. Witness (probe at this commit): readLocalCache through the planted symlink returned a cache: true; round-2 capture anchored: true; the dirty file was certified by the symlinked cache and left out of the delta; with an lstat symlink refusal the round falls back to a full review (flip-check). Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side. Unanchorable inline (the defect line is outside this diff's hunks), hence in the body.
[Critical] R6-2 (still stands — claim carried from rounds 6-14, scope corrected by this round's verifier probe; the defect lines in lib/paths.ts:297-317 are outside this diff's hunks, hence in the body): repoRelativeOf returns relative(repoRoot, abs) unnormalized — backslash separators on Windows (only the escapes check consults sep) — and capture-local stores it raw as sourcePath, persisted as the candidate source, which feeds the cache filename digest (file-<token>-<sha256(source)[:8]>.json in cache-commit) and the cross-machine source gate, both of which assume git's slash form. Scope corrected this round: a same-machine Windows loop is self-consistent, but CROSS-MACHINE is permanently broken — a Windows-written cache (source: "src\foo.ts", digest 36da7ba9 vs the slash form's 0c92983c) consumed by a slash machine is refused through the real gate ("the cache belongs to source path src\foo.ts, not src/foo.ts" / digest-mismatched filename "missing or unreadable"), permanently degrading file-path reviews; WSL/machine cache movement mismatches the same way. The PR's own suite pins the slash contract (capture-local.incremental.test.ts, not win32-skipped) and the round-6 Windows run observed AssertionError: expected src\foo.ts to be src/foo.ts. Witness: probe driving the real handlers with the win32 form forced at the derivation site — baseline source: "src/foo.ts" accepted; cross-machine arms refused; the one-line slash normalisation flips refusal to acceptance ("Incremental scope since state 6ca2040a9324: 1 changed file(s)"). The post-round-14 merge touched utils/paths.ts (safeTarget idempotence) but no normalisation reached repoRelativeOf. Fix: normalise rel to forward slashes in repoRelativeOf before returning (rel.split(sep).join('/')), matching the existing pattern in local-diff.ts; the escapes check above already uses sep and is unaffected.
[Critical] R10-3 (still stands — re-verified by probe at this commit; the defect lines capture-local.ts:1130 and fetch-pr.ts:1791 are outside this diff's hunks, hence in the body; carried under the round-10 id — round 8 posted this claim as R8-5): the plan report carrying the cacheCandidatePath field is written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds. The plan's cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself, and the stateId CHECK compares two values both read off the plan — a substituted plan controls both sides. With .qwen/tmp committed as a symlink (the PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — anyone with write access to the link target substitutes the machine-readable record that orchestrates the round. Witness (probe at this commit, .qwen/tmp symlinked to a victim dir): the candidate refused ("a symlink in the path would redirect this write") with plan_cacheCandidatePath null, but the victim received qwen-review-local-plan.json; after substituting the victim's plan, re-reading through .qwen/tmp gave cacheCandidatePath, the stateId CHECK passed (both sides forged-plan-owned), and the real cache-commit promoted stateId "forged-state-id", lastModelId "forged-model@ffffffff", verdict "Approve"; guarding the plan write flips plan_landed_in_victim true → false. Fix: route the plan write through assertUnredirectedParent(out, 'plan report', …) + atomicWriteFileSync(out, …, { noFollow: true }) in both captures. Context for the same fix: the unguarded class extends to the sibling .qwen/tmp writers (diff.txt, stop.json, fullDiffPath).
— qwen3.8-max via Qwen Code /review (v0.22.0)
| // `undefined === undefined`, and a clean verdict transferred over a | ||
| // rendering neither round ever certified. Failing closed costs a | ||
| // re-review; failing open costs the review. | ||
| out[p].attrs = attrs[p] ?? UNANSWERED_ATTRS; |
There was a problem hiding this comment.
[Critical] R8-1: Still stands — re-verified by probe at this commit. blobPairs stores renderingAttributes' UNHASHABLE answer (the literal string 'unhashable') verbatim as the pair's comparable attrs component — attrs[p] ?? UNANSWERED_ATTRS catches only undefined — and changedPairs treats only 'unanswered' as never-equal. Two rounds whose check-attr probes both answered UNHASHABLE therefore compare unchanged, and a clean verdict transfers over hunks neither round could certify — the exact fail-open the sentinel's own comment says it exists to close ("Failing closed costs a re-review; failing open costs the review"). A .gitattributes with *.dat diff=set (legal, common) or an undecodable driver name produces the UNHASHABLE answer; a between-rounds config move (e.g. diff.set.binary) then flips the rendering to "Binary files differ" while every identity component stands still.
Witness (probe through the real code at this commit):
PR: both rounds record attrs:"unhashable"; rendering flip observed (hunks -> "Binary files differ")
changedPairs(round1, round2) = [] <- verdict transfers
FIXED: fold UNHASHABLE into the never-equal sentinel -> changedPairs = ["data.dat"]
No production consumer reads fileVerdicts back at this commit yet (producer-only, documented) — the transfer goes live with the consumer, and the records are already persisted via cache-commit.
Fix:
import { UNHASHABLE } from './local-anchor.js';
// in blobPairs:
const a = attrs[p];
out[p].attrs = a === undefined || a === UNHASHABLE ? UNANSWERED_ATTRS : a;中文说明
[Critical] R8-1:依然存在——已在本提交上用探测重新验证。blobPairs 把 renderingAttributes 的 UNHASHABLE 回答(字面字符串 'unhashable')原样存为键对的可比较 attrs 分量——attrs[p] ?? UNANSWERED_ATTRS 只捕获 undefined——而 changedPairs 只把 'unanswered' 当作永不相等。于是两轮 check-attr 探测都回答 UNHASHABLE 时会被判定为未变化,干净的裁决就被传递到两轮都未能认证的 hunk 上——正是哨兵注释声称要堵住的失败开放("失败封闭代价是重审;失败开放代价是评审本身")。.gitattributes 里的 *.dat diff=set(合法且常见)或无法解码的 driver 名都会产生 UNHASHABLE 回答;两轮之间移动配置(如 diff.set.binary)即可把渲染翻转为 "Binary files differ",而所有身份分量纹丝不动。
证据(在本提交上经真实代码探测):PR 侧两轮均记录 attrs:"unhashable",观察到渲染翻转(hunks → "Binary files differ"),changedPairs(round1, round2) = [](裁决被传递);将 UNHASHABLE 折入永不相等哨兵后 changedPairs = ["data.dat"]。本提交尚无生产消费者读回 fileVerdicts(文档注明 producer-only)——消费者落地时该传递即生效,而记录此刻已经过 cache-commit 持久化。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| const controlled = (v: unknown): boolean => | ||
| typeof v === 'string' && CONTROL.test(v); | ||
| for (const key of candidateFieldsOf(candidate)) { | ||
| if (controlled(candidate[key])) { |
There was a problem hiding this comment.
[Critical] R8-4: Still stands — re-verified by probe at this commit. The refuse-at-write CONTROL sweep tests only candidate values — if (controlled(candidate[key])) — never the candidate's keys, and its own refusal interpolates the unchecked key raw into an error that reaches the operator's terminal, while every other untrusted string in this file's error paths goes through inertText. A tampered candidate at the deterministic .qwen/tmp path whose KEY carries ESC/C1/Cf bytes with a clean value is never checked (nothing throws) and the raw key is persisted as a key of the promoted cache (JSON.stringify passes C1 bytes through verbatim); with a control value the refusal message itself emits the raw forgery class the guard exists to refuse.
Witness (probe through the real handler at this commit):
PR: key 'k<U+009B>' + clean value -> NO refusal; raw U+009B persisted in a cache KEY
key + control value -> refused, but the message carries raw U+009B
(the C1 introducer ate text: `the candidate's `last ommitSha``)
FIXED: key sweep + inertText(key) in the message -> both arms refused, keys escaped
Fix: sweep keys too (if (controlled(key) || controlled(candidate[key]))) and wrap the key in the thrown message with inertText(key) — applying only the condition still leaves the refusal printing the raw key it just refused.
中文说明
[Critical] R8-4:依然存在——已在本提交上用探测重新验证。写入端 CONTROL 扫描只检查 candidate 的值——if (controlled(candidate[key]))——从不检查 candidate 的键,而它自己的拒绝信息把未检查的 key 原样插入错误文本直达操作者终端;同一文件其他所有错误路径的不可信字符串都经过 inertText。确定性 .qwen/tmp 路径上被篡改的 candidate,其键携带 ESC/C1/Cf 字节而值干净时无人检查(不抛错),裸键被原样持久化为晋升缓存的键(JSON.stringify 对 C1 字节原样透出);值含控制字符时,拒绝信息本身吐出的正是该守卫要拒绝的伪造序列。
证据(在本提交上经真实 handler 探测):键 'k<U+009B>' + 干净值 → 未拒绝,缓存键中含裸 U+009B;键 + 控制字符值 → 拒绝但信息携带裸 U+009B(C1 引导符吞掉文本,渲染为 the candidate's last ommitSha``);键扫描 + 信息中 inertText(key) 后两个分支均拒绝且键被转义。
修复:键也要扫(if (controlled(key) || controlled(candidate[key]))),且抛错信息中的键用 inertText(key) 包裹——只改条件仍会让拒绝信息打印它刚刚拒绝的裸键。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| // something a reader needs. | ||
| const merged: Record<string, unknown> = {}; | ||
| for (const key of LEDGER_FIELDS) { | ||
| if (key in ledger) merged[key] = ledger[key]; |
There was a problem hiding this comment.
[Critical] R12-1: Still stands — re-verified by probe at this commit. The refuse-at-write sweep tests only TOP-LEVEL candidate scalar strings: controlled() is typeof v === 'string' && CONTROL.test(v), so map-valued candidate fields (files, fileVerdicts) fail the typeof check and strings nested inside them are never seen — and this ledger copy loop persists every ledger-owned string (verdict, findings[]) through {...ledger} unswept. The comment above claims "Every persisted STRING is checked"; the code checks some.
The fileVerdicts case needs no tampering: fetch-pr records its keys byte-faithfully from ls-tree -z, and git permits control/invisible characters in filenames — a PR shipping a file named x<U+202E>y.ts puts that byte into a map key the sweep never looks at, and it is persisted raw into the trusted cache.
Witness (probe through the real handler at this commit):
PR: candidate fileVerdicts keyed 'x<U+202E>y.ts' -> NO refusal; raw U+202E persisted in a fileVerdicts key
ledger findings[] string with a C1 byte -> persisted unswept
FIXED: recursive sweep over candidate keys+values and ledger-owned strings -> all arms refused
Fix: sweep recursively — walk the candidate's fields and the ledger-owned copy, applying CONTROL to every string encountered, including object keys and nested array/object members.
中文说明
[Critical] R12-1:依然存在——已在本提交上用探测重新验证。写入端扫描只检查顶层 candidate 标量字符串:controlled() 是 typeof v === 'string' && CONTROL.test(v),因此 map 类型的 candidate 字段(files、fileVerdicts)过不了 typeof 检查,其中嵌套的字符串永远不被检查——而本 ledger 复制循环经 {...ledger} 把所有 ledger 所有的字符串(verdict、findings[])原样持久化、未经扫描。上方注释声称"每一个被持久化的字符串都会被检查";代码只检查了一部分。
fileVerdicts 这一支无需篡改即可触发:fetch-pr 用 ls-tree -z 逐字节记录键,而 git 允许文件名含控制/不可见字符——一个提交 x<U+202E>y.ts 文件名的 PR 就把该字节送进扫描永不查看的 map 键,并被原样持久化进受信任缓存。
证据(在本提交上经真实 handler 探测):candidate 的 fileVerdicts 键为 'x<U+202E>y.ts' → 未拒绝,裸 U+202E 持久化进 fileVerdicts 键;ledger findings[] 字符串含 C1 字节 → 未经扫描被持久化;对 candidate 键值与 ledger 所有字符串做递归扫描后所有分支均拒绝。
修复:递归扫描——遍历 candidate 字段与 ledger 所有的副本,对遇到的每个字符串(含对象键、嵌套数组/对象成员)应用 CONTROL。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| '-c', | ||
| 'diff.algorithm=myers', | ||
| '-c', | ||
| 'diff.indentHeuristic=true', |
There was a problem hiding this comment.
[Critical] R4-1: Still stands — carried as a CLASS finding under this family's round-4 id, re-verified against git's own registry at this commit. The per-file verdict pair identity enumerates git's rendering knobs one entrance at a time, and the surface keeps regenerating. This round's diff pins diff.algorithm=myers and diff.indentHeuristic=true here — closing two entrances — but measured against git help -c (git 2.47.3): diff.interHunkContext and diff.renameLimit are real config keys, neither pinned by PINNED_DIFF_CONFIG nor recorded in the pair identity. (Correction to earlier rounds, verifier-measured: diff.compactionHeuristic and diff.<driver>.algorithm are NOT real git config keys — the class stands on the two real ones.)
Witness (probe at this commit):
BASE (defaults): 2 @@ hunks
-c diff.interHunkContext=5: 1 @@ hunk <- same blobs/mode/attributes, different hunk shape
The identity cannot see that difference: a clean verdict transfers over hunks rendered differently. Five rounds have each found new entrances in this family.
Fix: close the class structurally rather than entrance by entrance — pin/record the whole rendering surface (consult git's own config for the hunk-shape knobs, or capture the effective rendering state into the pair identity), so a config the identity cannot see can never move hunk shape. Pinning the two real knobs above is the minimal down payment.
中文说明
[Critical] R4-1:依然存在——作为类级发现以本家族第 4 轮的 id 继续携带,并已在本提交上对照 git 自身注册表重新验证。每文件裁决的键对身份逐个入口地枚举 git 的渲染旋钮,而该表面不断再生。本轮 diff 在此固定了 diff.algorithm=myers 与 diff.indentHeuristic=true——关闭两个入口——但对照 git help -c(git 2.47.3)实测:diff.interHunkContext 与 diff.renameLimit 是真实配置键,既未被 PINNED_DIFF_CONFIG 固定,也未记录进键对身份。(对早前轮次的更正,经验证者实测:diff.compactionHeuristic 与 diff.<driver>.algorithm 并非真实 git 配置键——该类发现立于上述两个真实键之上。)
证据(在本提交上探测):默认配置渲染出 2 个 @@ hunk;-c diff.interHunkContext=5 渲染出 1 个 @@ hunk——blob/mode/属性完全相同,hunk 形状却不同。身份看不见这个差异:干净裁决会传递到渲染方式已变的 hunk 上。五轮评审每一轮都在这个家族里发现新入口。
修复:从结构上关闭该类,而不是逐入口——固定/记录整个渲染表面(向 git 自身配置查询 hunk 形状旋钮,或把生效的渲染状态纳入键对身份),使身份看不见的配置永远无法移动 hunk 形状。最低限度是先固定上面两个真实键。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it, and neither must a `--topology minimal` run at any effort — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. The reason is one reason, and it is why the rule is stated as a rule: the anchor's whole claim is "the next round may skip this", and a claim nobody ruled on is exactly what the next round must not skip — the round after it stops decided over a Critical that was never verified: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare model token (`YOUR_MODEL_ID`), which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the cache path for this target>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. **Where that path comes from differs by flow, and only one of them publishes it**: a LOCAL or file-path round reads it off the plan's `cachePath` — the capture derives the target and `safeTarget` is not hand-reproducible, so the name is not yours to spell — while the PR flow's plan carries no such field and the name is the one this section already gives, `.qwen/review-cache/pr-<n>.json`, which you already know from the PR number. Do not look for `cachePath` on a `fetch-pr` report; it has none, and treating its absence as a reason to skip the write would lose the PR cache entirely. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. **And these two, which complete the list**: a finding still marked `— [unverified]`, and an undecided blocker — a Critical whose verifier never returned, from a deadline stop or a timed-out batch. Neither enters `findings[]` (only confirmed findings do) and neither reads as "unreviewed scope", so without naming them a round promotes the anchor over a claim nobody ruled on and the round after it stops decided. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) |
There was a problem hiding this comment.
[Critical] R14-1: Still stands — re-verified at this commit. The rewritten Step 8 broadens the local-only pre-promotion CHECK to "for PR and local alike" — an absent cacheCandidateStateId field on a plan that published a path is "treated exactly like a withheld candidate: skip the cache write and say so" — but a fetch-pr plan structurally cannot pass it: fetch-pr contains zero stateId occurrences; its candidate JSON is {v, target, lastCommitSha, mergeBaseSha, fileVerdicts, lastModelId} (no stateId), and the plan spread at fetch-pr.ts:1782 publishes cacheCandidatePath only — cacheCandidateStateId is published solely by capture-local.ts:1124-1125.
Failure scenario: any clean high-effort PR round. A literal executor — which these docs instruct the model to be — applies the CHECK, finds no cacheCandidateStateId, and skips cache-commit; the hand-write fallback is keyed "when the plan carries no cacheCandidatePath" and never triggers for a healthy fetch-pr plan. Net effect: .qwen/review-cache/pr-<n>.json is never written again — a regression of the existing PR-cache persistence — contradicting this diff's own fetch-pr comment ("Step 8 promotes it into the review cache on a clean high-effort end").
Witness: not run — doc/plan-shape contradiction settled by reading fetch-pr.ts:1782, the candidate JSON literal (fetch-pr.ts:1657-1680) and this paragraph; fetch-pr needs a live PR, and no reading can change the field's absence.
Fix: re-scope the CHECK to local/file rounds ("a fetch-pr plan publishes cacheCandidatePath without cacheCandidateStateId by design — its candidate carries no stateId — and that absence is the normal PR state, not a withheld candidate"), or give fetch-pr a stateId (a hash of the candidate content) and publish a matching cacheCandidateStateId beside cacheCandidatePath.
中文说明
[Critical] R14-1:依然存在——已在本提交上重新验证。重写后的 Step 8 把原本仅限 local 的晋升前 CHECK 扩展为"PR 与 local 一视同仁"——"发布了路径的计划上缺少 cacheCandidateStateId 字段"将"与 candidate 被扣留完全同等对待:跳过缓存写入并说明"——但 fetch-pr 的计划在结构上不可能通过它:fetch-pr 全文没有任何 stateId;其 candidate JSON 为 {v, target, lastCommitSha, mergeBaseSha, fileVerdicts, lastModelId}(无 stateId),fetch-pr.ts:1782 的计划展开只发布 cacheCandidatePath——cacheCandidateStateId 仅由 capture-local.ts:1124-1125 发布。
失败场景:任何干净收尾的高强度 PR 轮次。字面执行者(这些文档要求模型如此执行)应用该 CHECK,发现计划上没有 cacheCandidateStateId,于是跳过 cache-commit;手写回退以"计划未携带 cacheCandidatePath"为触发条件,而健康的 fetch-pr 计划永远携带它。净效果:.qwen/review-cache/pr-<n>.json 再也不会被写入——现有 PR 缓存持久化的回归——与本 diff 自己的 fetch-pr 注释("Step 8 在干净收尾时把它晋升进评审缓存")相矛盾。
证据:未运行——文档/计划形态矛盾由阅读 fetch-pr.ts:1782、candidate JSON 字面量(fetch-pr.ts:1657-1680)与本段落即可判定;fetch-pr 需要真实 PR,且任何阅读都无法改变该字段的缺失。
修复:把该 CHECK 限定回 local/文件轮次("fetch-pr 计划按设计只发布 cacheCandidatePath 而不带 cacheCandidateStateId——其 candidate 本就不含 stateId——该缺失是 PR 流程的正常状态,不是 candidate 被扣留"),或者给 fetch-pr 一个 stateId(candidate 内容的哈希)并在 cacheCandidatePath 旁发布匹配的 cacheCandidateStateId。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| atomicWriteFileSync(args.out, `${JSON.stringify(merged, null, 2)}\n`, { | ||
| noFollow: true, | ||
| }); | ||
| writeStdoutLine(`Committed review cache to ${args.out}`); |
There was a problem hiding this comment.
[Critical] R15-4 (new this round, verified by probe): the intake sweep polices every candidate VALUE but never the command's own CLI path arguments, and this success line prints the unswept --out raw — the one print site in a file that escapes every other untrusted string through inertText. --out is not a trusted constant: in the PR flow Step 8 (persistence.md:77) has the reviewing model SPELL it, and that model's context is the attacker-authored PR; in local/file flow it is the plan's cachePath, which the R10-3 probe demonstrated is substitutable via the unguarded .qwen/tmp plan write. --out is also never confined to .qwen/review-cache/.
Failure scenario: a --out carrying C1/Cf/ESC bytes passes all checks (nothing sweeps args.out), then (1) this raw process.stdout.write emits them at the operator's terminal on every promotion — the same demonstrated forgery class as R8-4 — and (2) mkdirSync + atomicWriteFileSync persist those bytes as a live cache directory/file NAME, and the merged cache (with the ledger's contents) can be written to any path the reviewer can write whose basename matches the target contract.
Witness (probe through the real handler at this commit):
T1: control-byte --out -> promotion SUCCEEDED; stdout line carried raw U+009B;
control-byte directory and cache file created
T2: ESC variant -> raw ESC emitted on the success line
T3: --out outside .qwen/review-cache/ -> NO refusal; file written there
FIX: inertText(args.out) flips the stdout arm; sweep + confinement needed for the rest
Fix: extend the refusal sweep to the path arguments (reject a --out carrying CONTROL, matching the file's "refuse at the writing end" posture), print it through inertText if ever echoed, and validate it resolves inside the repo's .qwen/review-cache/ before mkdirSync.
中文说明
[Critical] R15-4(本轮新发现,已用探测验证):入口扫描审查了每一个 candidate 的值,却从不检查命令自己的 CLI 路径参数,而这行成功提示把未经扫描的 --out 原样打印——这是该文件中唯一一个不走 inertText 的打印点(其他所有不可信字符串都经过它)。--out 不是可信常量:PR 流程中 Step 8(persistence.md:77)让评审模型拼写它,而该模型的上下文正是攻击者撰写的 PR;local/文件流程中它是计划里的 cachePath,R10-3 的探测已证明该值可经无守卫的 .qwen/tmp 计划写入被替换。--out 也从未被限制在 .qwen/review-cache/ 内。
失败场景:携带 C1/Cf/ESC 字节的 --out 通过所有检查(无人扫描 args.out),随后(1)这行裸 process.stdout.write 在每次晋升时把序列射向操作者终端——与 R8-4 已被证实的伪造同类——(2)mkdirSync + atomicWriteFileSync 把这些字节持久化为真实存在的缓存目录/文件名,且合并后的缓存(连同 ledger 内容)可被写入评审者权限内任何基名符合 target 契约的路径。
证据(在本提交上经真实 handler 探测):T1 控制字符 --out → 晋升成功,标准输出行携带裸 U+009B,控制字符目录与缓存文件被创建;T2 ESC 变体 → 成功行吐出裸 ESC;T3 --out 指向 .qwen/review-cache/ 之外 → 未拒绝,文件落盘;修复侧 inertText(args.out) 翻转标准输出分支,其余需扫描 + 路径限制。
修复:把拒绝扫描扩展到路径参数(拒绝携带 CONTROL 的 --out,与本文件"在写入端拒绝"的姿态一致),如需回显则经 inertText 打印,并在 mkdirSync 前校验其解析结果位于仓库的 .qwen/review-cache/ 之内。
— qwen3.8-max via Qwen Code /review (v0.22.0)
…never exempt Three foldings of failure semantics onto the safe side, all instances of the discipline hashWorktreeFiles and revisionIdentities already carry: R19-1: the sparse exemption ran both its halves on a name the decode had already mangled — lstat missed the PRESENT file through the U+FFFD spelling and check-rules was fed bytes git never knew — so a bit-carrying present file was silently exempted. An undecodable name is never exempt. R19-2: every lstat failure folded into "absent", so a PRESENT flagged path under an unmeasurable ancestor (EACCES/ENOTDIR/ELOOP) was exempted while git diff stayed blind to its bytes. Only ENOENT proves absence; anything else stays flagged. R19-3: vanishedStillOnDisk folded every lstat failure into "genuinely gone", ending the round at a decided scope-emptied stop over bytes no round captured. Only ENOENT proves deletion; an unmeasurable cached path stays in the on-disk set, whose downstream is an anchor refusal at the cost of a full round. Deterministically staged as root via ENOTDIR (a regular file where the ancestor directory was) — EACCES needs a non-root runner. All three foldings mutation-checked red.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: reverse audit — stopped at the 5-round cap without two consecutive dry rounds (rounds 1-4 each surfaced findings; round 5's candidates were all duplicates of previously disclosed findings).
Not reviewed: test-efficacy probe — all probes inconclusive (the probe runner cannot satisfy this repo's vitest globalSetup build guard); harnessValidated: null (the positive control produced no verdict).
Not explored to full depth (tool budget reached): chunk 5: executing packages/cli/src/commands/review/lib/file-verdicts.test.ts under vitest — the review worktree has no node_modules , and the monorepo install+build ….
Deferred under the convergence posture (round 16, not a blocker) — recorded, not requested in this round:
packages/cli/src/commands/review/cache-commit.ts:67 — [review] stale first doc comment above LEDGER_FIELDS describes the opposite of the deny-list mergepackages/cli/src/commands/review/lib/inert-text.test.ts:1 — [probe] test file committed with raw control bytes — git classifies it binary, diff permanently opaquepackages/cli/src/commands/review/capture-local.incremental.test.ts:1673 — [probe] withheld-plan test asserts existsSync(undefined) — vacuously false; unlink mutant ships greenpackages/cli/src/commands/review/lib/diff-flags.test.ts — [probe] the two new config pins have no assertion; deleting them keeps the suite greenpackages/cli/src/commands/review/lib/file-verdicts.test.ts:351 — [probe] UNANSWERED sentinel test pins only the comparison half; producer fold mutation-invisible (suite 18/18 green with the fold removed)packages/core/src/skills/bundled/review/references/persistence.md:78 — [review] the ledger file is unpoliced against the stable-path race the candidate's stateId CHECK exists forpackages/cli/src/commands/review/lib/inert-text.ts:83 — [probe] pre-escape emits 5-hex \uXXXXX for astral Cf; standard decoders misidentify the character (0/127 astral Cf faithfully named)packages/cli/src/commands/review/lib/inert-text.ts:77 — [probe] pre-escape before JSON.stringify doubles the backslash — DEL/C1/Cf/Zl/Zp render as \\uXXXX, character identity lost on decode
Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had none either, so the next review re-reads the whole diff and will keep doing so until a round's marker carries an anchor again. (Stated, not acted on — this changes nothing about what the round posts.)
[Critical] R12-2 (still stands — re-verified by probe at this commit): the read side the anchor's trust lands on is unguarded — readLocalCache (lib/local-anchor.ts) is a plain readFileSync with shape-only validation and no lstat/symlink refusal, and resolveCachePath probes with following statSync/existsSync — while this PR polices the WRITE side of the same deterministic .qwen/review-cache path against exactly this threat. A forged cache planted as .qwen/review-cache/local.json (a symlink — git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: the round skips scope the attacker's stateId certifies as unchanged. Gate-passing forgery requires state knowledge, but HEAD is known when reviewing an attacker branch checkout and the model id is guessable — validation is shape/token-deep, as the PR's own comments concede. Witness (probe at this commit driving the real command end to end): forged cache planted as a symlink at .qwen/review-cache/local.json -> plan.incremental present (anchor honoured): true; deltaFiles over a fully-dirty tree: []; stderr: 'No changes since the last local review round (same model, same HEAD, same content) — nothing to re-review.'; symlink still in place after the read: true. Flip: lstatSync(path).isSymbolicLink() refusal in readLocalCache -> plan.incremental present: false; stderr: 'Incremental anchor not used — the cache is missing or unreadable. Running the full local review.' Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side. Unanchorable inline (the defect line is outside this diff's hunks), hence in the body.
[Critical] R6-2 (still stands — re-verified by probe at this commit; defect lines paths.ts:297-317 outside this diff's hunks, hence in the body): repoRelativeOf returns relative(repoRoot, abs) unnormalized — backslash separators on Windows (only the escapes check consults sep) — and capture-local stores it raw as sourcePath, persisted as the candidate source, which feeds the cache filename digest (file-<token>-<sha256(source)[:8]>.json in cache-commit) and the cross-machine source gate, both of which assume git's slash form. Same-machine Windows loop is self-consistent, but CROSS-MACHINE is permanently broken — a Windows-written cache (source: 'src\foo.ts', digest 36da7ba9 vs the slash form's 0c92983c) consumed by a slash machine is refused through the real gate ('the cache belongs to source path src\foo.ts, not src/foo.ts' / digest-mismatched filename 'missing or unreadable'), permanently degrading file-path reviews; WSL/machine cache movement mismatches the same way. The PR's own suite pins the slash contract (capture-local.incremental.test.ts, not win32-skipped). Witness (probe against node's real win32 implementation — the exact code Windows executes): win32 relative: 'src\foo.ts' vs posix relative: 'src/foo.ts'; digest(src/foo.ts): 0c92983c vs digest(src\foo.ts): 36da7ba9 — a Windows-written cache's filename is never found by a slash machine's cachePathFor prediction, and a direct read dies on the source gate. Fix: normalise rel to forward slashes in repoRelativeOf before returning (rel.split(sep).join('/')), matching the existing pattern in local-diff.ts.
[Critical] R10-3 (still stands — re-verified by probe at this commit; defect lines capture-local.ts:1138 and fetch-pr.ts:1791 outside this diff's hunks, hence in the body): the plan report carrying the cacheCandidatePath field is written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds. The plan's cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself, and the stateId CHECK compares two values both read off the plan — a substituted plan controls both sides. With .qwen/tmp committed as a symlink (the PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — anyone with write access to the link target substitutes the machine-readable record that orchestrates the round. Witness (probe at this commit, .qwen/tmp symlinked to a victim dir, driving the real captureLocalCommand.handler): plan_landed_in_victim: true, candidate_landed_in_victim: false, plan_cacheCandidatePath: null; after substituting the victim's plan: stateId_CHECK_passed: true, promoted_stateId: 'forged-state-id', promoted_lastModelId: 'forged-model@bbbbbbbb', promoted_verdict: 'Approve'. Fixed arm (assertUnredirectedParent + noFollow on the plan write): handler threw 'a symlink in the path would redirect this write', plan_landed_in_victim: false. Fix: route the plan write through assertUnredirectedParent + atomicWriteFileSync(noFollow) in both captures. Context for the same fix: the unguarded class extends to the sibling .qwen/tmp writers (diff.txt, stop.json, fullDiffPath).
中文说明
仅完成部分审查,审查缺口已披露。
未审查:reverse audit — stopped at the 5-round cap without two consecutive dry rounds (rounds 1-4 each surfaced findings; round 5's candidates were all duplicates of previously disclosed findings)。
未审查:test-efficacy probe — all probes inconclusive (the probe runner cannot satisfy this repo's vitest globalSetup build guard); harnessValidated: null (the positive control produced no verdict)。
未探索到全部深度(达到工具调用预算):chunk 5:executing packages/cli/src/commands/review/lib/file-verdicts.test.ts under vitest — the review worktree has no node_modules , and the monorepo install+build …。
收敛姿态下延后(第 16 轮,非阻断)——已记录,本轮不要求修改:共 8 条(原文未翻译,列表见上方英文部分)。
机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有锚点,因此下一次评审将重读整个 diff——并会一直如此,直到某一轮的标记重新带上锚点。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)
[Critical] R12-2 (still stands — re-verified by probe at this commit): the read side the anchor's trust lands on is unguarded — readLocalCache (lib/local-anchor.ts) is a plain readFileSync with shape-only validation and no lstat/symlink refusal, and resolveCachePath probes with following statSync/existsSync — while this PR polices the WRITE side of the same deterministic .qwen/review-cache path against exactly this threat. A forged cache planted as .qwen/review-cache/local.json (a symlink — git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: the round skips scope the attacker's stateId certifies as unchanged. Gate-passing forgery requires state knowledge, but HEAD is known when reviewing an attacker branch checkout and the model id is guessable — validation is shape/token-deep, as the PR's own comments concede. Witness (probe at this commit driving the real command end to end): forged cache planted as a symlink at .qwen/review-cache/local.json -> plan.incremental present (anchor honoured): true; deltaFiles over a fully-dirty tree: []; stderr: 'No changes since the last local review round (same model, same HEAD, same content) — nothing to re-review.'; symlink still in place after the read: true. Flip: lstatSync(path).isSymbolicLink() refusal in readLocalCache -> plan.incremental present: false; stderr: 'Incremental anchor not used — the cache is missing or unreadable. Running the full local review.' Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side. Unanchorable inline (the defect line is outside this diff's hunks), hence in the body.
[Critical] R6-2 (still stands — re-verified by probe at this commit; defect lines paths.ts:297-317 outside this diff's hunks, hence in the body): repoRelativeOf returns relative(repoRoot, abs) unnormalized — backslash separators on Windows (only the escapes check consults sep) — and capture-local stores it raw as sourcePath, persisted as the candidate source, which feeds the cache filename digest (file-<token>-<sha256(source)[:8]>.json in cache-commit) and the cross-machine source gate, both of which assume git's slash form. Same-machine Windows loop is self-consistent, but CROSS-MACHINE is permanently broken — a Windows-written cache (source: 'src\foo.ts', digest 36da7ba9 vs the slash form's 0c92983c) consumed by a slash machine is refused through the real gate ('the cache belongs to source path src\foo.ts, not src/foo.ts' / digest-mismatched filename 'missing or unreadable'), permanently degrading file-path reviews; WSL/machine cache movement mismatches the same way. The PR's own suite pins the slash contract (capture-local.incremental.test.ts, not win32-skipped). Witness (probe against node's real win32 implementation — the exact code Windows executes): win32 relative: 'src\foo.ts' vs posix relative: 'src/foo.ts'; digest(src/foo.ts): 0c92983c vs digest(src\foo.ts): 36da7ba9 — a Windows-written cache's filename is never found by a slash machine's cachePathFor prediction, and a direct read dies on the source gate. Fix: normalise rel to forward slashes in repoRelativeOf before returning (rel.split(sep).join('/')), matching the existing pattern in local-diff.ts.
[Critical] R10-3 (still stands — re-verified by probe at this commit; defect lines capture-local.ts:1138 and fetch-pr.ts:1791 outside this diff's hunks, hence in the body): the plan report carrying the cacheCandidatePath field is written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds. The plan's cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself, and the stateId CHECK compares two values both read off the plan — a substituted plan controls both sides. With .qwen/tmp committed as a symlink (the PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — anyone with write access to the link target substitutes the machine-readable record that orchestrates the round. Witness (probe at this commit, .qwen/tmp symlinked to a victim dir, driving the real captureLocalCommand.handler): plan_landed_in_victim: true, candidate_landed_in_victim: false, plan_cacheCandidatePath: null; after substituting the victim's plan: stateId_CHECK_passed: true, promoted_stateId: 'forged-state-id', promoted_lastModelId: 'forged-model@bbbbbbbb', promoted_verdict: 'Approve'. Fixed arm (assertUnredirectedParent + noFollow on the plan write): handler threw 'a symlink in the path would redirect this write', plan_landed_in_victim: false. Fix: route the plan write through assertUnredirectedParent + atomicWriteFileSync(noFollow) in both captures. Context for the same fix: the unguarded class extends to the sibling .qwen/tmp writers (diff.txt, stop.json, fullDiffPath).
— qwen3.8-max via Qwen Code /review (v0.22.0)
| // `undefined === undefined`, and a clean verdict transferred over a | ||
| // rendering neither round ever certified. Failing closed costs a | ||
| // re-review; failing open costs the review. | ||
| out[p].attrs = attrs[p] ?? UNANSWERED_ATTRS; |
There was a problem hiding this comment.
[Critical] R8-1: Still stands — re-verified by probe at this commit (and independently re-discovered this round). blobPairs stores renderingAttributes' UNHASHABLE answer (the literal string 'unhashable') verbatim as the pair's comparable attrs component — attrs[p] ?? UNANSWERED_ATTRS catches only undefined — and changedPairs treats only 'unanswered' as never-equal. Two rounds whose check-attr probes both answered UNHASHABLE therefore compare unchanged, and a clean verdict transfers over hunks neither round could certify — the exact fail-open the sentinel's own comment says it exists to close ("Failing closed costs a re-review; failing open costs the review"). A .gitattributes with *.dat diff=set (legal, common) or an undecodable driver name produces the UNHASHABLE answer; a between-rounds config move (e.g. diff.set.binary) then flips the rendering to "Binary files differ" while every identity component stands still. No production consumer reads fileVerdicts back at this commit yet (producer-only, documented) — the transfer goes live with the consumer, and the records are already persisted via cache-commit.
Witness (probe through the real code at this commit, real git repo, .gitattributes *.dat diff=set, then git config diff.set.binary true between rounds):
round1 rendering has @@ hunks: true
round2 rendering: ["Binary files a/data.dat and b/data.dat differ"]
changedPairs(round1, round2): [] <- verdict transfers
AFTER folding UNHASHABLE into never-equal:
changedPairs(round1, round2): ["data.dat"] <- probe flips
Fix: fold UNHASHABLE into the never-equal sentinel — import { UNHASHABLE } from './local-anchor.js'; and in blobPairs: const a = attrs[p]; out[p].attrs = a === undefined || a === UNHASHABLE ? UNANSWERED_ATTRS : a;
中文说明
[Critical] R8-1:依然存在——已在本提交上用探测重新验证(本轮并被独立重新发现)。blobPairs 把 renderingAttributes 的 UNHASHABLE 回答(字面字符串 'unhashable')原样存为键对的可比较 attrs 分量——attrs[p] ?? UNANSWERED_ATTRS 只捕获 undefined——而 changedPairs 只把 'unanswered' 当作永不相等。两轮 check-attr 探测都回答 UNHASHABLE 时会被判定为未变化,干净的裁决就被传递到两轮都未能认证的 hunk 上——正是哨兵注释声称要堵住的失败开放。.gitattributes 里的 *.dat diff=set(合法且常见)或无法解码的 driver 名都会产生 UNHASHABLE 回答;两轮之间移动配置(如 diff.set.binary)即可把渲染翻转为 "Binary files differ",而所有身份分量纹丝不动。本提交尚无生产消费者读回 fileVerdicts(文档注明 producer-only)——消费者落地时该传递即生效,而记录此刻已经过 cache-commit 持久化。证据为本提交上真实 git 仓库中的探测:两轮均记录 attrs:"unhashable",渲染已翻转,changedPairs = [](裁决被传递);将 UNHASHABLE 折入永不相等比较后 changedPairs = ["data.dat"](探测翻转)。修复:把 UNHASHABLE 折入永不相等哨兵。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| const controlled = (v: unknown): boolean => | ||
| typeof v === 'string' && CONTROL.test(v); | ||
| for (const key of candidateFieldsOf(candidate)) { | ||
| if (controlled(candidate[key])) { |
There was a problem hiding this comment.
[Critical] R8-4: Still stands — re-verified by probe at this commit. The refuse-at-write CONTROL sweep tests only candidate values — if (controlled(candidate[key])) — never the candidate's keys, and its own refusal interpolates the unchecked key raw into an error that reaches the operator's terminal, while every other untrusted string in this file's error paths goes through inertText. A tampered candidate at the deterministic .qwen/tmp path whose KEY carries ESC/C1/Cf bytes with a clean value is never checked (nothing throws) and the raw key is persisted as a key of the promoted cache (JSON.stringify passes C1 bytes through verbatim); with a control value the refusal message itself emits the raw forgery class the guard exists to refuse.
Witness (probe through the real handler at this commit):
key 'k<U+009B>' + clean value -> NO refusal; raw U+009B persisted in a cache KEY
key + control value -> refused, but the message carries raw U+009B
AFTER key sweep + inertText(key) in the message -> both arms refused, keys escaped
Fix: sweep keys too — if (controlled(key) || controlled(candidate[key])) — and wrap the key in the thrown message with inertText(key); applying only the condition still leaves the refusal printing the raw key it just refused.
中文说明
[Critical] R8-4:依然存在——已在本提交上用探测重新验证。写入端 CONTROL 扫描只检查 candidate 的值——if (controlled(candidate[key]))——从不检查 candidate 的键,而它自己的拒绝信息把未检查的 key 原样插入错误文本直达操作者终端;同一文件其他所有错误路径的不可信字符串都经过 inertText。确定性 .qwen/tmp 路径上被篡改的 candidate,其键携带 ESC/C1/Cf 字节而值干净时无人检查(不抛错),裸键被原样持久化为晋升缓存的键(JSON.stringify 对 C1 字节原样透出);值含控制字符时,拒绝信息本身吐出的正是该守卫要拒绝的伪造序列。证据(本提交上经真实 handler 探测):键 'k<U+009B>' + 干净值 → 未拒绝,缓存键中含裸 U+009B;键 + 控制字符值 → 拒绝但信息携带裸 U+009B;键扫描 + 信息中 inertText(key) 后两个分支均拒绝且键被转义。修复:键也要扫,且抛错信息中的键用 inertText(key) 包裹。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| // something a reader needs. | ||
| const merged: Record<string, unknown> = {}; | ||
| for (const key of LEDGER_FIELDS) { | ||
| if (key in ledger) merged[key] = ledger[key]; |
There was a problem hiding this comment.
[Critical] R12-1: Still stands — re-verified by probe at this commit (and independently re-discovered this round). The refuse-at-write sweep tests only TOP-LEVEL candidate scalar strings: controlled() is typeof v === 'string' && CONTROL.test(v), so map-valued candidate fields (files, fileVerdicts) fail the typeof check and strings nested inside them are never seen — and this ledger copy loop persists every ledger-owned string (verdict, findings[]) unswept, while the comment above claims "Every persisted STRING is checked". The fileVerdicts case needs no tampering: fetch-pr records its keys byte-faithfully from ls-tree -z, and git permits control/invisible characters in filenames — a PR shipping a file named x<U+202E>y.ts puts that byte into a map key the sweep never looks at, persisted raw into the trusted cache.
Witness (probe through the real handler at this commit):
candidate fileVerdicts keyed 'x<U+202E>y.ts' -> NO refusal; raw U+202E persisted in a fileVerdicts key
ledger findings[] string with a C1 byte -> persisted unswept
AFTER recursive sweep over candidate keys+values and ledger strings -> all arms refused
Fix: sweep recursively — walk the candidate's fields and the ledger-owned copy, applying CONTROL to every string encountered, including object keys and nested array/object members.
中文说明
[Critical] R12-1:依然存在——已在本提交上用探测重新验证(本轮并被独立重新发现)。写入端扫描只检查顶层 candidate 标量字符串:controlled() 是 typeof v === 'string' && CONTROL.test(v),因此 map 类型的 candidate 字段(files、fileVerdicts)过不了 typeof 检查,其中嵌套的字符串永远不被检查——而本 ledger 复制循环把所有 ledger 所有的字符串(verdict、findings[])原样持久化、未经扫描,上方注释却声称"每一个被持久化的字符串都会被检查"。fileVerdicts 这一支无需篡改即可触发:fetch-pr 用 ls-tree -z 逐字节记录键,而 git 允许文件名含控制/不可见字符——一个提交 x<U+202E>y.ts 文件名的 PR 就把该字节送进扫描永不查看的 map 键,并被原样持久化进受信任缓存。证据(本提交上经真实 handler 探测):两个分支均未拒绝、裸字节落盘;递归扫描后所有分支均拒绝。修复:递归扫描——遍历 candidate 字段与 ledger 所有的副本,对遇到的每个字符串(含对象键、嵌套数组/对象成员)应用 CONTROL。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| '-c', | ||
| 'diff.algorithm=myers', | ||
| '-c', | ||
| 'diff.indentHeuristic=true', |
There was a problem hiding this comment.
[Critical] R4-1: Still stands — carried as a CLASS finding under this family's round-4 id, re-verified against git's own registry at this commit. The per-file verdict pair identity enumerates git's rendering knobs one entrance at a time, and the surface keeps regenerating. This round's diff pins diff.algorithm=myers and diff.indentHeuristic=true here — closing two entrances — but measured against git help -c: diff.interHunkContext and diff.renameLimit are real config keys, neither pinned by PINNED_DIFF_CONFIG nor recorded in the pair identity. (Correction to earlier rounds, verifier-measured: diff.compactionHeuristic and diff.<driver>.algorithm are NOT real git config keys — the class stands on the two real ones.)
Witness (probe at this commit):
identical blobs/mode/attributes:
defaults -> 2 @@ hunks
-c diff.interHunkContext=5 -> 1 @@ hunk
inexact renames:
defaults -> 3 similarity-index rename sections
-c diff.renameLimit=1 -> 0 sections ("exhaustive rename detection was skipped")
The identity cannot see either move: a clean verdict transfers over hunks rendered differently. Five rounds have each found new entrances in this family.
Fix: close the class structurally rather than entrance by entrance — pin/record the whole rendering surface (consult git's own config for the hunk-shape knobs, or capture the effective rendering state into the pair identity). Pinning the two real knobs above is the minimal down payment.
中文说明
[Critical] R4-1:依然存在——作为类级发现以本家族第 4 轮的 id 继续携带,并已在本提交上对照 git 自身注册表重新验证。每文件裁决的键对身份逐个入口地枚举 git 的渲染旋钮,而该表面不断再生。本轮 diff 在此固定了 diff.algorithm=myers 与 diff.indentHeuristic=true——关闭两个入口——但对照 git help -c 实测:diff.interHunkContext 与 diff.renameLimit 是真实配置键,既未被 PINNED_DIFF_CONFIG 固定,也未记录进键对身份。(对早前轮次的更正,经验证者实测:diff.compactionHeuristic 与 diff.<driver>.algorithm 并非真实 git 配置键——该类发现立于上述两个真实键之上。)证据(本提交上探测):相同 blob/mode/属性下,默认渲染 2 个 @@ hunk,-c diff.interHunkContext=5 渲染 1 个;近似 rename 场景默认渲染 3 个 similarity 段,-c diff.renameLimit=1 渲染 0 个。身份看不见这些差异:干净裁决会传递到渲染方式已变的 hunk 上。五轮评审每一轮都在这个家族里发现新入口。修复:从结构上关闭该类——固定/记录整个渲染表面;最低限度是先固定上述两个真实键。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it, and neither must a `--topology minimal` run at any effort — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. The reason is one reason, and it is why the rule is stated as a rule: the anchor's whole claim is "the next round may skip this", and a claim nobody ruled on is exactly what the next round must not skip — the round after it stops decided over a Critical that was never verified: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare model token (`YOUR_MODEL_ID`), which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the cache path for this target>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. **Where that path comes from differs by flow, and only one of them publishes it**: a LOCAL or file-path round reads it off the plan's `cachePath` — the capture derives the target and `safeTarget` is not hand-reproducible, so the name is not yours to spell — while the PR flow's plan carries no such field and the name is the one this section already gives, `.qwen/review-cache/pr-<n>.json`, which you already know from the PR number. Do not look for `cachePath` on a `fetch-pr` report; it has none, and treating its absence as a reason to skip the write would lose the PR cache entirely. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. **And these two, which complete the list**: a finding still marked `— [unverified]`, and an undecided blocker — a Critical whose verifier never returned, from a deadline stop or a timed-out batch. Neither enters `findings[]` (only confirmed findings do) and neither reads as "unreviewed scope", so without naming them a round promotes the anchor over a claim nobody ruled on and the round after it stops decided. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) |
There was a problem hiding this comment.
[Critical] R14-1: Still stands — re-verified at this commit (and independently re-discovered this round). The rewritten Step 8 broadens the local-only pre-promotion CHECK to "for PR and local alike" — an absent cacheCandidateStateId field on a plan that published a path is "treated exactly like a withheld candidate: skip the cache write and say so" — but a fetch-pr plan structurally cannot pass it: fetch-pr contains zero stateId occurrences; its candidate JSON is {v, target, lastCommitSha, mergeBaseSha, fileVerdicts, lastModelId} (no stateId), and the plan spread publishes cacheCandidatePath only — cacheCandidateStateId is published solely by capture-local.
Failure scenario: any clean high-effort PR round. A literal executor — which these docs instruct the model to be — applies the CHECK, finds no cacheCandidateStateId, and skips cache-commit; the hand-write fallback is keyed "when the plan carries no cacheCandidatePath" and never triggers for a healthy fetch-pr plan. Net effect: .qwen/review-cache/pr-<n>.json is never written again — a regression of the existing PR-cache persistence — contradicting this diff's own fetch-pr comment ("Step 8 promotes it into the review cache on a clean high-effort end").
Witness: not run — the defect is a prose instruction to the model executor, which no harness settles; the parts a run can settle were verified at HEAD: 0 stateId matches in fetch-pr.ts; capture-local.ts is the field's only publisher; the paragraph reads as quoted.
Fix: re-scope the CHECK to local/file rounds ("a fetch-pr plan publishes cacheCandidatePath without cacheCandidateStateId by design — its candidate carries no stateId — and that absence is the normal PR state, not a withheld candidate"), or give fetch-pr a stateId digest and publish a matching cacheCandidateStateId beside cacheCandidatePath.
中文说明
[Critical] R14-1:依然存在——已在本提交上重新验证(本轮并被独立重新发现)。重写后的 Step 8 把原本仅限 local 的晋升前 CHECK 扩展为"PR 与 local 一视同仁"——"发布了路径的计划上缺少 cacheCandidateStateId 字段"将"与 candidate 被扣留完全同等对待:跳过缓存写入并说明"——但 fetch-pr 的计划在结构上不可能通过它:fetch-pr 全文没有任何 stateId;其 candidate JSON 为 {v, target, lastCommitSha, mergeBaseSha, fileVerdicts, lastModelId}(无 stateId),计划展开只发布 cacheCandidatePath——cacheCandidateStateId 仅由 capture-local 发布。失败场景:任何干净收尾的高强度 PR 轮次。字面执行者(这些文档要求模型如此执行)应用该 CHECK,发现计划上没有 cacheCandidateStateId,于是跳过 cache-commit;手写回退以"计划未携带 cacheCandidatePath"为触发条件,而健康的 fetch-pr 计划永远携带它。净效果:.qwen/review-cache/pr-<n>.json 再也不会被写入——现有 PR 缓存持久化的回归——与本 diff 自己的 fetch-pr 注释相矛盾。证据:未运行——缺陷是给模型执行者的文档指令,任何测试床都无法裁决;可运行的部分已在 HEAD 上核实。修复:把该 CHECK 限定回 local/文件轮次,或给 fetch-pr 一个 stateId 摘要并在 cacheCandidatePath 旁发布匹配的 cacheCandidateStateId。
— qwen3.8-max via Qwen Code /review (v0.22.0)
…e anchor, certify gitlinks R20-1: `core.fileMode` was read with a raw `--get`, which echoes the stored spelling, so `off`/`no`/`0`/`FALSE` all failed the `!== 'false'` test and silently disabled the exec fold — the same misreading R18-2 fixed for `core.sparseCheckout`, at the call site that fix did not touch. Read `--type=bool`, and fold only on an EXPLICIT false: the knob defaults to true and an unset one must not erase a real divergence. R20-2: a FILE review's subject entered the hashed population unconditionally, so `qwen review <dir>` recorded the directory itself as UNHASHABLE in every candidate. UNHASHABLE never equals itself, so `changedSince` reported the directory every round and the unchanged-since stop was unreachable for that target for ever — the very non-convergence `movedSince` was added to close. A confirmed directory is skipped now; its files carry the bytes, and an unmeasurable subject keeps the pre-existing coverage. R20-3: a submodule gitlink is UNHASHABLE on both sides by design, so the both-unhashable refusal wedged the loop for ever once a round had touched one. Git measures submodules itself and the pinned flags keep them in the capture, so a gitlink's absence from the diff is git's own answer that the pointer did not move. Which paths are gitlinks is asked of `ls-tree`, not inferred from the placeholder they share with undecodable names — those still refuse. R20-5, disclosed rather than folded: `core.symlinks=false` erases the file↔symlink type the same way, and the comment here claimed the type survives every fileMode — true of fileMode, false of this knob. A mode fold does NOT close it: the two spellings also differ in whether they carry rendering attributes at all, and equalizing those would drop the rendering dimension for that path. The bounded over-review stands, now pinned by a test so it cannot become a silent certification. R20-4 is pre-existing and NOT introduced here — before this PR the file token was the basename, so a `local` at ANY depth collided; the repo-relative derivation narrowed that to the repo root. The general class is tracked in #10057. Step 9 gains the cheap guard meanwhile: a file review whose token derives to a reserved name must not run `cleanup` at all, and removes only what it wrote. Every code fix mutation-checked red.
Import-line conflict only: the base added gitRaw + LITERAL_PATHSPECS for the gitlink certification while this branch had added inertText — all three kept. Everything else (bool-typed fileMode read, the directory subject exclusion, the gitlink certification, Step 9's reserved-token cleanup guard) merges clean.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
10 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- R1-7 seed() auto-injection makes the missing-lastModelId gate arm unreachable — already reported (cache-commit.test.ts:59, rounds 1-2)
- R2-4 fileForm digest-mismatch refusal untested on its refusal side — already reported (cache-commit.ts:173, round 2)
- R11-3 stale allowlist docblock above LEDGER_FIELDS contradicts the deny-list — already disclosed (cache-commit.ts:67, round-8/11/12/16 deferral lists)
- R1-14 --ledger help text lists lastModelId, contradicting the SKILL.md paragraph — already reported (cache-commit.ts:250, rounds 1-2)
- R11-8 inert-text.test.ts committed with raw control bytes (git classifies it binary) — already disclosed (round-11/16 deferral lists)
- R11-12 inert-text pre-escape doubles the backslash for DEL/C1/Cf/Zl — already disclosed (inert-text.ts:82, round-11 deferral list)
- R1-19 displayAnchor docblock names the phantom rescope renderer — already reported (report.ts:334, rounds 1-2)
- R1-4 fetch-pr candidate producer block has zero test coverage — already reported (fetch-pr.ts:1627, comment 3831106942)
- R11-4 new diff-config pins unasserted in diff-flags.test.ts — already disclosed (round-11/16 deferral lists)
- R1-20/R2-23b fallback routes withheld local rounds into the hand-write template — already reported (persistence.md:77, rounds 1-2); the round-3 escalation attempt (B1) was rejected by probe: the hand-written PR-shaped template cannot anchor…
Not reviewed: test-efficacy probe — all probes inconclusive (the probe runner cannot satisfy this repo's vitest globalSetup build guard, files this PR does not touch); harnessValidated: null (the positive control produced no verdict — no green baseline in the probe tree). Mutation claims this round were settled by the verifiers' own probes..
Deferred under the convergence posture (round 17, not a blocker) — recorded, not requested in this round:
packages/cli/src/commands/review/cache-commit.ts:121 — [probe] refusing the whole promotion on an empty candidate lastModelId also discards the round's ledger — a non-posting local round in a runtime with no published identity loses its fin…packages/cli/src/commands/review/cache-commit.test.ts:101 — [probe] the command-owned lastReviewDate stamp is pinned only as 'a string' and 'not the ledger's 1999 value' — module-scope hoist and literal-placeholder mutants both ship greenpackages/core/src/skills/bundled/review/references/persistence.md:77 — [review] 'And these two, which complete the list' asserts the withholding set is closed two sentences after 'The examples are the set as written, not the gate ... Anythi…
Convergence: round 17 posted 7 inline comment(s), 2 of them reported for the first time; the previous round posted 5 (0 new). Findings keep coming back to the same files: packages/cli/src/commands/review/cache-commit.ts (findings in rounds 8, 12; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)
Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)
Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (10 Critical(s)), the rate of first-time findings is not falling (this round 2, previous 0), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):
| standing Critical | attack surface | attacker-dependency | blast radius |
|---|---|---|---|
| (each standing Critical) | … | … | … |
Advisory only — it does not block this review.
[Critical] R12-2 (still stands — re-verified at this commit): the read side the anchor's trust lands on is unguarded — readLocalCache (lib/local-anchor.ts:645) is a plain readFileSync with shape-only validation and no lstat/symlink refusal (the file's lstat uses at :153/:599 are the worktree identity oracle and the vanished-path check, not the cache read), and resolveCachePath probes with following statSync/existsSync — while this PR polices the WRITE side of the same deterministic .qwen/review-cache path against exactly this threat. A forged cache planted as .qwen/review-cache/local.json (a symlink — git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: the round skips scope the attacker's stateId certifies as unchanged. Gate-passing forgery requires state knowledge, but HEAD is known when reviewing an attacker branch checkout and the model id is guessable — validation is shape/token-deep, as the PR's own comments concede. Witness: prior-round probe driving the real command end to end at this same code — forged cache planted as a symlink at .qwen/review-cache/local.json -> plan.incremental present (anchor honoured): true; deltaFiles over a fully-dirty tree: []; stderr: 'No changes since the last local review round (same model, same HEAD, same content) — nothing to re-review.'; symlink still in place after the read: true. Flip: lstatSync(path).isSymbolicLink() refusal in readLocalCache -> plan.incremental present: false; stderr: 'Incremental anchor not used — the cache is missing or unreadable. Running the full local review.' Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side. Unanchorable inline (the defect line is outside this diff's hunks), hence in the body.
[Critical] R6-2 (still stands — re-verified at this commit; defect lines paths.ts:297-317 outside this diff's hunks, hence in the body): repoRelativeOf returns relative(repoRoot, abs) unnormalized — backslash separators on Windows (only the escapes check consults sep; canonicalise resolves symlinks, not separators) — and capture-local stores it raw as sourcePath, persisted as the candidate source, which feeds the cache filename digest (file-<token>-<sha256(source)[:8]>.json in cache-commit) and the cross-machine source gate, both of which assume git's slash form. Same-machine Windows loop is self-consistent, but CROSS-MACHINE is permanently broken — a Windows-written cache (source: 'src\foo.ts', digest 36da7ba9 vs the slash form's 0c92983c) consumed by a slash machine is refused through the real gate ('the cache belongs to source path src\foo.ts, not src/foo.ts' / digest-mismatched filename 'missing or unreadable'), permanently degrading file-path reviews; WSL/machine cache movement mismatches the same way. The PR's own suite pins the slash contract (capture-local.incremental.test.ts, not win32-skipped). Witness: not run — the Windows surface cannot be executed on this Linux host; triangulated via Node's win32 implementation (path.win32.relative('C:\repo','C:\repo\src\foo.ts') -> src\foo.ts vs posix src/foo.ts; digests differ 36da7ba9 vs 0c92983c), the HEAD trace showing zero normalization between that return (paths.ts:303) and the candidate write, and the round-6 Windows run's observed test failure AssertionError: expected src\foo.ts to be src/foo.ts. Fix: normalise rel to forward slashes in repoRelativeOf before returning (rel.split(sep).join('/')), matching the existing pattern in local-diff.ts; the escapes check above already uses sep and is unaffected.
[Critical] R10-3 (still stands — re-verified at this commit; defect lines capture-local.ts:1255 and fetch-pr.ts:1791 outside this diff's hunks, hence in the body): the plan report carrying the cacheCandidatePath field is written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds. The plan's cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself, and the stateId CHECK compares two values both read off the plan — a substituted plan controls both sides. With .qwen/tmp committed as a symlink (the PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — anyone with write access to the link target substitutes the machine-readable record that orchestrates the round (fabricating cacheCandidatePath even after the capture withheld the real candidate). The unguarded class extends to the sibling .qwen/tmp writers (diff.txt, stop.json, fullDiffPath — prior-round findings R8-3/R1-15/R12-3 fold in here). Witness: prior-round probe at this same code (.qwen/tmp symlinked to a victim dir, driving the real captureLocalCommand.handler): plan_landed_in_victim: true, candidate_landed_in_victim: false, plan_cacheCandidatePath: null; after substituting the victim's plan: stateId_CHECK_passed: true, promoted_stateId: 'forged-state-id', promoted_lastModelId: 'forged-model@bbbbbbbb', promoted_verdict: 'Approve'. Fixed arm (assertUnredirectedParent + noFollow on the plan write): handler threw 'a symlink in the path would redirect this write', plan_landed_in_victim: false. Fix: route the plan write through assertUnredirectedParent(out, ...) + atomicWriteFileSync(out, ..., { noFollow: true }) in both captures.
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 10 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查:test-efficacy probe — all probes inconclusive (the probe runner cannot satisfy this repo's vitest globalSetup build guard, files this PR does not touch); harnessValidated: null (the positive control produced no verdict — no green baseline in the probe tree). Mutation claims this round were settled by the verifiers' own probes.。
收敛姿态下延后(第 17 轮,非阻断)——已记录,本轮不要求修改:共 3 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 17 轮发布了 7 条行内评论,其中 2 条是首次提出;上一轮发布了 5 条(其中 0 条首次提出)。发现反复回到同一批文件:packages/cli/src/commands/review/cache-commit.ts(第 8、12 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)
机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)
残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 10 条 Critical),首次发现的速率没有下降(本轮 2,上一轮 0),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。
[Critical] R12-2 (still stands — re-verified at this commit): the read side the anchor's trust lands on is unguarded — readLocalCache (lib/local-anchor.ts:645) is a plain readFileSync with shape-only validation and no lstat/symlink refusal (the file's lstat uses at :153/:599 are the worktree identity oracle and the vanished-path check, not the cache read), and resolveCachePath probes with following statSync/existsSync — while this PR polices the WRITE side of the same deterministic .qwen/review-cache path against exactly this threat. A forged cache planted as .qwen/review-cache/local.json (a symlink — git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: the round skips scope the attacker's stateId certifies as unchanged. Gate-passing forgery requires state knowledge, but HEAD is known when reviewing an attacker branch checkout and the model id is guessable — validation is shape/token-deep, as the PR's own comments concede. Witness: prior-round probe driving the real command end to end at this same code — forged cache planted as a symlink at .qwen/review-cache/local.json -> plan.incremental present (anchor honoured): true; deltaFiles over a fully-dirty tree: []; stderr: 'No changes since the last local review round (same model, same HEAD, same content) — nothing to re-review.'; symlink still in place after the read: true. Flip: lstatSync(path).isSymbolicLink() refusal in readLocalCache -> plan.incremental present: false; stderr: 'Incremental anchor not used — the cache is missing or unreadable. Running the full local review.' Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side. Unanchorable inline (the defect line is outside this diff's hunks), hence in the body.
[Critical] R6-2 (still stands — re-verified at this commit; defect lines paths.ts:297-317 outside this diff's hunks, hence in the body): repoRelativeOf returns relative(repoRoot, abs) unnormalized — backslash separators on Windows (only the escapes check consults sep; canonicalise resolves symlinks, not separators) — and capture-local stores it raw as sourcePath, persisted as the candidate source, which feeds the cache filename digest (file-<token>-<sha256(source)[:8]>.json in cache-commit) and the cross-machine source gate, both of which assume git's slash form. Same-machine Windows loop is self-consistent, but CROSS-MACHINE is permanently broken — a Windows-written cache (source: 'src\foo.ts', digest 36da7ba9 vs the slash form's 0c92983c) consumed by a slash machine is refused through the real gate ('the cache belongs to source path src\foo.ts, not src/foo.ts' / digest-mismatched filename 'missing or unreadable'), permanently degrading file-path reviews; WSL/machine cache movement mismatches the same way. The PR's own suite pins the slash contract (capture-local.incremental.test.ts, not win32-skipped). Witness: not run — the Windows surface cannot be executed on this Linux host; triangulated via Node's win32 implementation (path.win32.relative('C:\repo','C:\repo\src\foo.ts') -> src\foo.ts vs posix src/foo.ts; digests differ 36da7ba9 vs 0c92983c), the HEAD trace showing zero normalization between that return (paths.ts:303) and the candidate write, and the round-6 Windows run's observed test failure AssertionError: expected src\foo.ts to be src/foo.ts. Fix: normalise rel to forward slashes in repoRelativeOf before returning (rel.split(sep).join('/')), matching the existing pattern in local-diff.ts; the escapes check above already uses sep and is unaffected.
[Critical] R10-3 (still stands — re-verified at this commit; defect lines capture-local.ts:1255 and fetch-pr.ts:1791 outside this diff's hunks, hence in the body): the plan report carrying the cacheCandidatePath field is written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds. The plan's cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself, and the stateId CHECK compares two values both read off the plan — a substituted plan controls both sides. With .qwen/tmp committed as a symlink (the PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — anyone with write access to the link target substitutes the machine-readable record that orchestrates the round (fabricating cacheCandidatePath even after the capture withheld the real candidate). The unguarded class extends to the sibling .qwen/tmp writers (diff.txt, stop.json, fullDiffPath — prior-round findings R8-3/R1-15/R12-3 fold in here). Witness: prior-round probe at this same code (.qwen/tmp symlinked to a victim dir, driving the real captureLocalCommand.handler): plan_landed_in_victim: true, candidate_landed_in_victim: false, plan_cacheCandidatePath: null; after substituting the victim's plan: stateId_CHECK_passed: true, promoted_stateId: 'forged-state-id', promoted_lastModelId: 'forged-model@bbbbbbbb', promoted_verdict: 'Approve'. Fixed arm (assertUnredirectedParent + noFollow on the plan write): handler threw 'a symlink in the path would redirect this write', plan_landed_in_victim: false. Fix: route the plan write through assertUnredirectedParent(out, ...) + atomicWriteFileSync(out, ..., { noFollow: true }) in both captures.
— qwen3.8-max via Qwen Code /review (v0.22.2)
| // `undefined === undefined`, and a clean verdict transferred over a | ||
| // rendering neither round ever certified. Failing closed costs a | ||
| // re-review; failing open costs the review. | ||
| out[p].attrs = attrs[p] ?? UNANSWERED_ATTRS; |
There was a problem hiding this comment.
[Critical] R8-1: Still stands — re-verified at this commit, and independently re-discovered again this round. blobPairs records renderingAttributes' UNHASHABLE answer — the literal string 'unhashable', returned for a .gitattributes diff answer of set/unset, an undecodable driver name, or the unspecified answer with diff.unspecified.binary configured — verbatim as the pair's comparable attrs component: attrs[p] ?? UNANSWERED_ATTRS catches only undefined. changedPairs treats only 'unanswered' as never-equal, so 'unhashable' === 'unhashable' passes: a repository with *.dat diff=unset in .gitattributes reviews round 1 with diff.unset.binary=true configured (rendering 'Binary files differ') and round 2 with that config flipped — git diff emits full hunks for byte-identical blobs while the file is reported unchanged, and the clean verdict transfers over hunks no round ever read. The same collapse lets an untracked worktree-local .gitattributes swap *.dat -diff <-> *.dat diff=unset invisibly between rounds — exactly the threat the attrs component exists to catch. Witness: prior-round probe at this same code — the UNHASHABLE-recorded round and the config-flipped round compare unchanged, while the module's own changedSince gives UNHASHABLE the never-equal treatment (a !== UNHASHABLE) that changedPairs withholds; re-derived independently this round against local-anchor.ts:393-394. Give UNHASHABLE the same never-equal treatment at the comparison site, so cache records already holding 'unhashable' are also covered:
if (
rec.attrs === UNANSWERED_ATTRS ||
cur.attrs === UNANSWERED_ATTRS ||
rec.attrs === UNHASHABLE ||
cur.attrs === UNHASHABLE ||
rec.attrs !== cur.attrs
) {(with UNHASHABLE imported from ./local-anchor.js). Fix witness: a collocated file-verdicts.test.ts case with *.dat diff=unset must return the path from changedPairs even against a byte-identical re-probe — delete the two UNHASHABLE comparisons and run it; it must go red.
中文说明
[Critical] R8-1:依然存在——已在本提交上重新验证,且本轮再次被独立重新发现。blobPairs 把 renderingAttributes 的 UNHASHABLE 答案(字面字符串 'unhashable',在 .gitattributes 的 diff 答案为 set/unset、驱动名无法解码、或配置了 diff.unspecified.binary 时的 unspecified 答案时返回)原样记录为配对中可比较的 attrs 分量:attrs[p] ?? UNANSWERED_ATTRS 只捕获 undefined。changedPairs 只把 'unanswered' 视为永不相等,于是 'unhashable' === 'unhashable' 通过:.gitattributes 含 *.dat diff=unset 的仓库,第 1 轮在配置 diff.unset.binary=true 下评审(渲染为 'Binary files differ'),第 2 轮翻转该配置——blob 逐字节相同,git diff 却输出完整 hunk,而该文件被报告为未变,干净的裁决转移到了任何一轮都未曾读过的 hunk 之上。同样的塌缩还允许未跟踪的工作区本地 .gitattributes 在轮次之间无声地把 *.dat -diff 换成 *.dat diff=unset——这正是 attrs 分量存在要防的威胁。证据:前几轮在同一代码上的探测——记录 UNHASHABLE 的轮次与翻转配置的轮次比较为未变,而模块自己的 changedSince 对 UNHASHABLE 给予了 changedPairs 所拒绝给予的永不相等处理(a !== UNHASHABLE);本轮又针对 local-anchor.ts:393-394 独立重新推导。修复:在比较点给 UNHASHABLE 同样的永不相等处理(代码见上,UNHASHABLE 从 ./local-anchor.js 导入),使已持有 'unhashable' 的缓存记录也被覆盖。修复见证:在配套的 file-verdicts.test.ts 中,含 *.dat diff=unset 的用例即使对逐字节相同的重新探测也必须从 changedPairs 返回该路径——删除两个 UNHASHABLE 比较后运行,必须变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| const controlled = (v: unknown): boolean => | ||
| typeof v === 'string' && CONTROL.test(v); | ||
| for (const key of candidateFieldsOf(candidate)) { | ||
| if (controlled(candidate[key])) { |
There was a problem hiding this comment.
[Critical] R8-4: Still stands — re-verified by probe at this commit. The refuse-at-write CONTROL sweep tests only candidate VALUES — never KEYS — and its refusal interpolates the unchecked key raw into an error that reaches the operator's terminal, while every other untrusted string in this file's error paths goes through inertText. A tampered candidate at the deterministic in-repo path whose KEY carries ESC/C1/Cf bytes is never checked: with a clean value nothing throws and the raw key is persisted as a key of the promoted cache (JSON.stringify passes C1 bytes through verbatim); with a control-charactered value the refusal message emits the raw key at the terminal — and SKILL.md tells the orchestrator to repeat stderr lines into model context. The guard's own error path emits the exact forgery class the guard exists to refuse. Witness: prior-round probe through the real handler — key 'k\u001b[31m' + clean value -> promoted OK, raw C1 byte present in a cache KEY; key 'k\u001b[31m' + control value -> refusal message contains raw ESC; with the fix below both arms flip. Also escape the key in the thrown error (${inertText(key)}).
| if (controlled(candidate[key])) { | |
| if (controlled(key) || controlled(candidate[key])) { |
Fix witness: a candidate whose KEY carries U+001B must be refused with no cache file written; remove controlled(key) || and the new test must go red.
中文说明
[Critical] R8-4:依然存在——已在本提交上用探测重新验证。写入端的 CONTROL 扫描只检查 candidate 的值——从不检查键——且其拒绝信息把未检查的键原样插入错误文本,直达操作者终端;同一文件其他所有错误路径中的不可信字符串都经过 inertText。确定性仓库内路径上被篡改的 candidate,其键携带 ESC/C1/Cf 字节时从不被检查:配干净值则不抛错,裸键被原样持久化为晋升缓存的键(JSON.stringify 对 C1 字节原样透出);配控制字符值则拒绝信息把裸键射向终端——而 SKILL.md 要求 orchestrator 把 stderr 行复述进模型上下文。守卫自身的错误路径吐出的正是它存在要拒绝的那类伪造。证据:前几轮经真实 handler 的探测——键 'k\u001b[31m' + 干净值 -> 晋升成功,缓存键中含裸 C1 字节;键 'k\u001b[31m' + 控制字符值 -> 拒绝信息携带裸 ESC;应用下方修复后两个分支均翻转。抛错处的键同样要转义(${inertText(key)})。修复见证:键携带 U+001B 的 candidate 必须被拒绝且不落盘;移除 controlled(key) || 后新测试必须变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| // something a reader needs. | ||
| const merged: Record<string, unknown> = {}; | ||
| for (const key of LEDGER_FIELDS) { | ||
| if (key in ledger) merged[key] = ledger[key]; |
There was a problem hiding this comment.
[Critical] R12-1: Still stands — re-verified by probe at this commit (and independently re-discovered this round). The refuse-at-write sweep tests only TOP-LEVEL candidate scalar strings: controlled() is typeof v === 'string' && CONTROL.test(v) iterated over candidateFieldsOf(candidate), so map-valued candidate fields (files, fileVerdicts) fail the typeof check and strings nested inside them are never seen — and this ledger copy loop persists every ledger-owned string (verdict, nested findings[] content) unswept, while the comment above claims 'Every persisted STRING is checked'. A tampered candidate carrying \u001b[31m or \u202e inside a files/fileVerdicts map value or key sails through controlled() (the value is an object, not a string) and lands raw in .qwen/review-cache/; the ledger's strings carry the same hole. Witness: prior-round probe through the real handler — nested control bytes in files persisted raw while the top-level ESC arm refused; both arms flip with a recursive sweep. Walk both inputs recursively (keys and string values at any depth) and refuse on any CONTROL match — a small collectStrings walk feeding the existing controlled check — or rewrite the comment to state that only top-level candidate scalars are swept and why. Fix witness: a candidate with a control character in a files key and value, and a ledger with a control-charactered verdict, must each be refused with no cache file; removing the recursion must turn them red.
中文说明
[Critical] R12-1:依然存在——已在本提交上用探测重新验证(且本轮再次被独立重新发现)。写入端扫描只检查顶层 candidate 标量字符串:controlled() 是对 candidateFieldsOf(candidate) 逐个执行的 typeof v === 'string' && CONTROL.test(v),因此 map 类型的 candidate 字段(files、fileVerdicts)过不了 typeof 检查,其中嵌套的字符串永远不被检查——而这段 ledger 复制循环把所有 ledger 所有的字符串(verdict、嵌套的 findings[] 内容)未经扫描地持久化,尽管上方注释声称"每一个被持久化的字符串都会被检查"。被篡改的 candidate 在 files/fileVerdicts 的键或值中携带 \u001b[31m 或 \u202e 时,controlled() 看到的是对象而非字符串,直接放行,原样落入 .qwen/review-cache/;ledger 的字符串有同样的漏洞。证据:前几轮经真实 handler 的探测——files 中的嵌套控制字符被原样持久化,而顶层 ESC 分支被拒绝;改为递归扫描后两个分支均翻转。修复:递归遍历两个输入的所有键与任意深度的字符串值,命中 CONTROL 即拒绝;或把注释改写为只扫顶层 candidate 标量并说明原因。修复见证:files 键值携带控制字符的 candidate、verdict 携带控制字符的 ledger,都必须被拒绝且不落盘;移除递归后这些测试必须变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| '-c', | ||
| 'diff.algorithm=myers', | ||
| '-c', | ||
| 'diff.indentHeuristic=true', |
There was a problem hiding this comment.
[Critical] R4-1: Still stands — carried as a CLASS finding under this family's round-4 id, re-verified against git's own registry at this commit (ledger R9-3 diff.renameLimit and R9-4 driver-scoped diff.<driver>.algorithm entrances remain folded in as evidence, not re-filed). The per-file verdict pair identity enumerates git's rendering knobs one entrance at a time, and the surface keeps regenerating — five more rounds have each found new entrances. This round's diff pins diff.algorithm=myers and diff.indentHeuristic=true here — closing two entrances — but diff.interHunkContext, diff.compactionHeuristic, diff.renameLimit, and driver-scoped diff.<driver>.algorithm/xfuncname remain neither pinned nor recorded in the pair identity (grep confirms zero occurrences across the review command family at this commit). A reviewer or machine whose git config sets any of them renders hunks with different boundaries — or merges/splits adjacent hunks — while every <mode> <oid> identity and attrs component stands still; changedPairs reports the file unchanged and the recorded verdict transfers over a rendering different from the one that certified it. Witness: re-verified at this commit — grep over packages/cli/src/commands/review finds no occurrence of interHunkContext/compactionHeuristic/renameLimit; PINNED_DIFF_CONFIG holds four entries, none of them these. Fix the class structurally rather than entrance by entrance: derive the rendering dimension of the identity from git's own authoritative answer for the whole knob surface, or pin the complete rendering-relevant config surface in one enumerated constant with a registry cross-check test. Fix witness: a test asserting the pinned/recorded surface against git's own registry of rendering-affecting diff knobs must go red when any one knob is dropped.
中文说明
[Critical] R4-1:依然存在——作为该家族第 4 轮 id 下的类发现继续携带,并已在本提交上对照 git 自身的注册表重新验证(账本中的 R9-3 diff.renameLimit 与 R9-4 driver 作用域的 diff.<driver>.algorithm 入口仍作为证据折叠在内,不再单独重报)。每文件裁决的配对身份逐个入口地枚举 git 的渲染旋钮,而这个表面持续再生——此后每一轮都各自发现了新入口。本轮 diff 在此处固定了 diff.algorithm=myers 与 diff.indentHeuristic=true——关闭了两个入口——但 diff.interHunkContext、diff.compactionHeuristic、diff.renameLimit 以及 driver 作用域的 diff.<driver>.algorithm/xfuncname 仍然既未固定也未记录进配对身份(grep 证实本提交上整个 review 命令族中零次出现)。任何 git 配置了这些旋钮之一的评审者或机器,渲染出的 hunk 边界会不同——或合并/拆分相邻 hunk——而所有 <mode> <oid> 身份与 attrs 分量都静止不动;changedPairs 报告文件未变,已记录的裁决转移到与认证时不同的渲染之上。证据:本提交上重新验证——对 packages/cli/src/commands/review 的 grep 未发现 interHunkContext/compactionHeuristic/renameLimit 的任何出现;PINNED_DIFF_CONFIG 只有四个条目,均不含这些。修复应面向类而非逐个入口:让身份的渲染维度取自 git 对整个旋钮表面的权威答案,或把完整的渲染相关配置表面固定在一个枚举常量里,并配一个对照注册表的交叉检查测试。修复见证:对照 git 自身"影响渲染的 diff 旋钮"注册表断言已固定/已记录表面的测试,在任何一个旋钮被删除后必须变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it, and neither must a `--topology minimal` run at any effort — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. The reason is one reason, and it is why the rule is stated as a rule: the anchor's whole claim is "the next round may skip this", and a claim nobody ruled on is exactly what the next round must not skip — the round after it stops decided over a Critical that was never verified: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare model token (`YOUR_MODEL_ID`), which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the cache path for this target>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. **Where that path comes from differs by flow, and only one of them publishes it**: a LOCAL or file-path round reads it off the plan's `cachePath` — the capture derives the target and `safeTarget` is not hand-reproducible, so the name is not yours to spell — while the PR flow's plan carries no such field and the name is the one this section already gives, `.qwen/review-cache/pr-<n>.json`, which you already know from the PR number. Do not look for `cachePath` on a `fetch-pr` report; it has none, and treating its absence as a reason to skip the write would lose the PR cache entirely. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. **And these two, which complete the list**: a finding still marked `— [unverified]`, and an undecided blocker — a Critical whose verifier never returned, from a deadline stop or a timed-out batch. Neither enters `findings[]` (only confirmed findings do) and neither reads as "unreviewed scope", so without naming them a round promotes the anchor over a claim nobody ruled on and the round after it stops decided. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) |
There was a problem hiding this comment.
[Critical] R14-1: Still stands — re-verified at this commit (and independently re-confirmed this round by the issue-fidelity incident replay). The rewritten Step 8 broadens the local-only pre-promotion CHECK to 'for PR and local alike' — an absent cacheCandidateStateId field on a plan that published a path is 'treated exactly like a withheld candidate: skip the cache write and say so' — but a fetch-pr plan structurally cannot pass it: cacheCandidateStateId's sole publisher in the whole tree is capture-local.ts:1250; fetch-pr.ts contains zero occurrences and its plan carries cacheCandidatePath only. Every PR review round therefore lands in the absent-field arm and skips the write — .qwen/review-cache/pr-<n>.json is never persisted through the mechanical path, and the PR flow silently loses the very rebase-survival storage this PR exists to build. Replaying the PR's own headline incident ('a rebase that leaves a file's bytes untouched should not cost that file a re-review') against the post-change workflow completes it unchanged at this HEAD, step for step. Witness: grep at HEAD 6fdbac2 — cacheCandidateStateId appears only in capture-local.ts (producer, type, test). Publish cacheCandidateStateId from fetch-pr beside cacheCandidatePath, or narrow the CHECK back to the local/file flows whose plans publish the field. Fix witness: a fetch-pr test asserting the plan publishes cacheCandidateStateId whenever it publishes cacheCandidatePath, plus an end-to-end promotion through cacheCommitCommand.handler that does not skip.
中文说明
[Critical] R14-1:依然存在——已在本提交上重新验证(且本轮由 issue 忠实性事件重放独立再次确认)。重写后的 Step 8 把原本仅限 local 的晋升前 CHECK 扩展为"PR 与 local 一视同仁"——发布了路径却没有 cacheCandidateStateId 字段的计划"视同被扣留的 candidate:跳过缓存写入并说明"——但 fetch-pr 的计划在结构上不可能通过它:整棵树中 cacheCandidateStateId 的唯一发布者是 capture-local.ts:1250;fetch-pr.ts 零次出现,其计划只携带 cacheCandidatePath。因此每一轮 PR 评审都会落入"字段缺失"分支并跳过写入——.qwen/review-cache/pr-<n>.json 永远不会经机械路径持久化,PR 流程无声地失去本 PR 存在要构建的 rebase 存活存储。把 PR 自己的标题事件("rebase 没有改动字节就不该让该文件重审")在改动后的工作流上逐步重放,在本 HEAD 上原样走完、毫无变化。证据:在 HEAD 6fdbac2 上 grep——cacheCandidateStateId 只出现在 capture-local.ts(生产者、类型、测试)。修复:让 fetch-pr 在发布 cacheCandidatePath 的同时发布 cacheCandidateStateId,或把 CHECK 收窄回计划会发布该字段的 local/文件流程。修复见证:一个 fetch-pr 测试断言凡发布 cacheCandidatePath 必同时发布 cacheCandidateStateId,外加一个不跳过、端到端经 cacheCommitCommand.handler 晋升的用例。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| atomicWriteFileSync(args.out, `${JSON.stringify(merged, null, 2)}\n`, { | ||
| noFollow: true, | ||
| }); | ||
| writeStdoutLine(`Committed review cache to ${args.out}`); |
There was a problem hiding this comment.
[Critical] R15-4: Still stands — re-verified at this commit. The intake sweep polices every candidate VALUE but never the command's own CLI path arguments, and this success line prints the unswept --out raw — the one print site in a file that escapes every other untrusted string through inertText. --out is not a trusted constant: in the PR flow Step 8 has the reviewing model spell it, and that model's context is the attacker-authored PR; in local/file flow it is the plan's cachePath, which the R10-3 probe demonstrated is substitutable via the unguarded .qwen/tmp plan write. It is also never confined to .qwen/review-cache/. A --out carrying C1/Cf/ESC bytes passes all checks, then this raw stdout write emits them at the operator's terminal on every promotion — the same demonstrated forgery class as R8-4 — and mkdirSync + atomicWriteFileSync persist those bytes as a live cache directory/file NAME, anywhere the reviewer can write whose basename matches the target contract. Witness: round-15 probe through the real handler — control-byte --out -> promotion SUCCEEDED, stdout line carried raw U+009B, control-byte directory and cache file created; ESC variant -> raw ESC on the success line; --out outside .qwen/review-cache/ -> NO refusal; inertText(args.out) flips the stdout arm. Extend the refusal sweep to the path arguments (reject a --out carrying CONTROL, matching the file's refuse-at-the-writing-end posture), print it through inertText, and validate it resolves inside the repo's .qwen/review-cache/ before mkdirSync.
| writeStdoutLine(`Committed review cache to ${args.out}`); | |
| writeStdoutLine(`Committed review cache to ${inertText(args.out)}`); |
Fix witness: a control-byte --out must be refused (or at minimum echoed escaped) and write nothing; removing the sweep/escape must turn the new test red.
中文说明
[Critical] R15-4:依然存在——已在本提交上重新验证。入口扫描审查了每一个 candidate 的值,却从不检查命令自己的 CLI 路径参数,而这行成功提示把未经扫描的 --out 原样打印——这是该文件中唯一不走 inertText 的打印点。--out 不是可信常量:PR 流程中 Step 8 让评审模型拼写它,而该模型的上下文正是攻击者撰写的 PR;local/文件流程中它是计划里的 cachePath,R10-3 的探测已证明该值可经无守卫的 .qwen/tmp 计划写入被替换。它也从未被限制在 .qwen/review-cache/ 内。携带 C1/Cf/ESC 字节的 --out 通过所有检查,随后这行裸 stdout 写入在每次晋升时把序列射向操作者终端——与 R8-4 已被证实的伪造同类——且 mkdirSync + atomicWriteFileSync 把这些字节持久化为真实存在的缓存目录/文件名,落点可以是评审者权限内任何基名符合 target 契约的路径。证据:第 15 轮经真实 handler 的探测——控制字符 --out -> 晋升成功、标准输出行携带裸 U+009B、控制字符目录与缓存文件被创建;ESC 变体 -> 成功行吐出裸 ESC;--out 指向 .qwen/review-cache/ 之外 -> 未拒绝;inertText(args.out) 翻转标准输出分支。修复:把拒绝扫描扩展到路径参数(拒绝携带 CONTROL 的 --out,与本文件"在写入端拒绝"的姿态一致),如需回显则经 inertText 打印,并在 mkdirSync 前校验其解析结果位于仓库的 .qwen/review-cache/ 之内。修复见证:控制字符 --out 必须被拒绝(至少回显须被转义)且不落盘;移除扫描/转义后新测试必须变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| inertText((err as Error).message), | ||
| ); | ||
| } | ||
| if (real !== parent) { |
There was a problem hiding this comment.
[Critical] R11-13: Still stands — re-verified at this commit (and independently re-derived this round by the round-4 chunk-7 auditor). assertUnredirectedParent — the shared parent-chain guard this PR adds and wires into all three writers — detects redirection by EXACT STRING comparison of a lexical path (resolve(dirname(target)), built on process.cwd() case-as-typed) against realpathSync's canonical form, with no case folding or platform branch. On case-insensitive/redirected filesystems — Windows NTFS case mismatch (cd c:\dev\myrepo over C:\Dev\MyRepo), SUBSTed drives, junction/OneDrive-redirected prefixes, benign symlinked prefixes above the repo root — the two spellings legitimately differ with no planted symlink: capture-local/fetch-pr catch the throw and withhold the cache candidate every round, and cache-commit exits non-zero (the guard call has no try/catch — the throw escapes runCacheCommit, exit code 1). The rebase-survival cache this PR exists to build is dead on every such machine, with a diagnostic blaming a link that does not exist. Linux CI is case-sensitive with a kernel-resolved physical cwd, and the new tests construct roots via realpathSync(mkdtempSync(...)) — exactly the shape that always passes — so this can never surface in CI. Same 'permanently degrades reviews on a whole platform' shape as R6-2, via a different mechanism. Witness: round-11 probe (real code + real FS) — with a benign symlinked prefix above the repo the guard via link-spelling THREW while via physical spelling it PASSED; modeled-win32 case divergence driven through the real unmodified guard throws on both scenario tests; a one-line case-insensitive comparison flips both to pass; real cacheCommitCommand.handler exits 1. The repo already owns the pattern: lib/path-rules.ts:127-128 names this exact bug class, and lib/worktree.ts's redirectedAncestor is the house lstat-walk solution that deliberately stops at the checkout root. Detect the threat directly — walk the parent chain with lstatSync and refuse when any component isSymbolicLink() (stopping at the repo root, so benign prefixes above it pass), or at minimum compare case-insensitively on win32/darwin. Fix witness: with a realpath seam returning the input with only its case altered, the call must NOT throw, while both existing symlink-plant refusals still throw; reverting to real !== parent must turn the case-altered case red.
中文说明
[Critical] R11-13:依然存在——已在本提交上重新验证(且本轮由第 4 轮分块 7 审计员独立重新推导)。assertUnredirectedParent——本 PR 新增并接入全部三个写入点的共享父链守卫——通过精确字符串比较检测重定向:词法路径(resolve(dirname(target)),以大小写原样的 process.cwd() 为基础构造)对 realpathSync 的规范化形式,函数中没有任何大小写折叠或平台分支。在大小写不敏感/重定向的文件系统上——Windows NTFS 大小写不一致(在 C:\Dev\MyRepo 上 cd c:\dev\myrepo)、SUBST 虚拟驱动器、junction/OneDrive 重定向前缀、仓库根之上良性的符号链接前缀——两种拼写在没有植入任何符号链接时天然不同:capture-local/fetch-pr 每一轮都捕获抛错并扣留缓存 candidate,cache-commit 以非零退出(守卫调用没有 try/catch——抛错逃出 runCacheCommit,退出码 1)。本 PR 存在要构建的 rebase 存活缓存在每一台这样的机器上都是死的,而诊断信息却指责一个并不存在的链接。Linux CI 大小写敏感且 cwd 是内核解析的物理路径,新测试又用 realpathSync(mkdtempSync(...)) 构造根目录——恰好是永远通过的形态——所以这个问题在 CI 中永远无法浮现。与 R6-2 同属"在整个平台上永久降级评审"的形态,机制不同。证据:第 11 轮探测(真实代码+真实文件系统)——仓库上方存在良性符号链接前缀时,守卫经链接拼写抛错、经物理拼写通过;经真实未修改守卫驱动的 win32 大小写分歧模型在两个场景测试中都抛错;一行大小写不敏感比较使两者翻转为通过;真实 cacheCommitCommand.handler 退出码 1。仓库里已有现成模式:lib/path-rules.ts:127-128 点名了这个确切的 bug 类别,lib/worktree.ts 的 redirectedAncestor 就是屋内的 lstat 行走方案,它刻意在检出根处停止。修复:直接检测威胁——用 lstatSync 行走父链,任何分量 isSymbolicLink() 即拒绝(在仓库根处停止,让其上方的良性前缀通过),或至少在 win32/darwin 上做大小写不敏感比较。修复见证:realpath 接缝仅改变大小写返回时调用不得抛错,而两个既有的植入符号链接拒绝仍须抛错;回退到 real !== parent 后大小写被改的用例必须变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
…ists, one path spelling R20-3 follow-up: the gitlink exemption took the diff's ABSENCE as git's answer that the pointer did not move — but with the submodule's gitdir gone that absence is git's silence. The pointer is now read directly (`rev-parse HEAD` inside the submodule) and compared to HEAD's recorded oid; a submodule whose HEAD cannot be read is unmeasurable, and unmeasurable is uncertifiable. R20-4 follow-up: the reserved list was under-enumerated — a repo-root file literally named `pr` derives the bare token `pr`, whose sweep prefix engulfs EVERY PR family while the lease guard lives inside the `pr-<n>` branch a bare `pr` never enters. `cleanup` refuses the token outright now (no target legitimately owns that prefix), and the Step 9 reserved list names it. R21-1: `repoRelativeOf` returned node's platform-separated `relative()` output verbatim, which flows into git pathspecs, the candidate's recorded `source`, and `cachePathFor`'s digest — on win32 one file got two cache filenames across platforms and the Windows lane failed every posix-spelled assertion. `rel` is normalized to forward slashes (git's own spelling on every platform), after the escape check computes against the platform separator. R21-2: the sampling loop kept only `.diff` from re-captures 1 and 2 — a file entering the window that lands in a skip class is in no capture's diff BYTES, so the byte comparison read "held still" while two of the three captures explicitly skipped content, and every stop gate reads only capture 0's list. Skip-set movement is tree movement: the skipped path-sets ride the treeHeldStill comparison now. All but the win32 normalization mutation-checked red locally (that branch is a no-op where sep is '/'; the Windows lane is its enforcement).
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
2 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- R18-2 six contradicting sentence pairs in persistence.md:77 (filed Critical, verified and adjudicated Suggestion) — already reported as R1-20/R2-21/R2-23b (rounds 1-2, comment 3831107046 and siblings), repeatedly deferred (rounds 8/11/12/16…
- R1-3 rebase-survival description-vs-code gap at fetch-pr.ts:260 — escalated to Critical this round under the motivating-incident replay (replay confirmed: no consumer reads fileVerdicts back, the incident completes unchanged), verifier adju…
Not reviewed: reverse audit — stopped at the 5-round cap without two consecutive dry rounds (rounds 3 and 5 dry with substantive receipts; round 4 reported 2 findings, both verified).
Not reviewed: build-and-test — test phase never ran: the build pipeline aborted before it on a pre-existing packages/web-shell type error (TS2345 in client/App.tsx:8172 — file untouched by this PR, fails identically at the merge base); the changed workspaces packages/core and packages/cli built green.
Not reviewed: test-efficacy probe — all probes inconclusive (the probe runner cannot satisfy this repo's vitest globalSetup build guard, files this PR does not touch); harnessValidated: null (the positive control produced no verdict).
Not explored to full depth (tool budget reached): chunk 2: executing cache-commit.test.ts — the review worktree has no node_modules (vitest cannot start) and npm ci + workspace build exceeds this session's budget;….
Deferred under the convergence posture (round 18, not a blocker) — recorded, not requested in this round:
packages/cli/src/commands/review/cache-commit.ts:206 — [probe] plain-object merged silently drops a __proto__ candidate key through the prototype setterpackages/cli/src/commands/review/cache-commit.ts:227 — [probe] noFollow cache write inherits the planted symlink target's mode bits (sync atomicWriteFileSync statSync follows links)packages/cli/src/commands/review/capture-local.ts:869 — [probe] withhold-branch rmSync follows a symlinked .qwen/tmp parent — redirected deletion of the deterministic candidate namepackages/core/src/skills/bundled/review/references/persistence.md:77 — [review] routed fallback hand-write bypasses every cache-commit intake guard (symlink/parent guard, CONTROL sweep, target binding)packages/core/src/skills/bundled/review/references/persistence.md:81 — [review] fallback routes both flows into the PR-only template — local/file rounds have no lawful cache name and no lastModelId source
Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)
[Critical] R12-2 (still stands — re-verified by probe at this commit): the read side the anchor's trust lands on is unguarded — readLocalCache (lib/local-anchor.ts:645) is a plain readFileSync with shape-only validation and no lstat/symlink refusal, and resolveCachePath probes with following statSync/existsSync — while this PR polices the WRITE side of the same deterministic .qwen/review-cache path against exactly this threat. A forged cache planted as .qwen/review-cache/local.json (a symlink — git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: the round skips scope the attacker's stateId certifies as unchanged. Gate-passing forgery requires state knowledge, but HEAD is known when reviewing an attacker branch checkout and the model id is guessable — validation is shape/token-deep, as this PR's own comments concede. Witness (round-18 probe driving the real command end to end at this code — forged cache planted as a symlink over a fully-dirty tree): incremental_present=true, deltaFiles_over_fully_dirty_tree=[], nothingToReview={reason:'unchanged-since-last-round'}, stderr 'No changes since the last local review round (same model, same HEAD, same content) — nothing to re-review.' Flip: lstatSync(path).isSymbolicLink() refusal in readLocalCache -> incremental_present=false, stderr 'Incremental anchor not used — the cache is missing or unreadable. Running the full local review.', diff 21 lines -> 1 chunk (full capture). Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side. Unanchorable inline (the defect line is outside this diff's hunks), hence in the body.
[Critical] R10-3 (still stands — re-verified by probe at this commit; defect lines capture-local.ts:1280 and fetch-pr.ts:1791 outside this diff's hunks, hence in the body): the plan report carrying the cacheCandidatePath field is written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds. The plan's cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself, and the stateId CHECK compares two values both read off the plan — a substituted plan controls both sides. With .qwen/tmp committed as a symlink (this PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — anyone with write access to the link target substitutes the machine-readable record that orchestrates the round (fabricating cacheCandidatePath even after the capture withheld the real candidate). The unguarded class extends to the sibling .qwen/tmp writers (diff.txt at capture-local.ts:1243, fullDiffPath at :983, stop.json), and the new symlinked-.qwen/tmp test's comment certifies 'Nothing was written through the link' after asserting only the candidate's absence — in the very run the test performs, the diff bytes ARE written through the link. Witness (round-18 probe, .qwen/tmp symlinked to a victim dir, real handler): plan_landed_in_victim=true, candidate_landed_in_victim=false, victim_contents=['qwen-review-local-diff.txt','qwen-review-local-plan.json']; fixed arm (assertUnredirectedParent on the plan write): handler threw, plan_landed_in_victim=false. Fix: route the plan/diff/stop writes through assertUnredirectedParent + atomicWriteFileSync(..., { noFollow: true }) in both captures, and extend the symlink test to assert the diff file is absent from the victim.
[Critical] R17-2 (still stands — re-verified by probe at this commit; inline draft dropped by deterministic overlap with the round-11 lineage comments at paths.ts:359, which carry the id R11-13): assertUnredirectedParent — the shared parent-chain guard this PR adds and wires into all three writers — detects redirection by EXACT STRING comparison of a lexical path (resolve(dirname(target)), built on process.cwd() case-as-typed) against realpathSync's canonical form, with no case folding or platform branch (paths.ts:359). On case-insensitive/redirected filesystems — Windows NTFS case mismatch, SUBSTed drives, junction/OneDrive-redirected prefixes, benign symlinked prefixes above the repo root — the two spellings legitimately differ with no planted symlink: capture-local/fetch-pr catch the throw and withhold the cache candidate every round, and cache-commit exits non-zero (its guard call has no try/catch — the throw escapes runCacheCommit). The rebase-survival cache this PR exists to build is dead on every such machine, with a diagnostic blaming a link that does not exist. Linux CI is case-sensitive with a kernel-resolved physical cwd, and the new tests construct roots via realpathSync(mkdtempSync(...)) — the shape that always passes — so this cannot surface in CI. The repo already owns the fix pattern: lib/path-rules.ts names this exact bug class, and lib/worktree.ts's redirectedAncestor is the house lstat-walk solution. Witness (round-18 probe on real FS): link-spelled path (benign symlinked prefix ABOVE the tree, nothing planted inside) THREW 'resolves to … Refusing' while physical-spelled path PASSED; handler with link-spelled --out THREW OUT OF HANDLER, no cache written. The win32 case-mismatch arm is modeled from the same quoted comparison (no case-insensitive FS on this machine). Fix: walk the parent chain with lstatSync and refuse when any component isSymbolicLink(), stopping at the repo root so benign prefixes above it pass — or at minimum compare case-insensitively on win32/darwin.
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 2 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查:reverse audit — stopped at the 5-round cap without two consecutive dry rounds (rounds 3 and 5 dry with substantive receipts; round 4 reported 2 findings, both verified)。
未审查:build-and-test — test phase never ran: the build pipeline aborted before it on a pre-existing packages/web-shell type error (TS2345 in client/App.tsx:8172 — file untouched by this PR, fails identically at the merge base); the changed workspaces packages/core and packages/cli built green。
未审查:test-efficacy probe — all probes inconclusive (the probe runner cannot satisfy this repo's vitest globalSetup build guard, files this PR does not touch); harnessValidated: null (the positive control produced no verdict)。
未探索到全部深度(达到工具调用预算):chunk 2:executing cache-commit.test.ts — the review worktree has no node_modules (vitest cannot start) and npm ci + workspace build exceeds this session's budget;…。
收敛姿态下延后(第 18 轮,非阻断)——已记录,本轮不要求修改:共 5 条(原文未翻译,列表见上方英文部分)。
机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)
[Critical] R12-2 (still stands — re-verified by probe at this commit): the read side the anchor's trust lands on is unguarded — readLocalCache (lib/local-anchor.ts:645) is a plain readFileSync with shape-only validation and no lstat/symlink refusal, and resolveCachePath probes with following statSync/existsSync — while this PR polices the WRITE side of the same deterministic .qwen/review-cache path against exactly this threat. A forged cache planted as .qwen/review-cache/local.json (a symlink — git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: the round skips scope the attacker's stateId certifies as unchanged. Gate-passing forgery requires state knowledge, but HEAD is known when reviewing an attacker branch checkout and the model id is guessable — validation is shape/token-deep, as this PR's own comments concede. Witness (round-18 probe driving the real command end to end at this code — forged cache planted as a symlink over a fully-dirty tree): incremental_present=true, deltaFiles_over_fully_dirty_tree=[], nothingToReview={reason:'unchanged-since-last-round'}, stderr 'No changes since the last local review round (same model, same HEAD, same content) — nothing to re-review.' Flip: lstatSync(path).isSymbolicLink() refusal in readLocalCache -> incremental_present=false, stderr 'Incremental anchor not used — the cache is missing or unreadable. Running the full local review.', diff 21 lines -> 1 chunk (full capture). Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side. Unanchorable inline (the defect line is outside this diff's hunks), hence in the body.
[Critical] R10-3 (still stands — re-verified by probe at this commit; defect lines capture-local.ts:1280 and fetch-pr.ts:1791 outside this diff's hunks, hence in the body): the plan report carrying the cacheCandidatePath field is written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds. The plan's cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself, and the stateId CHECK compares two values both read off the plan — a substituted plan controls both sides. With .qwen/tmp committed as a symlink (this PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — anyone with write access to the link target substitutes the machine-readable record that orchestrates the round (fabricating cacheCandidatePath even after the capture withheld the real candidate). The unguarded class extends to the sibling .qwen/tmp writers (diff.txt at capture-local.ts:1243, fullDiffPath at :983, stop.json), and the new symlinked-.qwen/tmp test's comment certifies 'Nothing was written through the link' after asserting only the candidate's absence — in the very run the test performs, the diff bytes ARE written through the link. Witness (round-18 probe, .qwen/tmp symlinked to a victim dir, real handler): plan_landed_in_victim=true, candidate_landed_in_victim=false, victim_contents=['qwen-review-local-diff.txt','qwen-review-local-plan.json']; fixed arm (assertUnredirectedParent on the plan write): handler threw, plan_landed_in_victim=false. Fix: route the plan/diff/stop writes through assertUnredirectedParent + atomicWriteFileSync(..., { noFollow: true }) in both captures, and extend the symlink test to assert the diff file is absent from the victim.
[Critical] R17-2 (still stands — re-verified by probe at this commit; inline draft dropped by deterministic overlap with the round-11 lineage comments at paths.ts:359, which carry the id R11-13): assertUnredirectedParent — the shared parent-chain guard this PR adds and wires into all three writers — detects redirection by EXACT STRING comparison of a lexical path (resolve(dirname(target)), built on process.cwd() case-as-typed) against realpathSync's canonical form, with no case folding or platform branch (paths.ts:359). On case-insensitive/redirected filesystems — Windows NTFS case mismatch, SUBSTed drives, junction/OneDrive-redirected prefixes, benign symlinked prefixes above the repo root — the two spellings legitimately differ with no planted symlink: capture-local/fetch-pr catch the throw and withhold the cache candidate every round, and cache-commit exits non-zero (its guard call has no try/catch — the throw escapes runCacheCommit). The rebase-survival cache this PR exists to build is dead on every such machine, with a diagnostic blaming a link that does not exist. Linux CI is case-sensitive with a kernel-resolved physical cwd, and the new tests construct roots via realpathSync(mkdtempSync(...)) — the shape that always passes — so this cannot surface in CI. The repo already owns the fix pattern: lib/path-rules.ts names this exact bug class, and lib/worktree.ts's redirectedAncestor is the house lstat-walk solution. Witness (round-18 probe on real FS): link-spelled path (benign symlinked prefix ABOVE the tree, nothing planted inside) THREW 'resolves to … Refusing' while physical-spelled path PASSED; handler with link-spelled --out THREW OUT OF HANDLER, no cache written. The win32 case-mismatch arm is modeled from the same quoted comparison (no case-insensitive FS on this machine). Fix: walk the parent chain with lstatSync and refuse when any component isSymbolicLink(), stopping at the repo root so benign prefixes above it pass — or at minimum compare case-insensitively on win32/darwin.
— qwen3.8-max via Qwen Code /review (v0.22.2)
| // `undefined === undefined`, and a clean verdict transferred over a | ||
| // rendering neither round ever certified. Failing closed costs a | ||
| // re-review; failing open costs the review. | ||
| out[p].attrs = attrs[p] ?? UNANSWERED_ATTRS; |
There was a problem hiding this comment.
[Critical] R8-1: Still stands — re-verified by probe this round, and independently re-discovered by the chunk-6 finder. blobPairs stores renderingAttributes' UNHASHABLE answer (the literal string 'unhashable') verbatim as the pair's comparable attrs component — attrs[p] ?? UNANSWERED_ATTRS catches only undefined — and changedPairs treats only 'unanswered' as never-equal, so two rounds whose check-attr probes both answered UNHASHABLE compare equal and transfer a clean verdict over hunks neither round could certify. That contradicts local-anchor's own contract, which says UNHASHABLE never equals — not even itself. Concretely: a .gitattributes with x.txt diff=unset (or an undecodable driver name, or diff=unspecified with diff.unspecified.binary configured) makes both rounds' probes answer UNHASHABLE; when a non-tree rendering source flips between rounds — the untracked worktree .gitattributes swapped between x.txt diff=unset and x.txt -diff, .git/info/attributes, or a diff.<driver>.binary config change — check-attr answers byte-identically and blobs stand still, so the next round transfers the previous clean verdict over a diff whose rendering changed between "Binary files differ" and readable hunks no round ever read.
Witness (round-18 probe, real git): with x.txt diff=unset, round 1 rendered hunks, round 2 rendered BINARY, check-attr answers identical both rounds, changedPairs(recorded, current) = [] → transfer. Fixed arm (UNHASHABLE treated as never-equal): changedPairs = ["x.txt"] on both arms.
Fix — collapse the second never-certifiable route into the existing never-equal sentinel (import UNHASHABLE from ./local-anchor.js):
const a = attrs[p];
out[p].attrs = a === undefined || a === UNHASHABLE ? UNANSWERED_ATTRS : a;Fix witness: add a test with a committed .gitattributes x.txt diff=unset that calls blobPairs twice over identical trees and asserts changedPairs includes 'x.txt' — removing this guard must turn that test red.
中文说明
[Critical] R8-1:依然存在——本轮已用探测重新验证,且被分块 6 审查员独立重新发现。blobPairs 把 renderingAttributes 的 UNHASHABLE 回答(字面字符串 'unhashable')原样存为配对中可比较的 attrs 分量——attrs[p] ?? UNANSWERED_ATTRS 只捕获 undefined——而 changedPairs 只把 'unanswered' 视为永不相等,于是两轮 check-attr 探测都回答 UNHASHABLE 时会比较相等,把干净裁决转移到两轮都无法认证其渲染的 hunk 上。这与 local-anchor 自己的契约(UNHASHABLE 永不相等——甚至不与自身相等)矛盾。具体地:.gitattributes 中的 x.txt diff=unset(或不可解码的 driver 名、或配置了 diff.unspecified.binary 时的 diff=unspecified)会让两轮探测都回答 UNHASHABLE;轮间若非树内渲染源翻转(未跟踪的工作区 .gitattributes 在 x.txt diff=unset 与 x.txt -diff 之间被换掉、.git/info/attributes、diff.<driver>.binary 配置变更),check-attr 回答逐字节相同、blob 不变,下一轮就会把上一轮的干净裁决转移到一份渲染已在「Binary files differ」与可读 hunk 之间翻转、却没有任何一轮读过的 diff 上。
证据(第 18 轮探测,真实 git):x.txt diff=unset 下,第 1 轮渲染出 hunks,第 2 轮渲染为 BINARY,两轮 check-attr 回答逐字节相同,changedPairs(recorded, current) = [] → 转移。修复分支(把 UNHASHABLE 视为永不相等):两轮均为 changedPairs = ["x.txt"]。
修复——把第二条不可认证路径并入现有的永不相等哨兵(从 ./local-anchor.js 导入 UNHASHABLE):见上方代码块。
修复验收:新增一个带已提交 .gitattributes(x.txt diff=unset)的测试,对相同树调用两次 blobPairs 并断言 changedPairs 包含 'x.txt'——移除该守卫后该测试必须变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| // `inertText` on a refusal, so a gap in either sweep is a forged terminal | ||
| // line either way. | ||
| const controlled = (v: unknown): boolean => | ||
| typeof v === 'string' && CONTROL.test(v); |
There was a problem hiding this comment.
[Critical] R12-1: Still stands — re-verified by probe this round, and independently re-discovered by two finders. The refuse-at-write sweep tests only TOP-LEVEL candidate scalar strings — controlled() is typeof v === 'string' && CONTROL.test(v) iterated over candidateFieldsOf(candidate) — so map-valued candidate fields (files on the local candidate, fileVerdicts on the PR candidate) fail the typeof check and strings nested inside them, including the MAP KEYS, which are file paths and fully workspace-controlled, are never seen; and the ledger copy loop persists every ledger-owned string (findings[] summaries) unchecked. The comment above this code claims "Every persisted STRING is checked", which is doubly inaccurate at HEAD. git permits control characters in filenames, and this PR's own suite exercises such names against the sibling sinks: a fileVerdicts key carrying ESC/C1 bytes is accepted today and persisted raw into .qwen/review-cache/pr-<n>.json; the stated design is refuse-at-write rather than escaping at every reader, and fetch-pr.ts imports no escaper at all, so the persisted nested strings are one raw-printing reader away from a forged terminal line — and the announced fileVerdicts consumer would hand them back into briefs and refusals.
Witness (round-18 probe): fileVerdicts key 'a\u009b[2J.ts' → NO THROW, raw C1 byte in the persisted file; ledger findings[0].summary with C1 → persisted raw; nested map VALUE with U+2028 → persisted raw. Fixed arm (recursive key+value sweep over candidate and ledger): all refused.
Fix — make the sweep recursive over both candidate and ledger, keys included:
const containsControlled = (v: unknown): boolean => {
if (typeof v === 'string') return CONTROL.test(v);
if (Array.isArray(v)) return v.some(containsControlled);
if (typeof v === 'object' && v !== null)
return Object.entries(v).some(
([k, x]) => CONTROL.test(k) || containsControlled(x),
);
return false;
};Fix witness: extend the control-character cases with a candidate whose fileVerdicts KEY carries \u001b[31m, expecting /carries control/ and no output file — deleting the recursion turns it red.
中文说明
[Critical] R12-1:依然存在——本轮已用探测重新验证,且被两个审查员独立重新发现。写入端拒绝扫描只检查顶层 candidate 标量字符串——controlled() 是对 candidateFieldsOf(candidate) 逐键执行的 typeof v === 'string' && CONTROL.test(v)——因此 map 类型的 candidate 字段(本地 candidate 的 files、PR candidate 的 fileVerdicts)无法通过 typeof 检查,其内部嵌套的字符串(包括作为键的文件路径——完全由工作区控制)从来不被检查;而 ledger 拷贝循环会未经检查地持久化每一个 ledger 所有的字符串(findings[] 的摘要)。上方注释声称「每一个被持久化的字符串都会被检查」,在本 HEAD 上双重失实。git 允许文件名携带控制字符,本 PR 自己的测试套件也在兄弟写入点上测过这类文件名:携带 ESC/C1 字节的 fileVerdicts 键今天会被接受并原样持久化进 .qwen/review-cache/pr-<n>.json;本命令声明的设计是「在写入端拒绝,而不是在每个读取端转义」,而 fetch-pr.ts 根本没有导入任何转义器——这些被持久化的嵌套字符串距离伪造终端行只差一个原样打印的读取端;且已宣告的 fileVerdicts 消费者将来会把这些键送回 brief 与拒绝信息。
证据(第 18 轮探测):fileVerdicts 键 'a\u009b[2J.ts' → 不抛错,持久化文件中含裸 C1 字节;ledger findings[0].summary 带 C1 → 原样持久化;嵌套 map 值带 U+2028 → 原样持久化。修复分支(对 candidate 与 ledger 做递归的键+值扫描):全部拒绝。
修复——让扫描对 candidate 与 ledger 递归(含键),见上方代码块。
修复验收:在控制字符用例中新增一个 fileVerdicts 键携带 \u001b[31m 的 candidate,期望 /carries control/ 且不产生输出文件——删除递归后该测试变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| typeof v === 'string' && CONTROL.test(v); | ||
| for (const key of candidateFieldsOf(candidate)) { | ||
| if (controlled(candidate[key])) { |
There was a problem hiding this comment.
[Critical] R8-4: Still stands — re-verified by probe this round. The CONTROL sweep tests only candidate VALUES — if (controlled(candidate[key])) — never the candidate's KEYS, and its own refusal interpolates the unchecked key raw into an error that reaches the operator's terminal, while every other untrusted string in this file's error paths goes through inertText. A tampered candidate (the deterministic in-repo path this command's own header names as its threat) whose KEY carries ESC/C1/Cf bytes with a clean value is never checked: no throw, and the raw key is persisted verbatim as a promoted-cache key; with a control-charactered value as well, the refusal message itself renders the raw key — the exact forgery class the guard exists to refuse, and SKILL.md has orchestrators relay stderr lines into model context.
Witness (round-18 probe against the real handler): key 'k\u009b[31m' + clean value → NO THROW, cache written, persisted file carries the raw C1 byte: true; dirty key + dirty value → refusal message carries RAW ESC byte: true. Fixed arm (key check + inertText(key) in the message): throw-with-no-write and ESC-free message.
Fix — check every candidate key in this loop (CONTROL.test(key)), and wrap the interpolated key in the refusal with inertText(key).
Fix witness: add a case with a control-charactered candidate KEY expecting /carries control/ and no output file — removing the key check turns it red.
中文说明
[Critical] R8-4:依然存在——本轮已用探测重新验证。CONTROL 扫描只检查 candidate 的值——if (controlled(candidate[key]))——从不检查 candidate 的键,而其自己的拒绝信息把未检查的 key 原样插入一条会到达操作者终端的错误,而本文件其他所有错误路径中的不可信字符串都经过 inertText。一个被篡改的 candidate(本命令头部自己点名的确定性仓库内路径威胁)若其键携带 ESC/C1/Cf 字节而值干净,则完全不被检查:不抛错,裸键被原样持久化为晋升缓存的键;若值也带控制字符,拒绝信息本身就会渲染裸键——正是该守卫存在要拒绝的那类伪造,且 SKILL.md 要求 orchestrator 把 stderr 行复述进模型上下文。
证据(第 18 轮对真实 handler 的探测):键 'k\u009b[31m' + 干净值 → 不抛错,缓存已写入,持久化文件含裸 C1 字节:true;脏键 + 脏值 → 拒绝信息携带裸 ESC 字节:true。修复分支(键检查 + 信息中对键做 inertText(key) 包裹):抛错且不写文件、信息无 ESC。
修复——在该循环中检查每一个 candidate 键(CONTROL.test(key)),并把拒绝信息中被插值的键用 inertText(key) 包裹。
修复验收:新增一个键携带控制字符的用例,期望 /carries control/ 且不产生输出文件——移除键检查后该测试变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| '-c', | ||
| 'diff.algorithm=myers', | ||
| '-c', | ||
| 'diff.indentHeuristic=true', |
There was a problem hiding this comment.
[Critical] R4-1: Still stands — carried as a CLASS finding under this family's round-4 id (ledger R9-3 diff.renameLimit and R9-4 driver-scoped diff.<driver>.algorithm entrances remain folded in as evidence, not re-filed), re-verified against git's own registry this round. The per-file verdict pair identity enumerates git's rendering knobs one entrance at a time, and the surface keeps regenerating — five-plus rounds have each found new entrances. This round's diff pins diff.algorithm=myers and diff.indentHeuristic=true here — closing two entrances — but diff.interHunkContext, diff.renameLimit, and driver-scoped diff.<driver>.algorithm are neither pinned nor recorded in the pair identity. Measured against git 2.47.3 with this PR's exact pinned config+flags baseline: diff.interHunkContext=10 renders 2 hunks as 1 merged hunk with identical blobs; diff.renameLimit=1 turns rename from/similarity index into deleted file + new file despite the pinned --find-renames; diff.<driver>.algorithm=histogram on a diff=drv path relocates lines while check-attr — the identity's oracle — answers byte-identically in both arms. In all three cases a reviewer's config silently changes what the hunks show, changedPairs sees blobs/mode/attrs standing still, and the clean verdict transfers over hunks no round read as rendered. The structural fix remains owed: record the rendering-relevant config state in the pair identity, or defer to git's own rendering output as the authority, rather than enumerating knobs one pin at a time.
Witness: round-18 measurements quoted above (three knobs flipped the rendering against the pinned baseline; diff.compactionHeuristic could not be exhibited across 18 fixtures and is not claimed).
Fix witness: a file-verdicts test running blobPairs under a perturbed rendering config (e.g. diff.interHunkContext=10 via GIT_CONFIG_COUNT) asserting the path is flagged changed — today it transfers.
中文说明
[Critical] R4-1:依然存在——作为本家族的 CLASS 发现以第 4 轮的 id 继续携带(ledger 中 R9-3 diff.renameLimit 与 R9-4 driver 级 diff.<driver>.algorithm 入口仍作为证据折叠在内,不再单独重报),本轮已对照 git 自身的注册表重新验证。每文件裁决的配对身份逐个入口地枚举 git 的渲染旋钮,而该表面持续再生——五轮以上每轮都发现新入口。本轮 diff 在此处钉住 diff.algorithm=myers 与 diff.indentHeuristic=true——关闭两个入口——但 diff.interHunkContext、diff.renameLimit、driver 级 diff.<driver>.algorithm 既未被钉住,也未记录进配对身份。在本 PR 精确钉住的 config+flags 基线上对 git 2.47.3 实测:diff.interHunkContext=10 把 2 个 hunk 渲染为 1 个合并 hunk 而 blob 相同;diff.renameLimit=1 把 rename from/similarity index 变成 deleted file + new file(尽管钉住了 --find-renames);diff=drv 路径上的 diff.<driver>.algorithm=histogram 使行重排,而身份赖以判定的 check-attr 两侧回答逐字节相同。三种情形下评审者的配置都会悄悄改变 hunk 的内容,changedPairs 看到 blob/mode/attrs 不变,干净裁决便转移到没有任何一轮按其实际渲染读过的 hunk 上。结构性修复仍然欠着:把渲染相关的配置状态记录进配对身份,或以 git 自己的渲染输出为权威,而不是逐个旋钮地钉。
证据:上文引用的第 18 轮实测(三个旋钮相对钉住的基线翻转了渲染;diff.compactionHeuristic 在 18 个 fixture 中未能复现,不作为主张)。
修复验收:新增一个在扰动渲染配置下运行 blobPairs 的 file-verdicts 测试(例如通过 GIT_CONFIG_COUNT 设 diff.interHunkContext=10),断言该路径被标记为已变化——今天它会转移。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it, and neither must a `--topology minimal` run at any effort — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. The reason is one reason, and it is why the rule is stated as a rule: the anchor's whole claim is "the next round may skip this", and a claim nobody ruled on is exactly what the next round must not skip — the round after it stops decided over a Critical that was never verified: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare model token (`YOUR_MODEL_ID`), which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the cache path for this target>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. **Where that path comes from differs by flow, and only one of them publishes it**: a LOCAL or file-path round reads it off the plan's `cachePath` — the capture derives the target and `safeTarget` is not hand-reproducible, so the name is not yours to spell — while the PR flow's plan carries no such field and the name is the one this section already gives, `.qwen/review-cache/pr-<n>.json`, which you already know from the PR number. Do not look for `cachePath` on a `fetch-pr` report; it has none, and treating its absence as a reason to skip the write would lose the PR cache entirely. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. **And these two, which complete the list**: a finding still marked `— [unverified]`, and an undecided blocker — a Critical whose verifier never returned, from a deadline stop or a timed-out batch. Neither enters `findings[]` (only confirmed findings do) and neither reads as "unreviewed scope", so without naming them a round promotes the anchor over a claim nobody ruled on and the round after it stops decided. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) |
There was a problem hiding this comment.
[Critical] R14-1: Still stands — re-verified at this commit, and independently re-discovered this round by the removed-behavior audit and the cross-file tracer. The rewritten Step 8 broadens the local-only pre-promotion stateId CHECK to "for PR and local alike" — but a fetch-pr plan structurally cannot pass it: fetch-pr publishes cacheCandidatePath yet contains zero occurrences of cacheCandidateStateId (only capture-local produces it), and the PR candidate carries no stateId field. "A plan that published a path with an absent cacheCandidateStateId field" is exactly a fetch-pr plan, and this sentence resolves it as "skip the cache write and say so" — read literally, every clean high-effort PR round skips the write, .qwen/review-cache/pr-<n>.json never advances, and every later round loses the same-SHA shortcut and --since scoping, silently full-reviewing the whole PR again. This file documents that orchestrators apply such checks mechanically ("a literal check would skip this write on every round and the feature would never persist"); the paragraph plugs the parallel cachePath asymmetry ("treating its absence as a reason to skip the write would lose the PR cache entirely") but not this one, and the marker-rule section routes the PR write back through the same sentence.
Witness: not run — no harness can execute an orchestrator's reading of a doc; the verdict rests on the plan shape quoted above (verified at HEAD: fetch-pr.ts publishes cacheCandidatePath; grep finds zero cacheCandidateStateId producers outside capture-local.ts; the candidate object carries no stateId) and the paragraph text at HEAD, which leave the fetch-pr plan no reading but "skip the cache write".
Fix — re-scope the CHECK to local/file rounds and state the PR exemption in the same breath: "A PR round holds the worktree lease and its candidate carries no stateId — there is nothing to compare; run cache-commit on the fetch-pr candidate directly."
Fix witness: extend SKILL.test.ts's section test ('checks the candidate is this round's own before promoting') to assert the PR exemption clause — removing the exemption sentence turns it red.
中文说明
[Critical] R14-1:依然存在——已在本提交上重新验证,且本轮被删除行为审计与跨文件追踪两个审查员独立重新发现。重写后的 Step 8 把原本仅限本地流程的晋升前 stateId CHECK 扩展为「PR 与本地一视同仁」——但 fetch-pr 的计划在结构上不可能通过它:fetch-pr 会发布 cacheCandidatePath,却完全没有 cacheCandidateStateId(只有 capture-local 产出该字段),且 PR candidate 也不携带 stateId 字段。「发布了路径却没有 cacheCandidateStateId 字段的计划」恰好就是 fetch-pr 计划,而这句话把它判为「跳过缓存写入并说明」——按字面执行,每一轮干净的 PR 高力度评审都会跳过写入,.qwen/review-cache/pr-<n>.json 永不推进,其后每一轮都失去同 SHA 快捷与 --since 范围划定,悄悄地重新全量评审整个 PR。本文件自己记录了 orchestrator 会机械地套用此类检查(「按字面检查会在每一轮都跳过这次写入,该功能永远无法持久化」);段落补上了平行的 cachePath 不对称(「把它的缺失当作跳过写入的理由会整个丢掉 PR 缓存」),却没有补这一个;而 marker 规则一节又把 PR 写入路由回同一句话。
证据:无法运行——没有任何测试装置能执行「orchestrator 阅读文档」这一行为;裁决依据上文引用的计划形态(已在 HEAD 验证:fetch-pr.ts 发布 cacheCandidatePath;grep 确认 cacheCandidateStateId 的唯一产出者是 capture-local.ts;candidate 对象不含 stateId)与 HEAD 上的段落文本——它们使 fetch-pr 计划只有「跳过缓存写入」这一种读法。
修复——把 CHECK 重新限定到本地/文件轮次,并在同一处声明 PR 豁免:「PR 轮持有 worktree 租约,其 candidate 不携带 stateId——无从比较;直接对 fetch-pr 的 candidate 运行 cache-commit。」
修复验收:扩展 SKILL.test.ts 的段落测试('checks the candidate is this round's own before promoting')以断言 PR 豁免句——删除豁免句后该测试变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| atomicWriteFileSync(args.out, `${JSON.stringify(merged, null, 2)}\n`, { | ||
| noFollow: true, | ||
| }); | ||
| writeStdoutLine(`Committed review cache to ${args.out}`); |
There was a problem hiding this comment.
[Critical] R17-1 (lineage R15-4): Still stands — re-verified by probe this round. The intake sweep polices every candidate VALUE but never the command's own CLI path arguments, and this success line prints the unswept --out raw — the one print site in a file that escapes every other untrusted string through inertText (readJsonObject wraps the --candidate/--ledger paths in its error paths). --out is not a trusted constant: in the PR flow Step 8 has the reviewing model spell it. A control byte in the DIRECTORY component of --out (the target-stem spelling is refused by the target check, whose message wraps inertly — the directory part is never checked at all) succeeds and prints a forged terminal line on the SUCCESS path, with no throw and no human-present refusal, into stdout the orchestrator relays into model context.
Witness (round-18 probe): --out carrying a raw C1 byte in the directory component with a clean target → handler succeeds; stdout carries the raw C1 byte: true. Fixed arm (inertText(args.out)): raw byte gone.
| writeStdoutLine(`Committed review cache to ${args.out}`); | |
| writeStdoutLine(`Committed review cache to ${inertText(args.out)}`); |
Fix witness: a cache-commit test invoking the handler with a control-charactered --out directory and asserting the captured stdout contains no raw control byte — removing the wrap turns it red.
中文说明
[Critical] R17-1(谱系 R15-4):依然存在——本轮已用探测重新验证。摄入扫描检查每一个 candidate 的值,却从不检查该命令自己的 CLI 路径参数,而这行成功信息把未扫描的 --out 原样打印——这是本文件中唯一一个不经转义的打印点(其他所有不可信字符串都经过 inertText;readJsonObject 在其错误路径中包裹了 --candidate/--ledger 路径)。--out 不是可信常量:在 PR 流程中,Step 8 由评审模型亲手拼写它。--out 的目录分量中携带控制字节时(目标主干拼写会被 target 检查拒绝——其信息做了惰性包裹——但目录部分完全不被检查),命令成功执行,并在成功路径上打印出一条伪造的终端行——不抛错、无人在场的拒绝——进入 orchestrator 会复述进模型上下文的 stdout。
证据(第 18 轮探测):--out 目录分量携带裸 C1 字节、目标干净 → handler 成功;stdout 含裸 C1 字节:true。修复分支(inertText(args.out)):裸字节消失。
修复验收:新增一个用带控制字符的 --out 目录调用 handler 的测试,断言捕获的 stdout 不含裸控制字节——移除包裹后该测试变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
…ides R22-1: a moved or in-diff submodule pointer entered the hashed population and could only record UNHASHABLE — which never equals itself — so changedSince reported it every round and the unchanged-since stop was unreachable for the lifetime of any change set holding a dirty pointer, with a misdescribing diagnosis. Yet the module already measures exactly this identity (the R20-3 fix did it inside a special case). Make the identity real instead: hashWorktreeFiles answers 160000:<oid> for a readable, content-CLEAN submodule (the pointer oid says nothing about internal edits — git renders those as `-dirty` — so cleanliness is part of measurability and a dirty submodule stays UNHASHABLE), and revisionIdentities answers the same shape from ls-tree's recorded oid. With both sides real, vanishedStillOnDisk's R20-3 special case became redundant and is deleted along with its helper: a restored pointer certifies through the ordinary equality, an unreadable or dirty one refuses through the ordinary UNHASHABLE clause — net-negative plumbing. The submodule fixture now pins all three arms: the dirty pointer CONVERGES (unchanged-since reachable), internal dirt never certifies (no decided stop over `-dirty` bytes), and the odb-removed pointer still refuses. Both mutants — UNHASHABLE-again and dirt-invisible — turn it red.
…o p2 # Conflicts: # packages/cli/src/commands/review/capture-local.ts
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
12 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- R19-1 headline-vs-delivered scope gap (no production consumer reads fileVerdicts back) at fetch-pr.ts:262 — already reported as R1-3 (comment 3831106934, fetch-pr.ts:260)
- R19-2 stale allowlist docblock above LEDGER_FIELDS (cache-commit.ts:67) — already disclosed as R11-3 (round-8/11/12/16 deferral lists)
- R19-3 inert-text.test.ts committed with raw control bytes — git classifies it binary (inert-text.test.ts:41) — already disclosed as R11-8 (round-11/16 deferral lists)
- R19-4 fileForm digest refusal untested on its refusal side (cache-commit.ts:173) — already reported as R2-4 (comment 3835201651, cache-commit.ts:173)
- R19-5 UNANSWERED sentinel recording side untested (file-verdicts.ts:203) — already disclosed as R11-6 (round-11 deferral list)
- R19-6 new hunk-shape pins unasserted in diff-flags.test.ts (diff-flags.ts:46) — already disclosed as R11-4 (round-11 deferral list)
- R19-9 plain-target fileForm grammar collision (cache-commit.ts:165) — already reported as R2-7 (comment 3834450391, cache-commit.ts:165; the 'new arm' recorded in the round-12 deferral list)
- R19-11 'And these two, which complete the list' completeness claim (persistence.md:77) — already reported as R2-21 (round 2; round-6-8 and round-12 deferral lists)
- R19-12 \p{Zp}/U+2029 escape-pairing pin gap (inert-text.ts:73) — already disclosed in the round-3 deferral list (cache-commit.test.ts:137) and adjudicated there
- R19-13 astral Cf malformed 5-hex \u escape (inert-text.ts:83) — already disclosed as R11-9 (round-11/16 deferral lists)
- R19-15 readJsonObject parse-error inertText wrap untested (cache-commit.test.ts:169) — already disclosed in the round-2 deferral list (cache-commit.ts:52)
- R19-16 fallback-vs-withheld-candidate contradiction (persistence.md:77) — already reported as R1-20/R2-21/R2-23b (comment 3831107046; re-adjudicated in round 18 as R18-2)
Not reviewed: reverse audit — stopped at the 5-round cap without two consecutive dry rounds (all five rounds reported findings).
Not reviewed: test-efficacy probe — all probes inconclusive (the probe runner cannot satisfy this repo's vitest globalSetup build guard, files this PR does not touch); harnessValidated: null (the positive control produced no verdict).
Not explored to full depth (tool budget reached): chunk 5: live execution of file-verdicts.test.ts (the review worktree has no node_modules ; npm ci plus the workspace build the vitest globalSetup guard requires wa….
Deferred under the convergence posture (round 19, not a blocker) — recorded, not requested in this round:
packages/cli/src/commands/review/fetch-pr.ts:1627 — [probe] delta-round promotion silently erases previously promoted per-file verdicts — the rebase-survival record can never accumulate (latent: zero production consumers today; fail-safe di…
Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)
Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (9 Critical(s)), the rate of first-time findings is not falling (this round 0, previous 0), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):
| standing Critical | attack surface | attacker-dependency | blast radius |
|---|---|---|---|
| (each standing Critical) | … | … | … |
Advisory only — it does not block this review.
[Critical] R12-2 (still stands — re-verified by end-to-end probe at this commit): the read side the anchor's trust lands on is unguarded — readLocalCache (lib/local-anchor.ts:684) is a plain readFileSync with shape-only validation and no lstat/symlink refusal, and resolveCachePath probes with following statSync/existsSync — while this PR polices the WRITE side of the same deterministic .qwen/review-cache path against exactly this threat. A forged cache planted as .qwen/review-cache/local.json (a symlink — git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: the round skips scope the attacker's stateId certifies as unchanged. Gate-passing forgery requires state knowledge, but HEAD is known when reviewing an attacker branch checkout and the model id is guessable — validation is shape/token-deep, as this PR's own comments concede. Witness (probe driving the real captureLocalCommand.handler end to end at this commit — forged cache planted as a symlink over a fully-dirty tree): BASE: incremental_present=true, deltaFiles=[], nothingToReview={reason:'unchanged-since-last-round'}, stderr 'No changes since the last local review round (same model, same HEAD, same content) — nothing to re-review.' FIX (lstatSync(path).isSymbolicLink() refusal in readLocalCache): incremental_present=false, stderr 'Incremental anchor not used — the cache is missing or unreadable. Running the full local review.', full capture. Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side. Fix witness: a capture-local test planting a symlinked local.json cache and asserting the round falls back to full capture — removing the lstat refusal turns it red. Unanchorable inline (the defect line is outside this diff's hunks), hence in the body.
[Critical] R10-3 (still stands — re-verified by probe at this commit): the plan report carrying the cacheCandidatePath field is written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures (capture-local.ts:1211; fetch-pr.ts:1791) — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds. The plan's cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself, and the stateId CHECK compares two values both read off the plan — a substituted plan controls both sides. With .qwen/tmp committed as a symlink (this PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — anyone with write access to the link target substitutes the machine-readable record that orchestrates the round (fabricating cacheCandidatePath even after the capture withheld the real candidate). The unguarded class extends to the sibling .qwen/tmp writers (diff.txt at capture-local.ts:1174, diff-full.txt at :914, stop.json at :1150), and this PR newly adds cacheCandidatePath to the fetch-pr plan, raising what a substituted fetch-pr plan can forge; the new symlinked-.qwen/tmp test's comment certifies 'Nothing was written through the link' after asserting only the candidate's absence — in the very run the test performs, the diff bytes ARE written through the link. Witness (probe, .qwen/tmp symlinked to a victim dir, real handler): candidate_landed_in_victim=false (this PR's guard refuses), BUT plan_landed_in_victim=true, victim_contents=['qwen-review-local-diff.txt','qwen-review-local-plan.json']; fixed arm (assertUnredirectedParent on the plan write): handler threw, plan_landed_in_victim=false (diff.txt still lands — the class extends to the sibling writers). Fix: route the plan/diff/stop writes through assertUnredirectedParent + atomicWriteFileSync(..., { noFollow: true }) in both captures, and extend the symlink test to assert the diff/plan files are absent from the victim. Fix witness: the extended symlink test asserting the plan and diff files are absent from the victim — removing the plan-write guard turns it red. Defect lines are outside this diff's hunks (the asymmetric guard coverage, the new plan field and the over-claiming test comment are this PR's), hence in the body.
[Critical] R17-2 (still stands — re-verified by probe on a real filesystem at this commit; inline draft dropped by deterministic overlap with the round-11 lineage comments at paths.ts:359, which carry the id R11-13): assertUnredirectedParent — the shared parent-chain guard this PR adds and wires into all three writers — detects redirection by EXACT STRING comparison of a lexical path (resolve(dirname(target)), built on process.cwd() case-as-typed) against realpathSync's canonical form, with no case folding, no platform branch, and no stop at the repo root (paths.ts:359). On case-insensitive/redirected filesystems — Windows NTFS case mismatch, SUBSTed drives, junction/OneDrive-redirected prefixes, benign symlinked prefixes above the repo root — the two spellings legitimately differ with no planted symlink: capture-local/fetch-pr catch the throw and withhold the cache candidate every round, and cache-commit.ts:237 calls the guard bare, so the throw escapes the handler non-zero. The rebase-survival cache this PR exists to build is dead on every such machine, with a diagnostic blaming a link that does not exist. Linux CI is case-sensitive with a kernel-resolved physical cwd, and the new tests construct roots via realpathSync(mkdtempSync(...)) — the shape that always passes — so this cannot surface in CI. The repo already owns the fix pattern: lib/path-rules.ts names this exact bug class, and lib/worktree.ts's redirectedAncestor is the house lstat-walk solution. Witness (probe on real FS at this commit): link-spelled path (benign symlinked prefix ABOVE the tree, nothing planted inside) THREW 'resolves to … Refusing' while physical-spelled path PASSED; handler with link-spelled --out THREW OUT OF HANDLER, no cache written. FIXED arm (lstat walk stopping at repo root): benign prefix passed, handler completed, and a planted link INSIDE the tree still refused. (The win32 case-mismatch arm is modeled from the same quoted comparison — no case-insensitive FS on this machine.) Fix: walk the parent chain with lstatSync and refuse when any component isSymbolicLink(), stopping at the repo root so benign prefixes above it pass — or at minimum compare case-insensitively on win32/darwin — and catch the guard in cache-commit as the captures do. Fix witness: a paths.test.ts case whose target sits under a benign symlinked prefix above the repo root (passes) beside the existing planted-link case (refuses) — restoring the exact-string comparison turns the benign-prefix case red.
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 12 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查:reverse audit — stopped at the 5-round cap without two consecutive dry rounds (all five rounds reported findings)。
未审查:test-efficacy probe — all probes inconclusive (the probe runner cannot satisfy this repo's vitest globalSetup build guard, files this PR does not touch); harnessValidated: null (the positive control produced no verdict)。
未探索到全部深度(达到工具调用预算):chunk 5:live execution of file-verdicts.test.ts (the review worktree has no node_modules ; npm ci plus the workspace build the vitest globalSetup guard requires wa…。
收敛姿态下延后(第 19 轮,非阻断)——已记录,本轮不要求修改:共 1 条(原文未翻译,列表见上方英文部分)。
机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)
残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 9 条 Critical),首次发现的速率没有下降(本轮 0,上一轮 0),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。
[Critical] R12-2 (still stands — re-verified by end-to-end probe at this commit): the read side the anchor's trust lands on is unguarded — readLocalCache (lib/local-anchor.ts:684) is a plain readFileSync with shape-only validation and no lstat/symlink refusal, and resolveCachePath probes with following statSync/existsSync — while this PR polices the WRITE side of the same deterministic .qwen/review-cache path against exactly this threat. A forged cache planted as .qwen/review-cache/local.json (a symlink — git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: the round skips scope the attacker's stateId certifies as unchanged. Gate-passing forgery requires state knowledge, but HEAD is known when reviewing an attacker branch checkout and the model id is guessable — validation is shape/token-deep, as this PR's own comments concede. Witness (probe driving the real captureLocalCommand.handler end to end at this commit — forged cache planted as a symlink over a fully-dirty tree): BASE: incremental_present=true, deltaFiles=[], nothingToReview={reason:'unchanged-since-last-round'}, stderr 'No changes since the last local review round (same model, same HEAD, same content) — nothing to re-review.' FIX (lstatSync(path).isSymbolicLink() refusal in readLocalCache): incremental_present=false, stderr 'Incremental anchor not used — the cache is missing or unreadable. Running the full local review.', full capture. Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side. Fix witness: a capture-local test planting a symlinked local.json cache and asserting the round falls back to full capture — removing the lstat refusal turns it red. Unanchorable inline (the defect line is outside this diff's hunks), hence in the body.
[Critical] R10-3 (still stands — re-verified by probe at this commit): the plan report carrying the cacheCandidatePath field is written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures (capture-local.ts:1211; fetch-pr.ts:1791) — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds. The plan's cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself, and the stateId CHECK compares two values both read off the plan — a substituted plan controls both sides. With .qwen/tmp committed as a symlink (this PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — anyone with write access to the link target substitutes the machine-readable record that orchestrates the round (fabricating cacheCandidatePath even after the capture withheld the real candidate). The unguarded class extends to the sibling .qwen/tmp writers (diff.txt at capture-local.ts:1174, diff-full.txt at :914, stop.json at :1150), and this PR newly adds cacheCandidatePath to the fetch-pr plan, raising what a substituted fetch-pr plan can forge; the new symlinked-.qwen/tmp test's comment certifies 'Nothing was written through the link' after asserting only the candidate's absence — in the very run the test performs, the diff bytes ARE written through the link. Witness (probe, .qwen/tmp symlinked to a victim dir, real handler): candidate_landed_in_victim=false (this PR's guard refuses), BUT plan_landed_in_victim=true, victim_contents=['qwen-review-local-diff.txt','qwen-review-local-plan.json']; fixed arm (assertUnredirectedParent on the plan write): handler threw, plan_landed_in_victim=false (diff.txt still lands — the class extends to the sibling writers). Fix: route the plan/diff/stop writes through assertUnredirectedParent + atomicWriteFileSync(..., { noFollow: true }) in both captures, and extend the symlink test to assert the diff/plan files are absent from the victim. Fix witness: the extended symlink test asserting the plan and diff files are absent from the victim — removing the plan-write guard turns it red. Defect lines are outside this diff's hunks (the asymmetric guard coverage, the new plan field and the over-claiming test comment are this PR's), hence in the body.
[Critical] R17-2 (still stands — re-verified by probe on a real filesystem at this commit; inline draft dropped by deterministic overlap with the round-11 lineage comments at paths.ts:359, which carry the id R11-13): assertUnredirectedParent — the shared parent-chain guard this PR adds and wires into all three writers — detects redirection by EXACT STRING comparison of a lexical path (resolve(dirname(target)), built on process.cwd() case-as-typed) against realpathSync's canonical form, with no case folding, no platform branch, and no stop at the repo root (paths.ts:359). On case-insensitive/redirected filesystems — Windows NTFS case mismatch, SUBSTed drives, junction/OneDrive-redirected prefixes, benign symlinked prefixes above the repo root — the two spellings legitimately differ with no planted symlink: capture-local/fetch-pr catch the throw and withhold the cache candidate every round, and cache-commit.ts:237 calls the guard bare, so the throw escapes the handler non-zero. The rebase-survival cache this PR exists to build is dead on every such machine, with a diagnostic blaming a link that does not exist. Linux CI is case-sensitive with a kernel-resolved physical cwd, and the new tests construct roots via realpathSync(mkdtempSync(...)) — the shape that always passes — so this cannot surface in CI. The repo already owns the fix pattern: lib/path-rules.ts names this exact bug class, and lib/worktree.ts's redirectedAncestor is the house lstat-walk solution. Witness (probe on real FS at this commit): link-spelled path (benign symlinked prefix ABOVE the tree, nothing planted inside) THREW 'resolves to … Refusing' while physical-spelled path PASSED; handler with link-spelled --out THREW OUT OF HANDLER, no cache written. FIXED arm (lstat walk stopping at repo root): benign prefix passed, handler completed, and a planted link INSIDE the tree still refused. (The win32 case-mismatch arm is modeled from the same quoted comparison — no case-insensitive FS on this machine.) Fix: walk the parent chain with lstatSync and refuse when any component isSymbolicLink(), stopping at the repo root so benign prefixes above it pass — or at minimum compare case-insensitively on win32/darwin — and catch the guard in cache-commit as the captures do. Fix witness: a paths.test.ts case whose target sits under a benign symlinked prefix above the repo root (passes) beside the existing planted-link case (refuses) — restoring the exact-string comparison turns the benign-prefix case red.
— qwen3.8-max via Qwen Code /review (v0.22.2)
| // `undefined === undefined`, and a clean verdict transferred over a | ||
| // rendering neither round ever certified. Failing closed costs a | ||
| // re-review; failing open costs the review. | ||
| out[p].attrs = attrs[p] ?? UNANSWERED_ATTRS; |
There was a problem hiding this comment.
[Critical] R8-1: Still stands — re-verified by probe at this commit, and independently re-discovered this round. blobPairs records renderingAttributes' UNHASHABLE answer (the literal 'unhashable' — returned for a .gitattributes diff answer of set/unset, an undecodable driver name, or diff.unspecified.binary configured) verbatim as the pair's comparable attrs component — this fold catches only undefined — and changedPairs treats only 'unanswered' as never-equal, so 'unhashable' === 'unhashable' passes and a clean verdict transfers over hunks whose rendering neither round ever certified. The local-flow analogue changedSince implements "UNHASHABLE never equals — not even itself"; this comparison omits it. Concretely: a reviewer machine with git config diff.unset.binary true and a committed .gitattributes containing *.dat diff=unset renders data.dat as binary in round 1 and promotes a clean verdict with attrs: 'unhashable'; when the config is removed between rounds (or an untracked worktree .gitattributes flips diff=unset ↔ -diff — both answer unset, invisible to attributesMoved), the rendering flips between a binary marker and readable hunks while blobs, trees and attrs stand still — changedPairs returns [] and the verdict transfers over hunks no round ever read.
Witness (probe at this commit, real git + real blobPairs/changedPairs):
BASE: round1_attrs:"unhashable" round2_attrs:"unhashable"
rendering_flipped:true pairs_identical:true
changedPairs_over_flip: [] <- verdict transfers
FIX (UNHASHABLE never-equal in changedPairs):
changedPairs_over_flip: ["data.dat"]
| out[p].attrs = attrs[p] ?? UNANSWERED_ATTRS; | |
| out[p].attrs = attrs[p] === undefined || attrs[p] === UNHASHABLE ? UNANSWERED_ATTRS : attrs[p]; |
(import UNHASHABLE from ./local-anchor.js, and add the never-equal arm in changedPairs — rec.attrs === UNHASHABLE || cur.attrs === UNHASHABLE || — so already-written records also fail closed). Fix witness: mirror the existing 'never transfers a verdict over a rendering the probe could not report' test with recorded = current = { 'x.txt': { base, head, attrs: 'unhashable' } } expecting ['x.txt'] — removing the never-equal guard turns it red.
中文说明
[Critical] R8-1:依然存在——本轮已用探测重新验证,且被独立重新发现。blobPairs 把 renderingAttributes 的 UNHASHABLE 答复(字面量 'unhashable'——在 .gitattributes 的 diff 属性为 set/unset、驱动名无法解码、或配置了 diff.unspecified.binary 时返回)原样记为可比较的 attrs 分量——此处的折叠只捕获 undefined——而 changedPairs 只把 'unanswered' 视为永不相等,于是 'unhashable' === 'unhashable' 通过,干净裁决便传递到两轮都未曾认证其渲染的 hunk 之上。本地流程的对应物 changedSince 实现了「UNHASHABLE 永不相等——甚至不等于自身」;这里的比较漏掉了它。具体场景:评审机器配置了 git config diff.unset.binary true,且已提交的 .gitattributes 含 *.dat diff=unset:第 1 轮把 data.dat 渲染为二进制并以 attrs: 'unhashable' 晋升干净裁决;轮间该配置被移除(或未跟踪的工作区 .gitattributes 在 diff=unset ↔ -diff 间翻转——两者都答复 unset,attributesMoved 看不见)时,渲染在二进制标记与可读 hunk 之间翻转,而 blob、树条目与 attrs 全部不动——changedPairs 返回 [],裁决传递到没有任何一轮读过的 hunk 上。
证据(本提交上探测,真实 git + 真实 blobPairs/changedPairs):BASE:两轮 attrs 均为 "unhashable",渲染已翻转、pair 相同,changedPairs 返回 [](裁决传递);加入 UNHASHABLE 永不相等分支后返回 ["data.dat"]。
修复:按上方 suggestion 在捕获处归一化(import UNHASHABLE),并在 changedPairs 中加入永不相等分支,使已写出的记录也失败关闭。修复验收:以 attrs: 'unhashable' 镜像现有「探测无法报告的渲染不传递裁决」测试并期望 ['x.txt']——移除永不相等守卫后该测试变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| // `inertText` on a refusal, so a gap in either sweep is a forged terminal | ||
| // line either way. | ||
| const controlled = (v: unknown): boolean => | ||
| typeof v === 'string' && CONTROL.test(v); |
There was a problem hiding this comment.
[Critical] R12-1: Still stands — re-verified by probe at this commit. The refuse-at-write sweep tests only TOP-LEVEL candidate scalar strings — controlled() is typeof v === 'string' && CONTROL.test(v) iterated over candidateFieldsOf(candidate) — so map-valued candidate fields (files on the local candidate, fileVerdicts on the PR candidate) fail the typeof check and strings nested inside them, including the MAP KEYS, which are file paths and fully workspace-controlled, are never seen; and the ledger copy loop persists every ledger-owned string (findings[] summaries, verdict) unchecked. The comment above claims "Every persisted STRING is checked", which is doubly inaccurate at HEAD. git permits control characters in filenames, and this PR's own suite exercises such names against the sibling sinks; the stated design is refuse-at-write rather than escaping at every reader, and fetch-pr.ts imports no escaper at all — the persisted nested strings are one raw-printing reader away from a forged terminal line.
Witness (probe through the real handler at this commit):
BASE: fileVerdicts KEY 'evil\u009b.ts' -> threw:null wrote:true, raw U+009B in cache file
BASE: fileVerdicts value with U+009B -> threw:null wrote:true, raw U+009B persisted
BASE: ledger findings[0].summary with U+009B -> threw:null wrote:true, raw U+009B persisted
FIX (recursive key+value sweep, candidate and ledger): all three throw /carries control/, wrote:false
(C0 bytes are escaped by JSON.stringify itself, so the raw-persisting class is DEL/C1/Cf/Zl/Zp — exactly the class CONTROL sweeps but never reaches at these depths.)
Fix — make the sweep recursive over both candidate and ledger, keys included:
const containsControlled = (v: unknown): boolean => {
if (typeof v === 'string') return CONTROL.test(v);
if (Array.isArray(v)) return v.some(containsControlled);
if (typeof v === 'object' && v !== null)
return Object.entries(v).some(
([k, x]) => CONTROL.test(k) || containsControlled(x),
);
return false;
};Fix witness: extend the control-character cases with a candidate whose fileVerdicts KEY carries \u009b, expecting /carries control/ and no output file — deleting the recursion turns it red.
中文说明
[Critical] R12-1:依然存在——本轮已用探测重新验证。写入端拒绝扫描只检查顶层 candidate 标量字符串——controlled() 是对 candidateFieldsOf(candidate) 逐键执行的 typeof v === 'string' && CONTROL.test(v)——因此 map 类型的 candidate 字段(本地 candidate 的 files、PR candidate 的 fileVerdicts)无法通过 typeof 检查,其内部嵌套的字符串(包括作为键的文件路径——完全由工作区控制)从来不被检查;而 ledger 拷贝循环会未经检查地持久化每一个 ledger 所有的字符串(findings[] 摘要、verdict)。上方注释声称「每一个被持久化的字符串都会被检查」,在本 HEAD 上双重失实。git 允许文件名携带控制字符,本 PR 自己的测试套件也在兄弟写入点上测过这类文件名;本命令声明的设计是「在写入端拒绝,而不是在每个读取端转义」,而 fetch-pr.ts 根本没有导入任何转义器——这些被持久化的嵌套字符串距离伪造终端行只差一个原样打印的读取端。
证据(本提交上经真实 handler 探测):fileVerdicts 键 'evil\u009b.ts' → 不抛错、已写盘,缓存文件中含裸 U+009B;fileVerdicts 值带 U+009B → 原样持久化;ledger findings[0].summary 带 U+009B → 原样持久化。修复分支(对 candidate 与 ledger 做递归的键+值扫描):三者全部抛 /carries control/、不落盘。(C0 字节会被 JSON.stringify 自身转义,因此原样持久化的类别是 DEL/C1/Cf/Zl/Zp——恰为 CONTROL 所扫、却在这些深度永远够不到的类别。)
修复:让扫描对 candidate 与 ledger 递归(含键),见上方代码块。修复验收:在控制字符用例中新增一个 fileVerdicts 键携带 \u009b 的 candidate,期望 /carries control/ 且不产生输出文件——删除递归后该测试变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| const controlled = (v: unknown): boolean => | ||
| typeof v === 'string' && CONTROL.test(v); | ||
| for (const key of candidateFieldsOf(candidate)) { | ||
| if (controlled(candidate[key])) { |
There was a problem hiding this comment.
[Critical] R8-4: Still stands — re-verified by probe at this commit, with a trigger correction. The CONTROL sweep tests only candidate VALUES — this line checks candidate[key] — never the candidate's KEYS, and its own refusal interpolates the unchecked key raw into an error that reaches the operator's terminal, while every other untrusted string in this file's error paths goes through inertText. A tampered candidate at the deterministic in-repo path (the threat this command's own header names) whose KEY carries ESC/C1/Cf bytes with a controlled value passes the key check and hits the value refusal, whose message prints the raw key — a forged terminal line from the command that exists to refuse forged output. Correction to the earlier scenario: the reachable raw print is this sweep's own refusal when a controlled value sits under a dirty key (the target-mismatch arm escapes its values); a dirty key with a CLEAN value passes the sweep entirely and persists — that half is R12-1's family.
Witness (probe at this commit):
BASE: candidate key 'bad\u001bkey' + controlled value -> threw; messageHasRawEsc: TRUE (raw ESC in the error)
FIX (sweep keys + inertText(key) in the refusal): messageControl: []
| if (controlled(candidate[key])) { | |
| if (controlled(key) || controlled(candidate[key])) { |
(and route the interpolated key through inertText in the refusal message). Fix witness: a cache-commit.test.ts case seeding a candidate whose KEY carries \u001b and whose value is controlled, asserting the refusal message contains no raw \u001b — removing the key sweep or the inertText wrap turns it red.
中文说明
[Critical] R8-4:依然存在——本轮已用探测重新验证,并修正触发路径。CONTROL 扫描只检查 candidate 的值——此行检查 candidate[key]——从不检查 candidate 的键,而其自身的拒绝信息会把未经检查的 key 原样插入一条直达操作者终端的错误,而本文件其他所有错误路径中的不可信字符串都经过 inertText。一个被篡改的 candidate(位于仓库内确定性路径——本命令头部亲自点名的威胁)若其键携带 ESC/C1/Cf 字节而值受控,会通过键检查、撞上值拒绝,其错误信息将裸键打印出来——从一条以拒绝伪造输出为存在目的的命令发出的一行伪造终端输出。对先前场景的修正:可达的裸打印是「受控值位于脏键之下时本扫描自身的拒绝」(target 不匹配分支会转义其值);脏键配干净值则整体通过扫描并被持久化——那一半属于 R12-1 家族。
证据(本提交上探测):candidate 键 'bad\u001bkey' + 受控值 → 抛错,错误信息中含裸 ESC;修复(扫描键 + 拒绝信息中 inertText(key))后 → 信息中无控制字符。
修复:按上方 suggestion 同时扫描键;并在拒绝信息中把 key 经 inertText 输出。修复验收:新增一个键携带 \u001b、值受控的 candidate 用例,断言拒绝信息不含裸 \u001b——移除键扫描或 inertText 包裹后该测试变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| '-c', | ||
| 'diff.algorithm=myers', | ||
| '-c', | ||
| 'diff.indentHeuristic=true', |
There was a problem hiding this comment.
[Critical] R4-1: Still stands — carried as a CLASS finding under this family's round-4 id, re-verified against git at this commit with a freshly demonstrated open entrance. The per-file verdict pair identity enumerates git's diff-rendering knobs one entrance at a time, and the surface keeps regenerating — five consecutive rounds have each found new entrances (governing attributes; diff.algorithm/diff.indentHeuristic; diff.renameLimit; interHunkContext/compactionHeuristic; driver-scoped diff.<driver>.algorithm). This round's diff pins diff.algorithm=myers and diff.indentHeuristic=true here — closing two entrances — but driver-scoped diff.<driver>.algorithm resolves AHEAD of the generic key, and driver names come from .gitattributes (repo/attacker-controlled), so the per-driver config namespace cannot be pinned by enumeration ahead of time; renderingAttributes folds only diff.<driver>.binary.
Witness (probe at this commit, real git, all else held fixed): repo with *.txt diff=foo, file pair found by brute force where the algorithms disagree:
machine WITHOUT diff.foo.algorithm: hunks attributed per myers
machine WITH diff.foo.algorithm=histogram: different +/- line set for identical blobs
renderingAttributes probes on both machines: IDENTICAL (check-attr diff: foo, binary: unspecified)
-> identical pair identity, changedPairs: [] — verdict transfers over a differently-rendered diff
(interHunkContext/compactionHeuristic change hunk framing but not the +/- set — weaker harm; diff.<driver>.algorithm changes +/- attribution — the strong, demonstrated harm. A knob added in a future git re-opens the class.)
Structural fix — bind the verdict to the rendered bytes: hash the per-file rendered diff section at capture and compare at transfer, which folds every config/driver/attribute/version knob at once, instead of pinning knobs one entrance at a time. Fix witness: an end-to-end test in which two rounds run under different diff.<driver>.algorithm settings and the verdict does NOT transfer — it goes red if the identity falls back to knob enumeration.
中文说明
[Critical] R4-1:依然存在——作为本家族第 4 轮的 CLASS 发现继续携带,本轮已对 git 重新验证,并新演示了一个仍开放的入口。每文件裁决的 pair 身份逐个入口地枚举 git 的 diff 渲染旋钮,而该表面持续再生——连续五轮每轮都发现新入口(governing attributes;diff.algorithm/diff.indentHeuristic;diff.renameLimit;interHunkContext/compactionHeuristic;驱动级 diff.<driver>.algorithm)。本轮 diff 在此钉住 diff.algorithm=myers 与 diff.indentHeuristic=true——关闭两个入口——但驱动级 diff.<driver>.algorithm 的解析优先于通用键,且驱动名来自 .gitattributes(仓库/攻击者可控),因此每驱动的配置命名空间无法被提前枚举钉死;renderingAttributes 只折叠 diff.<driver>.binary。
证据(本提交上探测,真实 git,其余条件固定):含 *.txt diff=foo 的仓库,暴力找到使两种算法结论相异的文件对:无 diff.foo.algorithm 的机器按 myers 归属 hunk;配置 diff.foo.algorithm=histogram 的机器对相同 blob 给出不同的 +/- 行集;两台机器上 renderingAttributes 的探测答复完全相同(check-attr diff: foo, binary: unspecified)→ pair 身份相同,changedPairs: []——裁决传递到了渲染不同的 diff 上。(interHunkContext/compactionHeuristic 改变 hunk 框架但不改变 +/- 集——危害较弱;diff.<driver>.algorithm 改变 +/- 归属——即已演示的强危害。未来 git 新增的旋钮会重新打开该类别。)
结构性修复——把裁决绑定到渲染出的字节:在捕获时对每文件渲染出的 diff 段落取哈希、在传递时比较,一次性折叠所有配置/驱动/属性/版本旋钮,而不是逐入口钉旋钮。修复验收:一个端到端测试——两轮运行于不同的 diff.<driver>.algorithm 配置下,裁决不传递;若身份回退为旋钮枚举,该测试变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it, and neither must a `--topology minimal` run at any effort — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. The reason is one reason, and it is why the rule is stated as a rule: the anchor's whole claim is "the next round may skip this", and a claim nobody ruled on is exactly what the next round must not skip — the round after it stops decided over a Critical that was never verified: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare model token (`YOUR_MODEL_ID`), which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the cache path for this target>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. **Where that path comes from differs by flow, and only one of them publishes it**: a LOCAL or file-path round reads it off the plan's `cachePath` — the capture derives the target and `safeTarget` is not hand-reproducible, so the name is not yours to spell — while the PR flow's plan carries no such field and the name is the one this section already gives, `.qwen/review-cache/pr-<n>.json`, which you already know from the PR number. Do not look for `cachePath` on a `fetch-pr` report; it has none, and treating its absence as a reason to skip the write would lose the PR cache entirely. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. **And these two, which complete the list**: a finding still marked `— [unverified]`, and an undecided blocker — a Critical whose verifier never returned, from a deadline stop or a timed-out batch. Neither enters `findings[]` (only confirmed findings do) and neither reads as "unreviewed scope", so without naming them a round promotes the anchor over a claim nobody ruled on and the round after it stops decided. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) |
There was a problem hiding this comment.
[Critical] R14-1: Still stands — re-verified at this commit, and independently re-discovered this round by the cross-file tracer. The rewritten Step 8 broadens the local-only pre-promotion CHECK to "for PR and local alike" — "an absent cacheCandidateStateId field on a plan that published a path is treated exactly like a withheld candidate: skip the cache write and say so" — but a fetch-pr plan structurally cannot pass it: fetch-pr publishes cacheCandidatePath (spread at fetch-pr.ts:1782) yet contains zero occurrences of cacheCandidateStateId (the field's only publisher is capture-local.ts), and the fetch-pr candidate carries no stateId. So every high-effort PR round whose capture wrote a candidate reads the plan, finds the field absent, matches the mechanical fail clause and skips cache-commit — the PR cache never advances, the findings ledger is lost for runs that do not post, and the fileVerdicts record this PR exists to persist is never promoted. The hand-write fallback is keyed to the plan LACKING cacheCandidatePath, which does not apply. The paragraph itself later warns against this exact mistake for cachePath — "treating its absence as a reason to skip the write would lose the PR cache entirely" — while here it commands the skip.
Witness: not run — the claim is a prose-scoping condition plus a structural field absence, settled by grep/read at this commit: 0 occurrences of cacheCandidateStateId/stateId in fetch-pr.ts vs the quoted clause above; capture-local.ts is the field's only publisher.
Fix: scope the CHECK sentence to local/file-path rounds as the old opening did (the PR flow takes a lease, per the sentence's own rationale), or have fetch-pr publish a cacheCandidateStateId (e.g. a sha256 over the candidate bytes, carried in the candidate for comparison). Fix witness: for the code-side fix, a fetch-pr report test asserting cacheCandidateStateId is published whenever cacheCandidatePath is; for the prose fix, the SKILL.test.ts pin moves with the sentence.
中文说明
[Critical] R14-1:依然存在——本轮已重新验证,且被跨文件追踪员独立重新发现。重写后的 Step 8 把仅限本地流程的晋升前 CHECK 拓宽为「PR 与 local 一视同仁」——「发布了路径、却缺少 cacheCandidateStateId 字段的计划,视同被扣留的 candidate:跳过缓存写入并说明」——但 fetch-pr 的计划在结构上不可能通过它:fetch-pr 发布 cacheCandidatePath(fetch-pr.ts:1782 处展开),全文却没有 cacheCandidateStateId 的任何出现(该字段唯一的发布者是 capture-local.ts),且 fetch-pr 的 candidate 不携带 stateId。于是每一个写成功 candidate 的高强度 PR 轮次都会读到:字段缺失 → 命中机械失败条款 → 跳过 cache-commit——PR 缓存永不前进,不发布评论的轮次丢失发现台账,本 PR 存在意义所在的 fileVerdicts 记录永不被晋升。手写回退以计划缺少 cacheCandidatePath 为触发条件,而此处不适用。同一段落后面还亲自警告过 cachePath 上的同款错误——「把它的缺失当作跳过写入的理由会彻底丢掉 PR 缓存」——而在这里却命令跳过。
证据:未运行——该主张是行文范围条件加结构性字段缺失,由本提交上的 grep/阅读判定:fetch-pr.ts 中 cacheCandidateStateId/stateId 出现 0 次,对比上方引文;该字段唯一发布者为 capture-local.ts。
修复:把 CHECK 句限定回本地/文件轮次(如旧版开头所示——按该句自身的理由,PR 流程持有租约),或让 fetch-pr 发布 cacheCandidateStateId(如对 candidate 字节取 sha256,并随 candidate 携带以供比较)。修复验收:代码侧修复则加 fetch-pr 报告测试,断言凡发布 cacheCandidatePath 必发布 cacheCandidateStateId;行文侧修复则 SKILL.test.ts 的锚点随句子移动。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| atomicWriteFileSync(args.out, `${JSON.stringify(merged, null, 2)}\n`, { | ||
| noFollow: true, | ||
| }); | ||
| writeStdoutLine(`Committed review cache to ${args.out}`); |
There was a problem hiding this comment.
[Critical] R17-1: Still stands — re-verified by probe at this commit (lineage R15-4). The intake sweep polices every candidate VALUE but never the command's own CLI path arguments, and this success line prints the unswept --out raw — the one print site in a file that escapes every other untrusted string through inertText (readJsonObject wraps the --candidate/--ledger paths in its error paths). --out is not a trusted constant: in the PR flow Step 8 (persistence.md) has the reviewing model spell it, and the module's own threat model counts attacker-or-model-written text. A --out carrying ESC/C1/Cf bytes: mkdirSync persists the dirty name and the success line emits the raw bytes at the operator's terminal — a forged terminal line from the command whose stated purpose is refusing forged output at the writing end.
Witness (probe at this commit):
BASE: clean candidate + --out '<dir>/ca\u001bche/pr-7.json'
-> threw:null wrote:true, stdoutHasRawEsc:true
FIX (inertText(args.out) on the success line): stdoutControl: [] (quoted+escaped, no raw ESC)
| writeStdoutLine(`Committed review cache to ${args.out}`); | |
| writeStdoutLine(`Committed review cache to ${inertText(args.out)}`); |
Fix witness: a cache-commit.test.ts case running the handler with an ESC-bearing --out and asserting the success line contains no raw \u001b — removing the inertText wrap turns it red.
中文说明
[Critical] R17-1:依然存在——本轮已用探测重新验证(谱系 R15-4)。入口扫描监管每一个 candidate 值,却从不监管命令自身的 CLI 路径参数,而此成功行把未经扫描的 --out 原样打印——这是本文件中唯一一个把其他所有不可信字符串都经 inertText 转义、唯独自己例外的打印点(readJsonObject 在其错误路径中会包裹 --candidate/--ledger 路径)。--out 不是可信常量:在 PR 流程中,Step 8(persistence.md)由评审模型亲手拼写它,而本模块的威胁模型把「攻击者或模型写出的文本」计入其中。携带 ESC/C1/Cf 字节的 --out:mkdirSync 会把脏名字持久化,成功行则把裸字节射向操作者终端——从一条以「在写入端拒绝伪造输出」为存在目的的命令发出的一行伪造终端输出。
证据(本提交上探测):干净 candidate + --out '<dir>/ca\u001bche/pr-7.json' → 未抛错、已写盘,stdout 含裸 ESC;修复(成功行使用 inertText(args.out))后 → stdout 无控制字符(已加引号并转义)。
修复:按上方 suggestion。修复验收:新增一个以携带 ESC 的 --out 运行 handler 的用例,断言成功行不含裸 \u001b——移除 inertText 包裹后该测试变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
# Conflicts: # packages/core/src/skills/bundled/review/references/persistence.md
…ude file targets, keep every byte Six findings, all confirmed against the tree: The candidate write now gates on the dropped-out-while-on-disk set: a refused-anchor round used to write a candidate that silently OMITTED the dropped path, Step 8 promoted the omission, and two rounds later a scope-emptied stop certified bytes no round read. The cache is read before the write now, the withholding is voiced with its own sentence, and the scoping branch reuses the early read. The unchanged-since-last-round stop gains the file-review exclusion BOTH sibling stops carry — a cached round-2 file review of an unmodified subject stopped decided while the identical tree without a cache routed to the whole-file review. The excluded shape gets its own honest stderr line instead of falling into the unhashable-paths diagnosis, and the directory-subject test now pins convergence as the ABSENCE of the wedge. gitlinkIdentity asks the submodule's OWN visibility bits: status --porcelain honours an assume-unchanged bit set inside the submodule, so cleanliness judged by status alone held the identity still over interior bytes no round can see — the fix-induced half of R22-1, closed with the same oracle one level down. canonicalise strips only the platform's separators on the ancestor walk: `\` is a legal POSIX filename byte this PR's own fixtures insist on, and the two-class strip corrupted a dangling `\link` into `link`. SKILL.md: the Step 1 file bullet's --out template carries the 24-char truncation (the full-basename spelling died with ENAMETOOLONG past ~226-byte basenames, measured), and both PR stops (up-to-date, empty diff) now write the stop sidecar with the run's nonce before cleanup — the reader predicted the name but nothing in the PR flow ever wrote it, so every decided PR stop exited 1 "Review did not complete". All four code fixes mutation-checked red; guards pin both prose fixes.
…o p2 # Conflicts: # packages/cli/src/commands/review/capture-local.ts # packages/cli/src/commands/review/lib/paths.test.ts
The base publishes cacheCandidatePath and removes the file when the candidate is withheld; this branch keeps the FIELD off the plan (Step 8 branches on presence). The dropped-out-while-on-disk arm asserts the field's absence here instead of the file's.
Human review on #9659: the round-23 PR stop protocol writes the sidecar and runs `cleanup pr-<n>` in the same breath, but cleanup swept the same prefix — and the parent's first in-run poll is up to 250 ms away, so a write+cleanup finishing before the first tick left NOTHING for either the snapshot or the post-close fallback to read: an already-decided up-to-date/empty-diff round still exited 1 "Review did not complete". The local flow never hit this only because its stop (Step 1) and its cleanup (Step 9) are minutes apart. cleanup now spares `<prefix>stop.json` exactly when its runId matches the environment the parent stamped — a foreign or unstamped sidecar is residue and sweeps as before, and the NEXT run's cleanup (different nonce) collects this one. SKILL.md says so beside the write instruction, so the orchestrator does not "tidy" the file by hand. Pinned from both sides of the window: cleanup.test spares the matching sidecar and sweeps a foreign one (mutation-checked red); run.test gains the zero-timer-advance arm — child writes the sidecar and closes on a microtask, before ANY poll tick, and the post-close fallback still reads the decision (the existing race arms all advanced 1000 ms first, which is exactly how the window went unpinned).
# Conflicts: # packages/cli/src/commands/review/lib/paths.ts
…o p2 # Conflicts: # packages/cli/src/commands/review/lib/paths.ts
The base PR squashed into main as 40c77fc, so git's recursive merge saw the same content under different SHAs and conflicted across eleven files. Resolved by per-file three-way merges against the TRUE logical base — the old base branch tip (917e1d4), which still exists — where every file resolves clean: this branch's own delta (the cache-commit command and the guarded candidate write) does not overlap any post-squash main change.
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
18 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- producer-only scope gap (fetch-pr.ts:260) — already reported as R1-3 (comment 3831106934), re-adjudicated Suggestion-level rounds 18/19
- fetch-pr producer block zero coverage (fetch-pr.ts:1627) — already reported as R1-4 (comment 3831106942)
- fileForm digest refusal untested (cache-commit.ts:173) — already reported as R2-4 (comment 3832199063)
- stale allowlist docblock above LEDGER_FIELDS (cache-commit.ts:67) — already reported as R11-3
- diff-flags pins unasserted (diff-flags.ts:45-48) — already reported as R11-4
- inert-text.test.ts committed with raw control bytes, classified binary — already reported as R11-8
- displayAnchor doc names phantom rescope renderer (report.ts:334) — already reported as R1-19 (comment 3831107018)
- persistence.md:77 'nothing else' completeness contradiction — already reported as R2-21, re-adjudicated R18-2 at Suggestion
- persistence.md:77 skippedFiles 'both flows' vs 'one local-only condition' — already reported as R12-4
- persistence.md:77 fallback template shape for local/file rounds — already reported as R1-20/R2-23b, re-adjudicated R18-2
- persistence.md:77 fallback-vs-withheld-candidate contradiction — already reported as R19-16 (R1-20/R2-21 lineage)
- ledger-model test title overclaims (cache-commit.test.ts:295) — disclosed round 3
- --out .json extension never enforced (cache-commit.ts:164) — disclosed round-4 deferral list; re-probed this round (flip verified)
- mkdir-before-guard ordering at all three writers (cache-commit.ts:219) — disclosed round-8 deferral list; re-probed this round
- withhold-arm rmSync follows a symlinked .qwen/tmp parent (capture-local.ts:828) — disclosed rounds 2/3/18 deferral lists; probe-verified this round; verifier adjudicated Suggestion (bounded blast radius — only the deterministic artifact nam…
- assertUnredirectedParent '..'-after-symlink lexical-vs-kernel divergence (paths.ts:369) — disclosed round-4 deferral list; probe-verified this round; verifier adjudicated Suggestion (no shipped flow emits a '..' segment)
- findingsCount seeded but never asserted (cache-commit.test.ts:87) — disclosed round 10 as R10-4
- inert-text exactly-once test cannot witness the double-backslash defect — folded into R11-12 lineage
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.
Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)
Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (9 Critical(s)), the rate of first-time findings is not falling (this round 0, previous 0), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):
| standing Critical | attack surface | attacker-dependency | blast radius |
|---|---|---|---|
| (each standing Critical) | … | … | … |
Advisory only — it does not block this review.
[Critical] R12-2: Still stands — re-verified by probe at this commit: the read side the anchor's trust lands on is unguarded — readLocalCache (lib/local-anchor.ts:694) is a plain readFileSync with shape-only validation and no lstat/symlink refusal, and resolveCachePath probes with following statSync/existsSync — while this PR polices the WRITE side of the same deterministic .qwen/review-cache path against exactly this threat. A forged cache planted as .qwen/review-cache/local.json (a symlink — git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: gate-passing forgery requires state knowledge, but HEAD is known when reviewing an attacker branch checkout and the model id is guessable — validation is shape/token-deep, as this PR's own comments concede. Probe this round: readLocalCache through a planted symlink returned the forged stateId (anchor material accepted); round-18/19 end-to-end probes at the same code showed the anchor honoured with deltaFiles=[] over a fully-dirty tree, and an lstat refusal flipping it to full capture. Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side; fix witness — a capture-local test planting a symlinked local.json cache and asserting fallback to full capture. (Body-only: the defect line is outside this diff's hunks.)
[Critical] R10-3: Still stands — re-verified by code trace at this commit: the plan report carrying the cacheCandidatePath field is written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures (capture-local.ts:1265; fetch-pr.ts:1791) — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds, and the sibling writers are equally unguarded (diff at capture-local.ts:1228, fullDiffPath at :943, stop at :1203; fetch-pr diff at :1096). The plan's cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself, and the stateId CHECK compares two values both read off the plan — a substituted plan controls both sides. With .qwen/tmp committed as a symlink (this PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — anyone with write access to the link target substitutes the machine-readable record that orchestrates the round (fabricating cacheCandidatePath even after the capture withheld the real candidate). Round-18/19 probe at the same code: plan landed in the victim dir while the candidate did not; guarding the plan write flipped it. Fix: route the plan/diff/stop writes through assertUnredirectedParent + atomicWriteFileSync(noFollow) in both captures and extend the symlink test to assert diff/plan absence in the victim. (Body-only: the defect lines are outside this diff's hunks.)
[Critical] R17-2: Still stands — re-verified by probe on a real filesystem at this commit: assertUnredirectedParent (paths.ts:364) — the shared parent-chain guard this PR adds and wires into all three writers — detects redirection by EXACT STRING comparison of a lexical path (resolve(dirname(target)), built on process.cwd() case-as-typed) against realpathSync's canonical form, with no case folding, no platform branch, and no stop at the repo root. On case-insensitive/redirected filesystems — Windows NTFS case mismatch, SUBSTed drives, junction/OneDrive-redirected prefixes, benign symlinked prefixes above the repo root — the two spellings legitimately differ with no planted symlink: capture-local/fetch-pr catch the throw and withhold the cache candidate every round, and cache-commit.ts:220 calls the guard bare, so the throw escapes the handler non-zero. The rebase-survival cache this PR exists to build is dead on every such machine, with a diagnostic blaming a link that does not exist; Linux CI and realpath-built test roots always pass, so this cannot surface in CI. Probe this round: link-spelled path (benign prefix) THREW while physical-spelled path PASSED. Fix: walk the parent chain with lstatSync refusing only actual symlink components, stopping at the repo root (the house pattern: lib/worktree.ts redirectedAncestor), or compare case-insensitively on win32/darwin; and catch the guard in cache-commit as the captures do. (Body-only: the comparison lines are outside this diff's hunks.)
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 18 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查:反向审计——在 5 轮的反审轮数上限内未收敛。
机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)
残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 9 条 Critical),首次发现的速率没有下降(本轮 0,上一轮 0),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。
[Critical] R12-2: Still stands — re-verified by probe at this commit: the read side the anchor's trust lands on is unguarded — readLocalCache (lib/local-anchor.ts:694) is a plain readFileSync with shape-only validation and no lstat/symlink refusal, and resolveCachePath probes with following statSync/existsSync — while this PR polices the WRITE side of the same deterministic .qwen/review-cache path against exactly this threat. A forged cache planted as .qwen/review-cache/local.json (a symlink — git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: gate-passing forgery requires state knowledge, but HEAD is known when reviewing an attacker branch checkout and the model id is guessable — validation is shape/token-deep, as this PR's own comments concede. Probe this round: readLocalCache through a planted symlink returned the forged stateId (anchor material accepted); round-18/19 end-to-end probes at the same code showed the anchor honoured with deltaFiles=[] over a fully-dirty tree, and an lstat refusal flipping it to full capture. Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side; fix witness — a capture-local test planting a symlinked local.json cache and asserting fallback to full capture. (Body-only: the defect line is outside this diff's hunks.)
[Critical] R10-3: Still stands — re-verified by code trace at this commit: the plan report carrying the cacheCandidatePath field is written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures (capture-local.ts:1265; fetch-pr.ts:1791) — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds, and the sibling writers are equally unguarded (diff at capture-local.ts:1228, fullDiffPath at :943, stop at :1203; fetch-pr diff at :1096). The plan's cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself, and the stateId CHECK compares two values both read off the plan — a substituted plan controls both sides. With .qwen/tmp committed as a symlink (this PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — anyone with write access to the link target substitutes the machine-readable record that orchestrates the round (fabricating cacheCandidatePath even after the capture withheld the real candidate). Round-18/19 probe at the same code: plan landed in the victim dir while the candidate did not; guarding the plan write flipped it. Fix: route the plan/diff/stop writes through assertUnredirectedParent + atomicWriteFileSync(noFollow) in both captures and extend the symlink test to assert diff/plan absence in the victim. (Body-only: the defect lines are outside this diff's hunks.)
[Critical] R17-2: Still stands — re-verified by probe on a real filesystem at this commit: assertUnredirectedParent (paths.ts:364) — the shared parent-chain guard this PR adds and wires into all three writers — detects redirection by EXACT STRING comparison of a lexical path (resolve(dirname(target)), built on process.cwd() case-as-typed) against realpathSync's canonical form, with no case folding, no platform branch, and no stop at the repo root. On case-insensitive/redirected filesystems — Windows NTFS case mismatch, SUBSTed drives, junction/OneDrive-redirected prefixes, benign symlinked prefixes above the repo root — the two spellings legitimately differ with no planted symlink: capture-local/fetch-pr catch the throw and withhold the cache candidate every round, and cache-commit.ts:220 calls the guard bare, so the throw escapes the handler non-zero. The rebase-survival cache this PR exists to build is dead on every such machine, with a diagnostic blaming a link that does not exist; Linux CI and realpath-built test roots always pass, so this cannot surface in CI. Probe this round: link-spelled path (benign prefix) THREW while physical-spelled path PASSED. Fix: walk the parent chain with lstatSync refusing only actual symlink components, stopping at the repo root (the house pattern: lib/worktree.ts redirectedAncestor), or compare case-insensitively on win32/darwin; and catch the guard in cache-commit as the captures do. (Body-only: the comparison lines are outside this diff's hunks.)
— qwen3.8-max via Qwen Code /review (v0.22.2)
| // `undefined === undefined`, and a clean verdict transferred over a | ||
| // rendering neither round ever certified. Failing closed costs a | ||
| // re-review; failing open costs the review. | ||
| out[p].attrs = attrs[p] ?? UNANSWERED_ATTRS; |
There was a problem hiding this comment.
[Critical] R8-1: Still stands — re-verified by probe at this commit, and independently re-discovered this round. blobPairs records renderingAttributes' UNHASHABLE answer (the literal 'unhashable') verbatim as the pair's attrs component — this fold catches only undefined — and changedPairs treats only 'unanswered' as never-equal, so 'unhashable' === 'unhashable' passes and a clean verdict transfers over hunks whose rendering neither round ever certified. renderingAttributes returns UNHASHABLE for three documented classes: a diff= driver name containing invalid UTF-8, a diff=set/diff=unset value assignment, and diff=unspecified while diff.unspecified.binary is configured. The local-flow analogue changedSince implements "UNHASHABLE never equals — not even itself"; this comparison omits it.
Probe at this commit, real changedPairs from the built code: changedPairs over attrs: 'unhashable' on both sides returns [] (verdict transfers), while the 'unanswered' control arm returns the path (never-equal). Trigger: .gitattributes with *.dat diff=unset plus git config diff.unset.binary true on one machine flips that file between "Binary files … differ" and readable hunks with blobs and attrs standing still — the verdict transfers over a rendering no round read.
Fix: normalize at capture and fail closed at compare — import UNHASHABLE from ./local-anchor.js, record attrs[p] === undefined || attrs[p] === UNHASHABLE ? UNANSWERED_ATTRS : attrs[p], and add rec.attrs === UNHASHABLE || cur.attrs === UNHASHABLE || to the changedPairs guard so already-persisted records also fail closed.
Fix witness: mirror the existing 'never transfers a verdict over a rendering the probe could not report' test with attrs: 'unhashable' on both sides expecting the path returned — removing the never-equal guard turns it red (mutation check: delete the guard, run the new test, confirm it reds).
中文说明
[Critical] R8-1:依然存在——本轮已用探测重新验证,且被独立重新发现。blobPairs 把 renderingAttributes 的 UNHASHABLE 回答(字面量 'unhashable')原样记为配对的 attrs 分量——此折叠只捕获 undefined——而 changedPairs 只把 'unanswered' 当作永不相等,于是 'unhashable' === 'unhashable' 通过,干净裁决越过两轮都未曾认证的渲染被转移。本地流程的对应物 changedSince 实现了「UNHASHABLE 永不相等——连自身也不」;此比较遗漏了它。
本提交上探测:双侧 attrs: 'unhashable' 时 changedPairs 返回 [](裁决转移),而 'unanswered' 对照臂返回该路径(永不相等)。触发:.gitattributes 中 *.dat diff=unset 加某台机器上的 git config diff.unset.binary true,blob 与 attrs 不变 while 渲染在二进制标记与可读 hunk 之间翻转。
修复:记录侧归一化 + 比较侧失败关闭(见英文)。
修复验收:以 'unhashable' 双侧复用现有哨兵测试,期望返回该路径——移除永不相等守卫后变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| // persisted raw at a deterministic in-repo path and printed back through | ||
| // `inertText` on a refusal, so a gap in either sweep is a forged terminal | ||
| // line either way. | ||
| const controlled = (v: unknown): boolean => |
There was a problem hiding this comment.
[Critical] R12-1: Still stands — re-verified by probe through the real handler at this commit, and independently re-discovered this round. The refuse-at-write sweep tests only TOP-LEVEL candidate scalar strings — controlled() is typeof v === 'string' && CONTROL.test(v) iterated over candidateFieldsOf(candidate) — so map-valued candidate fields (files on the local candidate, fileVerdicts on the PR candidate) fail the typeof check, and strings nested inside them — including the MAP KEYS, which are file paths and fully workspace-controlled — are never seen; the ledger copy loop persists every ledger-owned string (findings[] summaries, verdict) unchecked. The comment above claims "Every persisted STRING is checked", which is doubly inaccurate at HEAD.
Probe at this commit through the real handler: a candidate whose fileVerdicts KEY carries U+009B → no throw, cache written, raw C1 byte in the file; a ledger findings[0].summary carrying U+009B → no throw, raw C1 persisted. DEL, the C1 range, the Cf class and U+2028/U+2029 are exactly the classes JSON.stringify passes through unescaped — they land raw in the durable in-repo cache, one raw-printing reader away from a forged terminal line.
Fix: make the sweep recursive over both candidate and ledger, keys included — a containsControlled(v) that tests strings, recurses arrays, and over object entries tests CONTROL.test(k) || containsControlled(x).
Fix witness: extend the control-character cases with a candidate whose fileVerdicts key carries \u009b, expecting /carries control/ and no output file — deleting the recursion turns it red (mutation check: remove the recursion, run the test, confirm it reds).
中文说明
[Critical] R12-1:依然存在——本轮已通过真实 handler 探测重新验证,且被独立重新发现。写入端拒绝扫描只测试顶层 candidate 标量字符串——对 map 值字段(files、fileVerdicts)typeof 检查即失败,其内部嵌套字符串——包括作为文件路径、完全由工作区控制的键——一律不被检查;ledger 复制循环原样持久化 findings[] summary 与 verdict。上方注释声称「每个被持久化的字符串都被检查」,在本提交上双重失实。
本提交上经真实 handler 探测:fileVerdicts 键携带 U+009B → 未拒绝、已写盘、文件含裸 C1;ledger summary 携带 U+009B → 未拒绝、裸 C1 持久化。
修复:递归扫描(含键),覆盖 candidate 与 ledger 两侧。
修复验收:新增键携带 \u009b 的用例,期望 /carries control/ 且无输出文件——移除递归后变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| const controlled = (v: unknown): boolean => | ||
| typeof v === 'string' && CONTROL.test(v); | ||
| for (const key of candidateFieldsOf(candidate)) { | ||
| if (controlled(candidate[key])) { |
There was a problem hiding this comment.
[Critical] R8-4: Still stands — re-verified by probe at this commit. The CONTROL sweep checks candidate VALUES only — this line tests candidate[key] — never the candidate's KEYS, and its own refusal interpolates the unchecked key raw into an error that reaches the operator's terminal, while every other untrusted string in this file's error paths goes through inertText. A tampered candidate at the deterministic in-repo path (the threat this command's own header names) whose KEY carries ESC/C1/Cf bytes passes the key check entirely when its value is clean and persists (R12-1's family); with a controlled value it hits this refusal, whose message prints the raw key — a forged terminal line from the command that exists to refuse forged output.
Probe at this commit: candidate key 'bad\u001b[key' with a controlled value → threw, and the refusal message carried the raw ESC byte.
| if (controlled(candidate[key])) { | |
| if (controlled(key) || controlled(candidate[key])) { |
and route the interpolated key through inertText in the refusal message.
Fix witness: a cache-commit.test.ts case seeding a candidate whose KEY carries \u001b[ and whose value is controlled, asserting the refusal message contains no raw ESC — removing the key sweep or the inertText wrap turns it red (mutation check: remove either, run the test, confirm it reds).
中文说明
[Critical] R8-4:依然存在——本轮已用探测重新验证。CONTROL 扫描只检查 candidate 的值(本行测试 candidate[key]),从不检查键;而其自身的拒绝消息把未检查的 key 原样插入一条抵达操作者终端的错误——本文件其他所有不可信字符串都经 inertText。键带控制字符而值干净的篡改 candidate 会整体通过扫描并被持久化;值受控时则命中此拒绝,其消息打印裸键——从一条以拒绝伪造输出为存在目的的命令发出伪造终端行。
修复:按上方 suggestion 同时扫描键,并在拒绝消息中以 inertText 包裹键。
修复验收:键携带 \u001b[ 且值受控的用例,断言拒绝消息不含裸 ESC——移除键扫描或 inertText 包裹后变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| '-c', | ||
| 'diff.algorithm=myers', | ||
| '-c', | ||
| 'diff.indentHeuristic=true', |
There was a problem hiding this comment.
[Critical] R4-1: Still stands — carried as a CLASS finding under this family's round-4 id, re-verified against git at this commit. The per-file verdict pair identity enumerates git's diff-rendering knobs one entrance at a time, and the surface keeps regenerating. This diff pins diff.algorithm=myers and diff.indentHeuristic=true — closing two entrances — but driver-scoped diff.<driver>.algorithm resolves AHEAD of the generic key, and driver names come from .gitattributes (repo/attacker-controlled), so the per-driver config namespace cannot be pinned by enumeration; renderingAttributes folds only diff.<driver>.binary. This round's audit additionally re-demonstrated diff.interHunkContext as an unpinned generic knob — folded here as evidence, not re-filed; it has ridden this class body since round 4.
Probe at this commit with real git: for a file pair where myers and histogram disagree, -c diff.algorithm=myers alone gave the myers +/- attribution; adding diff.foo.algorithm=histogram (.gitattributes: f.txt diff=foo) gave the histogram attribution — different rendering, while check-attr answers (the identity probe's input) were byte-identical on both machines, so the pair identity does not move and the verdict transfers over a diff rendered differently.
Fix (structural): bind the verdict to the rendered bytes — hash the per-file rendered diff section at capture and compare at transfer, folding every config/driver/attribute/version knob at once instead of pinning knobs one entrance at a time. Enumeration has been fix-on-fix here for six rounds; each pin closes one entrance of an open-ended namespace.
Fix witness: an end-to-end test in which two rounds run under different diff.<driver>.algorithm settings and the verdict does NOT transfer — it goes red if the identity falls back to knob enumeration.
中文说明
[Critical] R4-1:依然存在——以本族第 4 轮的 id 作为类级发现继续携带,本轮已在真实 git 上重新验证。逐文件裁决配对标识逐个入口枚举 git 的 diff 渲染旋钮,而该表面不断再生。本 diff 钉住 diff.algorithm=myers 与 diff.indentHeuristic=true(关闭两个入口),但驱动作用域的 diff.<driver>.algorithm 优先于通用键解析,驱动名来自 .gitattributes(仓库/攻击者可控),该命名空间无法靠枚举钉全;renderingAttributes 只折叠 diff.<driver>.binary。本轮审计另再证 diff.interHunkContext 为未钉的通用旋钮(作为证据并入本条,不另立)。
本提交上真实 git 探测:同一 blob 在两种机器上渲染出不同 +/- 归属,而 check-attr 回答(标识探测的输入)逐字节相同——配对标识不动,裁决越过不同渲染的 diff 被转移。
修复(结构性):把裁决绑定到渲染字节——捕获时对每个文件的渲染后 diff 段取哈希,转移时比较,一次性折叠所有配置/驱动/属性/版本旋钮。
修复验收:两轮在不同 diff.<driver>.algorithm 设置下运行且裁决不转移的端到端测试。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it, and neither must a `--topology minimal` run at any effort — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. The reason is one reason, and it is why the rule is stated as a rule: the anchor's whole claim is "the next round may skip this", and a claim nobody ruled on is exactly what the next round must not skip — the round after it stops decided over a Critical that was never verified: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare model token (`YOUR_MODEL_ID`), which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the cache path for this target>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. **Where that path comes from differs by flow, and only one of them publishes it**: a LOCAL or file-path round reads it off the plan's `cachePath` — the capture derives the target and `safeTarget` is not hand-reproducible, so the name is not yours to spell — while the PR flow's plan carries no such field and the name is the one this section already gives, `.qwen/review-cache/pr-<n>.json`, which you already know from the PR number. Do not look for `cachePath` on a `fetch-pr` report; it has none, and treating its absence as a reason to skip the write would lose the PR cache entirely. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. **And these two, which complete the list**: a finding still marked `— [unverified]`, and an undecided blocker — a Critical whose verifier never returned, from a deadline stop or a timed-out batch. Neither enters `findings[]` (only confirmed findings do) and neither reads as "unreviewed scope", so without naming them a round promotes the anchor over a claim nobody ruled on and the round after it stops decided. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) |
There was a problem hiding this comment.
[Critical] R14-1: Still stands — re-verified at this commit, and independently re-discovered this round. The rewritten Step 8 broadens the local-only pre-promotion CHECK to "for PR and local alike" — an absent cacheCandidateStateId field on a plan that published a path is "treated exactly like a withheld candidate: skip the cache write and say so" — but a fetch-pr plan structurally cannot pass it: fetch-pr publishes cacheCandidatePath yet contains zero occurrences of cacheCandidateStateId (the field's only publisher is capture-local.ts), and the fetch-pr candidate carries no stateId. So every high-effort PR round whose capture wrote a candidate reads the plan, finds the field absent, matches the mechanical fail clause and skips cache-commit — the PR cache never advances, the findings ledger is lost for runs that do not post, and the fileVerdicts record this PR exists to persist is never promoted. The hand-write fallback is keyed to the plan LACKING cacheCandidatePath, which does not apply. The paragraph itself later warns against this exact mistake for cachePath — "treating its absence as a reason to skip the write would lose the PR cache entirely" — while here it commands the skip.
Witness: grep/read at HEAD — 0 occurrences of cacheCandidateStateId/stateId in fetch-pr.ts vs the quoted clause; capture-local.ts is the field's only publisher.
Fix: scope the CHECK sentence to local/file-path rounds as the old opening did (the PR flow takes a lease, per the sentence's own rationale), or have fetch-pr publish a cacheCandidateStateId (e.g. a sha256 over the candidate bytes, carried in the candidate for comparison).
Fix witness: for the code-side fix, a fetch-pr report test asserting cacheCandidateStateId is published whenever cacheCandidatePath is; for the prose fix, the SKILL.test.ts pin moves with the sentence.
中文说明
[Critical] R14-1:依然存在——本轮已重新验证,且被独立重新发现。重写后的 Step 8 把仅限本地流程的晋升前 CHECK 拓宽为「PR 与 local 一视同仁」——「发布了路径、却缺少 cacheCandidateStateId 字段的计划,视同被扣留的 candidate:跳过缓存写入并说明」——但 fetch-pr 的计划在结构上不可能通过它:fetch-pr 发布 cacheCandidatePath,全文却没有 cacheCandidateStateId 的任何出现(该字段唯一的发布者是 capture-local.ts),且 fetch-pr 的 candidate 不携带 stateId。于是每一个写成功 candidate 的高强度 PR 轮次都会命中机械失败条款跳过 cache-commit——PR 缓存永不前进,不发布评论的轮次丢失发现台账,本 PR 存在意义所在的 fileVerdicts 记录永不被晋升。手写回退以计划缺少 cacheCandidatePath 为触发,此处不适用;同一段落后面还亲自警告过 cachePath 上的同款错误,在这里却命令跳过。
修复:把 CHECK 句限定回本地/文件轮次,或让 fetch-pr 发布 cacheCandidateStateId。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| atomicWriteFileSync(args.out, `${JSON.stringify(merged, null, 2)}\n`, { | ||
| noFollow: true, | ||
| }); | ||
| writeStdoutLine(`Committed review cache to ${args.out}`); |
There was a problem hiding this comment.
[Critical] R17-1: Still stands — re-verified by probe at this commit (lineage R15-4). The intake sweep polices every candidate VALUE but never the command's own CLI path arguments, and this success line prints the unswept --out raw — the one print site in a file that escapes every other untrusted string through inertText (readJsonObject wraps the --candidate/--ledger paths in its error paths). --out is not a trusted constant: in the PR flow Step 8 (persistence.md) has the reviewing model spell it, and the module's own threat model counts attacker-or-model-written text. A --out carrying ESC/C1/Cf bytes: mkdirSync persists the dirty name and the success line emits the raw bytes at the operator's terminal — a forged terminal line from the command whose stated purpose is refusing forged output at the writing end.
Probe at this commit: clean candidate + --out '<dir>/ca\u001bsche/pr-9.json' → no throw, cache written, and the success line carried the raw ESC.
| writeStdoutLine(`Committed review cache to ${args.out}`); | |
| writeStdoutLine(`Committed review cache to ${inertText(args.out)}`); |
Fix witness: a cache-commit.test.ts case running the handler with an ESC-bearing --out and asserting the success line contains no raw ESC — removing the inertText wrap turns it red (mutation check: remove the wrap, run the test, confirm it reds).
中文说明
[Critical] R17-1:依然存在——本轮已用探测重新验证(谱系 R15-4)。入口扫描监管每一个 candidate 值,却从不监管命令自身的 CLI 路径参数,而此成功行把未经扫描的 --out 原样打印——本文件中唯一一处其他所有不可信字符串都经 inertText 转义的打印点。--out 不是可信常量:PR 流程中由评审模型亲手拼写。携带 ESC/C1/Cf 字节的 --out:mkdirSync 持久化脏名字,成功行向操作者终端射出裸字节——从一条以「在写入端拒绝伪造输出」为目的的命令发出伪造终端行。
修复:按上方 suggestion。修复验收:以携带 ESC 的 --out 运行 handler,断言成功行不含裸 \^[——移除 inertText 包裹后变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
…ng-verdicts Resolve two spots: - commands/review.ts: take main's subcommand list (it gained emit-workflow, ab-drive, dedup-candidates, revert-hunk) and re-insert this branch's cache-commit after plan-diff. - review/lib/paths.ts: union of imports — main's crypto/fs/sanitize additions plus this branch's dirname. tsc -p packages/cli clean; review suite 5687 passed.
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
12 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- R1-3 headline rebase-survival claim not delivered (no production consumer reads fileVerdicts back) — already reported as R1-3 (fetch-pr.ts:260), re-adjudicated Suggestion rounds 18-20
- R1-4 fetch-pr cache-candidate producer block has zero test coverage — already reported as R1-4 (fetch-pr.ts:1627)
- R1-6 displayAnchor call-site pairing is vacuous — already reported as R1-6 (agent-prompt.ts:750)
- R1-7 seed() default injection makes the absent-lastModelId-key arm unreachable — already reported as R1-7 (cache-commit.test.ts:164)
- R1-14 --ledger help text enumerates lastModelId as a ledger field the command drops — already reported as R1-14 (cache-commit.ts:250)
- R2-4 fileForm digest refusal untested — already reported as R2-4 (cache-commit.ts:173)
- R11-3 stale allowlist docblock above LEDGER_FIELDS — already reported as R11-3 (cache-commit.ts:67)
- R11-4 the two new diff-flags pins have no assertion — already reported as R11-4 (diff-flags.ts:45-48)
- R11-8 inert-text.test.ts committed with raw control bytes, classified binary — already reported as R11-8
- R11-12 inertText double-escape and malformed astral-Cf escape — already reported in the round-2 deferral list (inert-text.ts:45), R11-12 lineage
- R19-16 persistence.md fallback-vs-withheld-candidate contradiction — already reported as R19-16 (R1-20/R2-21 lineage), re-adjudicated Suggestion round 18
- R18-2 persistence.md 'nothing else' completeness contradiction — already reported as R2-21, re-adjudicated R18-2 at Suggestion
Not reviewed: reverse audit — stopped at the 5-round cap without two consecutive dry rounds (every round surfaced findings; round 5 still reported 3).
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Not reviewed: test-efficacy probe — all probes inconclusive: the probe tree cannot satisfy this repo's vitest globalSetup build prerequisites (files this PR does not touch), so no mutants or hunk reverts were executed; mutation claims were instead run by the verifiers' own probes.
Deferred under the convergence posture (round 21, not a blocker) — recorded, not requested in this round; 1 Critical(s) among them are deferred by their axes — fails-closed on new surface, where no wrong result is certified and the merge base had neither the surface nor the defect — and remain follow-up work recorded in the findings artifact:
packages/cli/src/commands/review/lib/paths.ts:539 — [review] Critical [fails-closed] [new-surface] R17-2: assertUnredirectedParent exact-string compare false-positives on case-divergent/redirected filesystems — candidate withheld every roun…packages/cli/src/commands/review/cache-commit.ts:164 — [probe] promotion-to-target binding enforced one direction only: plain-name out orphaned by the sole reader; reserved-name clobber erases a foreign ledgerpackages/cli/src/commands/review/capture-local.ts:1257 — [probe] new withhold contract vacates capture-local.incremental.test.ts:1895 into an existsSync(undefined) tautologypackages/core/src/skills/bundled/review/SKILL.test.ts:1667 — [probe] nothing pins the new persistence.md machinery (cache-commit command line, No lastModelId, no cachePath on fetch-pr)packages/cli/src/commands/review/cache-commit.ts:210 — [probe] __proto__ candidate key hits the setter and is silently dropped — the one field shape that neither travels nor is refusedpackages/cli/src/commands/review/cache-commit.test.ts:170 — [probe] no test pins inertText on readJsonObject's parse-failure branch — the one error path carrying attacker-file bytespackages/cli/src/commands/review/lib/file-verdicts.test.ts:351 — [probe] the ?? UNANSWERED_ATTRS fold half has zero witnesses — dropping it keeps 18/18 green and a failed probe could transfer a verdictpackages/cli/src/commands/review/lib/file-verdicts.test.ts:292 — [probe] __proto__ test is a tautology; the readFileVerdicts reader path is never exercised with such a key
Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)
[Critical] R12-2: Still stands — re-verified by code trace at this commit: the read side the anchor's trust lands on is unguarded — readLocalCache (packages/cli/src/commands/review/lib/local-anchor.ts:767) is a plain readFileSync with shape-only validation and no lstat/symlink refusal, and resolveCachePath (capture-local.ts:453-471) probes with following statSync/existsSync — while this PR polices the WRITE side of the same deterministic .qwen/review-cache path against exactly this threat (noFollow + assertUnredirectedParent). A forged cache planted as .qwen/review-cache/local.json symlink (git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: gate-passing forgery needs state knowledge, but HEAD is known when reviewing an attacker branch checkout and the files map is computable from the tree. Witness: code trace at HEAD 51fa283; rounds 18/19 end-to-end probes at byte-identical code showed the anchor honoured with deltaFiles=[] over a fully-dirty tree, and an lstat refusal flipping it to full capture. Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side. (Body-only: the defect line is outside this diff's hunks.)
[Critical] R10-3: Still stands — re-verified by code trace at this commit: the plan report carrying the cacheCandidatePath field is written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures (capture-local.ts:1263; fetch-pr.ts:1791) — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds, and the sibling writers are equally unguarded (fullDiffPath at capture-local.ts:941, diff at :1226, stop at :1201; fetch-pr diff at :1096). With .qwen/tmp committed as a symlink (this PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — a substituted plan orchestrates the round: cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself, and the stateId CHECK compares two values both read off the plan — a substituted plan controls both sides. Witness: code trace at HEAD 51fa283 (six plain writeFileSync sites, none guarded); rounds 18/19 probe at the same code: plan landed in the victim dir while the candidate did not; guarding the plan write flipped it. Fix: route the plan/diff/stop writes through assertUnredirectedParent + atomicWriteFileSync(noFollow) in both captures and extend the symlink test to assert diff/plan absence in the victim. (Body-only: the defect lines are outside this diff's hunks.)
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 12 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查:reverse audit — stopped at the 5-round cap without two consecutive dry rounds (every round surfaced findings; round 5 still reported 3)。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
未审查:test-efficacy probe — all probes inconclusive: the probe tree cannot satisfy this repo's vitest globalSetup build prerequisites (files this PR does not touch), so no mutants or hunk reverts were executed; mutation claims were instead run by the verifiers' own probes。
收敛姿态下延后(第 21 轮,非阻断)——已记录,本轮不要求修改;其中 1 条 Critical 按其失败方向与对照基线延后——fails-closed 且 new-surface:未认证任何错误结果,且 merge base 既无该功能面也无该缺陷——作为后续工作记录在 findings 工件中:共 8 条(原文未翻译,列表见上方英文部分)。
机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)
[Critical] R12-2: Still stands — re-verified by code trace at this commit: the read side the anchor's trust lands on is unguarded — readLocalCache (packages/cli/src/commands/review/lib/local-anchor.ts:767) is a plain readFileSync with shape-only validation and no lstat/symlink refusal, and resolveCachePath (capture-local.ts:453-471) probes with following statSync/existsSync — while this PR polices the WRITE side of the same deterministic .qwen/review-cache path against exactly this threat (noFollow + assertUnredirectedParent). A forged cache planted as .qwen/review-cache/local.json symlink (git add -f defeats the gitignore, per this PR's own threat model) anchors the next local round on attacker-chosen state: gate-passing forgery needs state knowledge, but HEAD is known when reviewing an attacker branch checkout and the files map is computable from the tree. Witness: code trace at HEAD 51fa283; rounds 18/19 end-to-end probes at byte-identical code showed the anchor honoured with deltaFiles=[] over a fully-dirty tree, and an lstat refusal flipping it to full capture. Fix: lstat the cache path in readLocalCache and refuse a symlink, mirroring the noFollow/assertUnredirectedParent posture this PR adds on the write side. (Body-only: the defect line is outside this diff's hunks.)
[Critical] R10-3: Still stands — re-verified by code trace at this commit: the plan report carrying the cacheCandidatePath field is written by plain writeFileSync(out, stringifyPlanReport(result), 'utf8') in BOTH captures (capture-local.ts:1263; fetch-pr.ts:1791) — no assertUnredirectedParent, no noFollow — unguarded deterministic writers into .qwen/tmp beside the guarded candidate writes this PR adds, and the sibling writers are equally unguarded (fullDiffPath at capture-local.ts:941, diff at :1226, stop at :1201; fetch-pr diff at :1096). With .qwen/tmp committed as a symlink (this PR's own threat model — git add -f defeats the gitignore), the guarded candidate write is refused, but the plan write follows the link and is read back through it — a substituted plan orchestrates the round: cacheCandidatePath presence keys Step 8's cache-commit-vs-skip decision itself, and the stateId CHECK compares two values both read off the plan — a substituted plan controls both sides. Witness: code trace at HEAD 51fa283 (six plain writeFileSync sites, none guarded); rounds 18/19 probe at the same code: plan landed in the victim dir while the candidate did not; guarding the plan write flipped it. Fix: route the plan/diff/stop writes through assertUnredirectedParent + atomicWriteFileSync(noFollow) in both captures and extend the symlink test to assert diff/plan absence in the victim. (Body-only: the defect lines are outside this diff's hunks.)
— qwen3.8-max via Qwen Code /review (v0.22.3)
| rec.attrs === UNANSWERED_ATTRS || | ||
| cur.attrs === UNANSWERED_ATTRS || | ||
| rec.attrs !== cur.attrs |
There was a problem hiding this comment.
[Critical] R8-1: [certifies-falsely] [new-surface] Still stands — re-verified by probe at this commit, and independently re-discovered this round. blobPairs records renderingAttributes' UNHASHABLE answer (the literal 'unhashable' — returned for a .gitattributes diff answer of set/unset, an undecodable driver name, or diff.unspecified.binary configured) verbatim as the pair's attrs component — the fold at line 203 catches only undefined — and changedPairs treats only 'unanswered' as never-equal, so 'unhashable' === 'unhashable' passes and a clean verdict transfers over a rendering git could not certify. Round 1 reviews data.dat governed by *.dat diff=unset and promotes attrs: 'unhashable'; before round 2 the rendering config flips without any tracked tree entry moving (attributesMoved sees nothing); the pair is byte-identical and the verdict transfers over hunks no round ever read — the exact fail-open the attrs component exists to close.
Witness:
PROBE round1[data.dat] = {"base":"100644 c2981a9...","head":"100644 c2981a9...","attrs":"unhashable"}
PROBE changedPairs(unhashable attrs) = [] <- verdict transfers (bug)
PROBE changedPairs(unanswered control) = ["x.dat"] <- sentinel arm never transfers
FIX arm (UNHASHABLE -> UNANSWERED_ATTRS at the fold): changedPairs = ["data.dat"] <- flips
Fix: import UNHASHABLE from ./local-anchor.js and add it to the never-equal guard (or map attrs[p] === UNHASHABLE to the never-equal sentinel in blobPairs):
if (
rec.attrs === UNANSWERED_ATTRS ||
cur.attrs === UNANSWERED_ATTRS ||
rec.attrs === UNHASHABLE ||
cur.attrs === UNHASHABLE ||
rec.attrs !== cur.attrs
) {Fix constraint: the sentinel must be the exact string export const UNHASHABLE = 'unhashable'; at packages/cli/src/commands/review/lib/local-anchor.ts:41 — the value renderingAttributes writes; the never-equal discipline it must match is a !== UNHASHABLE in local-anchor's changedSince.
Fix witness: add a sibling of 'never transfers a verdict over a rendering the probe could not report' — identical recorded/current maps carrying attrs: 'unhashable' must yield changedPairs(m, m, ['data.dat']) → ['data.dat']; remove the guard and confirm it reds.
中文说明
[Critical] R8-1:依然存在——本轮已通过探测重新验证,且被独立重新发现。blobPairs 把 renderingAttributes 的 UNHASHABLE 回答(字面量 'unhashable'——.gitattributes 的 diff 回答为 set/unset、驱动名无法解码、或配置了 diff.unspecified.binary 时返回)原样记为键对的 attrs 分量——第 203 行的折叠只捕获 undefined——而 changedPairs 只把 'unanswered' 当作永不相等,于是 'unhashable' === 'unhashable' 通过比较,干净裁决被传递到一个 git 无法认证的渲染之上。第 1 轮审查受 *.dat diff=unset 管辖的 data.dat 并晋升 attrs: 'unhashable';第 2 轮之前渲染配置翻转而没有任何被跟踪的树条目移动(attributesMoved 看不到任何变化);键对逐字节相同,裁决越过任何一轮都不曾读过的 hunk 传递——正是 attrs 分量本要堵上的失败开放。
修复:从 ./local-anchor.js 导入 UNHASHABLE 并加入永不相等守卫(或在 blobPairs 中把 attrs[p] === UNHASHABLE 映射为永不相等哨兵)。修复前提:哨兵必须是 local-anchor.ts:41 的精确字符串 'unhashable'。修复验收:新增测试断言 attrs: 'unhashable' 的相同键对比较返回 ['data.dat'],移除守卫后变红。
— qwen3.8-max via Qwen Code /review (v0.22.3)
| const controlled = (v: unknown): boolean => | ||
| typeof v === 'string' && CONTROL.test(v); | ||
| for (const key of candidateFieldsOf(candidate)) { |
There was a problem hiding this comment.
[Critical] R12-1: [certifies-falsely] [new-surface] Still stands — re-verified by probe through the real handler at this commit, and independently re-discovered this round. The refuse-at-write sweep tests only TOP-LEVEL candidate scalar strings — controlled() is typeof v === 'string' && CONTROL.test(v) iterated over candidateFieldsOf(candidate) — so map-valued candidate fields (files on the local candidate, fileVerdicts on the PR candidate) fail the typeof check, and strings nested inside them — including the MAP KEYS, which are file paths and fully workspace-controlled — are never seen; the ledger copy loop persists every ledger-owned string (findings[] summaries, verdict) unchecked. The comment above claims "Every persisted STRING is checked", which is doubly inaccurate at HEAD. A candidate whose fileVerdicts KEY carries U+009B — or a ledger findings[0] summary carrying it — passes without throwing and the raw C1 byte is persisted into .qwen/review-cache/: DEL, the C1 range U+0080–U+009F, the Cf class and U+2028/U+2029 are exactly the classes JSON.stringify passes through unescaped — one raw-printing reader away from a forged terminal line.
Witness:
PROBE1 fileVerdicts KEY with C1: threw = null; cache written = true; raw C1 byte persisted = true
PROBE2 ledger findings[0].title with C1: threw = null; cache written = true; raw C1 persisted = true
PROBE3 control (lastModelId scalar with C1): threw = /carries control/; no file written
FIX arm (recursive sweep incl. keys + ledger fields): PROBE1/PROBE2 both throw, no file written
Fix: make the sweep recursive over both candidate and ledger, keys included:
const containsControlled = (v: unknown): boolean => {
if (typeof v === 'string') return CONTROL.test(v);
if (Array.isArray(v)) return v.some(containsControlled);
if (v !== null && typeof v === 'object') {
return Object.entries(v as Record<string, unknown>).some(
([k, x]) => CONTROL.test(k) || containsControlled(x),
);
}
return false;
};Fix constraint: the class swept must stay export const CONTROL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u (packages/cli/src/commands/review/lib/inert-text.ts:42) — its header documents two prior drifts between this classifier and inertText's — and the walk must still round-trip a file literally named __proto__ as an ordinary own key (the null-proto discipline at local-anchor.ts:191-195).
Fix witness: extend the control-character cases with a candidate whose fileVerdicts key carries \u009b, expecting /carries control/ and no output file — deleting the recursion must turn it red.
中文说明
[Critical] R12-1:依然存在——本轮已通过真实 handler 探测重新验证,且被独立重新发现。写入端拒绝扫描只测试顶层 candidate 标量字符串——对 map 值字段(files、fileVerdicts)typeof 检查即失败,其内部嵌套字符串——包括作为文件路径、完全由工作区控制的键——一律不被检查;ledger 复制循环原样持久化 findings[] summary 与 verdict。上方注释声称「每个被持久化的字符串都被检查」,在本提交上双重失实。携带 U+009B 的 fileVerdicts 键(或 ledger finding)可以不抛错通过并把裸 C1 持久化进 .qwen/review-cache/——DEL、C1 区段、Cf 类与 U+2028/U+2029 正是 JSON.stringify 不转义的类别——离伪造终端行只差一个原样打印的读者。
修复:递归扫描(含键),覆盖 candidate 与 ledger 两侧。修复前提:扫描类别必须保持 inert-text.ts:42 的 CONTROL,且遍历必须让名为 __proto__ 的文件仍按普通键往返。修复验收:新增键携带 \u009b 的用例,期望 /carries control/ 且无输出文件——移除递归后变红。
— qwen3.8-max via Qwen Code /review (v0.22.3)
| for (const key of candidateFieldsOf(candidate)) { | ||
| if (controlled(candidate[key])) { | ||
| throw new Error( | ||
| `cache-commit: the candidate's \`${key}\` carries control ` + |
There was a problem hiding this comment.
[Critical] R8-4: [certifies-falsely] [new-surface] Still stands — re-verified at this commit. The CONTROL sweep tests only candidate VALUES — this line checks candidate[key] — never the candidate's KEYS, and the sweep's own refusal interpolates the unchecked key raw into an error that reaches the operator's terminal, while every other untrusted string in this file's error paths goes through inertText (readJsonObject wraps the --candidate/--ledger paths and the parse-error snippet; both target-binding refusals wrap their values). A tampered candidate at the deterministic in-repo path — this command's own named threat — whose KEY carries ESC/C1/Cf bytes with a clean value is never checked, and when any other field trips the sweep the refusal prints that unchecked key raw — a forged terminal line emitted by the very guard that exists to refuse that class.
Witness:
code trace at HEAD 51fa2836: for (const key of candidateFieldsOf(candidate)) { if (controlled(candidate[key])) ... } — values only
refusal template embeds ${key} with no inertText; the four sibling error sites in the same file all wrap theirs
rounds 12-20 probes through the real handler at byte-identical code: raw key persisted/printed, fix arm refuses
Fix: sweep the keys too (CONTROL.test(key)) and route the refusal's key interpolation through inertText, matching the file's other error paths:
if (CONTROL.test(key) || controlled(candidate[key])) {
throw new Error(
`cache-commit: the candidate's \`${inertText(key)}\` carries control ` +Fix witness: a cache-commit test seeding a candidate whose KEY carries \u001b with clean values — expects /carries control/ and asserts the thrown message contains no raw ESC byte; removing the key check or the inertText wrap must turn it red.
中文说明
[Critical] R8-4:依然存在——本轮已重新验证。CONTROL 扫描只测试 candidate 的值(本行检查 candidate[key]),从不测试键;而扫描自己的拒绝信息把未检查的 key 原样插入一条会到达操作者终端的错误,而本文件其他所有错误路径的不可信字符串都经过 inertText。位于仓库内确定性路径上的被篡改 candidate(本命令自己点名的威胁)若键携带 ESC/C1/Cf 字节而值干净,则永不被检查;任何其他字段触发扫描时,拒绝信息会把该未检查的键原样打印——由本应拒绝该类别的守卫亲自发出的一行伪造终端输出。
修复:同时扫描键(CONTROL.test(key)),并把拒绝信息中的键经 inertText 处理。修复验收:新增键携带 \u001b 的用例,期望 /carries control/ 且错误信息不含裸 ESC 字节;移除键检查或 inertText 包裹后变红。
— qwen3.8-max via Qwen Code /review (v0.22.3)
| '-c', | ||
| 'diff.algorithm=myers', | ||
| '-c', | ||
| 'diff.indentHeuristic=true', |
There was a problem hiding this comment.
[Critical] R4-1: [certifies-falsely] [new-surface] Still stands — carried as a CLASS finding under this family's round-4 id, re-verified against git at this commit. The per-file verdict pair identity enumerates git's diff-rendering knobs one entrance at a time, and the surface keeps regenerating — rounds have found new entrances in every review since round 4. This diff pins diff.algorithm=myers and diff.indentHeuristic=true — closing two entrances — but diff.interHunkContext, diff.compactionHeuristic, diff.renameLimit (--find-renames pins rename detection ON, but git still applies the config's candidate cap, and when it trips git renders every move as delete+add) and driver-scoped diff.<driver>.algorithm remain neither pinned nor recorded in the pair identity; a repo-wide grep at HEAD confirms diff.interHunkContext is pinned nowhere. A reviewer with diff.interHunkContext 3 captures a diff whose near-adjacent hunks merge into one while the recording round read them split — same blobs, modes and attributes, different hunk layout the identity cannot see — and a clean verdict transfers over hunks rendered differently than the round that certified them. The family is unbounded: close it structurally — pin the full rendering-knob set, or record the effective rendering config in the pair identity — not entrance by entrance.
Witness:
PINNED_DIFF_CONFIG at HEAD: suppressBlankEmpty, quotePath, algorithm=myers, indentHeuristic=true — nothing else
repo-wide grep: diff.interHunkContext pinned nowhere
rounds 4-20: governing attributes, diff.algorithm, renameLimit, driver-scoped algorithm, interHunkContext — a new entrance in every review
Fix witness: a capture test running with GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=diff.interHunkContext GIT_CONFIG_VALUE_0=5 over a fixture with two hunks 1–4 context lines apart, asserting they stay two hunks; removing the pin must turn it red.
中文说明
[Critical] R4-1:依然存在——作为该家族第 4 轮的 CLASS 发现继续携带,本轮已对照 git 重新验证。每文件裁决的键对身份逐个入口地枚举 git 的 diff 渲染旋钮,而该表面持续再生——自第 4 轮起每轮评审都发现新入口。本 diff 固定了 diff.algorithm=myers 与 diff.indentHeuristic=true——关闭两个入口——但 diff.interHunkContext、diff.compactionHeuristic、diff.renameLimit(--find-renames 固定开启重命名检测,但 git 仍应用配置的候选上限,触发时会把每个移动渲染为删除+新增)以及驱动级 diff.<driver>.algorithm 既未固定也未记入键对身份;全仓库 grep 确认 HEAD 上 diff.interHunkContext 无处固定。配置了 diff.interHunkContext 3 的审查者捕获的 diff 会把相邻 hunk 合并,而记录裁决的一轮读到的是分开的——blob、mode、属性全同,hunk 布局不同而身份看不见——干净裁决就此传递到与认证轮渲染不同的 hunk 之上。该家族无界:请结构性关闭(固定完整渲染旋钮集,或把有效渲染配置记入键对身份),不要逐入口修补。
修复验收:以 GIT_CONFIG_* 注入 diff.interHunkContext=5,对相距 1–4 行上下文的两处变更断言仍为两个 hunk;移除固定后变红。
— qwen3.8-max via Qwen Code /review (v0.22.3)
| If reviewing a PR **at high effort**, update the review cache for incremental review support. Low and medium reviews must NOT write it, and neither must a `--topology minimal` run at any effort — a cache hit would make a later high-effort review of the same SHA report "No new changes since last review", silently converting a cheaper pass into a full-review verdict. | ||
|
|
||
| **A local or file-path review at high effort writes its cache the same way, from the capture's candidate.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. `capture-local` wrote this round's content anchor to the plan's `cacheCandidatePath` (`.qwen/tmp/qwen-review-<target>-cache-candidate.json`): the hashed per-file state and HEAD of exactly what was captured, deterministic, not yours to recompute. Read that file, add the ledger fields — `lastReviewDate`, `round`, `findingsCount`, `verdict`, and `findings[]` under the same id-carrying rules as the PR cache below — and write the merged object to the path the capture named in its plan, `cachePath` — **read that field, do not compute the name**, for the reason the incremental bullet in Step 1 gives: `target` is derived inside the command and `safeTarget` is not hand-reproducible. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. The reason is one reason, and it is why the rule is stated as a rule: the anchor's whole claim is "the next round may skip this", and a claim nobody ruled on is exactly what the next round must not skip — the round after it stops decided over a Critical that was never verified: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. A run that ended with unreviewed or undecided scope skips this write and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) Low and medium local reviews must NOT write it, for the PR cache's exact reason. | ||
| **The write is one command, for PR and local alike — never a hand-copied JSON.** **Before promoting, CHECK the candidate is this round's own:** read the file at the plan's `cacheCandidatePath` and compare its `stateId` to the plan's `cacheCandidateStateId` — the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's "no changes" to a tree this round never reviewed. A mismatch (or an absent `cacheCandidateStateId` field on a plan that published a path) is treated exactly like a withheld candidate: skip the cache write and say so. Both captures write a deterministic candidate beside the plan (`cacheCandidatePath` in the plan report: `fetch-pr` records the per-file `(base, head)` blob pairs plus the commit anchor; `capture-local` the hashed per-file working-tree state plus HEAD). A candidate is not yours to recompute or transcribe — a per-file map routed through your output is a copy job that fails silently, and a dropped or mangled pair reads downstream as a verdict it is not. So: write ONLY the small ledger file `.qwen/tmp/qwen-review-<target>-ledger.json` with this round's `round`, `findingsCount`, `verdict`, and `findings[]` (id rules below). **No `lastModelId`**: the capture already recorded the identity the runtime published, provider-qualified, in the candidate — and `cache-commit` lets the candidate win it. A hand-carried one would be the bare model token (`YOUR_MODEL_ID`), which two provider configurations exposing a single model name share, so the same-model contract the anchor rests on would pass between them, then run `"${QWEN_CODE_CLI:-qwen}" review cache-commit --candidate <the plan's cacheCandidatePath> --ledger <that file> --out <the cache path for this target>` **from the main checkout, with all three paths resolved against it** — every one of them is main-checkout-relative (the candidate is where the capture wrote it, and the cache must outlive the worktree Step 9 deletes; a cache written inside the worktree is thrown away with it, and this machine silently loses rebase survival) (`pr-<n>.json` / `local.json` / the file-review's target). The command merges mechanically, candidate fields winning every collision, and writes atomically. **Where that path comes from differs by flow, and only one of them publishes it**: a LOCAL or file-path round reads it off the plan's `cachePath` — the capture derives the target and `safeTarget` is not hand-reproducible, so the name is not yours to spell — while the PR flow's plan carries no such field and the name is the one this section already gives, `.qwen/review-cache/pr-<n>.json`, which you already know from the PR number. Do not look for `cachePath` on a `fetch-pr` report; it has none, and treating its absence as a reason to skip the write would lose the PR cache entirely. A plan with **no** `cacheCandidatePath` — a degraded capture, or a local round whose capture withheld the candidate because the tree moved mid-capture — falls back to hand-writing the template below — anchor fields only as accurate as what Step 1 recorded. The same fail-closed rule applies to BOTH flows, and for a LOCAL round it is the sentence that follows and nothing else — **do not go looking for the PR cache's marker rule below**, which keys on a `sha` inside a posted review's `qwen-review-ledger` comment: a local round posts nothing and has no marker to check, so read literally it would skip this write on every round and the feature would never persist. The rule: a run that ended with unreviewed or undecided scope skips the write entirely and says so, because the candidate would anchor the next round's skip past scope nobody reviewed. Low and medium reviews must NOT write it, for the reason above. **A non-empty `skippedFiles` in the capture is fail-closed for this write, both flows**: skipped content is in no diff and no hash, so a candidate promoted over it would anchor the next round's "no changes" past work this round could not read. **The fail-closed conditions here are keyed to the PR cache's marker rule below — one definition, read twice.** Do NOT apply that rule's literal CHECK here: it keys on a `sha` inside the posted review's `qwen-review-ledger` marker, and a local round posts nothing and has no marker to read — a literal check would skip this write on every round and the feature would never persist. What this write takes from that rule is its WITHHOLDING CONDITIONS, applied as conditions on this round's own caps. For a local or file-path round the rule is a POSITIVE one, deliberately — and its fail-closed half is deliberately NOT a list: the list that lived here was "completed" three times, and each time a fourth shape walked through it (the last one an Uncoverable chunk and a whiffed lens, which withheld the PR marker's `sha` but never this write). **Write the cache only when every Critical this round raised carries a confirmed disposition: fixed, or confirmed-standing and recorded in `findings[]` under an id — and skip this write under any condition that would withhold the PR marker's `sha` in the rule below, plus the one local-only condition the marker cannot see: a non-empty `skippedFiles` in the capture (skipped content is in no diff and no hash).** At the time of writing those conditions are: `cannotTellCriticals` (a verifier that returned CANNOT-TELL, or a deadline stop or timed-out batch that never returned one), an Uncoverable chunk, the context-unavailable state, `scopeUnproven` (a chunk nobody read, an idle or blind agent), a finding still marked `— [unverified]`, the deterministic gates, and any `unreviewedDimensions` entry other than a depth-only build-and-test one (a whiffed lens withholds the marker's `sha`, so it withholds this write). The examples are the set as written, not the gate: the gate is the marker paragraph's withholding set — if it changes, this write changes with it, because a second hand-copied list here is how they drifted once already. Anything else withholds the candidate and says so. **And these two, which complete the list**: a finding still marked `— [unverified]`, and an undecided blocker — a Critical whose verifier never returned, from a deadline stop or a timed-out batch. Neither enters `findings[]` (only confirmed findings do) and neither reads as "unreviewed scope", so without naming them a round promotes the anchor over a claim nobody ruled on and the round after it stops decided. (A capture that detected a mid-capture tree change — or a tracked path carrying a visibility bit, or an enumeration of them that failed — withholds the candidate itself and says so; then there is nothing to promote.) |
There was a problem hiding this comment.
[Critical] R14-1: [fails-closed] [regression] Still stands — re-verified at this commit, and independently re-discovered this round by three finders. The rewritten Step 8 broadens the local-only pre-promotion stateId CHECK to "for PR and local alike" — "an absent cacheCandidateStateId field on a plan that published a path is treated exactly like a withheld candidate: skip the cache write and say so" — but a fetch-pr plan structurally cannot pass it: fetch-pr publishes cacheCandidatePath yet contains zero occurrences of cacheCandidateStateId (the field's only publisher is capture-local.ts:1258), and the PR candidate JSON carries no stateId to compare either. The fallback clause is keyed to a plan with NO cacheCandidatePath — which this plan has. So every high-effort PR round following Step 8 as written hits the explicit absent-field clause, skips the cache write, and says so: .qwen/review-cache/pr-<n>.json never advances, this PR's fileVerdicts rebase-survival record never persists, and same-machine rounds permanently lose cache-based incremental scoping and the findings ledger — the hand-copy job this PR exists to eliminate is the only route left.
Witness:
full enumeration at HEAD: only publisher of cacheCandidateStateId is capture-local.ts:1258
fetch-pr.ts:1782 publishes ...(cacheCandidatePath ? { cacheCandidatePath } : {}) — the path alone
PR candidate carries {v, target, lastCommitSha, mergeBaseSha, fileVerdicts, lastModelId} — no stateId
witness: not run — the failing actor is a model following skill prose; no harness drives that
Fix: give the PR flow a CHECK it can pass — publish a checkable identity from fetch-pr alongside cacheCandidatePath (the candidate's lastCommitSha is the natural PR-flow certifier, compared against the candidate file before promoting) — or narrow the cacheCandidateStateId sentence back to the local/file flows and state the PR flow's own ownership check in its place.
Fix constraint: the CHECK exists because "the path is stable per target and local/file reviews take no lease, so a concurrent same-target run overwrites the file mid-round, and promoting the foreign candidate would anchor the next round's 'no changes' to a tree this round never reviewed" — a fix must preserve this race protection for the flows that have it; SKILL.test.ts:1633-1636 pins the current wording verbatim and must move in lockstep.
Fix witness: a fetch-pr test asserting the plan publishes the identity field Step 8 CHECKs whenever it publishes cacheCandidatePath — deleting the producer must turn it red (the producer block currently has zero coverage, standing R1-4).
中文说明
[Critical] R14-1:依然存在——本轮已重新验证,且被三个发现者独立重新发现。重写后的 Step 8 把原本仅限本地流程的晋升前 stateId 检查扩展为「PR 与本地一致」——「发布了路径却没有 cacheCandidateStateId 字段的计划视同被扣留的 candidate:跳过缓存写入并说明」——但 fetch-pr 的计划在结构上不可能通过它:fetch-pr 发布 cacheCandidatePath 却通篇没有 cacheCandidateStateId(该字段唯一发布者是 capture-local.ts:1258),PR candidate JSON 也不含可比较的 stateId。兜底子句以「计划没有 cacheCandidatePath」为键——而该计划有。于是每个照章行事的高强度 PR 轮都会命中显式的字段缺失子句、跳过缓存写入:pr-<n>.json 永不前进,本 PR 的 fileVerdicts 跨 rebase 记录永不落盘,同机轮次永久失去基于缓存的增量定界与发现台账——本 PR 本要消灭的手工誊写反而成了唯一通路。
修复:让 fetch-pr 在发布 cacheCandidatePath 的同时发布一个可校验的身份(候选的 lastCommitSha 是天然的 PR 流程认证者),或把该句收窄回本地/文件流程并就地写明 PR 流程自己的所有权检查。修复前提:该检查的同目标竞争保护理由必须保留;SKILL.test.ts:1633-1636 逐字固定了现行措辞,须同步移动。修复验收:新增 fetch-pr 测试断言发布路径时同时发布身份字段,删除发布者后变红。
— qwen3.8-max via Qwen Code /review (v0.22.3)
| atomicWriteFileSync(args.out, `${JSON.stringify(merged, null, 2)}\n`, { | ||
| noFollow: true, | ||
| }); | ||
| writeStdoutLine(`Committed review cache to ${args.out}`); |
There was a problem hiding this comment.
[Critical] R17-1: [certifies-falsely] [new-surface] Still stands — re-verified at this commit (lineage R15-4). The intake sweep polices every candidate VALUE but never the command's own CLI path arguments, and this success line prints the unswept --out raw — the one print site in a file that escapes every other untrusted string through inertText (readJsonObject wraps the --candidate/--ledger paths in its error paths; both target-binding refusals wrap their values). --out is not a trusted constant: in the PR flow Step 8 has the reviewing model spell it, and a path carrying control characters is printed raw at the operator's terminal on success — a forged terminal/context line through the one sink in the file that skips the escaper the rest of the file uses.
Witness:
code trace at HEAD 51fa2836: writeStdoutLine(`Committed review cache to ${args.out}`) — raw interpolation
every other untrusted-string print site in cache-commit.ts routes through inertText
rounds 15-20 probes at byte-identical code: unswept path arguments reach the terminal raw
| writeStdoutLine(`Committed review cache to ${args.out}`); | |
| writeStdoutLine(`Committed review cache to ${inertText(args.out)}`); |
Fix witness: a cache-commit test asserting the success line for an --out containing \u001b carries the escaped form and no raw ESC byte — removing the wrap must turn it red.
中文说明
[Critical] R17-1:依然存在——本轮已重新验证(谱系 R15-4)。入口扫描管制每一个 candidate 的值,却从不管命令自己的 CLI 路径参数,而本成功行把未扫描的 --out 原样打印——这是本文件中唯一一个不经 inertText 的打印点(readJsonObject 的错误路径包裹 --candidate/--ledger 路径,两处目标绑定拒绝都包裹其值)。--out 不是可信常量:PR 流程中 Step 8 让评审模型亲自拼写它;携带控制字符的路径会在成功时原样打印到操作者终端——通过本文件唯一跳过转义器的出口,发出一行伪造的终端/上下文内容。
修复:writeStdoutLine(\Committed review cache to ${inertText(args.out)}`);。修复验收:新增测试断言含 \u001b的--out` 的成功行携带转义形式且无裸 ESC 字节;移除包裹后变红。
— qwen3.8-max via Qwen Code /review (v0.22.3)
Part 2 of 2, stacked on #9659. Already reviewed on #9191 — 158 inline comments — and relanded here because that PR sat in a stack whose root had been closed with its own base branch deleted, which is unrecoverable. This stack's root is #9659: open, based on
main, and mergeable.Per-file verdicts survive a rebase
#9659 anchors a local round on per-file content. A rebase that leaves a file's bytes untouched should therefore not cost that file a re-review — but the commit half of the anchor moves and takes every file with it. The per-file verdicts now carry across, so only the files a rebase actually rewrote come back into scope.
The cache write becomes one command, for both flows
cache-commitmerges the capture's deterministic candidate with the round's small ledger file and writes atomically, candidate fields winning every collision.That replaces the orchestrator hand-copying a per-file map through its own output. A copy job of that shape fails silently: a dropped or mangled
(base, head)pair reads downstream as a verdict it is not, and nothing downstream can tell. Both captures already write the candidate beside the plan —fetch-prthe per-file blob pairs plus the commit anchor,capture-localthe hashed worktree state plus HEAD — so the model only writes the part that is genuinely its own:round,findingsCount,verdict,findings[].lastModelIdis deliberately not in that list. Left to the ledger it would be the bare{{model}}an orchestrator can type, which two provider configurations exposing a single model name share — so the token deciding the same-model contract is the provider-qualified one both captures record, andcache-commitlets the candidate win it like every other anchor field. Getting that precedence wrong would have silently undone #9659's identity gate, which is what the review caught.Hardening from review
Each mutation-checked:
lastModelIdis a candidate-owned field, validated on the candidate rather than the ledger, and covered by the same control-character sweep as every other persisted anchor string.fetch-pr's candidate records it too, so neither flow needs a hand-carried token.lastModelIdis missing or empty — an unverifiable same-model contract is a failed one.noFollow: the cache path is deterministic and lives in the repo, so a contributor branch can commit a symlink there and a maintainer's review would otherwise write merged-cache JSON onto the link's target.中文说明
两部分中的第二部分,叠在 #9659 之上。内容已在 #9191(158 条 inline 评论)审完;那个 PR 所处的栈其根节点已被关闭、且其自身 base 分支被删除,无法恢复,故在此重提。本栈的根是 #9659:开着、base 是 main、可合并。
per-file 裁决跨 rebase 存活。 #9659 把本地轮次按每文件内容定锚,那么 rebase 没有改动字节的文件本不该因此重审——但锚点里 commit 的那一半一动,就把所有文件一起带走。现在裁决按文件传递,只有 rebase 真正改写过的文件才重新进入范围。
缓存写入统一为一条命令,两条流程共用。
cache-commit把捕获产出的确定性 candidate 与本轮的小 ledger 文件机械合并、原子落盘,candidate 字段赢下每一次冲突。它取代的是「由 orchestrator 把 per-file 映射经自己的输出手工誊写」——这种抄写会静默失败:漏抄或写坏一对(base, head),下游会把它读成一个并不存在的裁决,且无从察觉。两条捕获路径本来就已在计划旁写出 candidate(fetch-pr写每文件 blob 对加 commit 锚点,capture-local写哈希后的工作区状态加 HEAD),所以模型只需要写真正属于它的那部分:round、findingsCount、verdict、findings[]。lastModelId刻意不在这份清单里。若交给 ledger,它就是 orchestrator 能打出的裸{{model}},而两个暴露同名模型的 provider 配置共享这个字符串——所以决定同模型契约的那个 token,是两条捕获都记录的 provider-qualified 身份,cache-commit让 candidate 像其他锚点字段一样赢下它。这处优先级如果搞错,会静默撤销 #9659 的身份门,这正是评审抓到的问题。评审中修掉的问题(每条都做了变异检查):
lastModelId归入 candidate 所有字段,校验改在 candidate 上,并纳入与其他持久化锚点字符串相同的控制字符扫描;fetch-pr的 candidate 同样记录它,两条流程都不再需要手工携带;写入拒绝跨 target 晋升,也拒绝lastModelId缺失或为空的 candidate(无法验证的同模型契约就是失败的契约);输出使用noFollow写入——缓存路径是确定性的且位于仓库内,贡献者分支可以在那里提交一个符号链接,否则维护者的评审会把合并后的缓存 JSON 写到链接指向的目标上。