From 521c866f09a0ac71c5e30454a7331b6094e574e1 Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 30 Jun 2026 15:28:16 +0200 Subject: [PATCH 1/3] Fix misplaced single-line PR-review suggestion anchoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve-SuggestionPlacement only re-anchored single-line suggestions by exact whitespace-insensitive equality. A one-line suggestion that edits a line (e.g. inserting `this.` for CodeCop AA0248) is never equal to the line it replaces, so it fell through to "trust the model anchor" and posted on the wrong line — a neighbouring comment in microsoft/BCApps#8893. Applying it would corrupt the file. Re-anchor by character-LCS similarity over a +/-8 window instead: pick the clear best line (absolute floor + ambiguity margin), and return $null to suppress the suggestion block (manual snippet) when no confident, unambiguous target exists. Fixes AB#640948 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/Invoke-CopilotPRReview.ps1 | 95 +++++++++++++++---- 1 file changed, 79 insertions(+), 16 deletions(-) diff --git a/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1 b/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1 index 29ddb7c50c..9b75f6cd17 100644 --- a/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1 +++ b/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1 @@ -497,6 +497,44 @@ function Test-OrderedSubsequence { return $true } +# Character-level longest-common-subsequence length between two strings. +# Used to score how close a suggested (edited) line is to a candidate file +# line. O(n*m) with a rolling two-row buffer; lines are short so this is cheap. +function Get-LcsLength { + param([string] $A, [string] $B) + + if ([string]::IsNullOrEmpty($A) -or [string]::IsNullOrEmpty($B)) { return 0 } + $n = $A.Length; $m = $B.Length + $prev = New-Object 'int[]' ($m + 1) + $curr = New-Object 'int[]' ($m + 1) + for ($i = 1; $i -le $n; $i++) { + $ai = $A[$i - 1] + for ($j = 1; $j -le $m; $j++) { + if ($ai -eq $B[$j - 1]) { + $curr[$j] = $prev[$j - 1] + 1 + } else { + $curr[$j] = [math]::Max($prev[$j], $curr[$j - 1]) + } + } + $tmp = $prev; $prev = $curr; $curr = $tmp + [Array]::Clear($curr, 0, $curr.Length) + } + return $prev[$m] +} + +# Similarity in [0,1] between two already-loosened lines (whitespace stripped), +# based on character LCS over max length. 1.0 == identical; pure insertions +# (e.g. adding 'this.') stay high because the shorter line is almost entirely a +# subsequence of the longer one, while unrelated lines score low. +function Get-LooseLineSimilarity { + param([string] $A, [string] $B) + + if ($A -eq $B) { return 1.0 } + $maxLen = [math]::Max($A.Length, $B.Length) + if ($maxLen -eq 0) { return 1.0 } + return ((Get-LcsLength -A $A -B $B) / $maxLen) +} + # Resolve the RIGHT-side file span a suggestion should replace. # Returns @{ startLine; endLine } (1-based, inclusive) or $null when the # suggestion cannot be placed with confidence (caller drops the block). @@ -514,25 +552,50 @@ function Resolve-SuggestionPlacement { $firstLoose = ConvertTo-LooseLine $SuggestedLines[0] $lastLoose = ConvertTo-LooseLine $SuggestedLines[$sCount - 1] - # --- Single-line suggestion: snap to the nearest unique content match. --- + # --- Single-line suggestion: re-anchor to the file line the edit targets. --- + # A one-line suggestion almost always *edits* a line, so it is NOT equal to + # the line it replaces (e.g. inserting 'this.' for CodeCop AA0248). Exact + # equality therefore fails for the common case and the model's reported + # anchor is unreliable (it frequently points at a neighbouring comment or + # blank line). We instead score every file line in a window around the + # anchor by similarity to the suggested line and take the clear winner. + # When no candidate is similar enough, or the best is ambiguous against the + # runner-up, we return $null so the caller suppresses the ```suggestion``` + # block (posting a manual snippet) rather than corrupting the file by + # trusting the wrong anchor. if ($sCount -eq 1) { - if ((ConvertTo-LooseLine $FileLines[$AnchorLine - 1]) -eq $firstLoose) { - return [pscustomobject]@{ startLine = $AnchorLine; endLine = $AnchorLine } - } - for ($d = 1; $d -le 8; $d++) { - $hits = @() - foreach ($cand in @(($AnchorLine - $d), ($AnchorLine + $d))) { - if ($cand -ge 1 -and $cand -le $fileCount -and - (ConvertTo-LooseLine $FileLines[$cand - 1]) -eq $firstLoose) { - $hits += $cand - } + $minSimilarity = 0.5 # absolute confidence floor for a re-anchor + $ambiguityMargin = 0.1 # winner must beat the runner-up by this much + $window = 8 + + $lo = [math]::Max(1, $AnchorLine - $window) + $hi = [math]::Min($fileCount, $AnchorLine + $window) + + $cands = for ($i = $lo; $i -le $hi; $i++) { + [pscustomobject]@{ + line = $i + score = Get-LooseLineSimilarity (ConvertTo-LooseLine $FileLines[$i - 1]) $firstLoose + dist = [math]::Abs($i - $AnchorLine) } - if ($hits.Count -eq 1) { return [pscustomobject]@{ startLine = $hits[0]; endLine = $hits[0] } } - if ($hits.Count -gt 1) { break } # ambiguous at this distance } - # No content match found: a one-line replacement of the model's anchor - # is still safe (it cannot duplicate context), so trust the anchor. - return [pscustomobject]@{ startLine = $AnchorLine; endLine = $AnchorLine } + # Best score first; ties broken by proximity to the model anchor. + $ranked = @($cands | Sort-Object @{ Expression = 'score'; Descending = $true }, @{ Expression = 'dist'; Descending = $false }) + $top = $ranked[0] + + # An exact loose match is always safe to apply (the replaced text is + # identical), so accept it regardless of the ambiguity margin. + if ($top.score -ge 1.0) { + return [pscustomobject]@{ startLine = $top.line; endLine = $top.line } + } + + $runnerScore = if ($ranked.Count -gt 1) { $ranked[1].score } else { -1.0 } + if ($top.score -ge $minSimilarity -and ($top.score - $runnerScore) -ge $ambiguityMargin) { + return [pscustomobject]@{ startLine = $top.line; endLine = $top.line } + } + + # No confident, unambiguous target — let the caller fall back to a + # manual snippet instead of posting an auto-applicable wrong anchor. + return $null } # --- Multi-line suggestion: find an additive span [s,e] near the anchor. --- From 6fbf012908effefd6b47bb35433edc77e821664d Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 30 Jun 2026 17:45:53 +0200 Subject: [PATCH 2/3] Widen single-line re-anchor window to recover larger mis-anchors The model-reported anchor for a single-line suggestion can be off by more than 8 lines (e.g. PR #8933 VendorNL Confirm() was off by 10, label rename off by 9). Widen the search window to 40 lines either side so the correct target within the same procedure is considered, while the 0.5 similarity floor and 0.1 ambiguity margin keep an unrelated look-alike from winning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/Invoke-CopilotPRReview.ps1 | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1 b/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1 index 9b75f6cd17..b4deeb900e 100644 --- a/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1 +++ b/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1 @@ -556,17 +556,20 @@ function Resolve-SuggestionPlacement { # A one-line suggestion almost always *edits* a line, so it is NOT equal to # the line it replaces (e.g. inserting 'this.' for CodeCop AA0248). Exact # equality therefore fails for the common case and the model's reported - # anchor is unreliable (it frequently points at a neighbouring comment or - # blank line). We instead score every file line in a window around the - # anchor by similarity to the suggested line and take the clear winner. - # When no candidate is similar enough, or the best is ambiguous against the - # runner-up, we return $null so the caller suppresses the ```suggestion``` - # block (posting a manual snippet) rather than corrupting the file by - # trusting the wrong anchor. + # anchor is unreliable: it frequently points at a neighbouring statement + # inside the same procedure (observed off by 10+ lines), a comment, or a + # blank line. We score the file lines in a window around the anchor by + # similarity to the suggested line and take the clear winner. The window is + # wide enough to recover a target elsewhere in the same procedure, while the + # similarity floor, ambiguity margin, and proximity tie-break keep an + # unrelated look-alike from being chosen. When no candidate is similar + # enough, or the best is ambiguous against the runner-up, we return $null so + # the caller suppresses the ```suggestion``` block (posting a manual + # snippet) rather than corrupting the file by trusting the wrong anchor. if ($sCount -eq 1) { $minSimilarity = 0.5 # absolute confidence floor for a re-anchor $ambiguityMargin = 0.1 # winner must beat the runner-up by this much - $window = 8 + $window = 40 # lines either side of the anchor to consider $lo = [math]::Max(1, $AnchorLine - $window) $hi = [math]::Min($fileCount, $AnchorLine + $window) From c6698d64e3bcec8524c3510649bf5daa193f6f4b Mon Sep 17 00:00:00 2001 From: Jesper Schulz-Wedde Date: Tue, 30 Jun 2026 17:57:11 +0200 Subject: [PATCH 3/3] Make single-line re-anchoring high-precision to avoid wrong targets Widening the search window exposed a precision problem: a label-rename suggestion whose added Comment text echoes the field captions at the Error()/Confirm() call site would re-anchor onto the call site (PR #8933 GenJournalLineNL/GeneralLedgerSetupNL 'Text1000000/1' findings), which is worse than the original mis-anchor. Raise the similarity floor to 0.6 and use a bounded window (20). Genuine edit targets - an edited statement or a renamed declaration - score ~0.75-0.99 and re-anchor confidently (the PR #8933 Confirm() findings and the PartnerTypeMismatchMsg->Qst rename now land on the right line). Lower- confidence look-alikes stay below the floor and are suppressed, so the caller posts a manual snippet instead of a wrong auto-applicable anchor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/Invoke-CopilotPRReview.ps1 | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1 b/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1 index b4deeb900e..583e5c3d70 100644 --- a/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1 +++ b/tools/Code Review/scripts/Invoke-CopilotPRReview.ps1 @@ -559,17 +559,24 @@ function Resolve-SuggestionPlacement { # anchor is unreliable: it frequently points at a neighbouring statement # inside the same procedure (observed off by 10+ lines), a comment, or a # blank line. We score the file lines in a window around the anchor by - # similarity to the suggested line and take the clear winner. The window is - # wide enough to recover a target elsewhere in the same procedure, while the - # similarity floor, ambiguity margin, and proximity tie-break keep an - # unrelated look-alike from being chosen. When no candidate is similar - # enough, or the best is ambiguous against the runner-up, we return $null so - # the caller suppresses the ```suggestion``` block (posting a manual - # snippet) rather than corrupting the file by trusting the wrong anchor. + # similarity to the suggested line and take the clear winner. + # + # The design favours PRECISION over recall: a wrong auto-applicable + # suggestion corrupts the file, whereas declining to re-anchor merely falls + # back to a manual (non-applicable) snippet. We therefore re-anchor only when + # a candidate is *confidently* the edit target -- it must clear a high + # similarity floor AND beat the runner-up by the ambiguity margin. This + # cleanly separates genuine targets (an edited statement or a renamed + # declaration score ~0.75-0.99) from coincidental look-alikes elsewhere in + # the window: e.g. a label-rename whose added Comment text echoes the field + # captions at the Error()/Confirm() call site tops out around ~0.55, so it + # stays below the floor and is suppressed rather than re-anchored onto the + # call site. When no candidate is confident and unambiguous we return $null + # so the caller posts a manual snippet instead of a wrong anchor. if ($sCount -eq 1) { - $minSimilarity = 0.5 # absolute confidence floor for a re-anchor + $minSimilarity = 0.6 # absolute confidence floor for a re-anchor $ambiguityMargin = 0.1 # winner must beat the runner-up by this much - $window = 40 # lines either side of the anchor to consider + $window = 20 # lines either side of the anchor to consider $lo = [math]::Max(1, $AnchorLine - $window) $hi = [math]::Min($fileCount, $AnchorLine + $window)