fix(lsp): shift baseline diagnostics into post-edit coordinates (#25978)

Pre-existing diagnostics below an edit point used to surface as 'LSP
diagnostics introduced by this edit' whenever the edit deleted or
inserted lines.  The delta-filter key included the diagnostic's
range, so the same logical error reported at a different line in
the post-edit snapshot looked like a brand new diagnostic.

Concrete case: deleting 14 lines in cli.py caused Pyright errors at
lines 9873, 10590, 12413, 13004 (unrelated to the edit) to be
reported as introduced by it.

Fix: build a piecewise-linear line-shift map (via difflib's
SequenceMatcher) from pre and post content, and remap baseline
diagnostics into post-edit coordinates before the set-difference.
Diagnostics in deleted regions drop out cleanly; diagnostics below
the edit shift by the right amount; diagnostics above are untouched.
The strict (range-aware) equality key stays — so a genuinely new
instance of an identical error class at a different line still
surfaces as new.

Pieces:
- agent/lsp/range_shift.py — build_line_shift, shift_diagnostic_range,
  shift_baseline.  Pure functions, no LSP state.
- agent/lsp/manager.py — LSPService.get_diagnostics_sync gains an
  optional line_shift kwarg; baseline is shift_baseline'd before
  computing the seen-set.  _diag_key keeps the strict range key.
- tools/file_operations.py — write_file captures pre_content for any
  LSP-handled extension (not just LINTERS_INPROC) and passes pre/post
  to _maybe_lsp_diagnostics, which builds the shift map.
- New _lsp_handles_extension helper guards the pre_content read.

Trade-offs preserved:
- Genuinely new same-class errors at different lines still surface
  (content-only key would have swallowed them).
- Pre-existing errors at unshifted positions still get filtered
  (covered by the strict-key path with no shift).
- Best-effort: when pre_content can't be captured (file didn't
  exist, permissions), the unshifted comparison still catches
  most pre-existing errors; the edge case it misses is a new file
  with a non-empty baseline, which is structurally impossible.
This commit is contained in:
Teknium 2026-05-14 15:56:07 -07:00 committed by GitHub
parent ed84637d11
commit 19071529f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 552 additions and 18 deletions

View file

@ -909,19 +909,29 @@ class ShellFileOperations(FileOperations):
if _is_write_denied(path):
return WriteResult(error=f"Write denied: '{path}' is a protected system/credential file.")
# Capture pre-write content for lint-delta computation. Only do this
# when an in-process OR shell linter exists for this extension — no
# point paying for the read otherwise. For in-process linters we
# pass the content directly; for shell linters the pre-state isn't
# useful (we'd have to re-write-read to lint the old version, which
# defeats the purpose), so we skip the capture and accept the naive
# "all errors" report.
# Capture pre-write content. Two consumers want it:
#
# 1. The lint-delta layer (for in-process linters like ast.parse
# and json.loads) needs the previous content to compute the
# set of NEW lint errors introduced by this write.
# 2. The LSP layer needs pre/post content to build a line-shift
# map — pre-existing diagnostics below the edit point shift
# when lines are added/removed, and the shift map remaps
# baseline diagnostics into post-edit coordinates so the
# strict (range-aware) delta key matches.
#
# The set of extensions we capture pre_content for is therefore
# the UNION of in-process lint coverage and LSP coverage. For
# extensions outside both sets (binaries, opaque formats),
# skipping the read keeps the hot path fast.
ext = os.path.splitext(path)[1].lower()
pre_content: Optional[str] = None
if ext in LINTERS_INPROC:
want_pre = ext in LINTERS_INPROC or self._lsp_handles_extension(ext)
if want_pre:
# Best-effort read; failure (file missing, permission) leaves
# pre_content as None which makes the delta step degrade
# gracefully to "report all errors".
# pre_content as None which makes both downstream consumers
# degrade gracefully (lint reports all errors; LSP skips the
# shift map).
read_cmd = f"cat {self._escape_shell_arg(path)} 2>/dev/null"
read_result = self._exec(read_cmd)
if read_result.exit_code == 0 and read_result.stdout:
@ -966,11 +976,15 @@ class ShellFileOperations(FileOperations):
# Semantic diagnostics from the LSP layer — separate channel.
# Only fired when the syntax tier reported clean (no point asking
# an LSP for a file that won't even parse). Best-effort:
# ``""`` is returned for any failure path.
# an LSP for a file that won't even parse). Pass pre/post
# content so the LSP layer can build a line-shift map and
# remap baseline diagnostics into post-edit coordinates.
# Best-effort: ``""`` is returned for any failure path.
lsp_diagnostics: Optional[str] = None
if lint_result.success or lint_result.skipped:
block = self._maybe_lsp_diagnostics(path)
block = self._maybe_lsp_diagnostics(
path, pre_content=pre_content, post_content=content
)
if block:
lsp_diagnostics = block
@ -1295,6 +1309,29 @@ class ShellFileOperations(FileOperations):
return False
return isinstance(env, LocalEnvironment)
def _lsp_handles_extension(self, ext: str) -> bool:
"""Return True iff some registered LSP server claims this extension.
Used to decide whether to capture pre-write content for the
line-shift map. Capturing is cheap (one ``cat`` on the host)
but pointless if no LSP would ever look at the file.
Safe to call on remote backends the registry is purely
in-process metadata; we still gate the actual LSP path on
:meth:`_lsp_local_only`.
"""
if not ext:
return False
try:
from agent.lsp.servers import SERVERS
except Exception: # noqa: BLE001
return False
ext_lower = ext.lower()
for srv in SERVERS:
if ext_lower in srv.extensions:
return True
return False
def _snapshot_lsp_baseline(self, path: str) -> None:
"""Capture pre-edit LSP diagnostics so the post-write delta is correct.
@ -1318,12 +1355,25 @@ class ShellFileOperations(FileOperations):
except Exception: # noqa: BLE001
pass
def _maybe_lsp_diagnostics(self, path: str) -> str:
def _maybe_lsp_diagnostics(
self,
path: str,
*,
pre_content: Optional[str] = None,
post_content: Optional[str] = None,
) -> str:
"""Best-effort LSP semantic diagnostics for ``path``.
Returns a formatted ``<diagnostics>`` block, or empty string
when LSP is unavailable / disabled / produced no errors.
When both ``pre_content`` and ``post_content`` are provided,
a line-shift map is built and passed to the LSPService so
baseline diagnostics are remapped into post-edit coordinates
before the set-difference. Without this, edits that delete
or insert lines surface every pre-existing diagnostic below
the edit point as "introduced by this edit".
Wraps everything in a try/except so a misbehaving LSP server
can't break a write. This intentionally swallows all errors
the calling tier already returned a clean syntax result, so
@ -1344,8 +1394,20 @@ class ShellFileOperations(FileOperations):
return ""
if svc is None or not svc.enabled_for(path):
return ""
# Build a line-shift map when we have both pre and post — it
# remaps baseline diagnostics into post-edit coordinates so
# the strict (range-aware) delta key matches correctly.
line_shift = None
if pre_content is not None and post_content is not None and pre_content != post_content:
try:
from agent.lsp.range_shift import build_line_shift
line_shift = build_line_shift(pre_content, post_content)
except Exception: # noqa: BLE001
line_shift = None
try:
diagnostics = svc.get_diagnostics_sync(path, delta=True)
diagnostics = svc.get_diagnostics_sync(path, delta=True, line_shift=line_shift)
except Exception: # noqa: BLE001
return ""
if not diagnostics: