fix(lsp): align operations and harden lifecycle

This commit is contained in:
Tianyi Cui
2026-07-21 13:29:40 +08:00
parent 2a55f6b684
commit 2fd995bf3c
46 changed files with 680 additions and 366 deletions

View File

@@ -10,7 +10,7 @@ This package is the interface third of the LSP capability:
| `@deepseek-ai/dsh-lsp-local` | a generic local backend that registers configured stdio language-server providers |
| `@deepseek-ai/dsh-tool-lsp` | the model-facing `lsp` tool over `ctx.lsp` |
The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`.
The seam exposes exactly four semantic operations — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`.
## Service API (`ctx.lsp`)
@@ -25,7 +25,7 @@ Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner
## Vocabulary
`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `references` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceRoot }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceRoot` is the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to; a caller relativizing display paths uses it, not the (possibly symlinked) request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes.
`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `findReferences` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceRoot }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceRoot` is the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to; a caller relativizing display paths uses it, not the (possibly symlinked) request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes, including `LSP_DISPOSED` and `LSP_MALFORMED_RESPONSE`.
## Model Experience

View File

@@ -1,6 +1,7 @@
/**
* The LSP capability seam (`ctx.lsp`): a language-server provider registry and per-query,
* order-independent selection over normalized definition/references/implementation/hover queries.
* order-independent selection over normalized goToDefinition/findReferences/goToImplementation/
* hover queries.
*
* A provider reserves a branded id and an exclusive set of file extensions atomically:
* {@link Lsp.registerProvider} validates and conflict-checks everything before mutating, so an
@@ -42,8 +43,9 @@ declare module 'cordis' {
/**
* Structured LSP failure. Extends {@link HarnessError} with a stable `code`
* (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_UNSUPPORTED_OPERATION`, …) that
* callers route on instead of parsing `message`.
* (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`,
* `LSP_UNSUPPORTED_OPERATION`, `LSP_MALFORMED_RESPONSE`, …) that callers route on instead of
* parsing `message`.
*/
export class LspError extends HarnessError {}

View File

@@ -14,7 +14,7 @@ import type { LspProviderId } from './brand.ts'
* compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are
* deliberately deferred (they need different schemas).
*/
export type LspOperation = 'definition' | 'references' | 'implementation' | 'hover'
export type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover'
/** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */
export interface LspPosition {
@@ -73,9 +73,9 @@ export interface LspHover {
}
/**
* The closed result union. Navigation operations (`definition`, `references`, `implementation`)
* normalize to `locations`; `hover` normalizes to content or `null`. Consumers `switch` on `kind`
* to exhaustiveness so a new arm breaks compilation until handled.
* The closed result union. Navigation operations (`goToDefinition`, `findReferences`,
* `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`.
* Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled.
*
* The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the
* request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that
@@ -88,8 +88,9 @@ export type LspQueryResult =
/**
* A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link
* LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). `references`
* always includes declarations — the provider enforces this internally; callers get no flag.
* LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys).
* `findReferences` always includes declarations — the provider enforces this internally; callers
* get no flag.
*/
export interface LspProvider {
/** Stable provider identity, reserved atomically with the extension mappings. */

View File

@@ -39,7 +39,7 @@ async function mountLsp(): Promise<{ ctx: Context; lsp: Lsp }> {
const hover: LspQueryResult = { kind: 'hover', hover: { contents: 'x' } }
function query(filePath: string, operation: LspProviderQuery['operation'] = 'definition'): Parameters<Lsp['query']>[0] {
function query(filePath: string, operation: LspProviderQuery['operation'] = 'goToDefinition'): Parameters<Lsp['query']>[0] {
return { operation, filePath, position: { line: 0, character: 0 }, workspaceRoot: '/ws' }
}