Skip to content

fix(cli): detect overlapping review comment ranges - #9801

Open
bluefateludi wants to merge 1 commit into
QwenLM:mainfrom
bluefateludi:codex/fix-review-range-overlap
Open

fix(cli): detect overlapping review comment ranges#9801
bluefateludi wants to merge 1 commit into
QwenLM:mainfrom
bluefateludi:codex/fix-review-range-overlap

Conversation

@bluefateludi

Copy link
Copy Markdown
Contributor

What this PR does

Preserves the start of multi-line review findings and classifies existing inline comments by same-file closed-interval intersection instead of comparing only their ending lines. Exact single-line behavior remains unchanged, and carried-finding re-post detection remains tied to the exact original anchor.

Why it's needed

/review presubmit could report noConflict when an existing comment sat inside a newly drafted multi-line finding. That allowed a duplicate finding to pass the deterministic overlap gate and forced reviewers to detect it manually.

Reviewer Test Plan

How to verify

  1. Run cd packages/cli && npx vitest run src/commands/review/presubmit.test.ts.
  2. Confirm all 109 tests pass.
  3. Confirm the regression cases classify a point inside a new range and two partially intersecting ranges as overlap, while disjoint ranges remain noConflict.
  4. Confirm the existing carried-id and re-post tests remain green, showing that range overlap does not broaden thread reuse.

Evidence (Before & After)

Before: the focused regression produced two failures because a comment at line 15 did not match a new finding spanning lines 12–18, and partially intersecting ranges were also reported as noConflict.

After: the complete presubmit test file passes 109/109, including point-inside-range, partial intersection, disjoint control, parsing, exact-line, and carried-id/re-post coverage. An independent test-engineer run passed the 19 parsing/range-focused tests and git diff --check.

Tested on

OS Status
🍏 macOS ⚠️
🪟 Windows
🐧 Linux ⚠️

Environment (optional)

Windows 11, Node.js 22.19.0, npm 10.x, Vitest 3.2.7. Repository-wide build and typecheck passed; focused Prettier and ESLint checks passed.

Risk & Scope

Linked Issues

Partially addresses #9219.

中文说明

本 PR 做了什么

保留多行 review finding 的起始行,并将既有行内评论的冲突判定从“仅比较结束行”改为“同一文件内闭区间相交”。单行行为保持不变,carried finding 的 re-post 识别仍严格绑定原始精确锚点。

为什么需要它

当既有评论位于新起草的多行 finding 范围内部时,/review presubmit 可能错误报告 noConflict。这会让重复 finding 穿过确定性 overlap gate,只能依赖 reviewer 手工发现。

Reviewer Test Plan

如何验证

  1. 运行 cd packages/cli && npx vitest run src/commands/review/presubmit.test.ts
  2. 确认 109 个测试全部通过。
  3. 确认回归用例将“点位于新区间内部”和“两个区间部分相交”归类为 overlap,同时不相交区间仍归类为 noConflict
  4. 确认既有 carried-id 与 re-post 测试保持通过,证明范围冲突不会放宽 thread 复用。

修改前后证据

修改前:定向回归产生两个失败;位于第 15 行的评论无法匹配第 12–18 行的新 finding,部分相交的区间也被报告为 noConflict

修改后:完整 presubmit 测试文件 109/109 通过,覆盖范围内点、部分相交、不相交对照、解析、精确单行以及 carried-id/re-post。独立 test-engineer 运行的 19 个解析/范围相关测试与 git diff --check 也通过。

测试平台

OS 状态
🍏 macOS ⚠️
🪟 Windows
🐧 Linux ⚠️

环境

Windows 11、Node.js 22.19.0、npm 10.x、Vitest 3.2.7。仓库级 build 与 typecheck 通过;定向 Prettier 与 ESLint 检查通过。

风险与范围

关联 Issue

部分解决 #9219,不自动关闭该跟踪 Issue。

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed bug, not theoretical. Linked #9219 documents a concrete occurrence (a manual /review presubmit run on PR 9204, commit 40ad8fd): a drafted multi-line finding (start_line: 2554, line: 2562) with an existing comment sitting at line 2558 inside that range was bucketed noConflict, because only the two line fields were compared for exact equality. The issue also lists two further buckets of misses (semantic duplicates, nearby-line proximity) that this PR explicitly leaves out — good scoping.

Direction: aligned — this is the project's own review-tooling dedup gate, fixing the first bullet of an open, reproduced P2 bug. CHANGELOG has no direct reference (internal tooling), but the area is squarely relevant. Doesn't touch auth/sandbox/model-selection/telemetry or any public contract.

Size: not applicable — packages/cli/src/commands/review/ is not a core-module path. 39 production lines (+30/−9 in presubmit.ts), 67 test lines (+66/−1), two files.

Approach: focused and minimal — carry start_line through the findings-file parser and the API comment shape, then classify by same-file closed-interval intersection instead of exact-line equality, with the carried-id re-post exemption deliberately kept exact-anchor. One substantive question before going deeper: the consumer of this report — the bundled /review skill — still speaks point locations only. Its documented new-findings file shape is {path, line, id?} (so a drafted finding's start_line never reaches presubmit today), the report's comment summaries carry only the end line, and the deterministic drop rule says "drop each finding whose (path, line) appears in existingComments.overlap". So the motivating #9219 scenario may still slip through end-to-end until that side learns ranges too — is the skill-side half meant to ride along here or land as a follow-up? Details in the code-review comment.

Risk: no elevated risk signals — neither changed file matches the revert-correlated high-risk paths.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题: 是已观测到的 bug,不是理论性问题。关联的 #9219 记录了具体场景(对 PR 9204、commit 40ad8fd 的一次人工 /review presubmit 运行):起草的多行 finding(start_line: 2554line: 2562)范围内第 2558 行有一条既有评论,却因只精确比较两个 line 字段而被归入 noConflict。该 issue 还列了另外两类漏检(语义重复、邻近行),本 PR 明确不处理——范围划分合理。

方向: 对齐——这是项目自身 review 工具的去重门,修复一个已复现的 P2 bug 的第一条。CHANGELOG 无直接引用(内部工具),但该领域明显相关。不触及 auth/沙箱/模型选择/遥测或任何公共契约。

规模: 不适用——packages/cli/src/commands/review/ 不是核心模块路径。生产代码 39 行(presubmit.ts +30/−9),测试 67 行(+66/−1),共两个文件。

方案: 聚焦且最小——把 start_line 贯穿 findings 文件解析与 API 评论结构,用同文件闭区间相交替代精确行相等,carried-id re-post 豁免有意保持精确锚点。深入之前有一个实质性问题:这份报告的消费者——内置 /review skill——目前仍然只认点位置。其文档中 new-findings 文件的形状是 {path, line, id?}(所以今天起草 finding 的 start_line 根本到不了 presubmit),报告里的评论摘要只带结束行,而确定性删除规则写的是"丢弃 (path, line) 出现在 existingComments.overlap 中的 finding"。因此在 skill 侧也学会范围之前,#9219 的动机场景端到端仍可能漏过——skill 侧那一半是计划随本 PR 一起,还是作为后续跟进?详见代码审查评论。

风险: 无升级风险信号——两个改动文件均未命中与回滚相关的高风险路径。

进入代码审查。🔍

Qwen Code · qwen3.8-max

Reviewed at b8038ece96afd18bc0a1d5259eca3ff328e7cad9 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Code review

Read against b8038ece, diff compared against an independent proposal (carry the range on both ends, classify by closed-interval intersection, keep the re-post exemption exact, and extend the consumer contract in the same change).

The command-side change is correct and careful. The range math normalizes missing/reversed ends with Math.min/Math.max, and both ends default to the point anchor when start_line is absent — so single-line inputs behave bit-for-bit as before, which is what the large pre-existing overlap/repost suite (plus the Aone tests, where comments never carry start_line) pins. Removing the newFindingKeys set is clean — it had exactly one read site, the one replaced. The carried-id re-post gate is untouched on purpose and stays correct under the widened bucket: range intersection is a superset of exact-line equality, so no previously-exempted re-post loses its path into the overlap branch, and carriedIdsByLocation still keys on the exact anchor. The lenient handling of a malformed start_line (drop it, keep the entry) matches the file's own precedent — line wrong-typed defaults to 0, while only id rejects the whole file because a corrupt id would actively corrupt a match; a corrupt start_line can only degrade a finding back to its pre-PR point semantics. No blockers found in the diff itself.

The open question is the seam with the report's consumer — the bundled /review skill — which still speaks point locations only, and that gap is what keeps me from calling this done:

So, @bluefateludi — was the skill-side half (findings-file format + report shape + drop-rule wording) meant as a follow-up, or could it ride along? The classification change stands on its own as a necessary first step; asking whether the plan for the second half exists before this merges, since landing just this half produces a report state the documented consumer contract can't consume deterministically. @wenshao — you own this subsystem and merged the #9212/#9629 presubmit changes, so this is ultimately your call.

Test evidence

This is an unattended CI run — PR code is never executed here by policy, so the evidence is the PR's own CI read through the API. On the reviewed commit, the PR's CI has not run yet: both Qwen Code CI and Security Checks sit at action_required, awaiting a maintainer's approval of this fork's workflow runs. Not verified: unit/lint/build results at this commit — there are none yet. The 109/109 figure in the PR body is the author's claim from their own machine (Windows), not independently re-run. The finalize workflow updates the table below once CI settles.

Final CI results for b8038ec (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Secret scan (TruffleHog) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Sandboxed verification would settle the behavioural claim: @qwen-code /verify — that presubmit actually re-buckets a #9219-shaped comment set (a comment sitting inside a multi-line range) from noConflict to overlap is not observable while CI is pending, and a green suite would prove the tests pass, not that the change is load-bearing. The author lacks write access, so this is a sponsored run: a maintainer's @qwen-code /verify comment approves the head it was written against, and the run carries a pre-execution risk screen plus a full workspace wipe — read its report with the same skepticism as the fork's own CI logs.

中文说明

代码审查

针对 b8038ece 阅读,并与独立方案对照(两端都携带范围、用闭区间相交分类、re-post 豁免保持精确、并在同一改动中扩展消费方契约)。

命令侧的改动正确且细致。 区间计算用 Math.min/Math.max 归一缺失/颠倒的端点,缺 start_line 时两端都退回点锚点——因此单行输入的行为与之前逐位一致,这正是大量既有 overlap/repost 测试(以及评论从不带 start_line 的 Aone 测试)所钉住的。删除 newFindingKeys 集合很干净——它只有一个读点,就是被替换的那个。carried-id re-post 门有意未动,且在放宽后的 bucket 下依然正确:区间相交是精确行相等的超集,原先能被豁免的 re-post 不会失去进入 overlap 分支的路径,carriedIdsByLocation 仍按精确锚点建键。对畸形 start_line 的宽容处理(丢弃该字段、保留条目)与本文件自身的先例一致——line 类型错误时默认为 0,只有 id 会整体拒绝文件,因为损坏的 id 会主动破坏匹配;而损坏的 start_line 最多让 finding 退回 PR 之前的点语义。diff 本身未发现阻塞性问题。

未决问题在于与报告消费方——内置 /review skill——的接缝:它目前仍只认点位置,这正是我不能称之为完成的原因:

所以,@bluefateludi——skill 侧那一半(findings 文件格式 + 报告形状 + 删除规则措辞)是计划作为后续跟进,还是可以随本 PR 一起?分类改动本身作为必要的第一步是成立的;想问的是在本 PR 合入之前,第二半的计划是否已经存在——因为只落这一半会产生一种文档化消费契约无法确定性消费的报告状态。@wenshao——这个子系统由你维护,#9212/#9629 的 presubmit 改动也是你合入的,最终由你定夺。

测试证据

这是无人值守的 CI 运行——按策略此处从不执行 PR 代码,证据是通过 API 读取的 PR 自身 CI。在被审查的 commit 上,PR 的 CI 尚未运行Qwen Code CISecurity Checks 均处于 action_required,等待维护者批准该 fork 的工作流运行。未验证:该 commit 上的单元/lint/构建结果——目前不存在。PR 正文中 109/109 的数字是作者在自己机器(Windows)上的声明,未经独立复跑。CI 落定后,finalize 工作流会更新上方表格。

沙箱验证可以坐实行为性声明:@qwen-code /verify——presubmit 是否真的把 #9219 形状的评论集(位于多行区间内部的评论)从 noConflict 重新归类为 overlap,在 CI 悬置期间不可观测;而且即使套件变绿也只证明测试通过,不证明改动负重。作者没有写权限,因此这是一次受助运行(sponsored run):由维护者发出 @qwen-code /verify 评论,批准其书写时对应的 head;该运行带有执行前风险筛查与完整工作区清理——请以待 fork 自身 CI 日志同样的怀疑态度阅读其报告。

Qwen Code · qwen3.8-max

Reviewed at b8038ece96afd18bc0a1d5259eca3ff328e7cad9 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — correct, conservative, and well-tested inside its stated scope; but the consumer seam means #9219's motivating scenario is not caught end-to-end yet, and no CI has run on this commit at all.

Stepping back: the command-side half of this is exactly the shape the fix should take — range carried on both ends, normalized interval intersection, the exact-anchor re-post exemption preserved, single-line behavior provably unchanged, and not a drive-by edit in the diff. If I were maintaining presubmit.ts in six months I'd thank whoever wrote that range math. What stops me short is not anything the diff gets wrong. The bundled /review skill consumes this report through a point-location contract: nothing writes start_line into the findings file, so #9219's headline case (a drafted multi-line finding containing an existing comment) still lands noConflict in a real run; and the newly-flagged direction (a finding inside an existing multi-line comment) sets blockOnExistingComments=true while the deterministic drop rule can't literally match it — shipping just this half produces a report state the documented consumer can't consume deterministically. Independently of scope: there is no unit, lint, or build evidence on b8038ece — the fork's workflow runs are sitting at action_required — so there is nothing to certify against yet anyway. I'm not approving because I ran out of objections; I'm deferring because two questions genuinely need the subsystem owner.

⏸️ Deferring to @wenshao — needs a human call on two things:

  1. Does the skill-side half (findings-file format, report comment shape, drop-rule wording) ride along here, or land as an accepted follow-up tracked on /review presubmit overlap matching is exact-line only — multi-line ranges and semantic duplicates pass as noConflict #9219 before this merges? The classification change is a necessary first step either way; the question is only whether the plan for the second half exists.
  2. Approving this fork's CI workflow runs, so the suite actually executes on this commit (the author's 109/109 is their own-machine claim; nothing has run here).

No changes requested — nothing in the diff needs reworking. This is a completeness call, not a defect report. @bluefateludi, nice, disciplined change within its scope; the above is the one seam worth settling before it lands.

中文说明

信心度:3/5 —— 在其声明范围内正确、保守且测试充分;但消费方接缝意味着 #9219 的动机场景端到端仍未被拦截,而且该 commit 上根本还没有 CI 运行过。

退一步看:命令侧这一半正是这个修复应有的形状——两端携带范围、归一化的区间相交、精确定锚的 re-post 豁免得以保留、单行行为可证明地不变,diff 里没有一处顺手改动。如果六个月后由我维护 presubmit.ts,我会感谢写下这段区间计算的人。让我停下来的不是 diff 做错了什么。内置 /review skill 通过点位置契约消费这份报告:没有任何东西往 findings 文件里写 start_line,所以 #9219 的头号场景(起草的多行 finding 内含既有评论)在真实运行中仍会落入 noConflict;而新近生效的方向(finding 落在既有多行评论内部)会置 blockOnExistingComments=true,确定性删除规则却无法字面匹配它——只落这一半会产生一种文档化消费方无法确定性消费的报告状态。与范围无关的另一点:b8038ece 上不存在任何单元、lint 或构建证据——该 fork 的工作流运行停在 action_required——因此目前根本没有可供认证的东西。我不是因为没有反对意见才不批准;而是因为有两个问题确实需要子系统负责人来回答,所以我选择暂缓。

⏸️ 转交 @wenshao —— 有两件事需要人来定夺:

  1. skill 侧那一半(findings 文件格式、报告中的评论形状、删除规则措辞)是随本 PR 一起,还是作为 /review presubmit overlap matching is exact-line only — multi-line ranges and semantic duplicates pass as noConflict #9219 跟踪的、合入前已获认可的后续跟进?无论哪种,分类改动都是必要的第一步;问题只是第二半的计划是否已经存在。
  2. 批准该 fork 的 CI 工作流运行,让套件真正在这个 commit 上执行(作者的 109/109 是其本机声明,这里什么都没有运行过)。

不请求修改——diff 中没有需要重写的东西。这是完整性判断,不是缺陷报告。@bluefateludi,在其范围内这是一次有章法的改动;合入前值得敲定的就是上面那个接缝。

Qwen Code · qwen3.8-max

Reviewed at b8038ece96afd18bc0a1d5259eca3ff328e7cad9 · re-run with @qwen-code /triage

@bluefateludi

Copy link
Copy Markdown
Contributor Author

Thanks for the review. This PR intentionally focuses on command-side range support. I can address the /review skill integration in a follow-up, or include it here if maintainers prefer.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

Not explored to full depth (tool budget reached): "agent 1a": run packages/cli presubmit.test.ts under vitest (blocked: no node_modules in worktree; full install not attempted); "agent 1a": npm run typecheck over the changed files (blocked: same missing-dependency condition).

中文说明

已审查。 建议见行内评论。

未探索到全部深度(达到工具调用预算):"agent 1a"run packages/cli presubmit.test.ts under vitest (blocked: no node_modules in worktree; full install not attempted)"agent 1a"npm run typecheck over the changed files (blocked: same missing-dependency condition)

— qwen3.8-max via Qwen Code /review (v0.22.0)

out.push({
path: e.path,
line: typeof e.line === 'number' ? e.line : 0,
...(typeof e.start_line === 'number' ? { startLine: e.start_line } : {}),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-3: The new start_line parse arm validates nothing beyond typeof === 'number', and both directions of that leniency silently corrupt the new range-overlap dedup while findingsFileInvalid stays false (the report reads as a clean pass).

Two shapes, both probed on this commit:

  • Wrong type collapses the range: [{"path":"a.ts","start_line":"12","line":18}] parses to the point [18,18] (the key is silently dropped), so an existing comment spanning lines 12–17 no longer intersects → noConflict, and a duplicate posts. The numeric control arm correctly reports overlap.
  • Out-of-domain widens it: start_line: -3 yields [-3,18], flipping an unrelated same-file comment at line 5 to overlap and blockOnExistingComments to true — a wrong premise handed to the deterministic drop rule; fractional 2.5 misses dedup at line 2. Pre-diff start_line was not parsed at all, so both shapes were inert.

Probe output (unmodified PR code):

parseFindingsFile('[{"path":"a.ts","start_line":"12","line":18}]') → [{path:'a.ts',line:18}]
string arm:    byBucket {overlap:0, noConflict:1}, blockOnExistingComments:false
numeric arm:   byBucket {overlap:1},               blockOnExistingComments:true
start_line:-3: unrelated line-5 comment → overlap   start_line:2.5 → line 2 escapes dedup
with a domain gate (positive safe integer, else reject whole file): both arms → findingsFileInvalid:true

The file's own fail-safe rejects the WHOLE file for a misshapen id because a corrupt id would actively corrupt a match — a corrupt range does the same here, and submit.ts's isDiffLine (Number.isSafeInteger(n) && n > 0) is the existing domain gate for exactly this value class on the posting side. Suggested fix: mirror the id fail-safe on the new arm — when start_line is present (and not null) but not a positive safe integer, reject the whole file (return null), e.g. by reusing/exporting isDiffLine; or, if the lenient drop is intentional, a comment saying so keeps the next reader from re-deriving this.

中文说明

新的 start_line 解析分支只校验 typeof === 'number',两个方向的宽松都会静默破坏新的区间去重,而 findingsFileInvalid 保持 false(报告读起来像一次干净的通过)。

两种形态,均在本提交上以探针实测:

  • 类型错误使区间塌缩:[{"path":"a.ts","start_line":"12","line":18}] 被解析为点 [18,18](该键被静默丢弃),横跨 12–17 行的既有评论不再与之相交 → 判为 noConflict,重复评论被发出;数字对照组正确给出 overlap
  • 域外数值放小区间:start_line: -3 得到 [-3,18],同文件第 5 行一条无关评论被翻转为 overlap,blockOnExistingComments 置 true——向确定性丢弃规则提供了错误前提;小数 2.5 则漏掉第 2 行的去重。diff 之前 start_line 根本不被解析,两种形态都是惰性的。

本文件自身的 fail-safe 先例是:畸形 id 拒绝整个文件,因为损坏的 id 会主动破坏匹配——损坏的区间在这里同理;submit.tsisDiffLine(Number.isSafeInteger(n) && n > 0)正是发布侧针对这一数值类型的既有域校验。建议修复:对新分支镜像 id 的 fail-safe——start_line 存在(且非 null)但不是正安全整数时拒绝整个文件(return null),例如复用/导出 isDiffLine;若宽松丢弃是有意为之,加一条注释说明,避免后续读者重新推导。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +698 to +700
const commentLine = c.line ?? 0;
const commentStartLine = c.start_line ?? commentLine;
const commentRangeStart = Math.min(commentStartLine, commentLine);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-1: No test discriminates the comment-side start_line read here. A mutant that ignores c.start_line (commentStartLine := commentLine) passes the entire suite — measured in a scratch-tree run: unmodified code 109/109 pass, same mutant 109/109 pass. In the two new tests that give a comment a start_line, the outcome already holds through the comment's END line alone ([8,14] intersects [12,18] via 14; [4,8] stays disjoint as [8,8]).

The regression that could then ship: an existing multi-line comment {start_line: 8, line: 20} with a new finding range [12,18] overlaps only via the comment's start line; with c.start_line ignored it classifies noConflict, the gate misses the duplicate, and the finding re-posts as a visible duplicate — the exact #9219 bug on the comment side. The finding-side counterpart IS discriminated, which makes this asymmetry easy to miss.

One test closes it, and it flips the mutant as required (fails under the mutant with expected +0 to be 1, passes on the real code): a comment { id, path: 'a.ts', start_line: 8, line: 20, commit_id: 'abc123', user: { login: 'qwen-code-ci-bot' } } with a **[Critical]** body against findings [{ path: 'a.ts', start_line: 12, line: 18 }], expecting byBucket.overlap 1 / byBucket.noConflict 0.

中文说明

此处评论侧的 start_line 读取没有任何测试可以区分:忽略 c.start_line 的变异体(commentStartLine := commentLine)能通过整个套件——在临时树中实测:未改动代码 109/109 通过,同一变异体同样 109/109 通过。两个给评论带 start_line 的新测试里,结果仅凭评论的结束行就已成立([8,14] 经由 14 与 [12,18] 相交;[4,8] 收缩为 [8,8] 后仍不相交)。

由此可能溜进发布的回归:既有多行评论 {start_line: 8, line: 20} 与新 finding 区间 [12,18] 仅经由评论的起始行相交;若 c.start_line 被忽略,则判为 noConflict,overlap 门漏掉重复,finding 被再次发出——正是 #9219 要修的 bug,只是发生在评论侧。finding 侧的对应读取是有测试区分的,这种不对称很容易被忽略。

补一个测试即可闭合,且它能按预期翻转变异体(在变异体下以 expected +0 to be 1 失败,在真实代码下通过):评论 { id, path: 'a.ts', start_line: 8, line: 20, commit_id: 'abc123', user: { login: 'qwen-code-ci-bot' } }(带 **[Critical]** 正文),findings 为 [{ path: 'a.ts', start_line: 12, line: 18 }],断言 byBucket.overlap 为 1、byBucket.noConflict 为 0。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +702 to +703
const overlapsNewFinding = newFindings.some((finding) => {
if (finding.path !== (c.path ?? '')) return false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-2: The overlap gate became range-based while the comment set it runs over is still recognized partly by an ungated, any-account shape match: the qwenComments filter accepts any comment whose body contains the short footer substring (via Qwen Code /review), with no account condition (~line 936). Pre-diff, exact ${path}:${line} keying meant one planted comment suppressed exactly its single anchor line; with range intersection, one forged multi-line comment silences every new finding whose range intersects it.

Measured on this commit: a comment from an account named attacker spanning a.ts lines 10–60 with the footer substring in the body, against a new finding at line 35 → byBucket.overlap: 1, blockOnExistingComments: true. Any user who can comment on the PR can plant one; the commit_id stale gate constrains them exactly as before (post at the current head), so feasibility is unchanged — only the width per plant grew. The drop log names the responsible comment after the fact but does not prevent a genuinely new Critical in that range from being withheld.

Suggested fix: restrict the new range-intersection branch to account-gated comments (the marker/severity disjuncts, where provenance is the posting account) and keep the any-account footer match exact-line as before; or explicitly document that an ungated footer match now grants range-wide suppression.

中文说明

overlap 门变为基于区间,而它作用的评论集合仍部分依赖一个不设账户门槛的形状匹配来识别:qwenComments 过滤器接受任何正文包含短尾注子串(via Qwen Code /review)的评论,没有账户条件(约第 936 行)。diff 之前,精确的 ${path}:${line} 键意味着一条植入的评论只能压制其锚点所在的那一行;改为区间相交后,一条伪造的多行评论可以压制所有与其区间相交的新 finding。

在本提交上实测:一个名为 attacker 的账号发布横跨 a.ts 10–60 行、正文含尾注子串的评论,对第 35 行的新 finding → byBucket.overlap: 1blockOnExistingComments: true。任何能在 PR 上评论的用户都可以植入这样一条评论;commit_id 过期门槛对攻击者的约束与之前完全一致(发在当前 head 即可),可行性未变——只是每条植入评论的压制宽度变大了。丢弃日志事后会点名该评论,但无法阻止该区间内真正的新 Critical 被扣下。

建议修复:将新的区间相交分支限制为有账户门槛的评论(marker/severity 两个分支,其来源是发布账号),任一账户的尾注匹配保持原有的精确行语义;或明确注明:无账户门槛的尾注匹配现在授予区间级压制。

— qwen3.8-max via Qwen Code /review (v0.22.0)

@wenshao

wenshao commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Local real-environment verification report

I built the merge of this PR with current main and ran it as a real qwen review presubmit against a real GitHub PR carrying real multi-line review comments, plus a same-tree A/B and a mutation matrix. Posting the findings here as a merge reference.

Verdict — the code is correct and safe, but the loop is not closed. The behaviour change is real and it fires on ~1 in 5 of our actual review comments. Nothing regresses: exact-line matching, the carried-id re-post exemption, the same-file guard and disjoint ranges are all unchanged, and no finding that used to be dropped stops being dropped. What I could not confirm is that the change produces the outcome the description claims: the half of it the description leads with is unreachable in production today, and the half that does fire raises blockOnExistingComments=true that Step 7 has no way to act on. Details and repro below.

Environment

Tree b8038ece96 (PR head) merged with origin/mainclean merge, both hunks survive; presubmit.ts/presubmit.test.ts/posting.md untouched on main since
Platform macOS 26.6.2 (arm64), Node v24.18.1, npm 11.16.0, Vitest 3.2.7
Real target wenshao/qwen-code#7 — a scratch PR in my own fork with three real inline comments posted through POST /pulls/{n}/comments, each carrying the genuine _— … via Qwen Code /review (v0.22.3)_ footer
Binary under test npm run build && npm run bundledist/cli.js, run as qwen review presubmit (no mocks, no stubs, real gh api)

1. Static checks — all green

  • npx vitest run src/commands/review/presubmit.test.ts110/110 pass (109 on the PR's base; main added one since).
  • Whole review command suite npx vitest run src/commands/review/116 files, 5634 pass, 3 skipped. (One unrelated [vitest-worker]: Timeout calling "onTaskUpdate" unhandled error — the known worker-RPC flake, not caused by this PR.)
  • npm run typecheck exit 0; npx eslint … --max-warnings 0 exit 0; npx prettier --check clean; git diff --check clean.
  • Reverting only the behavioural hunk (keeping the tests) fails exactly 2 of the 3 new tests, so they are genuine regression tests; the third (keeps disjoint ranges in noConflict) is a control that passes both ways. That is correct and worth saying out loud.

2. Same-tree A/B on a real GitHub PR

Two bundles from one tree — dist-pr (this PR) and dist-base (this PR's behavioural hunk reverted, nothing else changed) — pointed at the same live PR and the same findings files.

A/B matrix

The real multi-line comment the fix keys on — GitHub really does return start_line: 7 for it:

real multi-line review comment

P1 is the fix working, on a real comment, through the real gh path. P2 / P6 / P7 / P8 are unchanged controls: exact-line match, the carried-id re-post exemption (repost=1 on both sides — the exemption is not broadened or lost), the same-file guard, and the disjoint case.

Reachability is real, not theoretical. Across 8 recent PRs, 97 of 455 Qwen /review inline comments (21.3%) are multi-line, i.e. carry a start_line:

PR qwen inline comments multi-line
#9740 334 80 24.0%
#10390 32 7 21.9%
#10423 45 2 4.4%
#10301 26 2 7.7%
#10427 9 2 22.2%
#10421 6 2 33.3%
#9801 3 2 66.7%
total 455 97 21.3%

Their spans: median 3 lines, p90 10, max 141 (this one, revert-hunk.test.ts 1840–1980). So each such comment now claims a whole range as its overlap zone, and the widest one claims 141 lines of a file.

3. Gap A — the direction the description leads with cannot happen in production

The description says the bug is noConflict "when an existing comment sat inside a newly drafted multi-line finding". That direction needs finding.startLine, which comes only from start_line in the --new-findings file — and nothing writes it:

  • skills/bundled/review/references/posting.md, the findings-file section: the documented shape is still [{"path":"src/foo.ts","line":42,"id":"R3-2"}, ...]. Zero mentions of start_line anywhere in that section (checked against the shipped dist/bundled/review/).
  • The subcommand's own --new-findings help still reads: "a JSON file shaped as [{path, line, id?}, ...] — … existing comments are checked for same-(path, line) overlap".

My probe P3 only flips because I hand-wrote start_line into the findings file. With the file the orchestrator actually writes, finding.startLine is always undefined, findingRange collapses to a point, and parseFindingsFile's new branch never executes. What genuinely fires today is the mirror case: a new single-line finding landing inside an existing multi-line comment (P1) — a real and worthwhile fix, but not the one written up.

Two of the three new tests (classifies a comment inside a new finding range, classifies intersecting … ranges) set start_line on the finding, i.e. they pin the unreachable direction.

4. Gap B — the overlap fires, but Step 7's drop rule has no target

drop rule gap

CommentSummary carries only line: c.line ?? 0 — the comment's end line. It carries neither the comment's start_line nor which finding matched. Under the old exact-line rule those coincided by construction, which is what made posting.md's Step 7 rule work:

Drop each finding whose (path, line) appears in existingComments.overlap from your comments array

Under range matching they no longer coincide. In P1 the report hands back {"path":"verify-9801-fixture.ts","line":14} while the finding is at line 10 — so the drop set is empty, the duplicate is posted anyway, and blockOnExistingComments=true tells the model to block with nothing to drop. The drop-log line the rule prescribes (already reported at <path>:<line>) would also name a location no finding of this round occupies.

posting.md is explicit that this decision point must stay deterministic ("this has been improvised as an interactive question, which stalls a headless run forever"). A block with an empty drop set is exactly the state that invites improvisation.

5. Gap C — a new false-positive class, from posting.md's own instructions

posting.md tells the orchestrator to include body-only Criticals in the findings file with a placeholder line:

include one {path, line} per body Critical (any placeholder line, e.g. 1, and NO id …)

P5 does exactly that — [{"path":"…","line":1}] — against an unrelated existing comment spanning lines 1–3. Base: noConflict. This PR: overlap=1, blockOnExistingComments=true. A placeholder that was chosen precisely because it means nothing now aliases into any multi-line comment that happens to cover line 1, and produces a block with (again) nothing droppable.

6. Gap D — the production-reachable half is untested

Mutants applied to presubmit.ts, each run against the whole 110-test file:

mutation matrix

mutant change verdict
M1 <=< on both bounds KILLED (32)
M2 drop Math.min/max on the comment range SURVIVED
M3 ignore finding.startLine KILLED (2)
M4 ignore c.start_line SURVIVED
M5 delete the same-file guard (finding.path !== c.path) SURVIVED
M6 drop Math.min/max on the finding range SURVIVED
M7 parseFindingsFile drops start_line KILLED (3)

M4 is the important one. Deleting the comment-side start_line read — the only half of this PR that runs in production today — leaves all 110 tests green. M5 shows the same-file guard is unpinned too: the old code got path-safety for free from the ${path}:${line} key; the new predicate makes it an explicit if that no test exercises. (Runtime behaviour is correct — P7 confirms it on the real rig — it is the test that is missing.) M2/M6 are lower stakes: submit validates start_line <= line before posting and GitHub never returns a reversed range, so the normalization is defence-in-depth.

Recommendation

The change is safe to merge and I found no regression. But as it stands it broadens a detector without moving the consumer contract, so the outcome it advertises — a duplicate that stops being posted — does not actually happen. Three small follow-ups would close it, and they belong in this PR rather than a new one:

  1. Make the overlap actionable. Carry the matched finding's anchor (or at least the comment's start_line/range) in existingComments.overlap[], and update posting.md's Step 7 drop rule and drop-log wording to key on it instead of (path, line). Without this, blockOnExistingComments gains cases it cannot dispose of.
  2. Pick a side on the finding half. Either update posting.md's findings-file spec and the --new-findings help to emit and describe start_line (then the PR's headline scenario becomes real), or drop FindingAnchor.startLine + the parseFindingsFile branch as dead code. Today the code and the documented contract disagree.
  3. Pin what actually runs. Add a case where only the comment carries start_line (kills M4), and a cross-file case through the new predicate (kills M5). Optionally decide what a body-only placeholder anchor should do (Gap C) — excluding id-less placeholder anchors from range matching, or telling posting.md to place them out of range, would both work.

Happy to be talked out of any of this — everything above is reproducible from wenshao/qwen-code#7 and the probe files, and I'm glad to hand those over.

中文版本

本地真实环境验证报告

我把本 PR 与当前 main 合并后完整构建,用真实的 qwen review presubmit真实 GitHub PR 上真实的多行 review 评论,并做了同树 A/B 与变异矩阵。结论发在这里作为合并参考。

结论 —— 代码正确且安全,但链路没有闭合。 行为变更是真实的,在我们实际的 review 评论里约 1/5 会触发。没有任何回归:精确行匹配、carried-id re-post 豁免、同文件守卫、不相交区间全部不变,原本会被丢弃的 finding 也不会变成不丢弃。我没能确认的是它能否产出描述里声称的结果:描述主打的那半边在当前生产环境根本走不到,而真正会触发的那半边会把 blockOnExistingComments 置为 true,但 Step 7 对它无从下手。

环境

代码树 b8038ece96(PR head)与 origin/main 合并 —— 无冲突,两处 hunk 都存活;此后 main 未再改动 presubmit.ts/presubmit.test.ts/posting.md
平台 macOS 26.6.2 (arm64)、Node v24.18.1、npm 11.16.0、Vitest 3.2.7
真实目标 wenshao/qwen-code#7 —— 我自己 fork 里的临时 PR,三条评论全部通过 POST /pulls/{n}/comments 真实发出,都带真实的 _— … via Qwen Code /review (v0.22.3)_ 页脚
被测二进制 npm run build && npm run bundledist/cli.js,以 qwen review presubmit 运行(无 mock、无桩,真实 gh api

1. 静态检查 —— 全绿

  • npx vitest run src/commands/review/presubmit.test.ts110/110 通过(PR base 上是 109,main 后来加了一个)。
  • 整个 review 命令套件 npx vitest run src/commands/review/116 个文件、5634 通过、3 skipped。(另有一条与本 PR 无关的 [vitest-worker]: Timeout calling "onTaskUpdate" 未处理错误,是已知的 worker RPC 抖动。)
  • npm run typecheck 退出 0;npx eslint … --max-warnings 0 退出 0;npx prettier --check 通过;git diff --check 干净。
  • 只回退行为 hunk(保留测试),3 个新测试中恰好 2 个失败,说明它们是真的回归测试;第三个(keeps disjoint ranges in noConflict)是两边都通过的对照。这点值得明说。

2. 真实 GitHub PR 上的同树 A/B

同一棵树出两个 bundle —— dist-pr(本 PR)与 dist-base(只回退本 PR 的行为 hunk,其余完全一致)—— 指向同一个线上 PR、同一批 findings 文件。

A/B 矩阵

修复所依赖的那条真实多行评论 —— GitHub 确实返回了 start_line: 7

真实多行 review 评论

P1 就是修复在真实评论、真实 gh 链路上生效P2 / P6 / P7 / P8 是不变的对照组:精确行匹配、carried-id re-post 豁免(两侧都是 repost=1,豁免既没被放宽也没丢失)、同文件守卫、不相交区间。

可达性是实测的,不是推测。 最近 8 个 PR 里,455 条 Qwen /review 行内评论中有 97 条(21.3%) 是多行的,即带 start_line

PR qwen 行内评论 多行
#9740 334 80 24.0%
#10390 32 7 21.9%
#10423 45 2 4.4%
#10301 26 2 7.7%
#10427 9 2 22.2%
#10421 6 2 33.3%
#9801 3 2 66.7%
合计 455 97 21.3%

跨度:中位数 3 行,p90 10 行,最大 141 行这一条revert-hunk.test.ts 1840–1980)。也就是说每条这样的评论现在都会把整个区间圈成自己的 overlap 区,最宽的一条圈掉了 141 行。

3. 缺口 A —— 描述主打的那个方向在生产环境走不到

描述说 bug 是「既有评论位于新起草的多行 finding 内部」时报 noConflict。这个方向需要 finding.startLine,而它只能来自 --new-findings 文件里的 start_line —— 但没有任何地方会写它:

  • skills/bundled/review/references/posting.md 的 findings 文件章节,文档形状仍然是 [{"path":"src/foo.ts","line":42,"id":"R3-2"}, ...],该章节内 start_line 出现 0 次(对已打包dist/bundled/review/ 核对过)。
  • 子命令自己的 --new-findings 帮助文本仍写着:"a JSON file shaped as [{path, line, id?}, ...] — … existing comments are checked for same-(path, line) overlap"

我的探针 P3 之所以能翻转,只是因为我手工往 findings 文件里写了 start_line。用 orchestrator 实际写出的文件,finding.startLine 恒为 undefined,finding 区间退化成一个点,parseFindingsFile 的新分支永远不执行。今天真正会触发的是镜像情形:新的单行 finding 落进既有的多行评论内部(P1)—— 这是真实且有价值的修复,但不是被写进描述的那个。

3 个新测试里有 2 个(classifies a comment inside a new finding rangeclassifies intersecting … ranges)是在 finding 上设 start_line,也就是在钉那个走不到的方向。

4. 缺口 B —— overlap 触发了,但 Step 7 的丢弃规则找不到目标

丢弃规则缺口

CommentSummary 只带 line: c.line ?? 0,即评论的结束行;既不带评论的 start_line,也不带是哪个 finding 匹配上的。在旧的精确行规则下这两者按构造相等,posting.md 的 Step 7 规则正是靠这一点成立:

Drop each finding whose (path, line) appears in existingComments.overlap from your comments array

改成区间匹配后两者不再相等。P1 里报告回传的是 {"path":"verify-9801-fixture.ts","line":14},而 finding 在第 10 行 —— 于是丢弃集合为空,重复评论照发,同时 blockOnExistingComments=true 又要求模型阻塞。规则要求的丢弃日志行(already reported at <path>:<line>)也会指向本轮任何 finding 都不在的位置。

posting.md 明确要求这个决策点必须保持确定性("this has been improvised as an interactive question, which stalls a headless run forever")。「阻塞但没有可丢弃项」恰好就是最容易诱发即兴发挥的状态。

5. 缺口 C —— 一个新的误报类别,来自 posting.md 自己的指示

posting.md 要求 orchestrator 把 body-only Critical 也写进 findings 文件,用占位行号:

include one {path, line} per body Critical (any placeholder line, e.g. 1, and NO id …)

P5 就照着做了 —— [{"path":"…","line":1}] —— 对上一条无关的、跨越 1–3 行的既有评论。base:noConflict。本 PR:overlap=1, blockOnExistingComments=true。一个正因为「不表示任何含义」才被选中的占位行号,现在会跟任何恰好覆盖第 1 行的多行评论对上,并且(同样)产生一个没有可丢弃项的阻塞。

6. 缺口 D —— 生产环境唯一会跑到的那半边没有测试

presubmit.ts 上施加变异,每个变异体都跑完整的 110 个测试:

变异矩阵

变异体 改动 结论
M1 两个边界 <=< 被杀(32 失败)
M2 去掉评论区间的 Math.min/max 存活
M3 忽略 finding.startLine 被杀(2 失败)
M4 忽略 c.start_line 存活
M5 删掉同文件守卫finding.path !== c.path 存活
M6 去掉 finding 区间的 Math.min/max 存活
M7 parseFindingsFile 丢掉 start_line 被杀(3 失败)

M4 是关键。 把评论侧读取 start_line 整个删掉 —— 也就是本 PR 今天在生产环境唯一会跑到的那半边 —— 110 个测试仍然全绿。M5 说明同文件守卫同样没被钉住:旧代码靠 ${path}:${line} 这个 key 免费获得路径安全,新写法把它变成一个显式 if,而没有测试走到它。(运行时行为是对的,P7 在真机上确认过;缺的是测试。)M2/M6 风险较低:submit 在发帖前会校验 start_line <= line,GitHub 也不会返回反向区间,所以这层归一化属于纵深防御。

建议

改动可以安全合并,我没有发现回归。但就目前状态而言,它只拓宽了检测器,没有同步消费方契约,所以它宣称的效果 —— 重复评论不再被发出 —— 实际上并没有发生。三个小的后续可以把它闭合,且应该放在本 PR 里而不是新开一个:

  1. 让 overlap 可执行。existingComments.overlap[] 里带上匹配到的 finding 锚点(至少带上评论的 start_line/区间),并同步更新 posting.md 的 Step 7 丢弃规则与丢弃日志措辞,让它按这个新字段而不是 (path, line) 去匹配。否则 blockOnExistingComments 会多出一批无法处置的情形。
  2. 在 finding 那半边二选一。 要么更新 posting.md 的 findings 文件规格以及 --new-findings 帮助文本,让它产出并描述 start_line(这样 PR 的主打场景才真实存在);要么把 FindingAnchor.startLineparseFindingsFile 的对应分支作为死代码删掉。当前代码与文档化契约是不一致的。
  3. 钉住真正会跑的路径。 补一个只有评论start_line 的用例(杀掉 M4),以及一个走新谓词的跨文件用例(杀掉 M5)。可选地为 body-only 占位锚点定个说法(缺口 C)—— 把无 id 的占位锚点排除出区间匹配,或在 posting.md 里要求把它们放到不会落入区间的位置,两种都行。

以上任何一条都欢迎反驳 —— 全部结论都可以从 wenshao/qwen-code#7 和探针文件复现,需要的话我可以把它们交出来。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants