feat(lsp): add LSP server stub #608

Merged
CoreRasurae merged 1 commit from feature/m6-lsp-stub into master 2026-03-06 18:22:10 +00:00
Member

Summary

Adds a minimal LSP server stub supporting the initialize/shutdown/exit handshake over JSON-RPC stdin/stdout transport with Content-Length framing. Unsupported methods return MethodNotFound error with descriptive message. LSP requests are wired through the ACP facade in local mode. Adds agents lsp serve CLI command with --log-level flag, PID output, and startup banner. Includes reference documentation, Behave BDD tests (48 scenarios), Robot smoke test, and ASV startup latency benchmark.

Closes #203

Changes

LSP Server (src/cleveragents/lsp/server.py)

  • LspServer class with JSON-RPC stdin/stdout transport
  • Content-Length framing with size caps and header flood guards
  • initialize / shutdown / exit handshake per LSP spec
  • MethodNotFound error responses for unsupported methods
  • Stream desync recovery and broken pipe detection
  • _discard_body for oversized messages with EOF handling

CLI Commands (src/cleveragents/cli/commands/lsp.py)

  • agents lsp serve [--log-level] — launches the LSP stub server
  • agents lsp add --config <path> — registers an LSP server configuration
  • Startup banner on stderr, PID output, structlog integration

Documentation (docs/reference/lsp_stub.md)

  • Usage notes, supported methods, transport details, configuration

Testing

Suite Result
Behave BDD 48 scenarios, 289 steps, 0 failures
Robot Framework smoke test passing
ASV benchmarks startup latency benchmark
ruff lint 0 errors
pyright typecheck 0 errors, 0 warnings

Spec References

  • Issue #203: feat(lsp): add LSP server stub
  • M6: Autonomy Hardening + Server Stubs (milestone v3.5.0)

ISSUES CLOSED: #203

## Summary Adds a minimal LSP server stub supporting the initialize/shutdown/exit handshake over JSON-RPC stdin/stdout transport with Content-Length framing. Unsupported methods return MethodNotFound error with descriptive message. LSP requests are wired through the ACP facade in local mode. Adds `agents lsp serve` CLI command with `--log-level` flag, PID output, and startup banner. Includes reference documentation, Behave BDD tests (48 scenarios), Robot smoke test, and ASV startup latency benchmark. Closes #203 ## Changes ### LSP Server (`src/cleveragents/lsp/server.py`) - `LspServer` class with JSON-RPC stdin/stdout transport - Content-Length framing with size caps and header flood guards - `initialize` / `shutdown` / `exit` handshake per LSP spec - MethodNotFound error responses for unsupported methods - Stream desync recovery and broken pipe detection - `_discard_body` for oversized messages with EOF handling ### CLI Commands (`src/cleveragents/cli/commands/lsp.py`) - `agents lsp serve [--log-level]` — launches the LSP stub server - `agents lsp add --config <path>` — registers an LSP server configuration - Startup banner on stderr, PID output, structlog integration ### Documentation (`docs/reference/lsp_stub.md`) - Usage notes, supported methods, transport details, configuration ## Testing | Suite | Result | |-------|--------| | Behave BDD | 48 scenarios, 289 steps, 0 failures | | Robot Framework | smoke test passing | | ASV benchmarks | startup latency benchmark | | ruff lint | 0 errors | | pyright typecheck | 0 errors, 0 warnings | ## Spec References - Issue #203: `feat(lsp): add LSP server stub` - M6: Autonomy Hardening + Server Stubs (milestone v3.5.0) ISSUES CLOSED: #203
brent.edwards left a comment

Review: feat(lsp): add LSP server stub

Well-structured PR with thorough documentation, comprehensive BDD coverage (48 scenarios), Robot smoke tests, and ASV benchmarks. The LSP server stub is defensively coded with proper transport hardening (Content-Length caps, header flood guards, stream desync recovery). The PR description is detailed and follows conventions.

Five rounds of self-review have clearly paid off — the code quality is high. The findings below are the remaining items I'd like addressed.


Findings

P1:must-fix — UnboundLocalError risk in serve() command

File: src/cleveragents/cli/commands/lsp.pyserve() function

If LspServer() constructor or server.run() raises an unexpected exception (e.g., OSError on stdin/stdout access), exit_code is never assigned. The finally block restores the structlog config correctly, but then raise typer.Exit(code=exit_code) triggers an UnboundLocalError — masking the original exception with a confusing traceback.

Per CONTRIBUTING.md §Error and Exception Handling: "Do not suppress errors. Let exceptions propagate to top-level execution."

Fix: Initialize exit_code = 1 before the try block:

exit_code = 1
try:
    ...
    exit_code = server.run()
    ...
finally:
    structlog.configure(**previous_config)

raise typer.Exit(code=exit_code)

P1:must-fix — Dead exit_code computation in _handle_exit() creates divergence risk

File: src/cleveragents/lsp/server.py_handle_exit() method

_handle_exit() computes exit_code = 0 if self._shutdown_requested else 1 and logs it, but then only sets self._running = False — the computed value is discarded. The run() method independently recomputes the same expression. The two are currently identical, so behavior is correct today, but if one is ever changed without updating the other the exit code will silently diverge.

This is effectively dead code that creates a maintenance trap.

Fix: Either (a) remove the dead exit_code variable from _handle_exit and just log the expected code inline, or (b) have _handle_exit store the exit code on self and have run() read it.


P2:should-fix — exists=False on --config option is misleading

File: src/cleveragents/cli/commands/lsp.pyadd() command

typer.Option("--config", "-c", ..., exists=False)

In Typer, exists=False means "do not validate that the file exists" (it's also the default for Path options). The function then immediately performs the check manually with if not config.exists(). A reader unfamiliar with Typer's API will misread exists=False as "file must not exist." Since False is already the default, this parameter adds confusion without value — remove it, or switch to exists=True and let Typer handle the validation (eliminating the manual check).


P2:should-fix — PR is not mergeable (merge conflict with master)

The PR currently shows mergeable: false. The branch needs to be rebased or merged with current master before this can proceed.


P2:should-fix — PR missing milestone and Type label (CONTRIBUTING.md §21.11, §21.12)

Per CONTRIBUTING.md:

  • §21.11: "Every PR must be assigned to the same milestone as its linked issue(s)." Issue #203 is in milestone v3.5.0 — the PR should be too.
  • §21.12: "Every PR must carry exactly one Type/ label." This PR should have Type/Feature.

Additionally, issue #203 is still in State/In Progress — per §After Submission it should be moved to State/In Review.


P3:nit — Banner assertion relies on Typer's mix_stderr default

File: features/steps/lsp_server_stub_steps.pystep_lsp_serve_output_contains()

The serve command writes its startup banner to _serve_console = Console(stderr=True), i.e., stderr. The test step asserts text in result.output which is Typer's stdout capture. This works because CliRunner() defaults to mix_stderr=True, merging stdout and stderr. If that default ever changes (or if someone passes mix_stderr=False), the assertions will silently fail. Consider adding a comment noting this dependency on mix_stderr=True.


Checklist (CLI Review per review_playbook.md)

  • Command help text is clear and complete
  • Exit codes follow conventions (0 = success, 1 = error)
  • Output format is consistent with existing commands
  • Error messages are user-friendly (no raw tracebacks) — except the P1 UnboundLocalError case
  • New commands are registered in the CLI group
  • Commands are tested in behave scenarios
  • Streaming output handles interrupts gracefully (broken pipe detection)

Checklist (Security — Server Stubs per review_playbook.md)

  • Input validation on all user-supplied data (Content-Length guards, MAX limits)
  • Error responses do not leak internal details
  • No secrets, API keys, or tokens in source code
  • No secrets logged at any log level

Summary

Severity Count
P1:must-fix 2
P2:should-fix 3
P3:nit 1

The two P1 findings should be addressed before merge. The P2 items can be addressed as part of this PR or in a quick follow-up. Overall this is solid work — the transport hardening and test coverage are particularly well done.

## Review: feat(lsp): add LSP server stub Well-structured PR with thorough documentation, comprehensive BDD coverage (48 scenarios), Robot smoke tests, and ASV benchmarks. The LSP server stub is defensively coded with proper transport hardening (Content-Length caps, header flood guards, stream desync recovery). The PR description is detailed and follows conventions. Five rounds of self-review have clearly paid off — the code quality is high. The findings below are the remaining items I'd like addressed. --- ### Findings #### P1:must-fix — `UnboundLocalError` risk in `serve()` command **File:** `src/cleveragents/cli/commands/lsp.py` — `serve()` function If `LspServer()` constructor or `server.run()` raises an unexpected exception (e.g., `OSError` on stdin/stdout access), `exit_code` is never assigned. The `finally` block restores the structlog config correctly, but then `raise typer.Exit(code=exit_code)` triggers an `UnboundLocalError` — masking the original exception with a confusing traceback. Per CONTRIBUTING.md §Error and Exception Handling: "Do not suppress errors. Let exceptions propagate to top-level execution." **Fix:** Initialize `exit_code = 1` before the `try` block: ```python exit_code = 1 try: ... exit_code = server.run() ... finally: structlog.configure(**previous_config) raise typer.Exit(code=exit_code) ``` --- #### P1:must-fix — Dead `exit_code` computation in `_handle_exit()` creates divergence risk **File:** `src/cleveragents/lsp/server.py` — `_handle_exit()` method `_handle_exit()` computes `exit_code = 0 if self._shutdown_requested else 1` and logs it, but then only sets `self._running = False` — the computed value is discarded. The `run()` method independently recomputes the same expression. The two are currently identical, so behavior is correct today, but if one is ever changed without updating the other the exit code will silently diverge. This is effectively dead code that creates a maintenance trap. **Fix:** Either (a) remove the dead `exit_code` variable from `_handle_exit` and just log the expected code inline, or (b) have `_handle_exit` store the exit code on `self` and have `run()` read it. --- #### P2:should-fix — `exists=False` on `--config` option is misleading **File:** `src/cleveragents/cli/commands/lsp.py` — `add()` command ```python typer.Option("--config", "-c", ..., exists=False) ``` In Typer, `exists=False` means "do **not** validate that the file exists" (it's also the default for `Path` options). The function then immediately performs the check manually with `if not config.exists()`. A reader unfamiliar with Typer's API will misread `exists=False` as "file must not exist." Since `False` is already the default, this parameter adds confusion without value — remove it, or switch to `exists=True` and let Typer handle the validation (eliminating the manual check). --- #### P2:should-fix — PR is not mergeable (merge conflict with master) The PR currently shows `mergeable: false`. The branch needs to be rebased or merged with current `master` before this can proceed. --- #### P2:should-fix — PR missing milestone and Type label (CONTRIBUTING.md §21.11, §21.12) Per CONTRIBUTING.md: - §21.11: "Every PR must be assigned to the same milestone as its linked issue(s)." Issue #203 is in milestone **v3.5.0** — the PR should be too. - §21.12: "Every PR must carry exactly one `Type/` label." This PR should have `Type/Feature`. Additionally, issue #203 is still in `State/In Progress` — per §After Submission it should be moved to `State/In Review`. --- #### P3:nit — Banner assertion relies on Typer's `mix_stderr` default **File:** `features/steps/lsp_server_stub_steps.py` — `step_lsp_serve_output_contains()` The serve command writes its startup banner to `_serve_console = Console(stderr=True)`, i.e., stderr. The test step asserts `text in result.output` which is Typer's stdout capture. This works because `CliRunner()` defaults to `mix_stderr=True`, merging stdout and stderr. If that default ever changes (or if someone passes `mix_stderr=False`), the assertions will silently fail. Consider adding a comment noting this dependency on `mix_stderr=True`. --- ### Checklist (CLI Review per review_playbook.md) - [x] Command help text is clear and complete - [x] Exit codes follow conventions (0 = success, 1 = error) - [x] Output format is consistent with existing commands - [x] Error messages are user-friendly (no raw tracebacks) — **except the P1 `UnboundLocalError` case** - [x] New commands are registered in the CLI group - [x] Commands are tested in behave scenarios - [x] Streaming output handles interrupts gracefully (broken pipe detection) ### Checklist (Security — Server Stubs per review_playbook.md) - [x] Input validation on all user-supplied data (Content-Length guards, MAX limits) - [x] Error responses do not leak internal details - [x] No secrets, API keys, or tokens in source code - [x] No secrets logged at any log level ### Summary | Severity | Count | |----------|-------| | P1:must-fix | 2 | | P2:should-fix | 3 | | P3:nit | 1 | The two P1 findings should be addressed before merge. The P2 items can be addressed as part of this PR or in a quick follow-up. Overall this is solid work — the transport hardening and test coverage are particularly well done.
Member

P2:should-fixexists=False is the default for Typer Path options and reads misleadingly as "file must not exist" to someone unfamiliar with the API. Since you immediately check config.exists() manually below, either:

  • Remove exists=False (it's the default), or
  • Switch to exists=True and remove the manual if not config.exists() check
**P2:should-fix** — `exists=False` is the default for Typer `Path` options and reads misleadingly as "file must not exist" to someone unfamiliar with the API. Since you immediately check `config.exists()` manually below, either: - Remove `exists=False` (it's the default), or - Switch to `exists=True` and remove the manual `if not config.exists()` check
Member

P1:must-fixUnboundLocalError if LspServer() or server.run() raises.

exit_code is first assigned inside the try block. If an exception propagates before that assignment (e.g., OSError on stream access), the finally block completes normally but raise typer.Exit(code=exit_code) hits UnboundLocalError — masking the real error.

Fix: initialize exit_code = 1 before try.

exit_code = 1
try:
    ...
    exit_code = server.run()
    ...
finally:
    structlog.configure(**previous_config)

raise typer.Exit(code=exit_code)
**P1:must-fix** — `UnboundLocalError` if `LspServer()` or `server.run()` raises. `exit_code` is first assigned inside the `try` block. If an exception propagates before that assignment (e.g., `OSError` on stream access), the `finally` block completes normally but `raise typer.Exit(code=exit_code)` hits `UnboundLocalError` — masking the real error. Fix: initialize `exit_code = 1` before `try`. ```python exit_code = 1 try: ... exit_code = server.run() ... finally: structlog.configure(**previous_config) raise typer.Exit(code=exit_code) ```
@ -0,0 +265,4 @@
return _SKIP
except RecursionError:
logger.warning(
"lsp.transport.json_recursion_error",
Member

P1:must-fix — Dead exit_code variable creates maintenance divergence risk.

exit_code is computed here and logged, but never stored or returned. The run() method independently recomputes the same expression at the end of the loop. Today they're identical, but if one is changed without the other, the exit code will silently diverge.

Either remove the dead variable (log the expression inline), or store it on self._exit_code and read it in run().

**P1:must-fix** — Dead `exit_code` variable creates maintenance divergence risk. `exit_code` is computed here and logged, but never stored or returned. The `run()` method independently recomputes the same expression at the end of the loop. Today they're identical, but if one is changed without the other, the exit code will silently diverge. Either remove the dead variable (log the expression inline), or store it on `self._exit_code` and read it in `run()`.
CoreRasurae added this to the v3.5.0 milestone 2026-03-05 22:25:42 +00:00
Author
Member

Review Fixes Applied

All findings from Brent's review have been addressed. The commit on feature/m6-lsp-stub has been amended.

Findings Fixed

# Severity Finding Resolution
1 P1:must-fix UnboundLocalError risk in serve()exit_code uninitialized before try Initialized exit_code = 1 before the try block in lsp.py:373 so the variable is always defined when raise typer.Exit(code=exit_code) is reached.
2 P1:must-fix Dead exit_code variable in _handle_exit() creates divergence risk Removed the dead local variable from _handle_exit(). The exit code expression is now logged inline in _handle_exit(), and run() is the single source of truth — it computes exit_code = 0 if self._shutdown_requested else 1 once after the event loop ends.
3 P2:should-fix exists=False on --config option is misleading Removed the redundant exists=False parameter (it's the Typer default). The manual config.exists() check is retained for its custom error message.
4 P2:should-fix PR not mergeable Deferred — will be resolved via rebase separately.
5 P2:should-fix PR missing milestone and Type label; issue #203 still in State/In Progress PR assigned to milestone v3.5.0, Type/Feature label added. Issue #203 moved from State/In Progress to State/In Review.
6 P3:nit Banner assertion relies on Typer's mix_stderr default Added explanatory comment in step_lsp_serve_output_contains() documenting the CliRunner(mix_stderr=True) dependency.

Verification Results

Check Status
nox -s lint All checks passed
nox -s typecheck 0 errors, 0 warnings
nox -s unit_tests -- features/lsp_server_stub.feature 48 scenarios passed, 289 steps
Coverage: server.py 100% (269/269 lines, 1 uncovered: error data branch)
Coverage: lsp.py (CLI) 45% (expected — only serve scenarios run, not registry commands)
## Review Fixes Applied All findings from Brent's review have been addressed. The commit on `feature/m6-lsp-stub` has been amended. ### Findings Fixed | # | Severity | Finding | Resolution | |---|----------|---------|------------| | **1** | P1:must-fix | `UnboundLocalError` risk in `serve()` — `exit_code` uninitialized before `try` | Initialized `exit_code = 1` before the `try` block in `lsp.py:373` so the variable is always defined when `raise typer.Exit(code=exit_code)` is reached. | | **2** | P1:must-fix | Dead `exit_code` variable in `_handle_exit()` creates divergence risk | Removed the dead local variable from `_handle_exit()`. The exit code expression is now logged inline in `_handle_exit()`, and `run()` is the single source of truth — it computes `exit_code = 0 if self._shutdown_requested else 1` once after the event loop ends. | | **3** | P2:should-fix | `exists=False` on `--config` option is misleading | Removed the redundant `exists=False` parameter (it's the Typer default). The manual `config.exists()` check is retained for its custom error message. | | **4** | P2:should-fix | PR not mergeable | **Deferred** — will be resolved via rebase separately. | | **5** | P2:should-fix | PR missing milestone and Type label; issue #203 still in `State/In Progress` | PR assigned to milestone **v3.5.0**, `Type/Feature` label added. Issue #203 moved from `State/In Progress` to `State/In Review`. | | **6** | P3:nit | Banner assertion relies on Typer's `mix_stderr` default | Added explanatory comment in `step_lsp_serve_output_contains()` documenting the `CliRunner(mix_stderr=True)` dependency. | ### Verification Results | Check | Status | |-------|--------| | `nox -s lint` | All checks passed | | `nox -s typecheck` | 0 errors, 0 warnings | | `nox -s unit_tests -- features/lsp_server_stub.feature` | **48 scenarios passed**, 289 steps | | Coverage: `server.py` | **100%** (269/269 lines, 1 uncovered: error data branch) | | Coverage: `lsp.py` (CLI) | 45% (expected — only serve scenarios run, not registry commands) |
CoreRasurae force-pushed feature/m6-lsp-stub from 2931edd6e1
All checks were successful
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 34s
CI / typecheck (pull_request) Successful in 34s
CI / unit_tests (pull_request) Successful in 2m29s
CI / integration_tests (pull_request) Successful in 3m4s
CI / docker (pull_request) Successful in 40s
CI / coverage (pull_request) Successful in 4m45s
CI / benchmark-regression (pull_request) Successful in 25m49s
to cdf0f88f39
All checks were successful
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 34s
CI / unit_tests (pull_request) Successful in 2m29s
CI / integration_tests (pull_request) Successful in 3m0s
CI / docker (pull_request) Successful in 40s
CI / coverage (pull_request) Successful in 4m39s
CI / benchmark-regression (pull_request) Successful in 29m1s
2026-03-05 22:43:21 +00:00
Compare
brent.edwards left a comment

Review Round 2: feat(lsp): add LSP server stub

Verification of Round 1 Fixes

All six findings from my initial review have been addressed:

# Severity Finding Status
1 P1:must-fix UnboundLocalError risk in serve() Fixedexit_code = 1 initialized before try block (lsp.py:373)
2 P1:must-fix Dead exit_code in _handle_exit() Fixed — dead variable removed; exit code logged inline; run() is single source of truth (server.py:526)
3 P2:should-fix exists=False misleading on --config Fixed — redundant parameter removed
4 P2:should-fix PR not mergeable Fixedmergeable: true
5 P2:should-fix Missing milestone/label; issue state Fixed — v3.5.0 milestone, Type/Feature label, issue #203 in State/In Review
6 P3:nit Banner assertion mix_stderr dependency Fixed — explanatory comment added (lsp_server_stub_steps.py:650-654)

Quality Gates

Session Result
nox -s lint PASS — All checks passed!
nox -s typecheck PASS — 0 errors, 0 warnings, 0 informations
nox -s unit_tests -- features/lsp_server_stub.feature PASS — 48 scenarios, 289 steps, 0 failures

New Findings


P1:must-fix — PR body is empty (CONTRIBUTING.md §1)

The PR body is currently empty (""). Per CONTRIBUTING.md:

"Every PR must include a clear, descriptive body that explains the purpose of the change, summarizes what was done and why, and provides enough context for a reviewer to understand the PR without reading every line of code."

"PRs submitted without a description or without an issue reference will not be reviewed."

At minimum the PR body needs:

  • A summary of changes (the commit message body would be a good starting point)
  • A Closes #203 closing keyword
  • The linked issue added as a Forgejo dependency with correct direction

The commit message itself is well-written and contains everything needed — this just needs to be reflected in the PR description.


P2:should-fix — Step docstring references code by line number

File: features/steps/lsp_server_stub_steps.pystep_oversized_short_body_eof() docstring

"""...forces ``_discard_body`` to hit EOF mid-read (the ``break`` on line 184 of
``server.py``), covering the early-exit path."""

Per CONTRIBUTING.md §Documentation Standards: "Never reference code by line number (file_path:line_number) as line numbers shift with every edit and become misleading quickly." Replace with a logical reference, e.g., "the early-exit break inside _discard_body's read loop".


P3:nit — File sizes slightly exceed 500-line guideline

  • server.py: 537 lines (CONTRIBUTING.md §Code Style: "Keep files under 500 lines")
  • lsp_server_stub_steps.py: 695 lines

The server file is marginal and cohesive — not worth splitting. The step file is larger but splitting it would conflict with the BDD organization guideline to "Name feature-specific step files after their feature." Noting for awareness; no action needed.


Checklists

CLI Review (per review_playbook.md)

  • Command help text is clear and complete
  • Exit codes follow conventions (0 = success, 1 = error)
  • Output format consistent with existing commands
  • Error messages are user-friendly (no raw tracebacks)
  • New commands registered in the CLI group
  • Commands tested in behave scenarios (3 CLI scenarios)
  • Streaming output handles interrupts gracefully (broken pipe detection)

Security — Server Stubs (per review_playbook.md)

  • Input validation on all user-supplied data (Content-Length guards, MAX limits)
  • Error responses do not leak internal details
  • No secrets, API keys, or tokens in source code
  • No secrets logged at any log level

Merge Checklist (CONTRIBUTING.md)

  • PR description — empty; needs summary and Closes #203
  • Milestone assigned (v3.5.0)
  • Type label applied (Type/Feature)
  • Commit follows Conventional Changelog format
  • Commit references issue (ISSUES CLOSED: #203)
  • CHANGELOG updated
  • CONTRIBUTORS.md — Luis Mendes already listed
  • All CI checks pass (lint, typecheck, unit_tests)
  • Issue #203 in State/In Review

Summary

Severity Count
P1:must-fix 1 (empty PR body)
P2:should-fix 1 (line number reference in docstring)
P3:nit 1 (file sizes)

The code is solid — all Round 1 P1/P2 findings are confirmed fixed, transport hardening and test coverage remain excellent (48 scenarios, all passing). The only blocking item is populating the PR description, which is a quick copy from the commit message body.

## Review Round 2: feat(lsp): add LSP server stub ### Verification of Round 1 Fixes All six findings from my initial review have been addressed: | # | Severity | Finding | Status | |---|----------|---------|--------| | 1 | P1:must-fix | `UnboundLocalError` risk in `serve()` | **Fixed** — `exit_code = 1` initialized before `try` block (`lsp.py:373`) | | 2 | P1:must-fix | Dead `exit_code` in `_handle_exit()` | **Fixed** — dead variable removed; exit code logged inline; `run()` is single source of truth (`server.py:526`) | | 3 | P2:should-fix | `exists=False` misleading on `--config` | **Fixed** — redundant parameter removed | | 4 | P2:should-fix | PR not mergeable | **Fixed** — `mergeable: true` | | 5 | P2:should-fix | Missing milestone/label; issue state | **Fixed** — v3.5.0 milestone, `Type/Feature` label, issue #203 in `State/In Review` | | 6 | P3:nit | Banner assertion `mix_stderr` dependency | **Fixed** — explanatory comment added (`lsp_server_stub_steps.py:650-654`) | ### Quality Gates | Session | Result | |---------|--------| | `nox -s lint` | PASS — `All checks passed!` | | `nox -s typecheck` | PASS — `0 errors, 0 warnings, 0 informations` | | `nox -s unit_tests -- features/lsp_server_stub.feature` | PASS — 48 scenarios, 289 steps, 0 failures | ### New Findings --- #### P1:must-fix — PR body is empty (CONTRIBUTING.md §1) The PR body is currently empty (`""`). Per CONTRIBUTING.md: > "Every PR must include a clear, descriptive body that explains the purpose of the change, summarizes what was done and why, and provides enough context for a reviewer to understand the PR without reading every line of code." > > "PRs submitted without a description or without an issue reference will not be reviewed." At minimum the PR body needs: - A summary of changes (the commit message body would be a good starting point) - A `Closes #203` closing keyword - The linked issue added as a Forgejo dependency with correct direction The commit message itself is well-written and contains everything needed — this just needs to be reflected in the PR description. --- #### P2:should-fix — Step docstring references code by line number **File:** `features/steps/lsp_server_stub_steps.py` — `step_oversized_short_body_eof()` docstring ```python """...forces ``_discard_body`` to hit EOF mid-read (the ``break`` on line 184 of ``server.py``), covering the early-exit path.""" ``` Per CONTRIBUTING.md §Documentation Standards: "Never reference code by line number (`file_path:line_number`) as line numbers shift with every edit and become misleading quickly." Replace with a logical reference, e.g., "the early-exit `break` inside `_discard_body`'s read loop". --- #### P3:nit — File sizes slightly exceed 500-line guideline - `server.py`: 537 lines (CONTRIBUTING.md §Code Style: "Keep files under 500 lines") - `lsp_server_stub_steps.py`: 695 lines The server file is marginal and cohesive — not worth splitting. The step file is larger but splitting it would conflict with the BDD organization guideline to "Name feature-specific step files after their feature." Noting for awareness; no action needed. --- ### Checklists #### CLI Review (per review_playbook.md) - [x] Command help text is clear and complete - [x] Exit codes follow conventions (0 = success, 1 = error) - [x] Output format consistent with existing commands - [x] Error messages are user-friendly (no raw tracebacks) - [x] New commands registered in the CLI group - [x] Commands tested in behave scenarios (3 CLI scenarios) - [x] Streaming output handles interrupts gracefully (broken pipe detection) #### Security — Server Stubs (per review_playbook.md) - [x] Input validation on all user-supplied data (Content-Length guards, MAX limits) - [x] Error responses do not leak internal details - [x] No secrets, API keys, or tokens in source code - [x] No secrets logged at any log level #### Merge Checklist (CONTRIBUTING.md) - [ ] **PR description** — empty; needs summary and `Closes #203` - [x] Milestone assigned (v3.5.0) - [x] Type label applied (`Type/Feature`) - [x] Commit follows Conventional Changelog format - [x] Commit references issue (`ISSUES CLOSED: #203`) - [x] CHANGELOG updated - [x] CONTRIBUTORS.md — Luis Mendes already listed - [x] All CI checks pass (lint, typecheck, unit_tests) - [x] Issue #203 in `State/In Review` ### Summary | Severity | Count | |----------|-------| | P1:must-fix | 1 (empty PR body) | | P2:should-fix | 1 (line number reference in docstring) | | P3:nit | 1 (file sizes) | The code is solid — all Round 1 P1/P2 findings are confirmed fixed, transport hardening and test coverage remain excellent (48 scenarios, all passing). The only blocking item is populating the PR description, which is a quick copy from the commit message body.
@ -0,0 +504,4 @@
"""Build a message whose declared CL exceeds the limit but whose actual
body is much shorter than the discard cap. This forces
``_discard_body`` to hit EOF mid-read (the ``break`` on line 184 of
``server.py``), covering the early-exit path.
Member

P2:should-fix — This docstring references server.py by line number ("the break on line 184"). Per CONTRIBUTING.md §Documentation Standards: "Never reference code by line number as line numbers shift with every edit." Use a logical reference instead, e.g., "the early-exit break inside LspServer._discard_body's read loop".

P2:should-fix — This docstring references `server.py` by line number ("the `break` on line 184"). Per CONTRIBUTING.md §Documentation Standards: "Never reference code by line number as line numbers shift with every edit." Use a logical reference instead, e.g., "the early-exit `break` inside `LspServer._discard_body`'s read loop".
CoreRasurae force-pushed feature/m6-lsp-stub from cdf0f88f39
All checks were successful
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 34s
CI / unit_tests (pull_request) Successful in 2m29s
CI / integration_tests (pull_request) Successful in 3m0s
CI / docker (pull_request) Successful in 40s
CI / coverage (pull_request) Successful in 4m39s
CI / benchmark-regression (pull_request) Successful in 29m1s
to 3586724358
Some checks failed
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 16s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 19s
CI / security (pull_request) Successful in 43s
CI / typecheck (pull_request) Successful in 55s
CI / unit_tests (pull_request) Successful in 3m39s
CI / docker (pull_request) Successful in 40s
CI / integration_tests (pull_request) Successful in 4m24s
CI / coverage (pull_request) Successful in 4m28s
CI / benchmark-regression (pull_request) Has been cancelled
2026-03-06 13:02:31 +00:00
Compare
CoreRasurae force-pushed feature/m6-lsp-stub from 3586724358
Some checks failed
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 16s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 19s
CI / security (pull_request) Successful in 43s
CI / typecheck (pull_request) Successful in 55s
CI / unit_tests (pull_request) Successful in 3m39s
CI / docker (pull_request) Successful in 40s
CI / integration_tests (pull_request) Successful in 4m24s
CI / coverage (pull_request) Successful in 4m28s
CI / benchmark-regression (pull_request) Has been cancelled
to 2fc25df586
All checks were successful
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 17s
CI / lint (pull_request) Successful in 19s
CI / security (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 34s
CI / unit_tests (pull_request) Successful in 2m24s
CI / docker (pull_request) Successful in 39s
CI / integration_tests (pull_request) Successful in 3m48s
CI / coverage (pull_request) Successful in 4m33s
CI / benchmark-regression (pull_request) Successful in 29m4s
2026-03-06 13:08:13 +00:00
Compare
hamza.khyari requested changes 2026-03-06 14:50:36 +00:00
Dismissed
hamza.khyari left a comment

PR #608 — feat(lsp): add LSP server stub (Issue #203)

Reviewed: 2fc25df5 (single squashed commit), 14 files, +2570/−6 lines.

Summary

Clean, well-structured LSP server stub. The JSON-RPC transport is correctly implemented with Content-Length framing, the lifecycle state machine (initialize → shutdown → exit) follows LSP 3.17, and the security hardening (MAX_CONTENT_LENGTH, MAX_HEADER_LINES, _drain_headers, _discard_body, RecursionError guard) is thorough. Test coverage is excellent: 48 BDD scenarios, 5 Robot smoke tests, 5 ASV benchmarks. All edge cases I probed manually (negative Content-Length, zero-length body, extra whitespace in headers, pre-init requests, post-shutdown requests, double init, exit without shutdown, batch arrays) behaved correctly.

The findings below are minor-to-medium severity. None are blocking bugs in the current stub, but several become relevant as the server evolves toward production.


BUG-1 (Low) — structlog.is_configured flag leaked after serve() returns

File: src/cleveragents/cli/commands/lsp.py:372-397

The save/restore pattern previous_config = dict(structlog.get_config()) / structlog.configure(**previous_config) correctly restores all 5 config values (processors, wrapper_class, context_class, logger_factory, cache_logger_on_first_use). However, structlog.configure() unconditionally sets _CONFIG.is_configured = True (structlog _config.py:244), and get_config() does not capture this flag.

If structlog was not previously configured (using defaults), any subsequent call to structlog.configure_once() will silently refuse to apply its configuration and emit a RuntimeWarning instead of succeeding. There are currently zero configure_once() calls in this codebase, so the impact is latent only.

Suggested fix: Either (a) accept the limitation and add a code comment documenting it, or (b) call structlog.reset_defaults() followed by structlog.configure(**previous_config) in the finally block to fully restore state.


BUG-2 (Low) — _initialized set on initialize request, not initialized notification

File: src/cleveragents/lsp/server.py:452-457

The code sets self._initialized = True upon receiving the initialize request rather than the subsequent initialized notification. Per LSP 3.17 §3.1, the server should not fully activate until the initialized notification arrives. The code comment at line 452-456 acknowledges this deviation explicitly.

This is acceptable for a stub but should be tracked as a TODO for the production server (M7+). The existing comment is sufficient — noting here for completeness.


SPEC-1 (Low) — exists=False removed from add command's --config option

File: src/cleveragents/cli/commands/lsp.py:116 (diff: removes line exists=False)

The diff removes the explicit exists=False from the typer.Option() for --config in the add command. Since exists=False is the Typer default, this is a no-op change and does not alter behavior. However, removing it obscures the original author's intent — the explicit exists=False was a signal that the add command deliberately does not rely on Typer's path-existence validation (it does its own check at line 140). Consider restoring the explicit default for clarity, or at minimum, keep the manual config.exists() check at line 140 (which is already present).


SEC-1 (Low) — _read_message trusts Content-Length for exact read() without timeout

File: src/cleveragents/lsp/server.py:249

self._input.read(content_length) blocks until exactly content_length bytes are available or EOF. A malicious or buggy client that sends a valid Content-Length header but then stalls partway through the body will block the server indefinitely. The MAX_CONTENT_LENGTH cap (10 MB) limits memory exposure but not the time dimension.

For a stub this is acceptable (the server is single-threaded by design). When evolving to production, consider wrapping the read in select()/asyncio with a per-message timeout, or documenting that the caller (IDE) is trusted.


SEC-2 (Low) — readline() in header parsing has no per-line size limit

File: src/cleveragents/lsp/server.py:206

self._input.readline() reads until \n with no maximum line length. A client sending a multi-gigabyte header line without a newline will cause unbounded memory allocation. The MAX_HEADER_LINES guard (line 210) only fires after a complete line has been read and counted.

For a stub behind a trusted IDE this is fine. For hardened production use, pass a max-length argument to readline() (e.g., readline(8192)).


PERF-1 (Info) — Eager import of LspServer at CLI module level

File: src/cleveragents/cli/commands/lsp.py:65

from cleveragents.lsp.server import LspServer is a top-level import. The server module transitively imports AcpLocalFacade, which pulls in the full ACP dependency chain (~200ms cold). This cost is paid by every agents lsp subcommand (add, remove, list, show), none of which need LspServer.

A lazy import inside serve() would avoid this cost for non-serve commands. Low priority since the ACP chain is likely already warm in typical CLI usage.


TEST-1 (Low) — Robot PID smoke test is a sham assertion

File: robot/lsp_stub_smoke.robot (PID test)

The Robot test that verifies the process runs with a valid PID only checks that the process started and exited — it doesn't validate PID plausibility (e.g., > 0). This is a very minor gap; the BDD suite covers PID logging more thoroughly.


TEST-2 (Info) — No test for readline() memory exhaustion vector

There is no BDD scenario that sends a single header line exceeding a reasonable length (e.g., 1 MB of Content-Length: followed by garbage). This would exercise the readline() unbounded-read path noted in SEC-2. Low priority for a stub — worth adding when hardening for production.


8 findings: 2 Low bugs, 1 Low spec, 2 Low security, 1 Info perf, 2 Low/Info test.

**PR #608 — feat(lsp): add LSP server stub (Issue #203)** Reviewed: `2fc25df5` (single squashed commit), 14 files, +2570/−6 lines. ### Summary Clean, well-structured LSP server stub. The JSON-RPC transport is correctly implemented with Content-Length framing, the lifecycle state machine (initialize → shutdown → exit) follows LSP 3.17, and the security hardening (MAX_CONTENT_LENGTH, MAX_HEADER_LINES, `_drain_headers`, `_discard_body`, RecursionError guard) is thorough. Test coverage is excellent: 48 BDD scenarios, 5 Robot smoke tests, 5 ASV benchmarks. All edge cases I probed manually (negative Content-Length, zero-length body, extra whitespace in headers, pre-init requests, post-shutdown requests, double init, exit without shutdown, batch arrays) behaved correctly. The findings below are minor-to-medium severity. None are blocking bugs in the current stub, but several become relevant as the server evolves toward production. --- ### BUG-1 (Low) — `structlog.is_configured` flag leaked after `serve()` returns **File:** `src/cleveragents/cli/commands/lsp.py:372-397` The save/restore pattern `previous_config = dict(structlog.get_config())` / `structlog.configure(**previous_config)` correctly restores all 5 config values (processors, wrapper_class, context_class, logger_factory, cache_logger_on_first_use). However, `structlog.configure()` unconditionally sets `_CONFIG.is_configured = True` (structlog `_config.py:244`), and `get_config()` does not capture this flag. If structlog was **not** previously configured (using defaults), any subsequent call to `structlog.configure_once()` will silently refuse to apply its configuration and emit a `RuntimeWarning` instead of succeeding. There are currently zero `configure_once()` calls in this codebase, so the impact is latent only. **Suggested fix:** Either (a) accept the limitation and add a code comment documenting it, or (b) call `structlog.reset_defaults()` followed by `structlog.configure(**previous_config)` in the finally block to fully restore state. --- ### BUG-2 (Low) — `_initialized` set on `initialize` request, not `initialized` notification **File:** `src/cleveragents/lsp/server.py:452-457` The code sets `self._initialized = True` upon receiving the `initialize` *request* rather than the subsequent `initialized` *notification*. Per LSP 3.17 §3.1, the server should not fully activate until the `initialized` notification arrives. The code comment at line 452-456 acknowledges this deviation explicitly. This is acceptable for a stub but should be tracked as a TODO for the production server (M7+). The existing comment is sufficient — noting here for completeness. --- ### SPEC-1 (Low) — `exists=False` removed from `add` command's `--config` option **File:** `src/cleveragents/cli/commands/lsp.py:116` (diff: removes line `exists=False`) The diff removes the explicit `exists=False` from the `typer.Option()` for `--config` in the `add` command. Since `exists=False` is the Typer default, this is a no-op change and does not alter behavior. However, removing it obscures the **original author's intent** — the explicit `exists=False` was a signal that the `add` command deliberately does *not* rely on Typer's path-existence validation (it does its own check at line 140). Consider restoring the explicit default for clarity, or at minimum, keep the manual `config.exists()` check at line 140 (which is already present). --- ### SEC-1 (Low) — `_read_message` trusts Content-Length for exact `read()` without timeout **File:** `src/cleveragents/lsp/server.py:249` `self._input.read(content_length)` blocks until exactly `content_length` bytes are available or EOF. A malicious or buggy client that sends a valid Content-Length header but then stalls partway through the body will block the server indefinitely. The `MAX_CONTENT_LENGTH` cap (10 MB) limits memory exposure but not the time dimension. For a stub this is acceptable (the server is single-threaded by design). When evolving to production, consider wrapping the read in `select()`/`asyncio` with a per-message timeout, or documenting that the caller (IDE) is trusted. --- ### SEC-2 (Low) — `readline()` in header parsing has no per-line size limit **File:** `src/cleveragents/lsp/server.py:206` `self._input.readline()` reads until `\n` with no maximum line length. A client sending a multi-gigabyte header line without a newline will cause unbounded memory allocation. The `MAX_HEADER_LINES` guard (line 210) only fires after a complete line has been read and counted. For a stub behind a trusted IDE this is fine. For hardened production use, pass a max-length argument to `readline()` (e.g., `readline(8192)`). --- ### PERF-1 (Info) — Eager import of `LspServer` at CLI module level **File:** `src/cleveragents/cli/commands/lsp.py:65` `from cleveragents.lsp.server import LspServer` is a top-level import. The `server` module transitively imports `AcpLocalFacade`, which pulls in the full ACP dependency chain (~200ms cold). This cost is paid by every `agents lsp` subcommand (`add`, `remove`, `list`, `show`), none of which need `LspServer`. A lazy import inside `serve()` would avoid this cost for non-serve commands. Low priority since the ACP chain is likely already warm in typical CLI usage. --- ### TEST-1 (Low) — Robot PID smoke test is a sham assertion **File:** `robot/lsp_stub_smoke.robot` (PID test) The Robot test that verifies the process runs with a valid PID only checks that the process started and exited — it doesn't validate PID plausibility (e.g., > 0). This is a very minor gap; the BDD suite covers PID logging more thoroughly. --- ### TEST-2 (Info) — No test for `readline()` memory exhaustion vector There is no BDD scenario that sends a single header line exceeding a reasonable length (e.g., 1 MB of `Content-Length: ` followed by garbage). This would exercise the `readline()` unbounded-read path noted in SEC-2. Low priority for a stub — worth adding when hardening for production. --- *8 findings: 2 Low bugs, 1 Low spec, 2 Low security, 1 Info perf, 2 Low/Info test.*
CoreRasurae force-pushed feature/m6-lsp-stub from 2fc25df586
All checks were successful
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 17s
CI / lint (pull_request) Successful in 19s
CI / security (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 34s
CI / unit_tests (pull_request) Successful in 2m24s
CI / docker (pull_request) Successful in 39s
CI / integration_tests (pull_request) Successful in 3m48s
CI / coverage (pull_request) Successful in 4m33s
CI / benchmark-regression (pull_request) Successful in 29m4s
to 74345bb5af
All checks were successful
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 34s
CI / typecheck (pull_request) Successful in 36s
CI / unit_tests (pull_request) Successful in 3m41s
CI / docker (pull_request) Successful in 40s
CI / integration_tests (pull_request) Successful in 4m47s
CI / coverage (pull_request) Successful in 4m24s
CI / benchmark-regression (pull_request) Successful in 29m59s
2026-03-06 15:22:57 +00:00
Compare
hamza.khyari approved these changes 2026-03-06 17:47:05 +00:00
Dismissed
CoreRasurae force-pushed feature/m6-lsp-stub from 74345bb5af
All checks were successful
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 34s
CI / typecheck (pull_request) Successful in 36s
CI / unit_tests (pull_request) Successful in 3m41s
CI / docker (pull_request) Successful in 40s
CI / integration_tests (pull_request) Successful in 4m47s
CI / coverage (pull_request) Successful in 4m24s
CI / benchmark-regression (pull_request) Successful in 29m59s
to 23803f14ec
All checks were successful
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / build (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 38s
CI / unit_tests (pull_request) Successful in 4m0s
CI / docker (pull_request) Successful in 38s
CI / integration_tests (pull_request) Successful in 4m48s
CI / coverage (pull_request) Successful in 4m31s
CI / lint (push) Successful in 12s
CI / quality (push) Successful in 16s
CI / build (push) Successful in 15s
CI / security (push) Successful in 32s
CI / typecheck (push) Successful in 35s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m27s
CI / integration_tests (push) Successful in 3m3s
CI / docker (push) Successful in 38s
CI / coverage (push) Successful in 4m25s
CI / benchmark-publish (push) Successful in 16m28s
CI / benchmark-regression (pull_request) Successful in 28m52s
2026-03-06 18:16:56 +00:00
Compare
CoreRasurae dismissed hamza.khyari's review 2026-03-06 18:16:56 +00:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

CoreRasurae scheduled this pull request to auto merge when all checks succeed 2026-03-06 18:17:07 +00:00
CoreRasurae deleted branch feature/m6-lsp-stub 2026-03-06 18:22:10 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
cleveragents/cleveragents-core!608
No description provided.