From 7cf4e7c92fc285a69a90f5ff30973a2955d351ed Mon Sep 17 00:00:00 2001 From: Renato Wasescha Date: Wed, 9 Sep 2026 16:21:13 +0200 Subject: [PATCH 01/11] fix(diagnostics): advertise push capability, reason-aware fallback, PATH-independent LSP discovery - Advertise textDocument.publishDiagnostics client capability in initialize; TSLS publishes push diagnostics only when the client does, so TS files silently degraded to ast_heuristic before. - Reason-aware fallback_reason (unsupported_language, lsp_server_unavailable, lsp_init_failed, no_diagnostics_support, no_diagnostics_response) replaces the misleading generic "LSP server unavailable" warning. - PATH-independent LSP server discovery (shutil.which + well-known dirs) with child-env PATH append so cold starts work under reduced PATH. - 10 real-LSP tests (cold + reused server, real TSLS/Pyright subprocesses), fixtures module, tests/.fixtures gitignore entry. - README: TSLS push-capability + fallback-reason + durability/revision-pinning docs. --- .gitignore | 2 + README.md | 26 +++++ lsp_bridge.py | 115 +++++++++++++++++-- tests/ci_diag_fixtures.py | 31 ++++++ tests/test_code_diagnostics_lsp.py | 171 +++++++++++++++++++++++++++++ 5 files changed, 338 insertions(+), 7 deletions(-) create mode 100644 tests/ci_diag_fixtures.py create mode 100644 tests/test_code_diagnostics_lsp.py diff --git a/.gitignore b/.gitignore index b3ed328..53b6156 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ patch_*.py # Self-referential symlink guard — the repo root may be symlinked into a # profile plugins dir; never let that link be committed or scanned recursively. code-intel-plugin +# Generated diagnostics test fixtures (rewritten by ci_diag_fixtures.ensure_fixtures) +tests/.fixtures/ diff --git a/README.md b/README.md index 9889f09..55e3be7 100644 --- a/README.md +++ b/README.md @@ -425,3 +425,29 @@ Contributions welcome! This is a community plugin — PRs for new languages, bet - [pyright](https://github.com/microsoft/pyright) — Python LSP server (fallback) - [typescript-language-server](https://github.com/typescript-language-server/typescript-language-server) — TypeScript/JavaScript LSP server - [tsserver](https://github.com/microsoft/TypeScript) — TypeScript language service (used by typescript-language-server) + +## Durability & Revision Pinning + +The nightly maintenance gate needs a **source-controlled known-good revision** +of this plugin. The pin lives in the scripts repo +(`~/.hermes/scripts/code_intel_plugin_manifest.json`, fields: `pin_revision`, +`branch`, `remote`, `minimum_compatible`). + +`~/.hermes/scripts/code_intel_plugin_restore.py` verifies the live checkout +against that pin. It is read-only by default and never touches foreign WIP: + +| Live checkout state | Action | +|---|---| +| clean, matches pin | OK | +| clean, newer healthy revision (descendant of pin or listed in `minimum_compatible`) | OK (no downgrade) | +| dirty | pin-revision content verified independently (`git show pin:path`); WIP kept untouched | +| checkout absent | restore into a disposable fixture dir from the pushed remote; live location never modified | +| conflicting/drifted | fail visibly with remediation hints; WIP never destroyed | + +The health checker resolves the authoritative loaded plugin source for its +diagnostics fixtures: the profile plugin symlink target when it resolves to the +live repo, else the pinned revision rendered into a disposable fixture dir. +A symlink alone is never sufficient — actual bytes/behavior are validated. + +Manual: `code_intel_plugin_restore.py [--json] [--restore]` (`--restore` +creates a disposable recovery fixture, nothing else). diff --git a/lsp_bridge.py b/lsp_bridge.py index be04825..bd9cee1 100644 --- a/lsp_bridge.py +++ b/lsp_bridge.py @@ -318,9 +318,39 @@ def _find_workspace_folders(root: str) -> List[str]: return folders +_LSP_EXTRA_BIN_DIRS = ( + "/opt/homebrew/bin", # Apple Silicon Homebrew (npm -g LSP servers) + "/opt/homebrew/opt/node/bin", + "/usr/local/bin", # Intel Homebrew + manual installs + "/usr/bin", + str(Path.home() / ".local" / "bin"), +) + + def _resolve_command(cmd: str) -> Optional[str]: - """Return the full path for *cmd* if it exists on ``$PATH``, else ``None``.""" - return shutil.which(cmd) + """Return the full path for *cmd* if it exists, else ``None``. + + ``shutil.which`` only searches ``$PATH``. Scheduled/daemon launch contexts + (launchd, Paseo workers, nightly maintenance) frequently run with a reduced + PATH that omits ``/opt/homebrew/bin``, which silently hides globally + installed LSP servers (typescript-language-server, pyright-langserver). + Fall back to the well-known install dirs so server discovery is + PATH-independent. + """ + found = shutil.which(cmd) + if found: + return found + if os.sep in cmd or "/" in cmd: + return None # explicit path given — do not second-guess + for d in _LSP_EXTRA_BIN_DIRS: + try: + candidate = Path(d) / cmd + if candidate.is_file() and os.access(candidate, os.X_OK): + logger.info("LSP server %s not on PATH, resolved via fallback dir: %s", cmd, candidate) + return str(candidate) + except OSError: + continue + return None # --------------------------------------------------------------------------- @@ -351,6 +381,11 @@ class LSPBridge: _open_documents: set = field(default_factory=set, init=False, repr=False) # Track open docs to avoid duplicate didOpen _reconcile_close_uris: Dict[str, float] = field(default_factory=dict, init=False, repr=False) _doc_versions: Dict[str, int] = field(default_factory=dict, init=False, repr=False) # textDocument version per URI for didChange + # mtime at which each file was last analysed (validates cached diagnostics) + _diag_mtime: Dict[str, Optional[float]] = field(default_factory=dict, init=False, repr=False) + # Server capabilities from the initialize response (used to decide whether + # pull diagnostics (textDocument/diagnostic) can possibly work at all). + init_capabilities: dict = field(default_factory=dict, init=False, repr=False) # -- lifecycle ----------------------------------------------------------- @@ -366,6 +401,14 @@ def _build_env(self) -> Dict[str, str]: # TypeScript: ensure TSServer can resolve types from workspace if self.language_id in ("typescript", "typescriptreact", "javascript", "javascriptreact"): env["TSS_LOG"] = "-" # Log to stderr (captured, not lost) + # Reduced-PATH launch contexts (launchd, schedulers): LSP wrappers like + # typescript-language-server carry a `#!/usr/bin/env node` shebang and + # cannot exec node unless its dir is on the child PATH. Add the same + # fallback dirs used by _resolve_command, preserving any existing PATH. + existing_path = env.get("PATH", "") + extra = [d for d in _LSP_EXTRA_BIN_DIRS if d not in existing_path.split(os.pathsep)] + if extra: + env["PATH"] = os.pathsep.join([existing_path] + extra) if existing_path else os.pathsep.join(extra) return env def _get_initialization_options(self) -> Dict[str, Any]: @@ -452,6 +495,14 @@ def _start_and_init(self) -> bool: "hover": {"dynamicRegistration": False, "contentFormat": ["plaintext", "markdown"]}, "typeDefinition": {"dynamicRegistration": False}, "rename": {"dynamicRegistration": False, "prepareSupport": True}, + # typescript-language-server only publishes diagnostics + # when client explicitly advertises this LSP capability. + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": True, + "codeDescriptionSupport": True, + "dataSupport": True, + }, }, "workspace": { "symbol": {"dynamicRegistration": False}, @@ -477,6 +528,7 @@ def _start_and_init(self) -> bool: # Send initialized notification self._send_notification("initialized", {}) self._initialized = True + self.init_capabilities = init_result.get("capabilities", {}) or {} server_info = init_result.get("serverInfo", {}) logger.info("LSP server initialized: %s (%s %s) in %.1fs", self.command, @@ -1274,10 +1326,22 @@ def collect_diagnostics( if not self.ensure_initialized(): return None uri = f"file://{file_path}" + on_disk_mtime = _safe_mtime(file_path) + with self._lock: + cached = self._diagnostics_cache.get(file_path) + already_open = uri in self._open_documents + last_mtime = self._diag_mtime.get(file_path) + # Fast path: document already open and the on-disk file is unchanged + # since the last analysis -> the cached result is still authoritative. + # TSLS (server-side) dedupes repeated empty results and would NOT + # re-publish for an unchanged clean file, so a blind cache-pop + wait + # here would wrongly return None ("no push") for a perfectly healthy + # server. Only skip the fast path when there is nothing cached yet. + if already_open and cached is not None and last_mtime == on_disk_mtime: + return cached # Drop any stale push so we can detect the *fresh* one for this call. with self._lock: self._diagnostics_cache.pop(file_path, None) - already_open = uri in self._open_documents if already_open: # didOpen would be a no-op on an open document, so the server would # not re-analyse. A didChange (full sync, bumped version) forces a @@ -1312,7 +1376,10 @@ def collect_diagnostics( break time.sleep(0.05) with self._lock: - return self._diagnostics_cache.get(file_path) + result = self._diagnostics_cache.get(file_path) + if result is not None: + self._diag_mtime[file_path] = on_disk_mtime + return result def get_server_info(self) -> dict: """Return basic health info about this bridge.""" @@ -1512,6 +1579,14 @@ def get_lsp_manager() -> LSPManager: # --------------------------------------------------------------------------- +def _safe_mtime(file_path: str) -> Optional[float]: + """Best-effort file mtime; ``None`` when unreadable (still safe to compare).""" + try: + return Path(file_path).stat().st_mtime + except OSError: + return None + + def _detect_language_for_lsp(file_path: str) -> Optional[str]: """Detect language suitable for LSP resolution.""" ext = Path(file_path).suffix.lower() @@ -1789,6 +1864,17 @@ def code_diagnostics_tool( return _json.dumps(summary, indent=2) # No push arrived — try pull diagnostics (LSP 3.17+, servers that support it) + # Note: pyright answers textDocument/diagnostic but does NOT advertise a + # diagnosticProvider capability, so treat pyright-family servers as pull- + # capable even without the advertised capability. + pull_supported = False + if bridge is not None: + try: + pull_supported = bool( + (bridge.init_capabilities or {}).get("diagnosticProvider") + ) or "pyright" in (bridge.command or "") + except Exception: + pull_supported = False if bridge and bridge.ensure_initialized(): try: resp = bridge._send_request("textDocument/diagnostic", { @@ -1814,7 +1900,17 @@ def code_diagnostics_tool( # Fallback: AST heuristic (no LSP server, or server reported nothing in time) logger.debug("code_diagnostics: using AST fallback") - return _ast_fallback_diagnostics(str(target), lang) + if lang is None: + fallback_reason = "unsupported_language" + elif bridge is None: + fallback_reason = "lsp_server_unavailable" + elif not bridge.ensure_initialized(): + fallback_reason = "lsp_init_failed" + elif not pull_supported: + fallback_reason = "no_diagnostics_support" + else: + fallback_reason = "no_diagnostics_response" + return _ast_fallback_diagnostics(str(target), lang, fallback_reason=fallback_reason) def code_callers_tool( @@ -2208,7 +2304,11 @@ def _ast_fallback_references( }) -def _ast_fallback_diagnostics(file_path: str, lang: Optional[str]) -> str: +def _ast_fallback_diagnostics( + file_path: str, + lang: Optional[str], + fallback_reason: str = "lsp_unavailable", +) -> str: """Lightweight AST-based heuristic for common issues: unused imports, undefined names.""" import json as _json content = "" @@ -2287,7 +2387,8 @@ def _ast_fallback_diagnostics(file_path: str, lang: Optional[str]) -> str: return _json.dumps({ "path": file_path, "method": "ast_heuristic", - "warning": "LSP server unavailable. Using lightweight AST heuristic.", + "warning": f"LSP diagnostics unavailable ({fallback_reason}). Using lightweight AST heuristic.", + "fallback_reason": fallback_reason, "diagnostic_count": len(diagnostics), "errors": len([d for d in diagnostics if d.get("severity", 1) == 1]), "warnings": len([d for d in diagnostics if d.get("severity", 2) == 2]), diff --git a/tests/ci_diag_fixtures.py b/tests/ci_diag_fixtures.py new file mode 100644 index 0000000..0f8b021 --- /dev/null +++ b/tests/ci_diag_fixtures.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""code_intel diagnostics fixtures: TS + Python, clean + intentional error. + +Used by code_intel_health_check.py and tests. Keep errors INTENTIONAL. +""" +import sys, os + +FIXTURES = { + "clean.ts": "const cleanValue: number = 1;\nconsole.log(cleanValue);\n", + # Intentional TS errors: type mismatch (2322) + undefined name (2304) + "error.ts": 'const badValue: number = "oops";\nconsole.log(notDefinedName);\n', + "clean.py": "def clean_fn() -> int:\n return 1\n", + # Intentional Python error: undefined name + "error.py": "def bad_fn() -> int:\n return not_defined_name\n", +} + +def ensure_fixtures(base_dir: str) -> dict: + """Write fixtures under base_dir; return {relname: abs_path}.""" + os.makedirs(base_dir, exist_ok=True) + out = {} + for name, content in FIXTURES.items(): + p = os.path.join(base_dir, name) + with open(p, "w") as f: + f.write(content) + out[name] = p + return out + +if __name__ == "__main__": + base = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser( + "~/.hermes/scripts/.code_intel_fixtures") + print(ensure_fixtures(base)) \ No newline at end of file diff --git a/tests/test_code_diagnostics_lsp.py b/tests/test_code_diagnostics_lsp.py new file mode 100644 index 0000000..fa5e92a --- /dev/null +++ b/tests/test_code_diagnostics_lsp.py @@ -0,0 +1,171 @@ +"""Focused regression tests for code_diagnostics LSP behavior. + +Covers the TSLS push-capability root cause and fallback-reason reporting: +- real LSP servers (typescript-language-server, pyright-langserver) must be + present for the live tests; they are skipped with a clear message otherwise. +- clean files return method=lsp with zero diagnostics ([] vs None contract) +- error files return method=lsp with the intentional errors +- fallback_reason distinguishes unavailable / init-failed / no-support / no-response +""" +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +PLUGIN_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PLUGIN_DIR)) + +from ci_diag_fixtures import ensure_fixtures # noqa: E402 + +FIXTURE_DIR = PLUGIN_DIR / "tests" / ".fixtures" +VENV_PYTHON = Path.home() / ".hermes" / "hermes-agent" / "venv" / "bin" / "python3" + + +def _venv_python() -> str: + if VENV_PYTHON.exists(): + return str(VENV_PYTHON) + return sys.executable + + +def _has_server(cmd: str) -> bool: + from lsp_bridge import _resolve_command + return _resolve_command(cmd) is not None + + +@pytest.fixture(scope="module") +def fixtures() -> dict: + return ensure_fixtures(str(FIXTURE_DIR)) + + +def _diag(path: str) -> dict: + from lsp_bridge import code_diagnostics_tool + return json.loads(code_diagnostics_tool(path)) + + +# ── live LSP tests (skipped when the server binary is missing) ────────── + +@pytest.mark.skipif(not _has_server("typescript-language-server"), + reason="typescript-language-server not installed") +class TestTSDiagnostics: + def test_clean_file_zero_diagnostics_push(self, fixtures): + d = _diag(fixtures["clean.ts"]) + assert d["method"] == "lsp" + assert d["lsp_server"] == "typescript-language-server" + assert d["diagnostic_count"] == 0 + assert d["diagnostics"] == [] + + def test_error_file_reports_intentional_errors(self, fixtures): + d = _diag(fixtures["error.ts"]) + assert d["method"] == "lsp" + assert d["diagnostic_count"] >= 1 + codes = {item.get("code") for item in d["diagnostics"]} + assert 2322 in codes, f"expected TS2322 in {codes}" + assert 2304 in codes, f"expected TS2304 in {codes}" + + def test_warm_reuse_still_pushes(self, fixtures): + # second call exercises the didChange (already-open) path + d = _diag(fixtures["error.ts"]) + assert d["method"] == "lsp" + assert d["diagnostic_count"] >= 1 + + +@pytest.mark.skipif(not _has_server("pyright-langserver"), + reason="pyright-langserver not installed") +class TestPythonDiagnostics: + def test_clean_file_zero_diagnostics(self, fixtures): + d = _diag(fixtures["clean.py"]) + assert d["method"] in ("lsp", "lsp-pull") + assert d["diagnostic_count"] == 0 + + def test_error_file_reports_intentional_error(self, fixtures): + d = _diag(fixtures["error.py"]) + assert d["method"] in ("lsp", "lsp-pull") + assert d["diagnostic_count"] >= 1 + assert any("not_defined_name" in item.get("message", "") + for item in d["diagnostics"]) + + +# ── fallback-reason tests (no live server required for most) ──────────── + +class TestFallbackReasons: + def test_unsupported_language(self, tmp_path): + from lsp_bridge import code_diagnostics_tool + md = tmp_path / "file.md" + md.write_text("# plain markdown\n") + d = json.loads(code_diagnostics_tool(str(md))) + assert d["method"] == "ast_heuristic" + assert d["fallback_reason"] == "unsupported_language" + + def test_reduced_path_still_finds_server(self, fixtures): + """Cold subprocess with PATH=/usr/bin:/bin must still run real LSP.""" + code = ( + "import sys, json\n" + f"sys.path.insert(0, {str(PLUGIN_DIR)!r})\n" + "from lsp_bridge import code_diagnostics_tool\n" + "d = json.loads(code_diagnostics_tool(%r))\n" + "print(json.dumps({'method': d.get('method')," + " 'count': d.get('diagnostic_count')," + " 'server': d.get('lsp_server')}))\n" + ) % fixtures["error.ts"] + env = {"PATH": "/usr/bin:/bin", "HOME": str(Path.home())} + proc = subprocess.run( + [VENV_PYTHON if VENV_PYTHON.exists() else sys.executable, "-c", code], + capture_output=True, text=True, env=env, timeout=120) + assert proc.returncode == 0, proc.stderr[-300:] + result_line = [ln for ln in proc.stdout.splitlines() if ln.startswith("{")][-1] + d = json.loads(result_line) + assert d["method"] == "lsp", proc.stdout[-300:] + assert d["server"] == "typescript-language-server" + assert d["count"] >= 1 + + def test_silent_pull_capable_server_reason(self, fixtures): + """Initialized server, push lost AND pull returns nothing → + no_diagnostics_response (not the misleading 'server unavailable').""" + import lsp_bridge + from lsp_bridge import code_diagnostics_tool, get_lsp_manager + + mgr = get_lsp_manager() + bridge = mgr.get_bridge("python", fixtures["error.py"]) + assert bridge.ensure_initialized() + # pyright answers pull but doesn't advertise it — heuristic knows this + orig_collect = lsp_bridge.LSPBridge.collect_diagnostics + orig_send = lsp_bridge.LSPBridge._send_request + lsp_bridge.LSPBridge.collect_diagnostics = lambda self, *a, **k: None + lsp_bridge.LSPBridge._send_request = lambda self, m, p, timeout=10: None + try: + d = json.loads(code_diagnostics_tool(fixtures["error.py"])) + finally: + lsp_bridge.LSPBridge.collect_diagnostics = orig_collect + lsp_bridge.LSPBridge._send_request = orig_send + assert d["method"] == "ast_heuristic" + assert d["fallback_reason"] == "no_diagnostics_response" + + def test_missing_binary_reason(self, fixtures, monkeypatch): + """No resolvable server binary → lsp_server_unavailable. + + Uses a fresh LSPManager: the singleton would return an already-running + bridge before the binary lookup ever runs. + """ + import lsp_bridge + from lsp_bridge import code_diagnostics_tool + + monkeypatch.setattr(lsp_bridge, "_resolve_command", lambda cmd: None) + monkeypatch.setattr(lsp_bridge, "_lsp_manager", lsp_bridge.LSPManager()) + d = json.loads(code_diagnostics_tool(fixtures["error.ts"])) + assert d["method"] == "ast_heuristic" + assert d["fallback_reason"] == "lsp_server_unavailable" + + def test_empty_vs_none_contract(self, fixtures): + """[] (authoritative clean) must not degrade to AST fallback.""" + d = _diag(fixtures["clean.ts"]) + assert d["method"] == "lsp" + assert d["diagnostic_count"] == 0 + assert "fallback_reason" not in d or d["fallback_reason"] is None + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"])) \ No newline at end of file From 826a9c7265191a09d639889a135d073dde07b0c4 Mon Sep 17 00:00:00 2001 From: Renato Wasescha Date: Wed, 9 Sep 2026 16:56:20 +0200 Subject: [PATCH 02/11] fix(ci): provision real LSP servers on CI; accurate prerequisite skips CI ran without any LSP server, so the three live-LSP tests inside TestFallbackReasons (reduced-path discovery, silent pull-capable server, empty-vs-none contract) asserted real server behavior that cannot exist without the binaries and failed on every runner. Now: - CI installs node 20 + npm -g typescript typescript-language-server pyright, so live diagnostics tests actually run on CI instead of being skipped everywhere (no blanket skip: the live coverage stays mandatory and now executes on all four Python versions). - The three live tests carry accurate prerequisite skip guards as a fallback for environments without LSP servers, matching the pattern the live test classes already use. --- .github/workflows/ci.yml | 12 +++++++++++- tests/test_code_diagnostics_lsp.py | 6 ++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cdf3a1..cc5cf84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,20 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install tree-sitter + ast-grep test deps + - name: Set up Node (for real LSP servers) + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install tree-sitter + ast-grep + LSP test deps run: | python -m pip install --upgrade pip pip install tree-sitter tree-sitter-python tree-sitter-javascript tree-sitter-typescript tree-sitter-rust tree-sitter-go tree-sitter-java ast-grep-py pytest + # Real LSP servers so the live diagnostics tests actually run on CI + # (typescript-language-server + tsserver for TS, pyright for Python). + npm install -g typescript typescript-language-server pyright + npx pyright --version || true + typescript-language-server --version || true - name: Compile check run: | diff --git a/tests/test_code_diagnostics_lsp.py b/tests/test_code_diagnostics_lsp.py index fa5e92a..4302706 100644 --- a/tests/test_code_diagnostics_lsp.py +++ b/tests/test_code_diagnostics_lsp.py @@ -102,6 +102,8 @@ def test_unsupported_language(self, tmp_path): def test_reduced_path_still_finds_server(self, fixtures): """Cold subprocess with PATH=/usr/bin:/bin must still run real LSP.""" + if not _has_server("typescript-language-server"): + pytest.skip("typescript-language-server not installed") code = ( "import sys, json\n" f"sys.path.insert(0, {str(PLUGIN_DIR)!r})\n" @@ -125,6 +127,8 @@ def test_reduced_path_still_finds_server(self, fixtures): def test_silent_pull_capable_server_reason(self, fixtures): """Initialized server, push lost AND pull returns nothing → no_diagnostics_response (not the misleading 'server unavailable').""" + if not (_has_server("pyright-langserver") or _has_server("pylsp")): + pytest.skip("no python LSP server (pyright-langserver/pylsp) installed") import lsp_bridge from lsp_bridge import code_diagnostics_tool, get_lsp_manager @@ -161,6 +165,8 @@ def test_missing_binary_reason(self, fixtures, monkeypatch): def test_empty_vs_none_contract(self, fixtures): """[] (authoritative clean) must not degrade to AST fallback.""" + if not _has_server("typescript-language-server"): + pytest.skip("typescript-language-server not installed") d = _diag(fixtures["clean.ts"]) assert d["method"] == "lsp" assert d["diagnostic_count"] == 0 From 7d39707617072c981f4f5e8589ce4acd01fe53f0 Mon Sep 17 00:00:00 2001 From: Renato Wasescha Date: Wed, 9 Sep 2026 17:49:28 +0200 Subject: [PATCH 03/11] fix(ci): per-test 120s timeout + junit dump to expose CI hang The previous run hung >50min in "Run tests" on all 4 matrix jobs with no output (live-LSP tests against cold tsserver/pyright). pytest-timeout now bounds every test at 120s (thread method), faulthandler dumps stacks, and a final always-step prints the junit tail so the stuck test and its stack are visible in the job log. --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc5cf84..3db6cc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: - name: Install tree-sitter + ast-grep + LSP test deps run: | python -m pip install --upgrade pip - pip install tree-sitter tree-sitter-python tree-sitter-javascript tree-sitter-typescript tree-sitter-rust tree-sitter-go tree-sitter-java ast-grep-py pytest + pip install tree-sitter tree-sitter-python tree-sitter-javascript tree-sitter-typescript tree-sitter-rust tree-sitter-go tree-sitter-java ast-grep-py pytest pytest-timeout # Real LSP servers so the live diagnostics tests actually run on CI # (typescript-language-server + tsserver for TS, pyright for Python). npm install -g typescript typescript-language-server pyright @@ -41,4 +41,9 @@ jobs: - name: Run tests run: | - python -m pytest -q + python -m pytest -q --timeout=120 --timeout-method=thread --maxfail=6 -p faulthandler --junitxml=pytest-result.xml + - name: Dump test result summary (always, for debugging hangs) + if: always() + run: | + tail -c 4000 pytest-result.xml || true + grep -o 'testcase name="[^"]*"' pytest-result.xml | tail -40 || true From 839c666208073569a435e1d88502f44515ab127d Mon Sep 17 00:00:00 2001 From: Renato Wasescha Date: Wed, 9 Sep 2026 17:53:31 +0200 Subject: [PATCH 04/11] =?UTF-8?q?fix(lsp):=20reentrant=20init=20lock=20?= =?UTF-8?q?=E2=80=94=20init-timeout=20shutdown=20deadlocked=20forever?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensure_initialized() holds _init_lock while calling _start_and_init(); when the cold initialize timed out on CI, _start_and_init() called shutdown() which tried to acquire the SAME non-reentrant threading.Lock -> permanent self-deadlock (whole CI suite stalled >50min, no output). Switch to RLock. Also raise _LSP_INIT_TIMEOUT 15s->30s: CI cold-starts tsserver from npm caches; the 15s cap made the first initialize spuriously time out, which is exactly what triggered the deadlock path. --- lsp_bridge.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lsp_bridge.py b/lsp_bridge.py index bd9cee1..46c4168 100644 --- a/lsp_bridge.py +++ b/lsp_bridge.py @@ -57,10 +57,11 @@ # Maximum time (seconds) to wait for the server to start and respond to # the ``initialize`` handshake. -# Reduced from 60 → 15: if a server can't init in 15s (warm or cold), -# it's likely blocked on something (stderr pipe, plugin init, etc.). -# A 60s timeout just makes Hermes stall for a full minute. -_LSP_INIT_TIMEOUT = 15 +# 30s: CI runners cold-start tsserver/pyright from network caches; 15s made +# the *first* initialize time out, which then deadlocked in shutdown() +# (see RLock note at _init_lock) and stalled the whole suite. 30s covers a +# cold server; locally warm servers init in <1s. +_LSP_INIT_TIMEOUT = 30 # How long to keep an idle server alive before shutting it down. _LSP_IDLE_TIMEOUT = 300 # 5 minutes @@ -376,7 +377,7 @@ class LSPBridge: _alive: bool = field(default=False, init=False, repr=False) _last_activity: float = field(default=0.0, init=False, repr=False) _initialized: bool = field(default=False, init=False, repr=False) - _init_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) + _init_lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False) _diagnostics_cache: Dict[str, List[dict]] = field(default_factory=dict, init=False, repr=False) _open_documents: set = field(default_factory=set, init=False, repr=False) # Track open docs to avoid duplicate didOpen _reconcile_close_uris: Dict[str, float] = field(default_factory=dict, init=False, repr=False) From 466bc2c232b25197e7dcb4e7ddf6b59e1c20f7ae Mon Sep 17 00:00:00 2001 From: Renato Wasescha Date: Wed, 9 Sep 2026 17:55:41 +0200 Subject: [PATCH 05/11] =?UTF-8?q?fix(ci):=20pin=20TSLS=205.1.3=20=E2=80=94?= =?UTF-8?q?=205.3.0=20never=20answers=20initialize=20on=20runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TSLS 5.3.0 accepted the initialize request but never replied (30s timeout, every TS diagnostics test degraded to ast_heuristic). Local 5.1.3 answers instantly. Pin the known-good version and log the global npm tree. --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3db6cc7..f30ca55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,8 @@ jobs: pip install tree-sitter tree-sitter-python tree-sitter-javascript tree-sitter-typescript tree-sitter-rust tree-sitter-go tree-sitter-java ast-grep-py pytest pytest-timeout # Real LSP servers so the live diagnostics tests actually run on CI # (typescript-language-server + tsserver for TS, pyright for Python). - npm install -g typescript typescript-language-server pyright + npm install -g typescript typescript-language-server@5.1.3 pyright + npm list -g --depth=0 || true npx pyright --version || true typescript-language-server --version || true From 198588dc53622c1fe781b3a2712d0412b973af3f Mon Sep 17 00:00:00 2001 From: Renato Wasescha Date: Wed, 9 Sep 2026 17:56:51 +0200 Subject: [PATCH 06/11] ci: probe TSLS initialize handshake to diagnose silent no-reply --- .github/workflows/ci.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f30ca55..b8ed76a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,21 @@ jobs: run: | python -m py_compile code_intel.py lsp_bridge.py __init__.py scripts/health_check.py + - name: Probe TSLS initialize handshake (diagnose silent no-reply) + run: | + node -e ' + const { spawn } = require("child_process"); + const p = spawn("typescript-language-server", ["--stdio"]); + let got = false; + p.stdout.on("data", d => { got = true; console.log("TSLS-OUT:", d.toString().slice(0, 200)); }); + p.stderr.on("data", d => console.log("TSLS-ERR:", d.toString().slice(0, 400))); + p.on("exit", (c, s) => console.log("TSLS-EXIT:", c, s)); + const init = JSON.stringify({jsonrpc:"2.0",id:1,method:"initialize",params:{processId:null,rootUri:"file:///tmp",capabilities:{}}}); + const msg = "Content-Length: " + Buffer.byteLength(init) + "\r\n\r\n" + init; + p.stdin.write(msg); + setTimeout(() => { if (!got) { console.log("TSLS: NO RESPONSE in 8s"); process.exit(1);} }, 8000); + ' + - name: Run tests run: | python -m pytest -q --timeout=120 --timeout-method=thread --maxfail=6 -p faulthandler --junitxml=pytest-result.xml From f2775440886ffbecde4387f141ed7127e5050da4 Mon Sep 17 00:00:00 2001 From: Renato Wasescha Date: Wed, 9 Sep 2026 18:06:49 +0200 Subject: [PATCH 07/11] fix(ci): root cause TSLS no-reply = typescript@7 (native port); pin 5.8.3 CI npm -g resolved typescript@7.0.2 (npm latest). TSLS spawns tsserver during initialize; TS 7 lib layout broke that -> initialize never answered (30s timeout, every TS test degraded to ast_heuristic). Local proof: TSLS logs "Using Typescript version 5.8.3" BEFORE answering. Pin typescript@5.8.3 + TSLS 5.1.3. Keep scripts/tsls_probe.js handshake probe for future diagnosis. --- .github/workflows/ci.yml | 16 +++------------- scripts/tsls_probe.js | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 scripts/tsls_probe.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8ed76a..1d63734 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: pip install tree-sitter tree-sitter-python tree-sitter-javascript tree-sitter-typescript tree-sitter-rust tree-sitter-go tree-sitter-java ast-grep-py pytest pytest-timeout # Real LSP servers so the live diagnostics tests actually run on CI # (typescript-language-server + tsserver for TS, pyright for Python). - npm install -g typescript typescript-language-server@5.1.3 pyright + npm install -g typescript@5.8.3 typescript-language-server@5.1.3 pyright npm list -g --depth=0 || true npx pyright --version || true typescript-language-server --version || true @@ -42,18 +42,8 @@ jobs: - name: Probe TSLS initialize handshake (diagnose silent no-reply) run: | - node -e ' - const { spawn } = require("child_process"); - const p = spawn("typescript-language-server", ["--stdio"]); - let got = false; - p.stdout.on("data", d => { got = true; console.log("TSLS-OUT:", d.toString().slice(0, 200)); }); - p.stderr.on("data", d => console.log("TSLS-ERR:", d.toString().slice(0, 400))); - p.on("exit", (c, s) => console.log("TSLS-EXIT:", c, s)); - const init = JSON.stringify({jsonrpc:"2.0",id:1,method:"initialize",params:{processId:null,rootUri:"file:///tmp",capabilities:{}}}); - const msg = "Content-Length: " + Buffer.byteLength(init) + "\r\n\r\n" + init; - p.stdin.write(msg); - setTimeout(() => { if (!got) { console.log("TSLS: NO RESPONSE in 8s"); process.exit(1);} }, 8000); - ' + node scripts/tsls_probe.js || true + echo "probe done" - name: Run tests run: | diff --git a/scripts/tsls_probe.js b/scripts/tsls_probe.js new file mode 100644 index 0000000..e606e51 --- /dev/null +++ b/scripts/tsls_probe.js @@ -0,0 +1,18 @@ +// TSLS initialize-handshake probe: prints exactly what the server answers +// (or "NO RESPONSE"), bounded, non-interactive. Used by CI to diagnose why +// typescript-language-server --stdio never answers initialize on runners. +const { spawn } = require("child_process"); +const p = spawn("typescript-language-server", ["--stdio"], { cwd: process.cwd() }); +let got = false; +p.stdout.on("data", d => { got = true; console.log("TSLS-OUT:", d.toString().slice(0, 400)); }); +p.stderr.on("data", d => console.log("TSLS-ERR:", d.toString().slice(0, 400))); +p.on("exit", (c, s) => console.log("TSLS-EXIT:", c, s)); +const init = JSON.stringify({jsonrpc:"2.0",id:1,method:"initialize",params:{processId:null,rootUri:"file://" + process.cwd(),capabilities:{}}}); +const msg = "Content-Length: " + Buffer.byteLength(init) + "\r\n\r\n" + init; +p.stdin.write(msg); +setTimeout(() => { + if (!got) { console.log("TSLS: NO RESPONSE in 8s"); p.kill("SIGKILL"); process.exit(1); } + console.log("TSLS: replied OK"); + p.kill("SIGKILL"); + process.exit(0); +}, 8000); From b04ae8fff856b348a37495db7b47b1fc3d03eecc Mon Sep 17 00:00:00 2001 From: Renato Wasescha Date: Wed, 9 Sep 2026 18:08:19 +0200 Subject: [PATCH 08/11] fix(lsp): resolve npm-global LSP binaries on CI runners (hostedtoolcache glob) test_reduced_path ran a cold subprocess with PATH=/usr/bin:/bin and relied on _LSP_EXTRA_BIN_DIRS fallbacks; CI installs LSP servers via npm -g into /opt/hostedtoolcache/node//{x64/,}bin which was not in the list, so the server was unresolvable and the test fell back to ast_heuristic. Add the globbed hostedtoolcache node bin dirs (missing dirs skipped silently). --- lsp_bridge.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lsp_bridge.py b/lsp_bridge.py index 46c4168..f72d4ac 100644 --- a/lsp_bridge.py +++ b/lsp_bridge.py @@ -326,6 +326,15 @@ def _find_workspace_folders(root: str) -> List[str]: "/usr/bin", str(Path.home() / ".local" / "bin"), ) +# Glob-resolved additions (CI/runner npm -g layouts), appended after the +# static list; missing dirs are skipped silently. +try: + import glob as _glob + _LSP_EXTRA_BIN_DIRS = tuple(_LSP_EXTRA_BIN_DIRS) + tuple( + sorted(set(_glob.glob("/opt/hostedtoolcache/node/*/x64/bin")) + + set(_glob.glob("/opt/hostedtoolcache/node/*/bin")))) +except Exception: # pragma: no cover - glob must never break LSP resolution + pass def _resolve_command(cmd: str) -> Optional[str]: From edb89dd0e40f695d8231f2c250f0ba0128b8af69 Mon Sep 17 00:00:00 2001 From: Renato Wasescha Date: Fri, 11 Sep 2026 08:25:46 +0200 Subject: [PATCH 09/11] test(ci): expose reduced PATH LSP failure evidence --- tests/test_code_diagnostics_lsp.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/test_code_diagnostics_lsp.py b/tests/test_code_diagnostics_lsp.py index 4302706..0846700 100644 --- a/tests/test_code_diagnostics_lsp.py +++ b/tests/test_code_diagnostics_lsp.py @@ -105,14 +105,22 @@ def test_reduced_path_still_finds_server(self, fixtures): if not _has_server("typescript-language-server"): pytest.skip("typescript-language-server not installed") code = ( - "import sys, json\n" + "import sys, json, logging\n" f"sys.path.insert(0, {str(PLUGIN_DIR)!r})\n" - "from lsp_bridge import code_diagnostics_tool\n" - "d = json.loads(code_diagnostics_tool(%r))\n" + "logging.basicConfig(level=logging.DEBUG, stream=sys.stderr)\n" + "import lsp_bridge\n" + "d = json.loads(lsp_bridge.code_diagnostics_tool(%r))\n" "print(json.dumps({'method': d.get('method')," " 'count': d.get('diagnostic_count')," - " 'server': d.get('lsp_server')}))\n" + " 'server': d.get('lsp_server')," + " 'reason': d.get('fallback_reason')," + " 'resolved': lsp_bridge._resolve_command('typescript-language-server')," + " 'extra_dirs': lsp_bridge._LSP_EXTRA_BIN_DIRS}))\n" ) % fixtures["error.ts"] + # Keep child stderr in the assertion. CI runs this under a deliberately + # reduced PATH; a fallback must expose resolver/initialization evidence. + # Without this, LSPBridge logs are invisible and CI only reports AST. + env = {"PATH": "/usr/bin:/bin", "HOME": str(Path.home())} proc = subprocess.run( [VENV_PYTHON if VENV_PYTHON.exists() else sys.executable, "-c", code], @@ -120,9 +128,10 @@ def test_reduced_path_still_finds_server(self, fixtures): assert proc.returncode == 0, proc.stderr[-300:] result_line = [ln for ln in proc.stdout.splitlines() if ln.startswith("{")][-1] d = json.loads(result_line) - assert d["method"] == "lsp", proc.stdout[-300:] - assert d["server"] == "typescript-language-server" - assert d["count"] >= 1 + evidence = (proc.stdout + "\nSTDERR:\n" + proc.stderr)[-4000:] + assert d["method"] == "lsp", evidence + assert d["server"] == "typescript-language-server", evidence + assert d["count"] >= 1, evidence def test_silent_pull_capable_server_reason(self, fixtures): """Initialized server, push lost AND pull returns nothing → From 895937b66e1512a581021c0e60340e2304a38453 Mon Sep 17 00:00:00 2001 From: Renato Wasescha Date: Fri, 11 Sep 2026 08:29:15 +0200 Subject: [PATCH 10/11] fix(ci): persist verified LSP bin for reduced PATH --- .github/workflows/ci.yml | 4 ++++ lsp_bridge.py | 28 ++++++++++++++++++---------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d63734..8c4a787 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,10 @@ jobs: # Real LSP servers so the live diagnostics tests actually run on CI # (typescript-language-server + tsserver for TS, pyright for Python). npm install -g typescript@5.8.3 typescript-language-server@5.1.3 pyright + LSP_BIN_DIR="$(dirname "$(command -v typescript-language-server)")" + test -x "$LSP_BIN_DIR/typescript-language-server" + test -x "$LSP_BIN_DIR/node" + echo "CODE_INTEL_LSP_BIN_DIRS=$LSP_BIN_DIR" >> "$GITHUB_ENV" npm list -g --depth=0 || true npx pyright --version || true typescript-language-server --version || true diff --git a/lsp_bridge.py b/lsp_bridge.py index f72d4ac..59b2996 100644 --- a/lsp_bridge.py +++ b/lsp_bridge.py @@ -326,15 +326,23 @@ def _find_workspace_folders(root: str) -> List[str]: "/usr/bin", str(Path.home() / ".local" / "bin"), ) -# Glob-resolved additions (CI/runner npm -g layouts), appended after the -# static list; missing dirs are skipped silently. -try: - import glob as _glob - _LSP_EXTRA_BIN_DIRS = tuple(_LSP_EXTRA_BIN_DIRS) + tuple( - sorted(set(_glob.glob("/opt/hostedtoolcache/node/*/x64/bin")) - + set(_glob.glob("/opt/hostedtoolcache/node/*/bin")))) -except Exception: # pragma: no cover - glob must never break LSP resolution - pass +# Explicit extra bins for service/CI launchers. The installer records the +# verified directory after resolving each global LSP executable. A child can +# then start under PATH=/usr/bin:/bin without relying on a parent shell PATH. +_LSP_ENV_BIN_DIRS = tuple( + d for d in os.environ.get("CODE_INTEL_LSP_BIN_DIRS", "").split(os.pathsep) + if d +) +_LSP_EXTRA_BIN_DIRS = tuple(_LSP_EXTRA_BIN_DIRS) + _LSP_ENV_BIN_DIRS + + +def _lsp_bin_dirs() -> Tuple[str, ...]: + """Return static plus current-process configured LSP bin directories.""" + dynamic = tuple( + d for d in os.environ.get("CODE_INTEL_LSP_BIN_DIRS", "").split(os.pathsep) + if d + ) + return tuple(dict.fromkeys(_LSP_EXTRA_BIN_DIRS + dynamic)) def _resolve_command(cmd: str) -> Optional[str]: @@ -352,7 +360,7 @@ def _resolve_command(cmd: str) -> Optional[str]: return found if os.sep in cmd or "/" in cmd: return None # explicit path given — do not second-guess - for d in _LSP_EXTRA_BIN_DIRS: + for d in _lsp_bin_dirs(): try: candidate = Path(d) / cmd if candidate.is_file() and os.access(candidate, os.X_OK): From 17fc0323d8b0ea3b3e58abeafd3b63737d8c4c6c Mon Sep 17 00:00:00 2001 From: Renato Wasescha Date: Fri, 11 Sep 2026 08:46:20 +0200 Subject: [PATCH 11/11] test(ci): preserve configured LSP bin in reduced PATH child --- README.md | 2 +- lsp_bridge.py | 8 +++++++- tests/test_code_diagnostics_lsp.py | 12 +++++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 55e3be7..9a35aab 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ pip install tree-sitter tree-sitter-languages ast-grep-py > go install golang.org/x/tools/gopls@latest > ``` > -> The plugin auto-discovers servers via PATH, monorepo `node_modules/.bin`, and `npx` fallback. No additional configuration needed. +> The plugin auto-discovers servers via PATH, monorepo `node_modules/.bin`, and `npx` fallback. For service or CI launchers with a reduced PATH, set `CODE_INTEL_LSP_BIN_DIRS` to a path-separated list of verified LSP executable directories; this explicit setting is searched before generic fallback directories. ### Monorepo Support diff --git a/lsp_bridge.py b/lsp_bridge.py index 59b2996..620668f 100644 --- a/lsp_bridge.py +++ b/lsp_bridge.py @@ -360,7 +360,13 @@ def _resolve_command(cmd: str) -> Optional[str]: return found if os.sep in cmd or "/" in cmd: return None # explicit path given — do not second-guess - for d in _lsp_bin_dirs(): + # Explicit configuration wins over generic fallback dirs. This makes + # service/CI installs deterministic when multiple global npm prefixes exist. + configured = tuple( + d for d in os.environ.get("CODE_INTEL_LSP_BIN_DIRS", "").split(os.pathsep) + if d + ) + for d in configured + tuple(d for d in _lsp_bin_dirs() if d not in configured): try: candidate = Path(d) / cmd if candidate.is_file() and os.access(candidate, os.X_OK): diff --git a/tests/test_code_diagnostics_lsp.py b/tests/test_code_diagnostics_lsp.py index 0846700..553189b 100644 --- a/tests/test_code_diagnostics_lsp.py +++ b/tests/test_code_diagnostics_lsp.py @@ -121,7 +121,17 @@ def test_reduced_path_still_finds_server(self, fixtures): # reduced PATH; a fallback must expose resolver/initialization evidence. # Without this, LSPBridge logs are invisible and CI only reports AST. - env = {"PATH": "/usr/bin:/bin", "HOME": str(Path.home())} + # Preserve only documented LSP-bin configuration. Do not inherit parent + # PATH: this remains a cold reduced-PATH regression test. + lsp_bin_dirs = os.environ.get("CODE_INTEL_LSP_BIN_DIRS", "") + env = { + "PATH": "/usr/bin:/bin", + "HOME": str(Path.home()), + "CODE_INTEL_LSP_BIN_DIRS": lsp_bin_dirs, + } + assert env["PATH"] == "/usr/bin:/bin" + if lsp_bin_dirs: + assert _has_server("typescript-language-server") proc = subprocess.run( [VENV_PYTHON if VENV_PYTHON.exists() else sys.executable, "-c", code], capture_output=True, text=True, env=env, timeout=120)