mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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:
parent
ed84637d11
commit
19071529f6
5 changed files with 552 additions and 18 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue