Skip to content

fix(lint): skip per-file shell linter when LSP will handle the file#29054

Merged
OutThisLife merged 4 commits into
mainfrom
bb/skip-shell-lint-when-lsp-handles
May 20, 2026
Merged

fix(lint): skip per-file shell linter when LSP will handle the file#29054
OutThisLife merged 4 commits into
mainfrom
bb/skip-shell-lint-when-lsp-handles

Conversation

@OutThisLife

@OutThisLife OutThisLife commented May 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Stop dumping 25K tokens of phantom tsc errors on every .ts edit. When the LSP service is active and claims the file (LSPService.enabled_for(path)), _check_lint now skips the per-file shell linter for the structurally-broken extensions (.ts, .go, .rs). The LSP tier already gives us real diagnostics in the separate lsp_diagnostics field, so the shell linter on these was pure noise.

The bug

_check_lint runs npx tsc --noEmit FILE.ts after every TS edit. tsc ignores tsconfig.json when given an explicit file argument (documented quirk; no flag fixes it) and defaults to no-lib / ES5, so on real TS projects every edit dumps things like:

  • 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+

Up to 25K tokens per patch. The delta filter in _check_lint_delta is meant to mask these, but a tiny edit shifts line numbers and they all resurface as "introduced by this edit". Recent agent sessions exhausted their context budget on this dump.

Why the existing LSP work didn't cover it

PR #24168 added real tsserver / gopls / rust-analyzer diagnostics via the LSP service and surfaces them on lsp_diagnostics. But the broken shell linter kept running underneath — both channels fire on every edit. So even when LSP was giving us a clean authoritative signal, the phantom-error dump kept happening.

An even-earlier PR (feat(lint): opt-in LSP-backed lint path in _check_lint) actually had this short-circuit, but it was lost in the refactor to agent/lsp/.

The fix

tools/file_operations.py:

  • New _SHELL_LINTER_LSP_REDUNDANT = {'.ts', '.go', '.rs'} — extensions where the per-file shell linter is structurally weaker than LSP and produces phantom errors on real-world projects (each entry has a comment explaining the failure mode).
  • New _lsp_will_handle(path) method — stronger than _lsp_handles_extension(ext). Gates on _lsp_local_only() AND agent.lsp.get_service() returning a service AND svc.enabled_for(path). Exception-safe: any failure path returns False so the shell linter runs as before.
  • In _check_lint, after the in-process tier, before falling into LINTERS[ext]: if ext in _SHELL_LINTER_LSP_REDUNDANT and self._lsp_will_handle(path), return LintResult(skipped=True, message="LSP server handles {ext} — shell linter skipped"). _check_lint_delta already treats skipped as a clean post-syntax result.

tools/patch_parser.py:

  • V4A patches were silently dropping WriteResult.lsp_diagnostics because apply_v4a_operations calls _check_lint directly and never read the LSP field. With the new shell-linter skip, that gap became visible: a .ts/.go/.rs V4A patch with LSP active would return zero diagnostics from any channel.
  • _apply_add and _apply_update now return Tuple[bool, str, Optional[str]] where the third element is the underlying WriteResult.lsp_diagnostics. apply_v4a_operations accumulates these per-file blocks and surfaces a combined block on PatchResult.lsp_diagnostics.

What does NOT change

  • Default config (LSP disabled): zero behavior change. _lsp_will_handle returns False without LSP, shell linter runs as before.
  • Remote backends (Docker / SSH / Modal / Daytona): _lsp_local_only() returns False → _lsp_will_handle returns False → shell linter runs.
  • File outside a workspace (no tsconfig.json / Cargo.toml / go.mod / git worktree): svc.enabled_for(path) returns False → shell linter runs.
  • Python (py_compile) and JavaScript (node --check): always run. They're fast, file-local, and correct — no upside to suppressing.

.tsx is intentionally left out

.tsx has no entry in LINTERS (and is therefore not in _SHELL_LINTER_LSP_REDUNDANT either). Pre-PR, .tsx files fell through to the ext not in LINTERS branch and got LintResult(skipped=True, message="No linter for .tsx files") regardless of LSP status — that's preserved.

When LSP is enabled, .tsx is still covered by the LSP tier via _maybe_lsp_diagnostics (typescript-language-server's extensions tuple includes .tsx) — the diagnostics show up on lsp_diagnostics, not lint. Adding .tsx to LINTERS was tried in an earlier revision and rolled back because it would have inherited .ts's broken tsc --noEmit FILE invocation for LSP-disabled users (Copilot review feedback).

Tests

tests/agent/lsp/test_shell_linter_lsp_skip.py — 13 tests:

  • Skip happens for each of .ts, .go, .rs when LSP claims the file (asserted by patching _exec to raise — any shell-linter call surfaces as a test failure).
  • Shell linter still runs when LSP is inactive (regression guard for the default config).
  • .py / .js keep running unconditionally even with LSP active (proves the always-run set isn't accidentally suppressed).
  • _lsp_will_handle is exception-safe: returns False on None service, remote backend, or enabled_for raising.
  • .tsx stays out of LINTERS and _SHELL_LINTER_LSP_REDUNDANT; _check_lint returns skipped for .tsx regardless of LSP status.

tests/tools/test_patch_parser.py::TestV4ALspDiagnosticsPropagation — 4 tests:

  • ADD op: WriteResult.lsp_diagnostics flows through to PatchResult.lsp_diagnostics.
  • UPDATE op: same.
  • No diagnostics produced → PatchResult.lsp_diagnostics stays None (not "").
  • Multi-file V4A patch: combined block contains every per-file block.

All 257 tests in the changed scope pass (tests/agent/lsp/, tests/tools/test_file_operations*.py, tests/tools/test_patch_parser.py).

End-to-end verification

On this branch in a real TS workspace:

>>> fops._check_lint('ui-tui/src/app.tsx')
lsp_will_handle: True
skipped: True
message: 'No linter for .tsx files'
output_chars: 0

(.tsx skips via the generic ext not in LINTERS branch — the LSP tier still runs separately and populates lsp_diagnostics on the WriteResult/PatchResult.)

For .ts:

>>> fops._check_lint('some-real-ts-project/src/foo.ts')
lsp_will_handle: True
skipped: True
message: 'LSP server handles .ts — shell linter skipped'
output_chars: 0

vs. before: ~25K characters of phantom errors per call.

Files

  • tools/file_operations.py+44/-1 (new constant + helper method + 13-line short-circuit in _check_lint; documented inline)
  • tools/patch_parser.py+33/-7 (3-tuple returns from _apply_add/_apply_update, lsp_diagnostics aggregation in apply_v4a_operations)
  • tests/agent/lsp/test_shell_linter_lsp_skip.py+222 new file, 13 tests
  • tests/tools/test_patch_parser.py+138 new test class, 4 tests

`_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).
@github-actions

github-actions Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: bb/skip-shell-lint-when-lsp-handles vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 8975 on HEAD, 8974 on base (🆕 +1)

🆕 New issues (1):

Rule Count
unresolved-import 1
First entries
tests/agent/lsp/test_shell_linter_lsp_skip.py:37: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`

✅ Fixed issues: none

Unchanged: 4731 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@OutThisLife OutThisLife requested a review from Copilot May 20, 2026 03:12
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/tools Tool registry, model_tools, toolsets tool/file File tools (read, write, patch, search) labels May 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR reduces noisy/incorrect per-file shell lint output for certain languages by skipping the shell linter when the local LSP service is active and indicates it will handle the file, relying on the separate lsp_diagnostics channel for authoritative diagnostics.

Changes:

  • Add .tsx to the shell LINTERS table.
  • Introduce _SHELL_LINTER_LSP_REDUNDANT and _lsp_will_handle(path) to detect when LSP will cover a file.
  • Short-circuit _check_lint for .ts/.tsx/.go/.rs when LSP claims the file; add tests covering skip/fallback behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
tools/file_operations.py Adds LSP-aware skip for certain shell linters and adds .tsx shell linter entry.
tests/agent/lsp/test_shell_linter_lsp_skip.py Adds unit tests to ensure shell-linter skipping occurs only when intended and remains exception-safe.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tools/file_operations.py
Comment thread tools/file_operations.py
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)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread tools/file_operations.py
Comment thread tests/agent/lsp/test_shell_linter_lsp_skip.py Outdated
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).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread tests/agent/lsp/test_shell_linter_lsp_skip.py Outdated
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@OutThisLife OutThisLife merged commit 5e74355 into main May 20, 2026
21 of 22 checks passed
@OutThisLife OutThisLife deleted the bb/skip-shell-lint-when-lsp-handles branch May 20, 2026 06:46
dannyJ848 pushed a commit to dannyJ848/hermes-agent that referenced this pull request May 21, 2026
…ousResearch#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 NousResearch#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 NousResearch#29054

Two fixes from copilot-pull-request-reviewer on PR NousResearch#29054:

1. `.tsx` regression with LSP disabled
   (NousResearch#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`
   (NousResearch#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.
Lillard01 pushed a commit to Lillard01/hermes-agent that referenced this pull request May 21, 2026
…ousResearch#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 NousResearch#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 NousResearch#29054

Two fixes from copilot-pull-request-reviewer on PR NousResearch#29054:

1. `.tsx` regression with LSP disabled
   (NousResearch#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`
   (NousResearch#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.
Gpapas pushed a commit to Gpapas/hermes-agent that referenced this pull request May 23, 2026
…ousResearch#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 NousResearch#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 NousResearch#29054

Two fixes from copilot-pull-request-reviewer on PR NousResearch#29054:

1. `.tsx` regression with LSP disabled
   (NousResearch#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`
   (NousResearch#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.
Mucky010 pushed a commit to Mucky010/hermes-agent that referenced this pull request May 24, 2026
…ousResearch#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 NousResearch#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 NousResearch#29054

Two fixes from copilot-pull-request-reviewer on PR NousResearch#29054:

1. `.tsx` regression with LSP disabled
   (NousResearch#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`
   (NousResearch#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.
Bryce-huang pushed a commit to wbkunlun/hermes-agent that referenced this pull request May 29, 2026
…ousResearch#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 NousResearch#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 NousResearch#29054

Two fixes from copilot-pull-request-reviewer on PR NousResearch#29054:

1. `.tsx` regression with LSP disabled
   (NousResearch#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`
   (NousResearch#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.
#AI commit#
gweeteve pushed a commit to gweeteve/hermes-agent that referenced this pull request Jun 2, 2026
…ousResearch#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 NousResearch#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 NousResearch#29054

Two fixes from copilot-pull-request-reviewer on PR NousResearch#29054:

1. `.tsx` regression with LSP disabled
   (NousResearch#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`
   (NousResearch#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists tool/file File tools (read, write, patch, search) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants