Skip to content

Commit 5e74355

Browse files
authored
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 (#29054 (comment)) 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` (#29054 (comment)) `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.
1 parent 6a6766f commit 5e74355

4 files changed

Lines changed: 474 additions & 11 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""Skip the per-file shell linter when LSP will handle the same file.
2+
3+
The per-file ``npx tsc --noEmit FILE.ts`` shell linter cannot see
4+
``tsconfig.json`` (a documented ``tsc`` quirk: explicit file args bypass
5+
the project config), so it defaults to no-lib / ES5 and floods the
6+
agent's lint field with phantom "Cannot find 'Promise' / 'Map' / 'Set' /
7+
'ReadonlySet' / 'Iterable' / 'imul' / …" errors on every edit — up to
8+
25K tokens per patch. The LSP tier (``tsserver`` via
9+
typescript-language-server) reads tsconfig correctly and surfaces real
10+
diagnostics in the ``lsp_diagnostics`` field of the WriteResult /
11+
PatchResult.
12+
13+
These tests pin the contract:
14+
15+
- When LSP is active AND ``enabled_for(path)`` for a ``.ts`` / ``.go``
16+
/ ``.rs`` file, ``_check_lint`` returns ``skipped`` without invoking
17+
the shell linter at all.
18+
- When LSP is inactive or disabled-for-path, the shell linter runs
19+
exactly as before (regression guard for the default config).
20+
- The skip only applies to extensions in
21+
``_SHELL_LINTER_LSP_REDUNDANT`` — Python ``py_compile`` and
22+
``node --check`` keep running unconditionally because they're fast,
23+
file-local, and correct.
24+
- ``.tsx`` is intentionally NOT in either ``LINTERS`` or
25+
``_SHELL_LINTER_LSP_REDUNDANT``: it had no ``LINTERS`` entry
26+
pre-PR (so it was already implicitly ``skipped`` via the
27+
``ext not in LINTERS`` branch) and adding one would have inherited
28+
``.ts``'s broken ``tsc --noEmit FILE`` invocation for LSP-disabled
29+
users. When LSP IS enabled, ``.tsx`` is still covered by
30+
typescript-language-server via ``_maybe_lsp_diagnostics`` — the
31+
diagnostics show up on ``lsp_diagnostics``, not ``lint``.
32+
"""
33+
from __future__ import annotations
34+
35+
from unittest.mock import MagicMock, patch
36+
37+
import pytest
38+
39+
40+
def _make_fops():
41+
from tools.environments.local import LocalEnvironment
42+
from tools.file_operations import ShellFileOperations
43+
return ShellFileOperations(LocalEnvironment())
44+
45+
46+
@pytest.mark.parametrize("ext", [".ts", ".go", ".rs"])
47+
def test_shell_linter_skipped_when_lsp_will_handle(ext, tmp_path):
48+
"""When LSP is active and enabled_for(path), shell linter is skipped.
49+
50+
The shell linter's _exec must NOT be called — that's the whole
51+
point. We assert by patching ``_exec`` to raise, so any accidental
52+
invocation surfaces as a test failure.
53+
"""
54+
fops = _make_fops()
55+
src = tmp_path / f"bad{ext}"
56+
src.write_text("intentionally invalid content\n")
57+
58+
def _exec_must_not_run(*args, **kwargs): # pragma: no cover
59+
raise AssertionError(
60+
"shell linter was invoked despite LSP claiming the file"
61+
)
62+
63+
with patch.object(fops, "_lsp_will_handle", return_value=True), \
64+
patch.object(fops, "_exec", side_effect=_exec_must_not_run), \
65+
patch.object(fops, "_has_command", return_value=True):
66+
result = fops._check_lint(str(src))
67+
68+
assert result.skipped is True
69+
assert "LSP" in (result.message or "")
70+
71+
72+
@pytest.mark.parametrize("ext", [".ts", ".go", ".rs"])
73+
def test_shell_linter_runs_when_lsp_inactive(ext, tmp_path):
74+
"""When LSP is inactive (default config, no service, remote backend, ...),
75+
the shell linter runs as before — no behavior change."""
76+
fops = _make_fops()
77+
src = tmp_path / f"clean{ext}"
78+
src.write_text("// content\n")
79+
80+
fake_result = MagicMock()
81+
fake_result.exit_code = 0
82+
fake_result.stdout = ""
83+
84+
with patch.object(fops, "_lsp_will_handle", return_value=False), \
85+
patch.object(fops, "_exec", return_value=fake_result) as exec_mock, \
86+
patch.object(fops, "_has_command", return_value=True):
87+
result = fops._check_lint(str(src))
88+
89+
# _exec must have been called — proving the shell linter ran.
90+
assert exec_mock.called, "shell linter did NOT run when LSP was inactive"
91+
assert result.success is True
92+
93+
94+
@pytest.mark.parametrize("ext", [".py", ".js"])
95+
def test_lsp_does_not_skip_non_redundant_extensions(ext, tmp_path):
96+
"""``py_compile`` and ``node --check`` keep running even when an LSP
97+
server (pyright/pylsp/typescript-language-server-for-JS) is active —
98+
they're fast, file-local, and correct, so there's no upside to
99+
suppressing them.
100+
"""
101+
fops = _make_fops()
102+
src = tmp_path / f"clean{ext}"
103+
src.write_text("# valid\n" if ext == ".py" else "// valid\n")
104+
105+
fake_result = MagicMock()
106+
fake_result.exit_code = 0
107+
fake_result.stdout = ""
108+
109+
# Even with LSP claiming the file, the shell linter must still run
110+
# for these extensions.
111+
with patch.object(fops, "_lsp_will_handle", return_value=True), \
112+
patch.object(fops, "_exec", return_value=fake_result) as exec_mock, \
113+
patch.object(fops, "_has_command", return_value=True):
114+
fops._check_lint(str(src))
115+
116+
assert exec_mock.called, (
117+
f"shell linter for {ext} did not run despite being in the "
118+
"'always-run' set (py_compile / node --check)"
119+
)
120+
121+
122+
def test_lsp_will_handle_returns_false_when_service_is_none(tmp_path):
123+
"""``_lsp_will_handle`` must return False when the LSP service hasn't
124+
been initialized — otherwise we'd accidentally skip the shell linter
125+
on systems where LSP isn't configured at all."""
126+
fops = _make_fops()
127+
src = tmp_path / "foo.ts"
128+
src.write_text("const x = 1\n")
129+
130+
with patch.object(fops, "_lsp_local_only", return_value=True), \
131+
patch("agent.lsp.get_service", return_value=None):
132+
assert fops._lsp_will_handle(str(src)) is False
133+
134+
135+
def test_lsp_will_handle_returns_false_on_remote_backend(tmp_path):
136+
"""LSP servers run on the host process — remote backends (Docker,
137+
SSH, Modal, …) keep files inside the sandbox where the host LSP
138+
can't reach them. ``_lsp_will_handle`` must short-circuit before
139+
calling into the service in that case."""
140+
fops = _make_fops()
141+
src = tmp_path / "foo.ts"
142+
src.write_text("const x = 1\n")
143+
144+
with patch.object(fops, "_lsp_local_only", return_value=False), \
145+
patch("agent.lsp.get_service") as get_service_mock:
146+
result = fops._lsp_will_handle(str(src))
147+
148+
assert result is False
149+
# Importantly: we never even consulted the service.
150+
assert not get_service_mock.called
151+
152+
153+
def test_lsp_will_handle_swallows_enabled_for_exception(tmp_path):
154+
"""A flaky LSP service must never break the shell-linter fallback —
155+
if ``enabled_for`` raises, we treat the file as "not handled" so the
156+
shell linter still runs."""
157+
fops = _make_fops()
158+
src = tmp_path / "foo.ts"
159+
src.write_text("const x = 1\n")
160+
161+
fake_svc = MagicMock()
162+
fake_svc.enabled_for.side_effect = RuntimeError("server crashed")
163+
164+
with patch.object(fops, "_lsp_local_only", return_value=True), \
165+
patch("agent.lsp.get_service", return_value=fake_svc):
166+
assert fops._lsp_will_handle(str(src)) is False
167+
168+
169+
def test_tsx_stays_out_of_linters_table_for_default_compatibility():
170+
"""Regression: keep ``.tsx`` out of ``LINTERS`` so users with LSP
171+
DISABLED don't suddenly get the broken ``npx tsc --noEmit FILE.tsx``
172+
invocation that ``.ts`` historically used to get.
173+
174+
Pre-PR behavior: ``.tsx`` had no entry in ``LINTERS``, so it fell
175+
through to ``ext not in LINTERS`` → ``LintResult(skipped=True,
176+
message="No linter for .tsx files")``. This PR preserves that for
177+
the default config.
178+
179+
When LSP IS enabled, ``.tsx`` is still covered by the LSP tier via
180+
``_maybe_lsp_diagnostics`` (typescript-language-server claims
181+
``.tsx`` in its extensions list) — the diagnostics show up in the
182+
``lsp_diagnostics`` field, not the ``lint`` field.
183+
"""
184+
from tools.file_operations import LINTERS, _SHELL_LINTER_LSP_REDUNDANT
185+
186+
assert ".tsx" not in LINTERS
187+
assert ".tsx" not in _SHELL_LINTER_LSP_REDUNDANT
188+
189+
190+
def test_tsx_default_check_lint_returns_skipped(tmp_path):
191+
"""End-to-end: ``.tsx`` files get ``LintResult(skipped=True)`` from
192+
``_check_lint`` regardless of LSP status — this is the no-regression
193+
contract that addresses Copilot review #3271017282."""
194+
fops = _make_fops()
195+
src = tmp_path / "foo.tsx"
196+
src.write_text("export const X = () => <div/>\n")
197+
198+
# Even with LSP claiming the file, no shell linter runs for .tsx
199+
# because there's no LINTERS entry — the ``ext not in LINTERS``
200+
# branch fires before the LSP short-circuit is consulted.
201+
with patch.object(fops, "_lsp_will_handle", return_value=True), \
202+
patch.object(fops, "_exec") as exec_mock:
203+
result = fops._check_lint(str(src))
204+
205+
assert result.skipped is True
206+
assert not exec_mock.called, "no shell linter should run for .tsx"
207+
208+
209+
if __name__ == "__main__": # pragma: no cover
210+
pytest.main([__file__, "-v"])

tests/tools/test_patch_parser.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,3 +509,141 @@ def test_valid_patch_returns_no_error(self):
509509
ops, err = parse_v4a_patch(patch)
510510
assert err is None
511511
assert len(ops) == 1
512+
513+
514+
class TestV4ALspDiagnosticsPropagation:
515+
"""V4A patches must surface ``WriteResult.lsp_diagnostics`` from the
516+
underlying ``write_file`` calls on ``PatchResult.lsp_diagnostics``.
517+
518+
Without explicit propagation the LSP tier's output gets silently
519+
dropped on the V4A code path — see Copilot review #3271017295 on
520+
PR #29054. The shell-linter LSP skip introduced by that PR makes
521+
this gap visible: a ``.ts`` / ``.go`` / ``.rs`` V4A patch with LSP
522+
active would otherwise return ``lint = {f: {skipped: True, ...}}``
523+
and zero diagnostics from any channel.
524+
"""
525+
526+
def _build_ops_writing(self, path: str, content: str):
527+
"""Build a single ADD operation that writes ``content`` to ``path``."""
528+
# Use the V4A parser so we don't have to construct PatchOperation
529+
# / Hunk / Line objects by hand.
530+
lines = "\n".join(f"+{line}" for line in content.splitlines())
531+
patch_text = (
532+
"*** Begin Patch\n"
533+
f"*** Add File: {path}\n"
534+
f"{lines}\n"
535+
"*** End Patch"
536+
)
537+
ops, err = parse_v4a_patch(patch_text)
538+
assert err is None, err
539+
return ops
540+
541+
def test_lsp_diagnostics_propagated_from_write_file_on_add(self):
542+
"""ADD op: ``WriteResult.lsp_diagnostics`` flows through to
543+
``PatchResult.lsp_diagnostics``."""
544+
ops = self._build_ops_writing("foo.ts", "const x: number = 1\n")
545+
546+
diag_block = (
547+
"<diagnostics file=\"foo.ts\">\n"
548+
"ERROR [1:7] some diagnostic\n"
549+
"</diagnostics>"
550+
)
551+
552+
class FakeFileOps:
553+
def write_file(self, path, content):
554+
return SimpleNamespace(error=None, lsp_diagnostics=diag_block)
555+
556+
def _check_lint(self, path):
557+
return SimpleNamespace(to_dict=lambda: {"skipped": True})
558+
559+
result = apply_v4a_operations(ops, FakeFileOps())
560+
561+
assert result.success is True
562+
assert result.lsp_diagnostics == diag_block
563+
564+
def test_lsp_diagnostics_propagated_from_write_file_on_update(self):
565+
"""UPDATE op: ``WriteResult.lsp_diagnostics`` flows through to
566+
``PatchResult.lsp_diagnostics``."""
567+
patch_text = (
568+
"*** Begin Patch\n"
569+
"*** Update File: bar.ts\n"
570+
"-old\n"
571+
"+new\n"
572+
"*** End Patch"
573+
)
574+
ops, err = parse_v4a_patch(patch_text)
575+
assert err is None
576+
577+
diag_block = (
578+
"<diagnostics file=\"bar.ts\">\n"
579+
"ERROR [3:1] something\n"
580+
"</diagnostics>"
581+
)
582+
583+
class FakeFileOps:
584+
def read_file_raw(self, path):
585+
return SimpleNamespace(content="ctx\nold\nctx\n", error=None)
586+
587+
def write_file(self, path, content):
588+
return SimpleNamespace(error=None, lsp_diagnostics=diag_block)
589+
590+
def _check_lint(self, path):
591+
return SimpleNamespace(to_dict=lambda: {"skipped": True})
592+
593+
result = apply_v4a_operations(ops, FakeFileOps())
594+
595+
assert result.success is True
596+
assert result.lsp_diagnostics == diag_block
597+
598+
def test_lsp_diagnostics_none_when_no_blocks_emitted(self):
599+
"""When no underlying ``write_file`` produced diagnostics, the
600+
aggregated field stays ``None`` (so it doesn't get serialized
601+
as an empty string in ``PatchResult.to_dict``)."""
602+
ops = self._build_ops_writing("foo.py", "x = 1\n")
603+
604+
class FakeFileOps:
605+
def write_file(self, path, content):
606+
# lsp_diagnostics omitted entirely (older WriteResult shape).
607+
return SimpleNamespace(error=None)
608+
609+
def _check_lint(self, path):
610+
return SimpleNamespace(to_dict=lambda: {"success": True})
611+
612+
result = apply_v4a_operations(ops, FakeFileOps())
613+
614+
assert result.success is True
615+
assert result.lsp_diagnostics is None
616+
617+
def test_lsp_diagnostics_combined_across_multiple_files(self):
618+
"""When several files in one V4A patch produce diagnostics,
619+
each block appears in the combined output so per-file attribution
620+
is preserved."""
621+
patch_text = (
622+
"*** Begin Patch\n"
623+
"*** Add File: a.ts\n"
624+
"+const a = 1\n"
625+
"*** Add File: b.ts\n"
626+
"+const b = 2\n"
627+
"*** End Patch"
628+
)
629+
ops, err = parse_v4a_patch(patch_text)
630+
assert err is None
631+
632+
per_file = {
633+
"a.ts": "<diagnostics file=\"a.ts\">\nERR a\n</diagnostics>",
634+
"b.ts": "<diagnostics file=\"b.ts\">\nERR b\n</diagnostics>",
635+
}
636+
637+
class FakeFileOps:
638+
def write_file(self, path, content):
639+
return SimpleNamespace(error=None, lsp_diagnostics=per_file[path])
640+
641+
def _check_lint(self, path):
642+
return SimpleNamespace(to_dict=lambda: {"skipped": True})
643+
644+
result = apply_v4a_operations(ops, FakeFileOps())
645+
646+
assert result.success is True
647+
assert result.lsp_diagnostics is not None
648+
assert per_file["a.ts"] in result.lsp_diagnostics
649+
assert per_file["b.ts"] in result.lsp_diagnostics

0 commit comments

Comments
 (0)