mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-31 06:51:29 +00:00
fix(lint): skip per-file shell linter when LSP will handle the file (#29054)
* fix(lint): skip per-file shell linter when LSP will handle the file `_check_lint` ran `npx tsc --noEmit FILE.ts` after every `.ts`/`.tsx` edit. `tsc` ignores `tsconfig.json` when given an explicit file argument (documented quirk) and defaults to no-lib / ES5, so every ES2015+ stdlib reference reports as missing: - `Cannot find global value 'Promise'` - `Cannot find name 'Map' / 'Set' / 'ReadonlySet' / 'Iterable'` - `Property 'isFinite' does not exist on type 'NumberConstructor'` - `Module 'phaser' can only be default-imported using esModuleInterop` - `import.meta is only allowed when --module is es2020+` On real TypeScript projects this floods the `lint` field on WriteResult / PatchResult with up to 25K tokens of false positives per edit. The delta filter in `_check_lint_delta` is supposed to mask them, but a tiny edit shifts line numbers and every phantom resurfaces as "introduced by this edit". The result is a 1MB+ phantom-error dump on every patch that eats the agent's context budget. Same shape for `.go` (`go vet` outside a module) and `.rs` (`rustfmt --check` outside a Cargo project). PR #24168 added an LSP tier on top of this — real `tsserver` / `gopls` / `rust-analyzer` diagnostics surface in the separate `lsp_diagnostics` field. But the broken shell linter kept running underneath, so the phantom-error dump kept happening even when LSP was giving us a clean authoritative signal. This change short-circuits the shell linter for the structurally-broken extensions (`.ts`, `.tsx`, `.go`, `.rs`) when an LSP server is active and claims the file via `LSPService.enabled_for(path)`. The LSP tier runs as before and carries the real diagnostics in `lsp_diagnostics`. Other shell linters (`py_compile`, `node --check`) keep running unconditionally — they're fast, file-local, and correct. Default behavior (LSP disabled, LSP misconfigured, remote backend, file outside a workspace) is unchanged — the existing fallback paths trigger when `_lsp_will_handle` returns False, so users who haven't opted into LSP get the same shell-linter behavior they had before. Drive-by: `.tsx` was missing from the `LINTERS` table entirely, so TS React files got no post-edit syntax check at all. Added it for symmetry; in practice it now hits the LSP-skip path. Tests: - `tests/agent/lsp/test_shell_linter_lsp_skip.py` — 14 tests covering: * skip happens for each redundant extension when LSP claims the file (asserted by patching `_exec` to raise on any shell-linter call) * shell linter still runs when LSP is inactive (regression guard) * `.py` / `.js` continue to run unconditionally even with LSP active * `_lsp_will_handle` is exception-safe: returns False on None service, remote backend, or `enabled_for` raising * `.tsx` is in both `LINTERS` and `_SHELL_LINTER_LSP_REDUNDANT` - All pre-existing tests in `tests/agent/lsp/` and `tests/tools/test_file_operations*.py` still pass (233/233). * fix(lint): address Copilot review on #29054 Two fixes from copilot-pull-request-reviewer on PR #29054: 1. `.tsx` regression with LSP disabled (https://github.com/NousResearch/hermes-agent/pull/29054#discussion_r3271017282) The first revision added `.tsx` to the `LINTERS` table so that TypeScript React files would hit the LSP skip path. Side effect: when LSP is *disabled* (the default), `.tsx` edits would suddenly run `npx tsc --noEmit FILE.tsx` and inherit the same phantom-error dump this PR is supposed to fix. Pre-PR behavior was implicit `skipped` (no `LINTERS` entry); restore that. - Remove `.tsx` from `LINTERS`. - Remove `.tsx` from `_SHELL_LINTER_LSP_REDUNDANT` (the skip path is unreachable without a `LINTERS` entry — falls through to `ext not in LINTERS` first). - When LSP IS enabled, `.tsx` is still covered by the LSP tier via `_maybe_lsp_diagnostics` (typescript-language-server's `extensions` tuple includes `.tsx`), so the diagnostics still surface — just on the `lsp_diagnostics` channel, not `lint`. - Update test_shell_linter_lsp_skip.py to reflect this contract (drop `.tsx` from the parametrize lists; add `test_tsx_stays_out_of_linters_table_for_default_compatibility` and `test_tsx_default_check_lint_returns_skipped`). 2. V4A patches dropped `WriteResult.lsp_diagnostics` (https://github.com/NousResearch/hermes-agent/pull/29054#discussion_r3271017295) `tools/patch_parser.py::apply_v4a_operations` calls `file_ops.write_file()` per operation, then calls `_check_lint()` directly afterwards — but never propagates `WriteResult.lsp_diagnostics` to the `PatchResult`. The shell-linter skip introduced in this PR makes the gap visible: a `.ts` / `.go` / `.rs` V4A patch with LSP active would return `lint = {f: {skipped: True}}` and zero diagnostics from any channel. - `_apply_add` and `_apply_update` now return `Tuple[bool, str, Optional[str]]` where the third element is `WriteResult.lsp_diagnostics` (or `None` on failure / no diags). - `_apply_delete` and `_apply_move` stay 2-tuples — they don't produce diagnostics, no write goes through `write_file`. - `apply_v4a_operations` accumulates per-file diagnostics blocks and surfaces a combined block on `PatchResult.lsp_diagnostics`. Each block already carries its `<diagnostics file="...">` header from `LSPService.report_for_file`, so concatenation preserves per-file attribution. Tests added (`test_patch_parser.py::TestV4ALspDiagnosticsPropagation`): - ADD op: `WriteResult.lsp_diagnostics` flows to `PatchResult` - UPDATE op: same - No diagnostics → `PatchResult.lsp_diagnostics is None` (not "") - Multi-file patch: combined block contains every per-file block Verification: - Targeted test scope: 257/257 pass (tests/agent/lsp/, tests/tools/test_file_operations*.py, tests/tools/test_patch_parser.py) - Wider sweep: 5400 pass; 11 failures all pre-existing on origin/main (file_staleness / file_read_guards / file_state_registry — unrelated macOS /var/folders tmp-path sensitivity issues, confirmed by re-running on a clean origin/main checkout) * docs(test): align shell-linter LSP skip docstring with .tsx behavior Copilot review feedback (review #4324947616, comment #3271049036): the test module docstring still listed .tsx alongside .ts/.go/.rs in the skip contract, but .tsx is now intentionally NOT in LINTERS or _SHELL_LINTER_LSP_REDUNDANT. Updated the bullet list to drop .tsx from the skip contract and added a paragraph documenting why .tsx is left out (preserves pre-PR implicit-skip behavior for LSP-disabled users; LSP coverage still happens via _maybe_lsp_diagnostics). * test(lsp): drop unused tmp_path from _make_fops helper Copilot review #3271069484: the helper accepted tmp_path but never used it. Callers still need tmp_path themselves for the file they're asserting against, so we just drop the helper's parameter.
This commit is contained in:
parent
6a6766fb89
commit
5e743559e0
4 changed files with 474 additions and 11 deletions
|
|
@ -509,3 +509,141 @@ class TestParseErrorSignalling:
|
|||
ops, err = parse_v4a_patch(patch)
|
||||
assert err is None
|
||||
assert len(ops) == 1
|
||||
|
||||
|
||||
class TestV4ALspDiagnosticsPropagation:
|
||||
"""V4A patches must surface ``WriteResult.lsp_diagnostics`` from the
|
||||
underlying ``write_file`` calls on ``PatchResult.lsp_diagnostics``.
|
||||
|
||||
Without explicit propagation the LSP tier's output gets silently
|
||||
dropped on the V4A code path — see Copilot review #3271017295 on
|
||||
PR #29054. The shell-linter LSP skip introduced by that PR makes
|
||||
this gap visible: a ``.ts`` / ``.go`` / ``.rs`` V4A patch with LSP
|
||||
active would otherwise return ``lint = {f: {skipped: True, ...}}``
|
||||
and zero diagnostics from any channel.
|
||||
"""
|
||||
|
||||
def _build_ops_writing(self, path: str, content: str):
|
||||
"""Build a single ADD operation that writes ``content`` to ``path``."""
|
||||
# Use the V4A parser so we don't have to construct PatchOperation
|
||||
# / Hunk / Line objects by hand.
|
||||
lines = "\n".join(f"+{line}" for line in content.splitlines())
|
||||
patch_text = (
|
||||
"*** Begin Patch\n"
|
||||
f"*** Add File: {path}\n"
|
||||
f"{lines}\n"
|
||||
"*** End Patch"
|
||||
)
|
||||
ops, err = parse_v4a_patch(patch_text)
|
||||
assert err is None, err
|
||||
return ops
|
||||
|
||||
def test_lsp_diagnostics_propagated_from_write_file_on_add(self):
|
||||
"""ADD op: ``WriteResult.lsp_diagnostics`` flows through to
|
||||
``PatchResult.lsp_diagnostics``."""
|
||||
ops = self._build_ops_writing("foo.ts", "const x: number = 1\n")
|
||||
|
||||
diag_block = (
|
||||
"<diagnostics file=\"foo.ts\">\n"
|
||||
"ERROR [1:7] some diagnostic\n"
|
||||
"</diagnostics>"
|
||||
)
|
||||
|
||||
class FakeFileOps:
|
||||
def write_file(self, path, content):
|
||||
return SimpleNamespace(error=None, lsp_diagnostics=diag_block)
|
||||
|
||||
def _check_lint(self, path):
|
||||
return SimpleNamespace(to_dict=lambda: {"skipped": True})
|
||||
|
||||
result = apply_v4a_operations(ops, FakeFileOps())
|
||||
|
||||
assert result.success is True
|
||||
assert result.lsp_diagnostics == diag_block
|
||||
|
||||
def test_lsp_diagnostics_propagated_from_write_file_on_update(self):
|
||||
"""UPDATE op: ``WriteResult.lsp_diagnostics`` flows through to
|
||||
``PatchResult.lsp_diagnostics``."""
|
||||
patch_text = (
|
||||
"*** Begin Patch\n"
|
||||
"*** Update File: bar.ts\n"
|
||||
"-old\n"
|
||||
"+new\n"
|
||||
"*** End Patch"
|
||||
)
|
||||
ops, err = parse_v4a_patch(patch_text)
|
||||
assert err is None
|
||||
|
||||
diag_block = (
|
||||
"<diagnostics file=\"bar.ts\">\n"
|
||||
"ERROR [3:1] something\n"
|
||||
"</diagnostics>"
|
||||
)
|
||||
|
||||
class FakeFileOps:
|
||||
def read_file_raw(self, path):
|
||||
return SimpleNamespace(content="ctx\nold\nctx\n", error=None)
|
||||
|
||||
def write_file(self, path, content):
|
||||
return SimpleNamespace(error=None, lsp_diagnostics=diag_block)
|
||||
|
||||
def _check_lint(self, path):
|
||||
return SimpleNamespace(to_dict=lambda: {"skipped": True})
|
||||
|
||||
result = apply_v4a_operations(ops, FakeFileOps())
|
||||
|
||||
assert result.success is True
|
||||
assert result.lsp_diagnostics == diag_block
|
||||
|
||||
def test_lsp_diagnostics_none_when_no_blocks_emitted(self):
|
||||
"""When no underlying ``write_file`` produced diagnostics, the
|
||||
aggregated field stays ``None`` (so it doesn't get serialized
|
||||
as an empty string in ``PatchResult.to_dict``)."""
|
||||
ops = self._build_ops_writing("foo.py", "x = 1\n")
|
||||
|
||||
class FakeFileOps:
|
||||
def write_file(self, path, content):
|
||||
# lsp_diagnostics omitted entirely (older WriteResult shape).
|
||||
return SimpleNamespace(error=None)
|
||||
|
||||
def _check_lint(self, path):
|
||||
return SimpleNamespace(to_dict=lambda: {"success": True})
|
||||
|
||||
result = apply_v4a_operations(ops, FakeFileOps())
|
||||
|
||||
assert result.success is True
|
||||
assert result.lsp_diagnostics is None
|
||||
|
||||
def test_lsp_diagnostics_combined_across_multiple_files(self):
|
||||
"""When several files in one V4A patch produce diagnostics,
|
||||
each block appears in the combined output so per-file attribution
|
||||
is preserved."""
|
||||
patch_text = (
|
||||
"*** Begin Patch\n"
|
||||
"*** Add File: a.ts\n"
|
||||
"+const a = 1\n"
|
||||
"*** Add File: b.ts\n"
|
||||
"+const b = 2\n"
|
||||
"*** End Patch"
|
||||
)
|
||||
ops, err = parse_v4a_patch(patch_text)
|
||||
assert err is None
|
||||
|
||||
per_file = {
|
||||
"a.ts": "<diagnostics file=\"a.ts\">\nERR a\n</diagnostics>",
|
||||
"b.ts": "<diagnostics file=\"b.ts\">\nERR b\n</diagnostics>",
|
||||
}
|
||||
|
||||
class FakeFileOps:
|
||||
def write_file(self, path, content):
|
||||
return SimpleNamespace(error=None, lsp_diagnostics=per_file[path])
|
||||
|
||||
def _check_lint(self, path):
|
||||
return SimpleNamespace(to_dict=lambda: {"skipped": True})
|
||||
|
||||
result = apply_v4a_operations(ops, FakeFileOps())
|
||||
|
||||
assert result.success is True
|
||||
assert result.lsp_diagnostics is not None
|
||||
assert per_file["a.ts"] in result.lsp_diagnostics
|
||||
assert per_file["b.ts"] in result.lsp_diagnostics
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue