Skip to content

fix(diff): disambiguate duplicate comment anchors - #992

Open
sandaogouchen wants to merge 1 commit into
alibaba:mainfrom
sandaogouchen:fix/ambiguous-comment-relocation
Open

fix(diff): disambiguate duplicate comment anchors#992
sandaogouchen wants to merge 1 commit into
alibaba:mainfrom
sandaogouchen:fix/ambiguous-comment-relocation

Conversation

@sandaogouchen

@sandaogouchen sandaogouchen commented Aug 18, 2026

Copy link
Copy Markdown

Description

This PR fixes the issue where duplicate existing_code within the same file causes comments to be anchored to the first matching entry. For relevant background and examples, please refer to issue #991.

The fix works by first collecting candidates and then deciding whether to perform positioning: if there is only one candidate, it will still be applied directly; if there are multiple candidates, they will be handed over to the new CANDIDATE_RE_LOCATION_TASK to select a candidate_id; if a candidate cannot be reliably selected, the comment will remain in the unpositioned 0-0 state instead of guessing the first matching position.

Current file candidates still take priority; cross-file candidate selection will only be entered when there are no candidates for the current file. Corresponding protection has also been added in the final output stage to prevent ambiguous comments from being re-parsed to the first matching item.

Limitation

The main goal of this PR is to fix the matching ambiguity of duplicate existing_code within the same file, while reusing the same candidate selection mechanism to handle the cross-file multi-candidate scenario when there are no candidates in the current file. It does not change the scan behavior, timeout behavior, prompt candidate budget, nor does it modify the larger relocation process.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Refactoring (no functional changes)
  • Documentation update
  • CI / Build / Tooling

How Has This Been Tested?

  • make test passes locally
  • Manual testing (describe below)

Local verification passed:

  • make test
  • go vet ./...
  • make license-check
  • make english-check
  • git diff --cached --check

Added regression test coverage:

  • The duplicate existing_code in both the hunk and the full file content will not be directly parsed to the first matching position;
  • Multiple candidates from the same file will be collected with context attached;
  • Ambiguous code_comment will trigger CANDIDATE_RE_LOCATION_TASK;
  • Current file candidates take precedence over cross-file candidates;
  • Invalid candidate selection output will be retried in accordance with strict rules;
  • In the final output phase, ambiguous comments will not be reattached to the first matching entry;
  • The parsing of candidate_id only accepts strict JSON, and rejects plain text, bare numbers, fenced JSON, missing fields, and the string "null".

Checklist

  • My code follows the project's coding style (go fmt, go vet)
  • I have performed a self-review of my code
  • I have added tests that prove my fix is effective or my feature works
  • New and existing unit tests pass locally with my changes
  • I have updated the documentation accordingly (if applicable)
  • I have signed the CLA

Related Issues

Fixes #991

Open Question

I would like to ask core developers to confirm a frontend display issue. Currently, the viewer displays the LLM's output but not the input. For the newly added candidate relocation process, the model intentionally outputs only strict JSON, such as {"candidate_id": 2}. Without seeing the input candidate list, users may not know which code position the 2 corresponds to.

There are two possible approaches here:

  1. Keep the status quo.
  2. Add a minor viewer enhancement to map candidate_id back to the corresponding code context.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 3 issue(s) in this PR.

  • ✅ Successfully posted inline: 3 comment(s)

⚠️ 1 warning(s) occurred during review.


⚠️ Warnings:

  • internal/diff/relocation.go (comment_refiled): comment filed against internal/diff/resolver.go describes code in internal/diff/relocation.go; re-filed

