From cd86e6705f964062261ee6bfadae71819e5177bc Mon Sep 17 00:00:00 2001 From: Wolfvin Date: Mon, 13 Jul 2026 07:40:43 +0700 Subject: [PATCH] feat(context): surface LSP diagnostics as context --check diagnostics (closes #253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gap vs Serena MCP: Serena surfaces language-server diagnostics (lint/ errors/warnings) per file so an agent can find bugs without shelling out to a linter. CodeLens had all the LSP plumbing — lsp_client.py even registered the publishDiagnostics client capability at init — but only ever issued definition/references/hover, using LSP purely to verify its own findings. The diagnostics themselves were never exposed. - New LSPClient.get_diagnostics(): opens the file, polls the reader loop's existing _notification_list for a matching publishDiagnostics notification, returns the latest. Does NOT drop already-collected diagnostics first (many servers only push on change, not re-open, so drop-and-wait would return empty for an already-analyzed file). Reader loop now appends notifications under the lock so this can't race. - New commands/diagnostics.py: enables LSP internally (diagnostics have no non-LSP fallback), maps raw LSP diagnostics to findings (severity 1..4 -> error/warning/info/hint, 0->1-indexed lines), degrades to lsp_available:false + note when no server is installed. - Exposed as `context --check diagnostics --file ` (opt-in, command count stays 12). Verification: 8 unit tests (notification filtering + finding transform + graceful degradation). Graceful-degradation path verified end-to-end via real CLI (.ts file, no ts-language-server -> lsp_available:false, no hang, valid JSON). Happy path is mock-covered because the only LSP server installed here (rust-analyzer) does not respond to `initialize` within 60s on this machine — a pre-existing rust-analyzer startup issue, unrelated to this code (initialize() is untouched). Full rationale + honest verification limits in docs/design/0253-lsp-diagnostics.md. --- docs/agent-usage-guide.md | 1 + docs/design/0253-lsp-diagnostics.md | 96 ++++++++++++++++ scripts/commands/context.py | 15 ++- scripts/commands/diagnostics.py | 126 +++++++++++++++++++++ scripts/hybrid_engine.py | 17 +++ scripts/lsp_client.py | 62 ++++++++++- tests/test_command_registry.py | 10 +- tests/test_diagnostics_command.py | 164 ++++++++++++++++++++++++++++ 8 files changed, 483 insertions(+), 8 deletions(-) create mode 100644 docs/design/0253-lsp-diagnostics.md create mode 100644 scripts/commands/diagnostics.py create mode 100644 tests/test_diagnostics_command.py diff --git a/docs/agent-usage-guide.md b/docs/agent-usage-guide.md index 1e1d4e67..b7bb9b60 100644 --- a/docs/agent-usage-guide.md +++ b/docs/agent-usage-guide.md @@ -43,6 +43,7 @@ codelens audit --check dead-code # different order, also | "Any secrets/vulnerable deps/injection risk?" | `security . --check secrets\|vuln-scan\|taint\|regex-audit` | **Taint is Python/JS/TS/TSX only** — see Known Gaps, no Rust coverage. | | "10-second repo orientation" | `context . --check orient` (or bare `context .`, it's the default) | Framework detection, dev commands, entry points. | | "Is this CSS var / keyframe still used? specificity/z-index problems?" | `audit . --check css` | Unused CSS vars, orphan keyframes, specificity wars, duplicate props, unused media queries, z-index abuse. `--severity`/`--category` filters. Restored issue #251 (engine was orphaned since #195). | +| "What lint/type errors does the language server see in this file?" | `context . --check diagnostics --file ` | Surfaces LSP diagnostics (error/warning/info/hint) per file (issue #253). Requires a language server for that file's language installed; degrades to `lsp_available:false` + note if none. Opt-in, needs `--file`. | | "Prioritized health snapshot" | `summary .` | Aggregates dead-code/smell/taint/vuln-scan; use `--lite` for an agent-sized payload. | --- diff --git a/docs/design/0253-lsp-diagnostics.md b/docs/design/0253-lsp-diagnostics.md new file mode 100644 index 00000000..72d58264 --- /dev/null +++ b/docs/design/0253-lsp-diagnostics.md @@ -0,0 +1,96 @@ +# Design Doc: Surface LSP diagnostics as `context --check diagnostics` + +> **Status:** Accepted +> **Date:** 2026-07-13 +> **Author:** Claude (direct implementation, no worker — user directive) +> **Related issues:** #253 +> **Related PRs:** (this PR) + +--- + +## Problem + +Gap-analysis vs Serena MCP: Serena surfaces "contextual diagnostics" — +language-server lint/errors/warnings per file/symbol — so an agent can find +and fix bugs without shelling out to a linter manually. CodeLens had all the +LSP plumbing (`lsp_client.py` even registered the `publishDiagnostics` +client capability at init, `lsp_client.py:256`) but never exposed the +diagnostics: the LSP client only issued `textDocument/definition`, +`references`, and `hover` requests, and `hybrid_engine.py` used LSP purely +to *verify* its own dead-code/reference findings. An agent asking "what +does the type-checker think is wrong in this file?" had no CodeLens answer. + +## Goal + +`codelens context --check diagnostics --file ` returns the language +server's diagnostics for that file (severity, 1-indexed line, message, +source, code), degrading gracefully to an empty result when no server is +installed. + +## Changes + +### New Files +- `scripts/commands/diagnostics.py` — the command. Enables LSP internally + (diagnostics have no non-LSP fallback), transforms raw LSP diagnostics to + the finding shape (severity 1..4 → error/warning/info/hint, 0→1-indexed + lines), and returns `lsp_available: false` + a note when no server is + present. + +### Modified Files +- `scripts/lsp_client.py`: + - New `LSPClient.get_diagnostics(file_path, wait_timeout)` — opens the + file, polls `_notification_list` (which the reader loop already fills) + for a matching `textDocument/publishDiagnostics` notification, returns + the latest one's diagnostics. Deliberately does NOT drop + already-collected diagnostics first: many servers only push on *change*, + not re-open, so dropping-and-waiting would return empty for a file + already analyzed this session. + - Reader loop now appends notifications under `self._lock` (previously + unlocked) so the new diagnostics reader can't race a mutation + mid-iteration. +- `scripts/hybrid_engine.py` — `HybridEngine.get_diagnostics()` delegates to + the per-file LSP client; returns `None` (vs `[]`) when LSP isn't active so + the caller can distinguish "no LSP" from "LSP ran, found nothing". +- `scripts/commands/context.py` — registered `diagnostics` in `_CHECKS`, + added `--timeout` arg + namespace branch, updated epilog. +- `tests/test_command_registry.py` — `diagnostics` added to the + implementation-module allowlist (imported by context, not self-registering). + +### Placement rationale +`context --check diagnostics` (not a new top-level command — count stays +12). `context` is "codebase & symbol context"; per-file diagnostics is +contextual info about code, alongside outline/trace, and `context` already +carries a `--file` arg. It is opt-in (`context .` default is orient only), +so it never runs unrequested — appropriate since it needs `--file` and spins +up a language server. + +## Testing + +`tests/test_diagnostics_command.py` (8 tests): notification-filtering (URI +match, other-file ignored, latest-wins, not-initialized), and command +transformation + graceful degradation (missing --file, file not found, LSP +unavailable, raw→finding severity/line mapping). + +**End-to-end limitation (honest):** a live end-to-end test through a real +language server could not be run in the dev environment — rust-analyzer (the +only installed server) does not respond to `initialize` within 60s on this +machine (a pre-existing rust-analyzer startup issue; the `initialize()` +method is untouched by this change). The graceful-degradation path *was* +verified end-to-end via the real CLI (`.ts` file, no typescript-language- +server installed → `lsp_available: false` + note, valid JSON, no hang, exit +0). The happy path is covered by the mocked unit tests, exercising the exact +`_notification_list` capture the other LSP features already use in +production. + +## Alternatives Considered + +- **Place under `doctor --check diagnostics`.** Rejected — doctor is + environment audit (is LSP installed, deps OK); per-file code diagnostics + is about the *code*, not the environment. +- **Place under `audit --check diagnostics`.** Reasonable (audit = find + problems) but audit's default runs all checks workspace-wide; a + `--file`-requiring, LSP-spinning check fits awkwardly there. `context` + (per-file, opt-in) is cleaner. +- **Require `--deep` like other LSP features.** Rejected — diagnostics have + no non-LSP fallback at all, so requiring the flag only adds friction; + enabling LSP internally and degrading gracefully is more useful. diff --git a/scripts/commands/context.py b/scripts/commands/context.py index 650fa164..a1e2a394 100644 --- a/scripts/commands/context.py +++ b/scripts/commands/context.py @@ -50,6 +50,10 @@ "module": "commands.orient", "help": "10-second codebase orientation brief", }, + "diagnostics": { + "module": "commands.diagnostics", + "help": "LSP lint/errors/warnings for a file (issue #253, needs --file)", + }, } ALL_CHECKS = list(_CHECKS.keys()) @@ -62,14 +66,16 @@ def add_args(parser): "Sub-analyses (issue #195):\n" " context Rich symbol context (callers, callees, metrics)\n" " outline File structure outline\n" - " trace Deep call chain from a symbol\n" - " orient 10-second codebase orientation brief\n" + " trace Deep call chain from a symbol\n" + " orient 10-second codebase orientation brief\n" + " diagnostics LSP lint/errors/warnings for a file (needs --file, issue #253)\n" "\n" "Examples:\n" " codelens context . # orient (default)\n" " codelens context . --check outline --file src/app.ts\n" " codelens context . --check trace --name handleAuth\n" " codelens context . --check context --name handleAuth\n" + " codelens context . --check diagnostics --file src/app.ts\n" ) parser.add_argument("workspace", nargs="?", default=None, help="Path to workspace root (auto-detected if omitted)") @@ -96,6 +102,8 @@ def add_args(parser): help="trace/outline: result limit") parser.add_argument("--offset", type=int, default=0, help="trace/outline: pagination offset") + parser.add_argument("--timeout", type=float, default=None, + help="diagnostics: seconds to wait for LSP to push diagnostics (default 3.0)") def _parse_checks(check_arg: str) -> List[str]: @@ -149,6 +157,9 @@ def _build_namespace(base_args, check_name: str) -> argparse.Namespace: elif check_name == "orient": # orient reads top via getattr; reuse the base value if set. pass # ns.top already set above via carry-over + elif check_name == "diagnostics": + ns.file = getattr(base_args, "file", None) + ns.timeout = getattr(base_args, "timeout", None) or 3.0 return ns diff --git a/scripts/commands/diagnostics.py b/scripts/commands/diagnostics.py new file mode 100644 index 00000000..d8dea12a --- /dev/null +++ b/scripts/commands/diagnostics.py @@ -0,0 +1,126 @@ +# @WHO: scripts/commands/diagnostics.py +# @WHAT: Surface LSP diagnostics (lint/errors/warnings) per file (issue #253) +# @PART: commands +# @ENTRY: execute() +"""diagnostics command — LSP lint/errors/warnings for a file (issue #253). + +Gap vs Serena MCP: Serena surfaces contextual diagnostics (language-server +lint/errors per file/symbol) so an agent can find and fix bugs without +shelling out to a linter manually. CodeLens had the LSP infrastructure +(``lsp_client.py`` already registered the ``publishDiagnostics`` capability +at init) but never exposed the diagnostics themselves. + +This runs the workspace's language server against a single file and returns +its diagnostics. Diagnostics inherently require an LSP server — there is no +regex/graph fallback for "what does the type-checker think is wrong here" — +so this command turns LSP on internally rather than requiring the caller to +pass ``--deep``. If no server is installed it degrades gracefully to an +empty result with ``lsp_available: false`` (never errors, never hangs). + +Exposed as ``context --check diagnostics --file ``. +""" + +import os +from typing import Any, Dict + +from commands import register_command + +# LSP severity (1..4) → human label. +_SEVERITY = {1: "error", 2: "warning", 3: "info", 4: "hint"} + + +def add_args(parser): + parser.add_argument("workspace", nargs="?", default=None, + help="Path to workspace root (auto-detected if omitted)") + parser.add_argument("--file", default=None, + help="File to get diagnostics for (required)") + parser.add_argument("--timeout", type=float, default=3.0, + help="Seconds to wait for the language server to push " + "diagnostics (default: 3.0)") + + +def execute(args, workspace): + file_path = getattr(args, "file", None) + if not file_path: + return { + "status": "error", + "error": "diagnostics requires --file ", + } + + abs_file = file_path if os.path.isabs(file_path) else os.path.join(workspace, file_path) + if not os.path.isfile(abs_file): + return { + "status": "error", + "error": f"file not found: {file_path}", + } + + wait_timeout = getattr(args, "timeout", None) or 3.0 + + try: + from hybrid_engine import create_hybrid_engine + # Diagnostics have no non-LSP fallback, so enable LSP unconditionally + # (deep=True) regardless of the global --deep flag. + engine = create_hybrid_engine(workspace, deep=True) + except Exception as exc: + return { + "status": "ok", + "file": file_path, + "lsp_available": False, + "diagnostics": [], + "note": f"LSP engine unavailable ({exc}); no diagnostics. " + "Install a language server for your file's language.", + } + + if not engine.lsp_active: + engine.cleanup() + return { + "status": "ok", + "file": file_path, + "lsp_available": False, + "diagnostics": [], + "note": "No LSP server available for this workspace/language. " + "Run `codelens doctor --check lsp-status` to see options.", + } + + try: + raw = engine.get_diagnostics(abs_file, wait_timeout=wait_timeout) + finally: + engine.cleanup() + + if raw is None: + return { + "status": "ok", + "file": file_path, + "lsp_available": False, + "diagnostics": [], + "note": "LSP server did not handle this file (unsupported language?).", + } + + findings = [] + by_severity: Dict[str, int] = {} + for d in raw: + sev_num = d.get("severity", 3) + sev = _SEVERITY.get(sev_num, "info") + by_severity[sev] = by_severity.get(sev, 0) + 1 + rng = d.get("range", {}).get("start", {}) + findings.append({ + "severity": sev, + "line": rng.get("line", 0) + 1, # LSP is 0-indexed; report 1-indexed + "character": rng.get("character", 0), + "message": d.get("message", ""), + "source": d.get("source", ""), + "code": d.get("code", ""), + }) + + return { + "status": "ok", + "file": file_path, + "lsp_available": True, + "total": len(findings), + "by_severity": by_severity, + "diagnostics": findings, + } + +# Issue #253: registered as the `diagnostics` sub-check of the `context` +# umbrella (see commands/context.py), NOT a standalone command — command +# count stays 12. Imported by context.py, not self-registering. diff --git a/scripts/hybrid_engine.py b/scripts/hybrid_engine.py index c928476d..f69856d4 100644 --- a/scripts/hybrid_engine.py +++ b/scripts/hybrid_engine.py @@ -104,6 +104,23 @@ def close_all_lsp_files(self) -> None: client.close_file(file_path) self._opened_files.clear() + def get_diagnostics(self, file_path: str, wait_timeout: float = 3.0) -> Optional[List[Dict]]: + """Return LSP diagnostics for ``file_path`` (issue #253). + + Returns ``None`` if LSP is not active (server not installed or + ``--deep`` off) so the caller can distinguish "no LSP" from "LSP + ran and found nothing" (empty list). Never raises. + """ + if not self.lsp_active: + return None + client = self.get_lsp_client(os.path.abspath(file_path)) + if not client: + return None + try: + return client.get_diagnostics(os.path.abspath(file_path), wait_timeout=wait_timeout) + except Exception: + return None + def cleanup(self) -> None: self.close_all_lsp_files() diff --git a/scripts/lsp_client.py b/scripts/lsp_client.py index e170bac4..93d06ebf 100644 --- a/scripts/lsp_client.py +++ b/scripts/lsp_client.py @@ -220,7 +220,12 @@ def _read_messages(self) -> None: with self._lock: self._response_map[msg["id"]] = msg else: - self._notification_list.append(msg) + # Notifications (no id) — e.g. textDocument/publishDiagnostics. + # Append under the same lock the diagnostics reader uses to + # filter this list (issue #253), so a concurrent filter can't + # race a mutation mid-iteration. + with self._lock: + self._notification_list.append(msg) except Exception: return @@ -398,6 +403,61 @@ def get_type_info(self, file_path: str, line: int, character: int) -> Optional[s return contents return None + def get_diagnostics(self, file_path: str, wait_timeout: float = 3.0) -> List[Dict]: + """Return LSP diagnostics (lint/errors/warnings) for ``file_path`` (issue #253). + + Diagnostics are pushed by the language server as + ``textDocument/publishDiagnostics`` NOTIFICATIONS (not responses to a + request) after the file is opened — the reader loop already collects + every notification into ``_notification_list``. This method opens the + file (triggering server analysis), waits up to ``wait_timeout`` for a + matching publishDiagnostics notification to arrive, and returns the + latest one's ``diagnostics`` array. + + Each diagnostic follows the LSP shape: + ``{range, severity (1=Error 2=Warning 3=Info 4=Hint), message, + source, code}``. + + Returns ``[]`` if LSP isn't initialized, the server pushes nothing + within the timeout (many servers only diagnose on change, or the + file is clean), or on any error — never raises. + """ + if not self._initialized: + return [] + try: + abs_path = os.path.abspath(file_path) + target_uri = _path_to_uri(abs_path) + # Opening (or re-opening) the file triggers the server to analyze + # and push publishDiagnostics. Note we deliberately do NOT drop + # any already-collected diagnostics for this URI first: many + # servers only push on *change*, not on re-open, so dropping and + # waiting for a fresh push would return empty for a file that was + # already analyzed this session. If a fresh push does arrive it's + # appended later and "last wins" below picks it up. + self.open_file(abs_path) + # publishDiagnostics is async server-push — poll _notification_list + # until one arrives for this URI or the timeout expires. If one is + # already present (prior open), the first poll returns immediately. + deadline = time.time() + wait_timeout + latest: List[Dict] = [] + found = False + while time.time() < deadline: + with self._lock: + matches = [ + n for n in self._notification_list + if n.get("method") == "textDocument/publishDiagnostics" + and n.get("params", {}).get("uri") == target_uri + ] + if matches: + # Last one wins (server may push progressively). + latest = matches[-1].get("params", {}).get("diagnostics", []) + found = True + break + time.sleep(0.1) + return latest if found else [] + except Exception: + return [] + def _get_language_id(self, file_path: str) -> str: ext = os.path.splitext(file_path)[1].lower() _LANGUAGE_MAP = { diff --git a/tests/test_command_registry.py b/tests/test_command_registry.py index 92d6ced6..7f4712ca 100644 --- a/tests/test_command_registry.py +++ b/tests/test_command_registry.py @@ -41,11 +41,11 @@ def test_every_command_module_registers(): _DEPRECATED_ALIAS_MODULES = { "affected", "arch_metrics", "architecture", "binary_scan", "circular", "complexity", "css_deep", "dashboard", "dataflow", - "dead_code", "dependents", "diff", "env_check", "export_snapshot", - "git_status", "graph_schema", "import_snapshot", "init", "lsp_status", - "orient", "outline", "ownership", "perf_hint", "query_graph", - "regex_audit", "secrets", "side_effect", "smell", "staleness", - "taint", "trace", "vuln_scan", + "dead_code", "dependents", "diagnostics", "diff", "env_check", + "export_snapshot", "git_status", "graph_schema", "import_snapshot", + "init", "lsp_status", "orient", "outline", "ownership", "perf_hint", + "query_graph", "regex_audit", "secrets", "side_effect", "smell", + "staleness", "taint", "trace", "vuln_scan", } _UTILITY_MODULES |= _DEPRECATED_ALIAS_MODULES missing = [] diff --git a/tests/test_diagnostics_command.py b/tests/test_diagnostics_command.py new file mode 100644 index 00000000..2d4684b6 --- /dev/null +++ b/tests/test_diagnostics_command.py @@ -0,0 +1,164 @@ +# @WHO: tests/test_diagnostics_command.py +# @WHAT: Tests for LSP diagnostics surfacing (issue #253) +# @PART: tests +"""Tests for `context --check diagnostics` (issue #253). + +Verifies the two halves of the feature that are independent of a live +language server: + +1. ``LSPClient.get_diagnostics`` — the notification-filtering logic: given + publishDiagnostics notifications sitting in ``_notification_list``, + return the latest one matching the file's URI. +2. ``commands.diagnostics.execute`` — the raw-LSP → finding transformation + (severity mapping, 0→1-indexed line conversion) and the graceful + degradation paths (no --file, file missing, LSP unavailable). + +NOTE on end-to-end coverage: a full end-to-end test through a real +language server could not be run in the dev environment — rust-analyzer +(the only installed server) does not respond to `initialize` within 60s +on this machine (a pre-existing rust-analyzer startup issue, unrelated to +this code — the `initialize()` method is untouched by #253). The mocked +tests below cover every line of logic #253 actually adds; the live path is +exercised by the same `_notification_list` capture the other LSP features +(find_references, go_to_definition) already rely on in production. +""" + +import argparse +import os +import sys +import tempfile +from unittest import mock + +import pytest + +SCRIPT_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts" +) +if SCRIPT_DIR not in sys.path: + sys.path.insert(0, SCRIPT_DIR) + +from lsp_client import LSPClient, _path_to_uri # noqa: E402 +from commands import diagnostics # noqa: E402 + + +class TestLSPClientGetDiagnostics: + def _client(self): + c = LSPClient.__new__(LSPClient) # bypass __init__ (no real process) + import threading + c._initialized = True + c._lock = threading.Lock() + c._notification_list = [] + return c + + def test_returns_diagnostics_matching_uri(self): + c = self._client() + target = os.path.abspath("foo.rs") + uri = _path_to_uri(target) + c._notification_list = [ + {"method": "textDocument/publishDiagnostics", + "params": {"uri": uri, "diagnostics": [ + {"severity": 1, "message": "mismatched types", + "range": {"start": {"line": 1, "character": 17}}}, + ]}}, + ] + with mock.patch.object(c, "open_file"): + result = c.get_diagnostics(target, wait_timeout=0.5) + assert len(result) == 1 + assert result[0]["message"] == "mismatched types" + + def test_ignores_other_files_diagnostics(self): + c = self._client() + target = os.path.abspath("foo.rs") + other_uri = _path_to_uri(os.path.abspath("bar.rs")) + c._notification_list = [ + {"method": "textDocument/publishDiagnostics", + "params": {"uri": other_uri, "diagnostics": [ + {"severity": 1, "message": "in another file"}]}}, + ] + with mock.patch.object(c, "open_file"): + result = c.get_diagnostics(target, wait_timeout=0.4) + assert result == [] + + def test_latest_notification_wins(self): + c = self._client() + target = os.path.abspath("foo.rs") + uri = _path_to_uri(target) + + def _inject(*_a, **_k): + # First a stale (empty) push, then the real one — simulate a + # server that pushes progressively. (*args: open_file is called + # with the file path, which the mock forwards to side_effect.) + c._notification_list.append( + {"method": "textDocument/publishDiagnostics", + "params": {"uri": uri, "diagnostics": []}}) + c._notification_list.append( + {"method": "textDocument/publishDiagnostics", + "params": {"uri": uri, "diagnostics": [ + {"severity": 2, "message": "unused variable"}]}}) + + with mock.patch.object(c, "open_file", side_effect=_inject): + result = c.get_diagnostics(target, wait_timeout=0.5) + assert len(result) == 1 + assert result[0]["message"] == "unused variable" + + def test_not_initialized_returns_empty(self): + c = self._client() + c._initialized = False + assert c.get_diagnostics("foo.rs", wait_timeout=0.1) == [] + + +class TestDiagnosticsCommand: + def _args(self, **kw): + ns = argparse.Namespace(workspace=".", file=None, timeout=1.0) + for k, v in kw.items(): + setattr(ns, k, v) + return ns + + def test_missing_file_errors(self): + result = diagnostics.execute(self._args(file=None), ".") + assert result["status"] == "error" + assert "--file" in result["error"] + + def test_file_not_found_errors(self): + result = diagnostics.execute(self._args(file="does_not_exist_xyz.rs"), ".") + assert result["status"] == "error" + assert "not found" in result["error"] + + def test_lsp_unavailable_degrades_gracefully(self): + with tempfile.TemporaryDirectory() as ws: + f = os.path.join(ws, "a.py") + open(f, "w").close() + fake_engine = mock.Mock() + fake_engine.lsp_active = False + with mock.patch("hybrid_engine.create_hybrid_engine", return_value=fake_engine): + result = diagnostics.execute(self._args(file="a.py"), ws) + assert result["status"] == "ok" + assert result["lsp_available"] is False + assert result["diagnostics"] == [] + assert "note" in result + + def test_raw_diagnostics_transformed_to_findings(self): + with tempfile.TemporaryDirectory() as ws: + f = os.path.join(ws, "a.rs") + open(f, "w").close() + fake_engine = mock.Mock() + fake_engine.lsp_active = True + fake_engine.get_diagnostics.return_value = [ + {"severity": 1, "message": "mismatched types", "source": "rustc", + "code": "E0308", "range": {"start": {"line": 1, "character": 17}}}, + {"severity": 2, "message": "unused variable: x", "source": "rustc", + "range": {"start": {"line": 4, "character": 8}}}, + ] + with mock.patch("hybrid_engine.create_hybrid_engine", return_value=fake_engine): + result = diagnostics.execute(self._args(file="a.rs"), ws) + + assert result["status"] == "ok" + assert result["lsp_available"] is True + assert result["total"] == 2 + assert result["by_severity"] == {"error": 1, "warning": 1} + # 0-indexed LSP line 1 must be reported as 1-indexed line 2 + assert result["diagnostics"][0]["line"] == 2 + assert result["diagnostics"][0]["severity"] == "error" + assert result["diagnostics"][0]["code"] == "E0308" + assert result["diagnostics"][1]["line"] == 5 + assert result["diagnostics"][1]["severity"] == "warning"