Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/agent-usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ codelens audit <workspace> --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 <path>` | 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. |

---
Expand Down
96 changes: 96 additions & 0 deletions docs/design/0253-lsp-diagnostics.md
Original file line number Diff line number Diff line change
@@ -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 <path>` 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.
15 changes: 13 additions & 2 deletions scripts/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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)")
Expand All @@ -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]:
Expand Down Expand Up @@ -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


Expand Down
126 changes: 126 additions & 0 deletions scripts/commands/diagnostics.py
Original file line number Diff line number Diff line change
@@ -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 <path>``.
"""

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 <path>",
}

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.
17 changes: 17 additions & 0 deletions scripts/hybrid_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
62 changes: 61 additions & 1 deletion scripts/lsp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = {
Expand Down
10 changes: 5 additions & 5 deletions tests/test_command_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
Loading
Loading