Comment on lines +225 to +231
func ParseCandidateID(content string) (string, bool) {
content = strings.TrimSpace(content)
if content == "" {
return "", false
}
var payload map[string]json.RawMessage
if err := json.Unmarshal([]byte(content), &payload); err == nil && payload != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

bug · medium
Potential issue: ParseCandidateID does not handle markdown code-fence wrappers.

LLMs frequently wrap JSON output in code fences (e.g., ```json\n{"candidate_id": 1}\n```) despite explicit instructions not to. The existing ReLocateComment function calls extractCodeBlock(resp.Content()) before parsing the LLM output, but ReLocateCommentCandidate passes resp.Content() directly to ParseCandidateID without stripping fences. This means a perfectly valid candidate response wrapped in fences will fail to parse, trigger an unnecessary retry, and waste an LLM round-trip (or fail outright after exhausting retries).

Consider stripping a code-fence wrapper before JSON unmarshal, for example by trying extractCodeBlock first and falling back to the raw content, similar to how ReLocateComment handles its response.

Suggestion:

Suggested change
func ParseCandidateID(content string) (string, bool) {
content = strings.TrimSpace(content)
if content == "" {
return "", false
}
var payload map[string]json.RawMessage
if err := json.Unmarshal([]byte(content), &payload); err == nil && payload != nil {
func ParseCandidateID(content string) (string, bool) {
content = strings.TrimSpace(content)
if content == "" {
return "", false
}
// LLMs sometimes wrap JSON in code fences despite being told not to.
if block := extractCodeBlock(content); block != "" {
content = block
}
var payload map[string]json.RawMessage
if err := json.Unmarshal([]byte(content), &payload); err == nil && payload != nil {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The prompt already includes few-shot examples and the candidate relocation path retries invalid responses up to two times, but it currently only parses raw JSON and does not handle the common Markdown fenced JSON form.
I’ll keep the strict JSON contract, but add a small normalization step to unwrap Markdown code fences before parsing.

Comment on lines +269 to +279
func isPositiveInteger(s string) bool {
for i, r := range s {
if r < '0' || r > '9' {
return false
}
if i == 0 && r == '0' {
return false
}
}
return s != ""
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

maintainability · low
Bug: isPositiveInteger has a logic error. When the first character is '0', it returns false immediately, which correctly rejects "0" and leading-zero numbers like "01". However, this also means that the string "0" passes the loop (since '0' is between '0' and '9') but then fails the i == 0 && r == '0' check, returning false. This is actually correct behavior for rejecting "0" as a candidate ID (since IDs start at 1). But the function name is misleading — it's not checking for "positive integer" in the mathematical sense, but rather "positive integer without leading zeros". The current logic works for the use case, but the function name could be clearer, or a comment should explain the leading-zero rejection.

Suggestion:

Suggested change
func isPositiveInteger(s string) bool {
for i, r := range s {
if r < '0' || r > '9' {
return false
}
if i == 0 && r == '0' {
return false
}
}
return s != ""
}
// isPositiveInteger checks if s is a positive integer string (1, 2, 3, ...)
// without leading zeros. Returns false for "0", "01", negative numbers, etc.
func isPositiveInteger(s string) bool {
if s == "" {
return false
}
for i, r := range s {
if r < '0' || r > '9' {
return false
}
// Reject leading zeros (including "0" itself)
if i == 0 && r == '0' {
return false
}
}
return true
}

Comment thread internal/llmloop/loop.go
Comment on lines +658 to +659
if d != nil {
if !located && len(candidates) == 0 && r.deps.Template.ReLocationTask != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

bug · high
Fallback gap when multiple candidates exist but disambiguation fails or is skipped.

When len(candidates) > 1 and either (a) CandidateReLocationTask is nil, (b) the prompt exceeds the token budget, or (c) the LLM call fails, the comment remains unlocated. The ReLocationTask (snippet-based) fallback below requires len(candidates) == 0, so it is never reached.

In the old code, the ReLocationTask was the fallback for any unlocated comment regardless of how many matches existed. The new condition len(candidates) == 0 creates a gap: comments with multiple ambiguous candidates that can't be disambiguated get no fallback attempt.

Consider relaxing the fallback condition to !located (without the len(candidates) == 0 guard), or at least also trying ReLocationTask when len(candidates) > 1 && !located.

Suggestion:

Suggested change
if d != nil {
if !located && len(candidates) == 0 && r.deps.Template.ReLocationTask != nil {
if d != nil {
if !located && r.deps.Template.ReLocationTask != nil {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

When multiple candidates already match the same existing_code, falling back to the old snippet-based RE_LOCATION_TASK can reintroduce the original bug: the regenerated snippet may still be ambiguous and get resolved to the first matching location.

So this PR keeps that path fail-closed:

  • 0 candidates: use the existing RE_LOCATION_TASK.
  • 1 candidate: apply directly.
  • multiple candidates: use CANDIDATE_RE_LOCATION_TASK.
  • if candidate selection fails, leave it unlocated instead of guessing.

@sandaogouchen
sandaogouchen force-pushed the fix/ambiguous-comment-relocation branch from 83bf850 to d8c61f0 Compare August 18, 2026 09:46
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.

Review comment attaches to the first same-file existing_code match when duplicates exist

1 participant