Skip to content

Commit d527242

Browse files
authored
chore: add Copilot custom instructions for code review (#105)
Repository-wide instructions (.github/copilot-instructions.md) cover architecture overview, lock scope rules, glob anchoring, channel semantics, and deny.toml license gaps. Path-specific instruction files cover: - bridge/lsp: TOCTOU, version overflow, notification_pump deadlock, file watcher - config/deps: license allow list, serde round-trip tests, TOML defaults - mcp: 1-based positions, JsonSchema derive, tool dispatch completeness - tests: #[ignore] hygiene, resync coverage, glob pattern correctness - ci: feature flag parity, RUSTDOCFLAGS, MSRV guards - CHANGELOG: breaking change format, PR references Instructions are derived from findings in PR #101 and #103 reviews.
1 parent 522bad5 commit d527242

7 files changed

Lines changed: 341 additions & 0 deletions

.github/copilot-instructions.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Copilot Instructions for mcpls
2+
3+
mcpls is a bridge between MCP (Model Context Protocol) and LSP (Language Server Protocol).
4+
AI clients speak MCP to mcpls; mcpls spawns and communicates with language servers over LSP.
5+
6+
```
7+
AI Client ←→ [MCP/stdio] ←→ mcpls ←→ [LSP/stdio] ←→ language server
8+
```
9+
10+
Key crates: `mcpls-core/src/bridge/`, `lsp/`, `mcp/`, `config/`.
11+
12+
## Type safety
13+
14+
Prefer newtypes over primitive aliases for domain values. A raw `u32` for a line number
15+
and a raw `u32` for a column are indistinguishable to the compiler; a `Line(u32)` and
16+
`Column(u32)` make transpositions a compile error.
17+
18+
Encode protocol state in the type system where possible. A document that has been opened
19+
and one that has not should ideally be different types, not the same type with a boolean
20+
field. Typestate prevents calling operations that are only valid on open documents.
21+
22+
Avoid stringly-typed dispatch. Matching on `&str` method names to branch protocol
23+
behaviour should be replaced with a typed enum as soon as the set of methods is known
24+
and bounded.
25+
26+
`Option<T>` and `Result<T, E>` must not be unwrapped with `.unwrap()` or `.expect()` in
27+
production code paths. Use `?`, `if let`, `let-else`, or explicit error mapping.
28+
29+
## Idiomatic Rust
30+
31+
Prefer iterator adapters over manual loops. `map`, `filter`, `flat_map`, `collect`, and
32+
`fold` express intent more clearly and are easier to compose than `for` with a mutable
33+
accumulator.
34+
35+
Avoid unnecessary heap allocation. Prefer `&str` over `String` in function signatures
36+
where ownership is not required. Prefer `Cow<str>` when a function sometimes borrows and
37+
sometimes owns.
38+
39+
Use `let-else` (stabilised in Rust 1.65) to reduce nesting when early return on `None`
40+
or `Err` is needed:
41+
```rust
42+
// prefer
43+
let Some(value) = option else { return Err(...) };
44+
// over
45+
let value = match option { Some(v) => v, None => return Err(...) };
46+
```
47+
48+
Derive `Clone`, `Debug`, `PartialEq` on data types unless there is a specific reason not
49+
to. Their absence makes testing harder and the omission is rarely intentional.
50+
51+
## Architecture
52+
53+
A component that bridges two protocols (MCP and LSP) must not let either protocol's
54+
failure mode affect the other. LSP request timeouts must not stall MCP response
55+
delivery, and LSP push notifications must not block MCP request handling.
56+
57+
Shared mutable state must be scoped as narrowly as possible. A `Mutex` that guards a
58+
large composite struct is a concurrency bottleneck; prefer separate locks for
59+
independent sub-components (e.g. a cache and a client are independent).
60+
61+
A `Mutex` guard must never be held across an `.await` point that involves I/O. Doing so
62+
holds the lock for the full duration of the network round-trip, blocking every other
63+
task that needs the lock. Extract the guarded value before the `.await` or restructure
64+
so the lock is released first.
65+
66+
## Async
67+
68+
Prefer `tokio::join!` or `tokio::try_join!` over sequential `.await` chains when
69+
operations are independent. Sequential awaits serialise work that could run concurrently.
70+
71+
Blocking operations (`std::fs`, `std::thread::sleep`, CPU-heavy loops) must not run on
72+
the async executor. Use `tokio::fs`, `tokio::time::sleep`, or `tokio::task::spawn_blocking`
73+
for work that would block a task for more than a few microseconds.
74+
75+
Use `tokio::sync::mpsc` for async-to-async channels. Use `std::sync::mpsc::sync_channel`
76+
only for bridging a synchronous context to async, and always with `try_send` when the
77+
intent is to drop events under backpressure — `send` blocks on a full channel.
78+
79+
Prefer `tokio::select!` with a cancellation token over bare `loop { recv().await }`
80+
for background tasks that must shut down cleanly.
81+
82+
## API currency and MSRV
83+
84+
When reviewing code, check whether newer stable Rust APIs would simplify it. If a
85+
simpler or safer API was stabilised after the current `rust-version` in `Cargo.toml`,
86+
suggest bumping MSRV and using the new API. Document the bump in CHANGELOG under
87+
`### Changed`.
88+
89+
Examples of APIs worth adopting when MSRV permits:
90+
- `let-else` expressions (1.65) — replace verbose `match`/`unwrap_or_else` for early return
91+
- `std::io::read_to_string` on a `File` handle (1.0, but pairing with `.metadata()` on the same
92+
handle closes TOCTOU windows — prefer over separate `stat` + `open`)
93+
- `OnceLock` (1.70) — prefer over `lazy_static` or `once_cell` for static initialisation
94+
- `is_some_and` / `is_none_or` (1.70) — replace `map(|v| ...).unwrap_or(false)` patterns
95+
- `Iterator::array_chunks` (nightly → stable tracking) — watch stabilisation status
96+
97+
When a dependency provides a feature already in `std`, prefer `std`. Fewer dependencies
98+
reduce supply-chain risk and compile time.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
applyTo: "CHANGELOG.md"
3+
---
4+
5+
## Changelog format
6+
7+
Breaking changes to public APIs must appear under `### Changed` with an explicit
8+
"Breaking change:" prefix. A `### Fixed` entry alone is not sufficient — downstream
9+
users scanning the changelog for breakage will miss it.
10+
11+
Additions of `#[non_exhaustive]` to existing public enums or structs are breaking
12+
changes for crates that match exhaustively. Document the required migration (add a
13+
wildcard arm, update struct initialisation) in the entry.
14+
15+
Every entry must reference the PR or issue number: `(#NNN)`.
16+
17+
New entries go under `## [Unreleased]`. Do not add entries directly under a versioned
18+
section — version assignment happens in the release PR.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
applyTo: ".github/workflows/**,deny.toml,.cargo/**"
3+
---
4+
5+
## Feature flag parity
6+
7+
Feature flag sets in CI jobs must match the pre-commit commands in `CLAUDE.md` exactly.
8+
Divergence causes "passes locally, fails CI" failures where neither side is obviously
9+
wrong.
10+
11+
The rustdoc job must set `RUSTDOCFLAGS="-D warnings"`. Without it, broken intra-doc
12+
links and missing `///` on public items pass silently in CI.
13+
14+
Nightly toolchain is required only for `cargo fmt`. All other jobs must use stable to
15+
avoid failures caused by nightly regressions unrelated to this project.
16+
17+
## Dependency and license policy
18+
19+
New dependencies require a license check in the same PR. `cargo deny check licenses`
20+
fails fast and blocks the pipeline — do not merge before it passes.
21+
22+
`cargo deny check advisories` must be clean. Suppress an advisory only with an explicit
23+
`ignore` entry and a comment explaining why the vulnerable code path is unreachable.
24+
25+
When a newer stable `std` API covers functionality provided by a dependency, suggest
26+
removing the dependency and bumping `rust-version` instead. Fewer dependencies reduce
27+
compile time and supply-chain risk. Document the MSRV bump in CHANGELOG.
28+
29+
## MSRV tracking
30+
31+
`rust-version` in `Cargo.toml` is the enforced minimum. The CI matrix must include a
32+
job that builds and tests on exactly that version to catch accidental use of newer APIs.
33+
Without such a job, MSRV guarantees are not verifiable.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
applyTo: "crates/mcpls-core/src/mcp/**"
3+
---
4+
5+
## Type safety
6+
7+
Tool parameter structs must use domain types, not raw primitives, for values with
8+
protocol-specific conventions. Position fields (line, column) must be documented as
9+
1-based in both the `///` doc comment and the `#[schemars(description)]` — the MCP↔LSP
10+
coordinate conversion happens in the bridge layer, not here.
11+
12+
Path parameters must accept absolute paths. State this in the schema description; AI
13+
clients use it to form requests correctly.
14+
15+
New tool structs require `#[derive(JsonSchema)]`. Omitting it silently removes the tool
16+
from the schema exposed to clients — they cannot discover or invoke it.
17+
18+
## Error handling
19+
20+
Tool handlers must return structured MCP error responses on failure, never panic.
21+
`unwrap()` and `expect()` in handler code kill the entire server process and drop all
22+
active client sessions. Use `?` with proper error mapping instead.
23+
24+
All error variants must carry enough context for the caller to understand what went
25+
wrong. A bare `"internal error"` string is not actionable. Include the file path,
26+
method name, or other relevant detail.
27+
28+
## Idiomatic dispatch
29+
30+
When adding a new tool, register it in both the capability advertisement (tool list)
31+
and the dispatch match arm. A tool missing from the list is undiscoverable; a tool
32+
missing from dispatch panics at runtime. A compile-time check (e.g. a const assertion
33+
or a test that calls every listed tool name through dispatch) is preferable to relying
34+
on manual review.
35+
36+
Prefer exhaustive match arms over wildcard catch-alls in dispatch. A wildcard silently
37+
swallows unhandled tool names; exhaustive matching makes unregistered names a compile
38+
error.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
applyTo: "crates/mcpls-core/src/bridge/**,crates/mcpls-core/src/lsp/**,crates/mcpls-core/src/lib.rs"
3+
---
4+
5+
## Type safety
6+
7+
Position types (line, column) must be distinct newtypes, not bare `u32`. The MCP
8+
convention (1-based) and the LSP convention (0-based) must be expressed in the type
9+
system so that passing an MCP position directly to an LSP call is a compile error.
10+
11+
Protocol message variants must be an enum, not a string-matched dispatch. Matching on
12+
`"textDocument/hover"` strings is fragile; a closed enum of known methods makes
13+
unhandled cases visible at compile time and exhaustiveness-checked.
14+
15+
`InboundMessage` and similar enums that may gain variants in future protocol versions
16+
must be `#[non_exhaustive]` so downstream crates are not broken by additions.
17+
18+
## Document state
19+
20+
Caching document content alongside a filesystem signature (`mtime` + size) prevents
21+
stale reads after external edits. On every access, stat the file and compare against
22+
the cached signature; re-read and re-notify the LSP server on mismatch.
23+
24+
Stat and read must use the same file handle to close the TOCTOU window. Open the file
25+
once with `tokio::fs::File::open`, call `.metadata().await` on the handle, then read
26+
through the same handle. A separate `tokio::fs::metadata` call before `read_to_string`
27+
leaves a window where an atomic `rename` can change the file between the two calls.
28+
29+
Document version numbers must be strictly monotone per URI. On overflow, reset to 1
30+
rather than saturating — after `didClose`/`didOpen` the server treats the document as
31+
fresh and the version value is irrelevant to continuity.
32+
33+
## Diagnostics pipeline
34+
35+
When a feature draws from multiple independent sources (pull request + push cache),
36+
fetch them concurrently with `tokio::try_join!` or `tokio::join!`. Awaiting them
37+
sequentially serialises work that has no data dependency.
38+
39+
A cache miss must be distinguishable from an empty result. Returning `Vec::new()` for
40+
both "no errors" and "not yet analysed" is ambiguous to callers. Use an `Option<Vec>`
41+
or a dedicated status type to express the difference.
42+
43+
Deduplication keys should prefer structured fields (error code) over free-form message
44+
text. When a code is present, key on `(range, severity, code)`. The doc comment must
45+
describe the actual key — a mismatch between the comment and the implementation causes
46+
silent behavioural bugs.
47+
48+
## Concurrency and lock scope
49+
50+
Background notification pumps must hold only the narrowest lock needed (e.g. a
51+
`Mutex<Cache>`, not a `Mutex<WholeTranslator>`). A pump that shares a lock with request
52+
handlers will stall when a handler holds the lock across a long-running await, causing
53+
the notification channel to fill and the in-flight response to be undeliverable.
54+
55+
Never hold a `MutexGuard` across an `.await`. Extract needed values before the await,
56+
or restructure so the guard is dropped before any I/O begins.
57+
58+
## File watcher
59+
60+
Glob patterns from external registrations must be matched against full absolute paths.
61+
`globset` does not implicitly anchor bare patterns to a directory prefix — a pattern
62+
like `*.rs` only matches a bare filename. Prepend `**/` to patterns that lack a leading
63+
`/` or `**/` before compiling them into a `GlobSet`.
64+
65+
Noise filtering (build artifacts, VCS directories) must run before events enter any
66+
bounded channel, not after. Filtering post-send wastes channel capacity and can stall
67+
the OS watcher thread when the channel fills during high-churn builds.
68+
69+
Use `try_send` for the handoff from the OS watcher callback to the async processing
70+
loop. The watcher callback runs on a system thread; blocking it with `send` on a full
71+
channel stalls the OS-level event delivery.
72+
73+
## LSP client
74+
75+
Server-to-client requests (messages with both `method` and `id`) require a JSON-RPC
76+
response. Unhandled methods must return error code `-32601` (Method Not Found). Dropping
77+
them silently causes the LSP server to wait indefinitely for an acknowledgement.
78+
79+
`id`-only messages (no `method`, `result`, or `error`) are protocol errors and must not
80+
be coerced into a successful `null` response.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
applyTo: "crates/mcpls-core/src/config/**,deny.toml,Cargo.toml,crates/*/Cargo.toml"
3+
---
4+
5+
## Type safety and idiomatic config
6+
7+
Config option enums must derive `Default` with `#[default]` on the intended default
8+
variant rather than implementing `Default` manually. This keeps the default co-located
9+
with the type definition and is less error-prone.
10+
11+
Boolean config fields that represent a choice between more than two states should be
12+
`enum`, not `bool`. A `bool` cannot be extended without a breaking change; an enum can
13+
gain variants under `#[non_exhaustive]`.
14+
15+
New `#[serde(default)]` fields must have a test that deserialises a config with the
16+
field omitted and asserts the default value. Cover both JSON and TOML — parsers can
17+
behave differently on edge cases like empty strings and absent vs. null keys.
18+
19+
Enum config options must have a round-trip test for every variant. A typo in
20+
`#[serde(rename_all = "...")]` silently changes the on-disk format and breaks existing
21+
user configs without a compile error.
22+
23+
## MSRV and dependencies
24+
25+
When introducing a new dependency, check whether the functionality is already available
26+
in `std` for the current MSRV. Prefer `std` over external crates to reduce
27+
compile time and supply-chain surface.
28+
29+
New crates require a license check in the same PR. Add the license to `deny.toml`
30+
before merging — `cargo deny check licenses` fails fast and blocks the pipeline.
31+
32+
If a new stable Rust API (e.g. `OnceLock` at 1.70 replacing `once_cell::sync::OnceCell`)
33+
makes an existing dependency redundant, suggest removing the dependency and bumping
34+
`rust-version` instead. Document the bump in CHANGELOG.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
applyTo: "crates/mcpls-core/tests/**,crates/mcpls-core/src/**/tests.rs,crates/mcpls-core/src/**/*_tests.rs"
3+
---
4+
5+
## Test design
6+
7+
Tests for async code must use `#[tokio::test]` and exercise the actual async paths, not
8+
synchronous wrappers that bypass scheduler interactions. Concurrency bugs (lock
9+
contention, channel saturation) are only reachable through genuine async execution.
10+
11+
Tests that require an external binary must be marked `#[ignore = "Requires <binary>
12+
in PATH"]`. CI runners do not have language server binaries; an unmarked test will fail
13+
on every clean run.
14+
15+
## Correctness invariants to cover
16+
17+
Any code path that pairs a filesystem operation with a subsequent network notification
18+
(open, close, re-open after external edit) must have a test that exercises the full
19+
sequence using a mock network client and a real temporary file — not just the filesystem
20+
primitive in isolation.
21+
22+
Glob matching tests must use absolute path strings as input. Passing a bare filename
23+
to a glob set will pass even when the production code fails on absolute paths, because
24+
`globset` anchoring behaviour differs between the two cases.
25+
26+
Round-trip tests for serialisable types must cover every variant, and must assert the
27+
serialised string value, not just that deserialisation succeeds. A `rename_all` typo
28+
produces a wrong on-disk format that round-trips correctly in isolation.
29+
30+
## Idiomatic test code
31+
32+
Prefer `assert_eq!` over `assert!(a == b)``assert_eq!` prints both values on
33+
failure, making diagnosis faster.
34+
35+
Use `tempfile::tempdir()` for tests that need real filesystem paths. Hard-coded `/tmp`
36+
paths cause test interference when multiple test threads run in parallel.
37+
38+
Prefer `#[tokio::test]` over `tokio::runtime::Runtime::block_on` in test bodies —
39+
`block_on` does not integrate with tokio's test utilities (`time::pause`,
40+
`time::advance`) needed for testing debounce and timeout logic.

0 commit comments

Comments
 (0)