feat(lsp): LSP capability seam, generic stdio provider, and lsp tool
Implements the LSP capability seam RFC as three packages: dsh-lsp (the ctx.lsp interface — provider registry by branded id + exclusive extension mapping, per-query order-independent selection, closed request/result vocabulary, LspError taxonomy), dsh-lsp-local (a generic stdio language-server provider — Content-Length JSON-RPC framing, per-(provider, workspace) process single-flight, transient didOpen/query/didClose, an abortable per-instance queue, UTF-16 negotiation, host-namespace source reads outside ctx.fs, and bounded shutdown/kill teardown), and dsh-tool-lsp (the model-facing lsp tool — four operations, one-based UTF-16 cursor conversion, workspace-grouped location rendering, hover capping, a required session workspace, and a timeout budget). Why: an agent had text search and file reads but no way to identify a program symbol — follow an alias, connect an interface to implementations, or read an inferred type — before changing code. Splitting model contract, seam, and local subprocess behavior keeps the four semantic queries stable across future remote or sandbox-native providers without leaking a JSON-RPC escape hatch.
This commit is contained in:
@@ -15,6 +15,7 @@ packages/ Harness packages at packages/<group>/<pkg>/, all named @deepseek-ai
|
||||
llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin)
|
||||
bash/ bash executor seam + local impl + model-facing bash tools
|
||||
fs/ filesystem seam + local impl + policy gate + read/write/edit tools
|
||||
lsp/ LSP seam + stdio provider + lsp tool
|
||||
skill/ skill provider registry + local impl + catalog/loader tool
|
||||
web/ web seam + search/fetch providers + model-facing web tools
|
||||
compact/ compaction seam + basic backend
|
||||
@@ -90,13 +91,13 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
|
||||
## Conventions
|
||||
|
||||
- Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.
|
||||
- ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers only.
|
||||
- ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers.
|
||||
- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
|
||||
- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns.
|
||||
- **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default.
|
||||
- **Waterfall listeners MUST call `next()`** to delegate; returning without it is the veto ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)).
|
||||
- **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.
|
||||
- **Plugins, not loop changes**: new behavior goes on the documented extension seams; changing `agent-loop` requires updating docs/architecture.md.
|
||||
- **Model-visible ⟺ logged**: anything reaching a model request must be reconstructable from the session log; a new model-visible input requires a session event.
|
||||
- **Plugins, not loop changes**: new behavior goes on documented extension seams; changing `agent-loop` requires updating docs/architecture.md.
|
||||
- **Capability seams are three packages** — interface / implementation / consumer; don't split preemptively.
|
||||
- **Explicit > implicit at package seams**: defaulting is an explicit `resolve(request): Spec` step in the owning implementation, never a hidden `?? default` inside `run()` (the `dsh-bash` request/spec split is the template).
|
||||
- **No hardcoded tunables in plugins**: deployment choices are defaulted, validated `Config` fields changeable from cordis.yml; a `DEFAULT_*` constant or test seam is not configurability. Protocol constants, external specs, and security invariants stay fixed.
|
||||
@@ -119,15 +120,15 @@ Read [docs/defensive-patterns.md](docs/defensive-patterns.md) before lifecycle,
|
||||
|
||||
## Type safety and documentation
|
||||
|
||||
Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` explains why a narrower type is infeasible. Every module and export has concise JSDoc for its non-obvious contract; function-like exports include `@param`/`@returns`, as enforced by `verify-export-jsdoc`. Heritage-declared members, plugin-protocol slots, and constructors keep their docs at the declaring seam, protocol, or class.
|
||||
Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` explains why a narrower type is infeasible. Every module and export has concise JSDoc for its non-obvious contract; function-like exports include `@param`/`@returns`, enforced by `verify-export-jsdoc`. Heritage-declared members, plugin-protocol slots, and constructors keep docs at the declaring seam, protocol, or class.
|
||||
|
||||
Comments and docs preserve complete contracts and non-obvious orientation, not reasoning transcripts. Do not narrate control flow or tests, preserve review history, or restate code. Keep factual clauses affecting behavior, failure, timing, ownership, or safe use; link aggressively to owning rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Encode enforceable invariants in checks, using narrow justified exceptions rather than disabling a rule globally.
|
||||
Comments and docs preserve complete contracts and non-obvious orientation, not reasoning transcripts. Do not narrate control flow or tests, preserve review history, or restate code. Keep factual clauses affecting behavior, failure, timing, ownership, or safe use; link aggressively to owning rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Encode enforceable invariants in checks, with narrow justified exceptions rather than disabling a rule.
|
||||
|
||||
Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document the current state never the history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md).
|
||||
Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document current state not history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md).
|
||||
|
||||
## Editing these instructions
|
||||
|
||||
`CLAUDE.md` symlinks `AGENTS.md` at root, `packages/`, and `examples/`; edit the real file. Keep each rule self-contained while linking high-level docs. Condense when clarity survives; raise a `verify-doc-budgets` ceiling when the contract genuinely needs more space.
|
||||
`CLAUDE.md` symlinks `AGENTS.md` at root, `packages/`, and `examples/`; edit the real file. Keep each rule self-contained while linking high-level docs. Condense when clarity survives; raise a `verify-doc-budgets` ceiling only when the contract needs more space.
|
||||
|
||||
## Vendoring policy
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service
|
||||
| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) |
|
||||
| `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution |
|
||||
| `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events |
|
||||
| `ctx.lsp` | [`lsp/`](../packages/lsp/README.md) | language-server provider registry and semantic navigation |
|
||||
| `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry and progressive disclosure |
|
||||
| `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries |
|
||||
| `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-log compaction |
|
||||
|
||||
@@ -419,6 +419,42 @@ export interface Config {
|
||||
|
||||
Source: [`packages/support/llm-replay/src/index.ts:306`](../packages/support/llm-replay/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-lsp-local`
|
||||
|
||||
Requires: `lsp`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin configuration: one server command plus its extension mapping and host bounds. */
|
||||
export interface Config {
|
||||
/** Stable provider id, reserved on `ctx.lsp` with the extensions. */
|
||||
providerId: string
|
||||
/** Executable to spawn (absolute, or resolved on PATH at load). */
|
||||
command: string
|
||||
/** Arguments passed to the executable (no shell). */
|
||||
args: string[]
|
||||
/** Extra env vars merged on top of the scrubbed ambient env. */
|
||||
env: Record<string, string>
|
||||
/** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */
|
||||
extensionToLanguage: Record<string, string>
|
||||
/** Static `initialize` options forwarded to the server. */
|
||||
initializationOptions: unknown
|
||||
/** Static answer to every `workspace/configuration` item. */
|
||||
configuration: unknown
|
||||
/** Largest single framed message accepted from the server (bytes). */
|
||||
maxMessageBytes: number
|
||||
/** Largest stderr tail retained for diagnostics (bytes). */
|
||||
maxStderrBytes: number
|
||||
/** Largest source file this host will open (bytes). */
|
||||
maxDocumentBytes: number
|
||||
/** Graceful `shutdown`/`exit` budget before escalation (ms). */
|
||||
shutdownTimeoutMs: number
|
||||
/** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */
|
||||
killGraceMs: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/lsp/lsp-local/src/index.ts:59`](../packages/lsp/lsp-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-mcp-client`
|
||||
|
||||
Requires: `tools`
|
||||
@@ -901,6 +937,24 @@ export interface Config {
|
||||
|
||||
Source: [`packages/fs/tool-fs/src/index.ts:22`](../packages/fs/tool-fs/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-lsp`
|
||||
|
||||
Requires: `tools` · `lsp` · `systemPrompt`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin configuration: result caps and the timeout budget. */
|
||||
export interface Config {
|
||||
/** Largest number of rendered locations before an omission marker (default 100). */
|
||||
maxLocations?: number
|
||||
/** Largest hover length in characters after normalization (default 16000). */
|
||||
maxHoverChars?: number
|
||||
/** Tool-call timeout budget in ms (default 60000). */
|
||||
timeoutMs?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/lsp/tool-lsp/src/index.ts:56`](../packages/lsp/tool-lsp/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-skill`
|
||||
|
||||
Requires: `tools` · `skills`
|
||||
@@ -1214,6 +1268,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
|
||||
- `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts))
|
||||
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
|
||||
- `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
|
||||
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
|
||||
- `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts))
|
||||
|
||||
@@ -115,6 +115,11 @@ flowchart TD
|
||||
subgraph group_guard["packages/guard"]
|
||||
pkg_repeat_tool_guard["repeat-tool-guard"]
|
||||
end
|
||||
subgraph group_lsp["packages/lsp"]
|
||||
pkg_lsp["lsp"]
|
||||
pkg_lsp_local["lsp-local"]
|
||||
pkg_tool_lsp["tool-lsp"]
|
||||
end
|
||||
subgraph group_mcp["packages/mcp"]
|
||||
pkg_mcp_client["mcp-client"]
|
||||
end
|
||||
@@ -139,6 +144,8 @@ flowchart TD
|
||||
pkg_fs --> pkg_brand
|
||||
pkg_fs --> pkg_llm
|
||||
pkg_web --> pkg_llm
|
||||
pkg_lsp --> pkg_brand
|
||||
pkg_lsp --> pkg_llm
|
||||
pkg_sandbox --> pkg_llm
|
||||
pkg_agent --> pkg_brand
|
||||
pkg_agent --> pkg_llm
|
||||
@@ -162,6 +169,10 @@ flowchart TD
|
||||
pkg_session_persistence --> pkg_session
|
||||
pkg_llm_replay --> pkg_llm
|
||||
pkg_llm_replay --> pkg_session
|
||||
pkg_lsp_local --> pkg_brand
|
||||
pkg_lsp_local --> pkg_llm
|
||||
pkg_lsp_local --> pkg_lsp
|
||||
pkg_lsp_local --> pkg_timeout
|
||||
pkg_sandbox_local --> pkg_llm
|
||||
pkg_sandbox_local --> pkg_sandbox
|
||||
pkg_bash_local --> pkg_bash
|
||||
@@ -273,6 +284,10 @@ flowchart TD
|
||||
pkg_tool_ask_user --> pkg_user_interaction
|
||||
pkg_repeat_tool_guard --> pkg_agent
|
||||
pkg_repeat_tool_guard --> pkg_tools
|
||||
pkg_tool_lsp --> pkg_llm
|
||||
pkg_tool_lsp --> pkg_lsp
|
||||
pkg_tool_lsp --> pkg_system_prompt
|
||||
pkg_tool_lsp --> pkg_tools
|
||||
pkg_mcp_client --> pkg_llm
|
||||
pkg_mcp_client --> pkg_tools
|
||||
pkg_tool_workflow --> pkg_agent
|
||||
@@ -370,6 +385,7 @@ flowchart TD
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
|
||||
| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) |
|
||||
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
|
||||
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) |
|
||||
| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
|
||||
@@ -383,6 +399,7 @@ flowchart TD
|
||||
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`web`](../packages/web/web) |
|
||||
| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) |
|
||||
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) |
|
||||
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
|
||||
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) |
|
||||
| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
@@ -412,6 +429,7 @@ flowchart TD
|
||||
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) |
|
||||
| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) |
|
||||
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) |
|
||||
|
||||
+1
-1
@@ -28,7 +28,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
|---|---|
|
||||
| [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 |
|
||||
| [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 |
|
||||
| [LSP capability seam and model-facing query tool](proposed/architecture/2026-07-15-lsp-capability-seam.md) | 2026-07-15 |
|
||||
|
||||
### Process
|
||||
|
||||
@@ -146,6 +145,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 |
|
||||
| [Single-file executable SDK runtime distribution (single-exe)](implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) | 2026-07-10 |
|
||||
| [Agent-scope runtime design and correctness](implemented/architecture/2026-07-12-agent-scope-runtime-design.md) | 2026-07-12 |
|
||||
| [LSP capability seam and model-facing query tool](implemented/architecture/2026-07-15-lsp-capability-seam.md) | 2026-07-15 |
|
||||
|
||||
### Process
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-15-lsp-capability-seam.md: 89e58c3ae0ba9f49ed0164a76a230b141296eee5
|
||||
2026-07-15-lsp-capability-seam.zh.md: c1c2448b1e980fc17a347f6c434e8553639304f3
|
||||
2026-07-15-lsp-capability-seam.md: 90cc7fce8ce86582cc27bd70fadcc46309438983
|
||||
2026-07-15-lsp-capability-seam.zh.md: 12873d33684255b7177a78dd4588e11aa61eb26b
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
# RFC: LSP capability seam and model-facing query tool
|
||||
|
||||
Status: proposed
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-15-lsp-capability-seam.zh.md)
|
||||
|
||||
@@ -12,7 +12,7 @@ LSP support has three owners: the model needs a stable query schema, the harness
|
||||
|
||||
Many language servers behave best when the queried document is opened with current text. A compatible agent client must bound that state, define whether its source read is a model observation, and keep the document snapshot in the same filesystem namespace as the server's workspace index.
|
||||
|
||||
## Proposal
|
||||
## Decision
|
||||
|
||||
Add LSP as a three-package capability seam with one read-only model tool and one generic local provider implementation:
|
||||
|
||||
@@ -171,7 +171,7 @@ The local provider trusts its configured server and claims no sandbox confinemen
|
||||
|
||||
**Ship presets or PATH discovery.** A catalog would make the generic host own language policy, while discovery cannot infer arguments, language ids, or initialization. Deployments configure providers explicitly; composition plugins may package presets.
|
||||
|
||||
## Acceptance criteria
|
||||
## Testing
|
||||
|
||||
- Package tests pin the three-package dependency direction, runtime injections, and `ctx.lsp`-only communication.
|
||||
- Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and ACP presentation.
|
||||
@@ -185,7 +185,7 @@ The local provider trusts its configured server and claims no sandbox confinemen
|
||||
- Snapshots cover model-visible schema, prompt, results, omissions, and ACP rendering; a built-artifact smoke test covers framing and cleanup.
|
||||
- Package and architecture docs cover configuration, security boundaries, and search/read guidance; the new `packages/lsp/` group is added to the AGENTS.md repository-layout block, the packages/README.md group table, and architecture.md in the same change.
|
||||
|
||||
## Risks
|
||||
## Consequences
|
||||
|
||||
Language servers vary in method support, capability interpretation, and indexing readiness; LSP has no universal “index complete” signal. Servers without compatible transient-open synchronization are unsupported even if closed-document queries work. Supported servers may still return empty or partial results, so the tool promises no cross-server completeness. The pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim.
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
# RFC: LSP 能力服务边界与面向模型的查询工具
|
||||
|
||||
Status: proposed
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-15-lsp-capability-seam.md) | 中文
|
||||
|
||||
@@ -12,7 +12,7 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程
|
||||
|
||||
许多语言服务器只有在查询文档已按当前文本打开时才能稳定工作。兼容的 agent 客户端必须限制这项状态、定义内部读取是否算作模型观察,并确保文档快照与服务器工作区索引位于同一文件系统命名空间。
|
||||
|
||||
## 提案
|
||||
## 决策
|
||||
|
||||
将 LSP 建成由三个 package 组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现:
|
||||
|
||||
@@ -171,7 +171,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p
|
||||
|
||||
**内置 preset 或 PATH 发现。** 目录会让通用 host 承担语言策略,而发现机制无法推断参数、语言 id 或初始化配置。部署显式配置提供方,组合插件可以封装 preset。
|
||||
|
||||
## 验收标准
|
||||
## 测试
|
||||
|
||||
- Package 测试固定三个 package 的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。
|
||||
- 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。
|
||||
@@ -185,7 +185,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p
|
||||
- 快照覆盖模型可见 schema、提示词、结果、省略提示和 ACP 渲染;构建产物冒烟测试覆盖分帧与清理。
|
||||
- Package 与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` package 组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。
|
||||
|
||||
## 风险
|
||||
## 影响
|
||||
|
||||
各语言服务器对方法支持、能力解释和索引就绪时机的处理不同;LSP 没有统一的“索引完成”信号。无法声明兼容临时打开同步能力的服务器不受支持,即使它能查询已关闭文档。受支持的服务器仍可能返回空结果或不完整结果,因此工具不承诺跨服务器完整性。固定的 TypeScript e2e 只建立一条兼容性基线,不代表跨语言承诺。
|
||||
|
||||
@@ -20,6 +20,7 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
| `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. |
|
||||
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. |
|
||||
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
|
||||
| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. |
|
||||
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
|
||||
| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. |
|
||||
@@ -371,6 +372,52 @@ Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts
|
||||
|
||||
The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.
|
||||
|
||||
## `@deepseek-ai/dsh-tool-lsp`
|
||||
|
||||
### `lsp`
|
||||
|
||||
Query a language server for precise code navigation. operation is one of definition, references, implementation, hover. line and character are one-based UTF-16 cursor coordinates. references includes the declaration.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"description": "definition, references, implementation, or hover.",
|
||||
"enum": [
|
||||
"definition",
|
||||
"references",
|
||||
"implementation",
|
||||
"hover"
|
||||
]
|
||||
},
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "The source file to query, relative to the workspace or absolute."
|
||||
},
|
||||
"line": {
|
||||
"type": "number",
|
||||
"description": "One-based line of the cursor."
|
||||
},
|
||||
"character": {
|
||||
"type": "number",
|
||||
"description": "One-based UTF-16 column of the cursor."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"operation",
|
||||
"file_path",
|
||||
"line",
|
||||
"character"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/lsp/tool-lsp/src/index.ts`](../packages/lsp/tool-lsp/src/index.ts)
|
||||
|
||||
The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema.
|
||||
|
||||
## `@deepseek-ai/dsh-tool-skill`
|
||||
|
||||
### `skill`
|
||||
|
||||
@@ -118,6 +118,11 @@
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"ignoreDependencies": ["@modelcontextprotocol/server-everything", "@modelcontextprotocol/server-filesystem"]
|
||||
},
|
||||
"packages/lsp/lsp-local": {
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"ignoreDependencies": ["typescript-language-server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -1,10 +1,10 @@
|
||||
# Packages
|
||||
|
||||
Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
|
||||
Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md), [root](../AGENTS.md#conventions).
|
||||
|
||||
## Hierarchy
|
||||
|
||||
Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group directory is a pure container (no `package.json`); the package name stays `@deepseek-ai/dsh-<pkg>` regardless of group. **Each group README is the canonical per-package map** — package roles, ctx keys, and the product-vs-support split live there, next to the code.
|
||||
Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group directory is a pure container (no `package.json`); the package name stays `@deepseek-ai/dsh-<pkg>` regardless of group. **Each group README is the canonical per-package map** — roles, ctx keys, and the product-vs-support split live there, next to the code.
|
||||
|
||||
| Group | Role | Release expectation |
|
||||
|---|---|---|
|
||||
@@ -14,6 +14,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
|
||||
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
|
||||
| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface |
|
||||
| [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
|
||||
| [`context/`](context/README.md) | Opt-in request-context enrichment | Product — stable surface |
|
||||
@@ -23,20 +24,19 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
|
||||
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
|
||||
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect live plugins/services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration: ACP bridge, JSON-RPC SDK server, app packages, user-approval/interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
|
||||
|
||||
The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and this table).
|
||||
The split is the point: a package's group says whether it is product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a top-level group is a deliberate act (extend the group READMEs and this table).
|
||||
|
||||
## Dependencies
|
||||
|
||||
The inter-package dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
|
||||
The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other spine plugins). The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
|
||||
The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
|
||||
Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts).
|
||||
Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or its [allowlist](../scripts/verify-package-readme-limitations.ts).
|
||||
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'run_code', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'lsp', 'read', 'run_code', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# lsp/ - LSP capability family
|
||||
|
||||
The language-server capability seam: an abstract LSP interface, a generic stdio provider, and the model-facing `lsp` tool. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `lsp/` | Abstract LSP seam (provider registry by branded id + extension mapping, per-query selection, vocabulary, `LspError`) | `ctx.lsp` |
|
||||
| `lsp-local/` | Generic stdio language-server provider (spawn, JSON-RPC, transient-open queries) | (registers on `ctx.lsp`) |
|
||||
| `tool-lsp/` | Model-facing `lsp` tool (four operations, one-based UTF-16 cursor coordinates) | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation.
|
||||
|
||||
See the [LSP capability seam RFC](../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md) for the design rationale, including why documents open transiently per query, why the local host reads through Node APIs rather than `ctx.fs`, and why extension ownership is exclusive within one runtime.
|
||||
@@ -0,0 +1,49 @@
|
||||
# @deepseek-ai/dsh-lsp-local
|
||||
|
||||
A **generic stdio language-server provider** for `ctx.lsp`. One plugin instance configures one server command and its extension-to-language-id map; load multiple instances for multiple servers. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in composition plugins or `cordis.yml` overlays.
|
||||
|
||||
Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
|
||||
|
||||
## What it does
|
||||
|
||||
- Lazily single-flights one server process per `(provider id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process.
|
||||
- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
|
||||
- Serializes queries through one abortable per-instance queue so a cancellation that fails to stop the server can terminate it without killing unrelated work; distinct instances run in parallel.
|
||||
- Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `providerId` | (required) | Stable provider id reserved on `ctx.lsp` with the extensions. |
|
||||
| `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. |
|
||||
| `args` | `[]` | Arguments passed to the executable. |
|
||||
| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded). |
|
||||
| `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). |
|
||||
| `initializationOptions` | `null` | Static `initialize` options forwarded to the server. |
|
||||
| `configuration` | `null` | Static answer to every `workspace/configuration` item. |
|
||||
| `maxMessageBytes` | `16000000` | Largest single framed message accepted from the server. |
|
||||
| `maxStderrBytes` | `1000000` | Largest stderr tail retained for diagnostics. |
|
||||
| `maxDocumentBytes` | `4000000` | Largest source file this host will open. |
|
||||
| `shutdownTimeoutMs` | `5000` | Graceful `shutdown`/`exit` budget before escalation. |
|
||||
| `killGraceMs` | `2000` | SIGTERM→SIGKILL grace after graceful shutdown fails. |
|
||||
|
||||
The executable is resolved at load (after credential scrubbing); a missing command fails before registration. The process itself launches lazily on the first matching query.
|
||||
|
||||
## Protocol behavior
|
||||
|
||||
Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. The server's returned capabilities are authoritative: an unsupported operation, or synchronization without transient open/close, fails the query. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. The client answers `workspace/configuration` from static config, accepts lifecycle bookkeeping requests, and rejects `workspace/applyEdit` — it never applies edits or runs commands. Navigation maps `Location` directly and `LocationLink` from `targetUri` + `targetSelectionRange`; hover normalization takes `MarkupContent.value`, preserves string `MarkedString`s, renders language-tagged values as fenced code, and joins arrays with one blank line.
|
||||
|
||||
## Security boundary
|
||||
|
||||
The provider trusts its configured server and claims no sandbox confinement. It canonicalizes and reads source through Node APIs, rejecting a source that is missing, non-regular, non-UTF-8, oversized, or whose canonical path resolves outside the canonical workspace (symlink aliases share one instance). Result locations may be external, but an external path cannot become a query source. The first implementation therefore requires trusted host-local deployment; restricted, remote, or virtual workspaces require another provider.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-lsp`, which surfaces this provider's normalized results; this host contributes no prompt or schema itself.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)).
|
||||
- **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim.
|
||||
- **Per-instance serialization latency** — parallel agents sharing a workspace queue behind one process; long-lived workspace processes consume memory until disposal.
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-lsp-local",
|
||||
"description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open definition/references/implementation/hover queries in the host filesystem namespace",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-lsp": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-lsp": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-language-server": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* A JSON-RPC endpoint over one spawned language server's stdio. Owns id correlation, outbound
|
||||
* requests/notifications, and inbound server→client requests: it answers `workspace/configuration`
|
||||
* from static config, and rejects `workspace/applyEdit` (this host never applies edits or runs
|
||||
* commands). It caps stderr, surfaces framing/decoder failures as a fatal close, and exposes the
|
||||
* child handle so the instance owns process-signal teardown.
|
||||
* @module @deepseek-ai/dsh-lsp-local/connection
|
||||
*/
|
||||
|
||||
import type { ChildProcessByStdio } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import { encodeMessage, MessageDecoder } from './framing.ts'
|
||||
|
||||
/** How to launch the server and answer its config requests. */
|
||||
export interface ConnectionSpec {
|
||||
/** The resolved absolute executable path (no shell). */
|
||||
readonly command: string
|
||||
/** Arguments passed to the executable. */
|
||||
readonly args: readonly string[]
|
||||
/** The child's working directory (the canonical workspace). */
|
||||
readonly cwd: string
|
||||
/** The child's environment (credential-scrubbed, with overrides applied). */
|
||||
readonly env: Record<string, string>
|
||||
/** Largest single framed message accepted from the server. */
|
||||
readonly maxMessageBytes: number
|
||||
/** Largest stderr tail retained for diagnostics. */
|
||||
readonly maxStderrBytes: number
|
||||
/** Static answer to every `workspace/configuration` item. */
|
||||
readonly configuration: unknown
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
/** A live JSON-RPC endpoint bound to one child process. */
|
||||
export class LspConnection {
|
||||
private readonly child: ChildProcessByStdio<Writable, Readable, Readable>
|
||||
private readonly decoder: MessageDecoder
|
||||
private readonly pending = new Map<number, Pending>()
|
||||
private nextId = 1
|
||||
private stderr = ''
|
||||
private closeReason: Error | undefined
|
||||
/** Set once the process has fully exited; the instance awaits it during teardown. */
|
||||
readonly closed: Promise<void>
|
||||
|
||||
/**
|
||||
* @param spec - how to launch the server and answer its config requests.
|
||||
* @param onServerRequest - answers a server→client request; rejects to send an error response.
|
||||
*/
|
||||
constructor(
|
||||
private readonly spec: ConnectionSpec,
|
||||
private readonly onServerRequest: (method: string, params: unknown) => Promise<unknown>,
|
||||
) {
|
||||
this.decoder = new MessageDecoder(spec.maxMessageBytes)
|
||||
this.child = spawn(spec.command, [...spec.args], {
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
this.closed = new Promise<void>((resolve) => {
|
||||
this.child.on('close', () => {
|
||||
const reason = this.closeReason ?? new Error('language server exited')
|
||||
// Record the reason so any request issued AFTER close rejects immediately instead of hanging
|
||||
// (a closed process sends no further responses).
|
||||
this.closeReason = reason
|
||||
this.failAll(reason)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
this.child.on('error', (error) => { this.fail(error) })
|
||||
// A write to the child's stdin after it exits emits an async 'error'; swallow it so an EPIPE
|
||||
// during teardown does not crash the process. Pending requests fail via the 'close' handler.
|
||||
/* v8 ignore next -- the handler only fires on an async stdin write error during teardown. */
|
||||
this.child.stdin.on('error', () => { /* swallow */ })
|
||||
this.child.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) })
|
||||
this.child.stderr.on('data', (chunk: Buffer) => { this.onStderr(chunk) })
|
||||
}
|
||||
|
||||
/** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */
|
||||
get pid(): number {
|
||||
/* v8 ignore next -- the `-1` fallback only applies to a spawn that produced no pid; defensive. */
|
||||
return this.child.pid ?? -1
|
||||
}
|
||||
|
||||
/** The retained stderr tail, for diagnostics on a failed server. */
|
||||
get stderrTail(): string {
|
||||
return this.stderr
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and await its result.
|
||||
* @param method - the JSON-RPC method.
|
||||
* @param params - the request params.
|
||||
* @returns the response result; rejects on an error response, write failure, or close.
|
||||
*/
|
||||
request(method: string, params: unknown): Promise<unknown> {
|
||||
const id = this.nextId++
|
||||
const promise = new Promise<unknown>((resolve, reject) => {
|
||||
if (this.closeReason !== undefined) {
|
||||
reject(this.closeReason)
|
||||
return
|
||||
}
|
||||
this.pending.set(id, { resolve, reject })
|
||||
try {
|
||||
this.write({ jsonrpc: '2.0', id, method, params })
|
||||
} catch (error) {
|
||||
/* v8 ignore start -- a stdin write failure surfaces asynchronously via the swallowed
|
||||
'error' listener, so this synchronous catch is a defensive guard. */
|
||||
this.pending.delete(id)
|
||||
reject(asError(error))
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
})
|
||||
// A caller that stops awaiting (e.g. an aborted query) can leave this promise to reject later
|
||||
// when the process closes; a benign no-op handler keeps that from surfacing as an unhandled
|
||||
// rejection. The returned promise still delivers the rejection to the caller's own await/catch.
|
||||
promise.catch(() => {})
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification (no id, no response).
|
||||
* @param method - the JSON-RPC method.
|
||||
* @param params - the notification params.
|
||||
*/
|
||||
notify(method: string, params: unknown): void {
|
||||
this.write({ jsonrpc: '2.0', method, params })
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a `$/cancelRequest` for an in-flight request id (best-effort; ignores write failure).
|
||||
* @param requestId - the numeric id of the request to cancel.
|
||||
*/
|
||||
cancel(requestId: number): void {
|
||||
try {
|
||||
this.write({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id: requestId } })
|
||||
} catch {
|
||||
// The server is already gone or unwritable; the pending request will fail on close.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The id the NEXT `request()` will use, so the instance can pre-arm a cancel.
|
||||
* @returns the numeric id the next request will be assigned.
|
||||
*/
|
||||
peekNextId(): number {
|
||||
return this.nextId
|
||||
}
|
||||
|
||||
/** Send SIGTERM to the child (idempotent-safe; a dead child ignores it). */
|
||||
terminate(): void {
|
||||
this.child.kill('SIGTERM')
|
||||
}
|
||||
|
||||
/** Send SIGKILL to the child. */
|
||||
kill(): void {
|
||||
this.child.kill('SIGKILL')
|
||||
}
|
||||
|
||||
private onStdout(chunk: Buffer): void {
|
||||
let messages: unknown[]
|
||||
try {
|
||||
messages = this.decoder.push(chunk)
|
||||
} catch (error) {
|
||||
// A framing/JSON failure corrupts the stream position irrecoverably: fail the instance.
|
||||
this.fail(asError(error))
|
||||
this.child.kill('SIGKILL')
|
||||
return
|
||||
}
|
||||
for (const message of messages) this.dispatch(message)
|
||||
}
|
||||
|
||||
private onStderr(chunk: Buffer): void {
|
||||
if (this.stderr.length >= this.spec.maxStderrBytes) return
|
||||
this.stderr = (this.stderr + chunk.toString('utf8')).slice(0, this.spec.maxStderrBytes)
|
||||
}
|
||||
|
||||
private dispatch(message: unknown): void {
|
||||
if (message === null || typeof message !== 'object') return
|
||||
const frame = message as Record<string, unknown>
|
||||
const id = frame.id
|
||||
const method = frame.method
|
||||
if (typeof method === 'string' && (typeof id === 'number' || typeof id === 'string')) {
|
||||
void this.handleServerRequest(id, method, frame.params)
|
||||
return
|
||||
}
|
||||
if (typeof method === 'string') {
|
||||
// A server→client notification (e.g. diagnostics, logs): ignored by this MVP host.
|
||||
return
|
||||
}
|
||||
if (typeof id === 'number') this.handleResponse(id, frame)
|
||||
}
|
||||
|
||||
private async handleServerRequest(id: number | string, method: string, params: unknown): Promise<void> {
|
||||
try {
|
||||
const result = await this.onServerRequest(method, params)
|
||||
this.write({ jsonrpc: '2.0', id, result })
|
||||
} catch (error) {
|
||||
this.write({ jsonrpc: '2.0', id, error: { code: -32601, message: asError(error).message } })
|
||||
}
|
||||
}
|
||||
|
||||
private handleResponse(id: number, frame: Record<string, unknown>): void {
|
||||
const pending = this.pending.get(id)
|
||||
if (!pending) return
|
||||
this.pending.delete(id)
|
||||
const error = frame.error
|
||||
if (error !== null && typeof error === 'object') {
|
||||
const record = error as Record<string, unknown>
|
||||
pending.reject(new Error(typeof record.message === 'string' ? record.message : 'LSP error response'))
|
||||
return
|
||||
}
|
||||
pending.resolve(frame.result)
|
||||
}
|
||||
|
||||
private write(message: unknown): void {
|
||||
this.child.stdin.write(encodeMessage(message))
|
||||
}
|
||||
|
||||
private fail(error: Error): void {
|
||||
/* v8 ignore next -- the second arm (closeReason already set) needs two fail() calls before close; defensive. */
|
||||
if (this.closeReason === undefined) this.closeReason = error
|
||||
this.failAll(error)
|
||||
}
|
||||
|
||||
private failAll(error: Error): void {
|
||||
const waiting = [...this.pending.values()]
|
||||
this.pending.clear()
|
||||
for (const pending of waiting) pending.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Coerce an unknown thrown value to an `Error`. */
|
||||
function asError(value: unknown): Error {
|
||||
/* v8 ignore next -- the non-Error branch guards against a non-Error throw, which our paths never produce. */
|
||||
return value instanceof Error ? value : new Error(String(value))
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* LSP base-protocol framing: `Content-Length`-delimited JSON-RPC over a byte stream. The encoder
|
||||
* produces one framed buffer; the decoder buffers incoming bytes and yields complete message bodies,
|
||||
* bounding the header and total message size so a hostile or broken server cannot exhaust memory.
|
||||
* @module @deepseek-ai/dsh-lsp-local/framing
|
||||
*/
|
||||
|
||||
/** The header/body separator in the LSP base protocol. */
|
||||
const HEADER_SEPARATOR = '\r\n\r\n'
|
||||
|
||||
/** Cap on the header section so a server that never sends the separator cannot grow the buffer forever. */
|
||||
const MAX_HEADER_BYTES = 1 << 16
|
||||
|
||||
/**
|
||||
* Encode one JSON-RPC message as a framed LSP buffer (`Content-Length: N\r\n\r\n<utf-8 json>`).
|
||||
* @param message - the JSON-RPC message object to serialize.
|
||||
* @returns the framed bytes ready to write to the server's stdin.
|
||||
*/
|
||||
export function encodeMessage(message: unknown): Buffer {
|
||||
const body = Buffer.from(JSON.stringify(message), 'utf8')
|
||||
const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii')
|
||||
return Buffer.concat([header, body])
|
||||
}
|
||||
|
||||
/**
|
||||
* A streaming decoder for `Content-Length`-framed JSON-RPC. Feed it stdout chunks; it returns any
|
||||
* whole message bodies that completed. It parses only the `Content-Length` header and ignores other
|
||||
* headers (e.g. `Content-Type`), matching the base protocol.
|
||||
*/
|
||||
export class MessageDecoder {
|
||||
private buffer: Buffer = Buffer.alloc(0)
|
||||
private readonly maxMessageBytes: number
|
||||
|
||||
/**
|
||||
* @param maxMessageBytes - reject any single framed body larger than this (guards memory).
|
||||
*/
|
||||
constructor(maxMessageBytes: number) {
|
||||
this.maxMessageBytes = maxMessageBytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a chunk and return every message body that is now complete.
|
||||
* @param chunk - raw bytes from the server's stdout.
|
||||
* @returns the parsed JSON bodies, in arrival order (possibly empty).
|
||||
* @throws Error when a header is malformed or a body exceeds `maxMessageBytes`.
|
||||
*/
|
||||
push(chunk: Buffer): unknown[] {
|
||||
this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk])
|
||||
const messages: unknown[] = []
|
||||
for (;;) {
|
||||
const step = this.next()
|
||||
if (!step.ready) break
|
||||
messages.push(step.message)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
/** Parse and consume the next complete message, or report that more bytes are needed. */
|
||||
private next(): { ready: false } | { ready: true; message: unknown } {
|
||||
const separator = this.buffer.indexOf(HEADER_SEPARATOR)
|
||||
if (separator < 0) {
|
||||
if (this.buffer.length > MAX_HEADER_BYTES) {
|
||||
throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes without a terminator`)
|
||||
}
|
||||
return { ready: false }
|
||||
}
|
||||
const headerText = this.buffer.toString('ascii', 0, separator)
|
||||
const contentLength = parseContentLength(headerText)
|
||||
if (contentLength > this.maxMessageBytes) {
|
||||
throw new Error(`LSP message length ${contentLength} exceeds the ${this.maxMessageBytes}-byte limit`)
|
||||
}
|
||||
const bodyStart = separator + HEADER_SEPARATOR.length
|
||||
const bodyEnd = bodyStart + contentLength
|
||||
if (this.buffer.length < bodyEnd) return { ready: false }
|
||||
const body = this.buffer.toString('utf8', bodyStart, bodyEnd)
|
||||
this.buffer = this.buffer.subarray(bodyEnd)
|
||||
try {
|
||||
return { ready: true, message: JSON.parse(body) }
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- JSON.parse throws a SyntaxError (an Error); the String() fallback is defensive. */
|
||||
throw new Error(`LSP message body was not valid JSON: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the `Content-Length` header value (case-insensitive), rejecting a missing or non-numeric one. */
|
||||
function parseContentLength(headerText: string): number {
|
||||
for (const line of headerText.split('\r\n')) {
|
||||
const colon = line.indexOf(':')
|
||||
if (colon < 0) continue
|
||||
if (line.slice(0, colon).trim().toLowerCase() !== 'content-length') continue
|
||||
const value = Number(line.slice(colon + 1).trim())
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`invalid Content-Length header: ${JSON.stringify(line)}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
throw new Error(`LSP header block missing Content-Length: ${JSON.stringify(headerText)}`)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Host-filesystem source access for the local provider, using Node APIs directly in the
|
||||
* subprocess's namespace (never `ctx.fs`): only the LSP result is model-visible, so a query does not
|
||||
* satisfy read-before-write policy and emits no `fs/observed`. Canonicalization derives target
|
||||
* identity from `realpath`, so symlink aliases share a workspace; a source is rejected before server
|
||||
* startup when it is missing, non-regular, non-UTF-8, oversized, or canonically outside the
|
||||
* workspace. External result locations are allowed, but an external path can never become a query
|
||||
* source.
|
||||
* @module @deepseek-ai/dsh-lsp-local/host
|
||||
*/
|
||||
|
||||
import { readFile, realpath, stat } from 'node:fs/promises'
|
||||
import { isAbsolute, resolve as resolvePath, sep } from 'node:path'
|
||||
|
||||
/** A validated source: its canonical absolute path and current UTF-8 text. */
|
||||
export interface HostSource {
|
||||
/** The canonical (realpath-resolved) absolute path, inside the canonical workspace. */
|
||||
readonly canonicalPath: string
|
||||
/** The file's current text, read as UTF-8. */
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a workspace root: it must exist and be a directory. The returned realpath supplies
|
||||
* process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity, so symlinked roots
|
||||
* collapse to one instance.
|
||||
* @param workspaceRoot - the caller's workspace root (absolute).
|
||||
* @returns the canonical directory path.
|
||||
* @throws Error when the path is missing or not a directory.
|
||||
*/
|
||||
export async function canonicalizeWorkspace(workspaceRoot: string): Promise<string> {
|
||||
let canonical: string
|
||||
try {
|
||||
canonical = await realpath(workspaceRoot)
|
||||
} catch (error) {
|
||||
throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`)
|
||||
}
|
||||
const info = await stat(canonical)
|
||||
if (!info.isDirectory()) {
|
||||
throw new Error(`workspace root "${workspaceRoot}" is not a directory`)
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve, canonicalize, validate, and read a query source in one pass. A relative `filePath`
|
||||
* resolves against `canonicalWorkspace`; an absolute one is taken directly. The canonical target
|
||||
* must be a regular UTF-8 file no larger than `maxDocumentBytes`, and must lie inside the canonical
|
||||
* workspace.
|
||||
* @param filePath - the model-supplied source path (relative or absolute).
|
||||
* @param canonicalWorkspace - the already-canonicalized workspace root.
|
||||
* @param maxDocumentBytes - the largest source this host will open.
|
||||
* @returns the canonical path and current UTF-8 text.
|
||||
* @throws Error when the source is missing, non-regular, oversized, non-UTF-8, or out of workspace.
|
||||
*/
|
||||
export async function readHostSource(
|
||||
filePath: string,
|
||||
canonicalWorkspace: string,
|
||||
maxDocumentBytes: number,
|
||||
): Promise<HostSource> {
|
||||
const requested = isAbsolute(filePath) ? filePath : resolvePath(canonicalWorkspace, filePath)
|
||||
let canonicalPath: string
|
||||
try {
|
||||
canonicalPath = await realpath(requested)
|
||||
} catch (error) {
|
||||
throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`)
|
||||
}
|
||||
if (!isInside(canonicalWorkspace, canonicalPath)) {
|
||||
throw new Error(`source "${filePath}" resolves outside the workspace`)
|
||||
}
|
||||
const info = await stat(canonicalPath)
|
||||
if (!info.isFile()) {
|
||||
throw new Error(`source "${filePath}" is not a regular file`)
|
||||
}
|
||||
if (info.size > maxDocumentBytes) {
|
||||
throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`)
|
||||
}
|
||||
const buffer = await readFile(canonicalPath)
|
||||
const text = decodeUtf8Strict(buffer, filePath)
|
||||
return { canonicalPath, text }
|
||||
}
|
||||
|
||||
/** Whether `child` is the workspace itself or a descendant of it (both already canonical). */
|
||||
function isInside(workspace: string, child: string): boolean {
|
||||
if (child === workspace) return true
|
||||
/* v8 ignore next -- a canonical non-root workspace never ends with a separator; the guard covers the filesystem root. */
|
||||
const base = workspace.endsWith(sep) ? workspace : workspace + sep
|
||||
return child.startsWith(base)
|
||||
}
|
||||
|
||||
/** Decode UTF-8 strictly (a replacement char means the source was not valid UTF-8 text). */
|
||||
function decodeUtf8Strict(buffer: Buffer, filePath: string): string {
|
||||
const text = buffer.toString('utf8')
|
||||
if (text.includes('�')) {
|
||||
throw new Error(`source "${filePath}" is not valid UTF-8 text`)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/** Extract a message from an unknown thrown value without leaking `any`. */
|
||||
function messageOf(error: unknown): string {
|
||||
/* v8 ignore next -- Node fs rejections are always Error instances; the String() fallback is defensive. */
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Generic stdio language-server provider for `ctx.lsp`. One plugin instance configures one server
|
||||
* command and its extension→language-id map; load multiple instances for multiple servers. The
|
||||
* provider lazily single-flights one server process per `(provider id, canonical workspace
|
||||
* realpath)`, serves transient-open queries through it, and evicts a crashed process so a later
|
||||
* query can replace it. It reads sources through Node APIs in the host namespace (not `ctx.fs`) and
|
||||
* trusts its configured server — no sandbox confinement.
|
||||
*
|
||||
* Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal
|
||||
* unregisters from `ctx.lsp` and tears down every live server.
|
||||
* @module @deepseek-ai/dsh-lsp-local
|
||||
*/
|
||||
|
||||
import { accessSync, constants } from 'node:fs'
|
||||
import { delimiter, isAbsolute, join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { LspProviderId } from '@deepseek-ai/dsh-lsp'
|
||||
import type {
|
||||
LspProvider,
|
||||
LspProviderQuery,
|
||||
LspQueryResult,
|
||||
} from '@deepseek-ai/dsh-lsp'
|
||||
// Side-effect type import: declaration-merges `ctx.lsp` onto Context.
|
||||
import type {} from '@deepseek-ai/dsh-lsp'
|
||||
import { canonicalizeWorkspace } from './host.ts'
|
||||
import { LspInstance } from './instance.ts'
|
||||
import type { InstanceSpec } from './instance.ts'
|
||||
|
||||
export { canonicalizeWorkspace, readHostSource } from './host.ts'
|
||||
export { encodeMessage, MessageDecoder } from './framing.ts'
|
||||
export {
|
||||
negotiatePositionEncoding,
|
||||
normalizeHover,
|
||||
normalizeLocations,
|
||||
requestMethod,
|
||||
supportsOperation,
|
||||
supportsTransientOpen,
|
||||
} from './translate.ts'
|
||||
export { LspInstance } from './instance.ts'
|
||||
export { LspConnection } from './connection.ts'
|
||||
|
||||
/** Cordis plugin name for loader diagnostics. */
|
||||
export const name = 'lsp-local'
|
||||
|
||||
/** Services required by this plugin. */
|
||||
export const inject = ['lsp']
|
||||
|
||||
/** Credential-shaped ambient env vars are NOT forwarded to the child by default. */
|
||||
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
|
||||
|
||||
const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000
|
||||
const DEFAULT_MAX_STDERR_BYTES = 1_000_000
|
||||
const DEFAULT_MAX_DOCUMENT_BYTES = 4_000_000
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000
|
||||
const DEFAULT_KILL_GRACE_MS = 2_000
|
||||
|
||||
/** Plugin configuration: one server command plus its extension mapping and host bounds. */
|
||||
export interface Config {
|
||||
/** Stable provider id, reserved on `ctx.lsp` with the extensions. */
|
||||
providerId: string
|
||||
/** Executable to spawn (absolute, or resolved on PATH at load). */
|
||||
command: string
|
||||
/** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */
|
||||
extensionToLanguage: Record<string, string>
|
||||
/** Arguments passed to the executable (no shell). Default `[]`. */
|
||||
args?: string[]
|
||||
/** Extra env vars merged on top of the scrubbed ambient env. Default `{}`. */
|
||||
env?: Record<string, string>
|
||||
/** Static `initialize` options forwarded to the server. Default `null`. */
|
||||
initializationOptions?: unknown
|
||||
/** Static answer to every `workspace/configuration` item. Default `null`. */
|
||||
configuration?: unknown
|
||||
/** Largest single framed message accepted from the server (bytes). Default 16000000. */
|
||||
maxMessageBytes?: number
|
||||
/** Largest stderr tail retained for diagnostics (bytes). Default 1000000. */
|
||||
maxStderrBytes?: number
|
||||
/** Largest source file this host will open (bytes). Default 4000000. */
|
||||
maxDocumentBytes?: number
|
||||
/** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */
|
||||
shutdownTimeoutMs?: number
|
||||
/** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). Default 2000. */
|
||||
killGraceMs?: number
|
||||
}
|
||||
|
||||
/** The resolved config after schemastery fills every default; the provider reads this shape. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
providerId: z.string().required(),
|
||||
command: z.string().required(),
|
||||
args: z.array(String).default([]),
|
||||
env: z.dict(String).default({}),
|
||||
extensionToLanguage: z.dict(String).required(),
|
||||
initializationOptions: z.any().default(null),
|
||||
configuration: z.any().default(null),
|
||||
maxMessageBytes: z.number().default(DEFAULT_MAX_MESSAGE_BYTES),
|
||||
maxStderrBytes: z.number().default(DEFAULT_MAX_STDERR_BYTES),
|
||||
maxDocumentBytes: z.number().default(DEFAULT_MAX_DOCUMENT_BYTES),
|
||||
shutdownTimeoutMs: z.number().default(DEFAULT_SHUTDOWN_TIMEOUT_MS),
|
||||
killGraceMs: z.number().default(DEFAULT_KILL_GRACE_MS),
|
||||
})
|
||||
|
||||
/**
|
||||
* Register a generic stdio LSP provider. Resolves the executable at load (after credential
|
||||
* scrubbing) and fails before registration when it is unavailable; the process itself launches
|
||||
* lazily on the first matching query.
|
||||
* @param ctx - the plugin context (must inject `lsp`).
|
||||
* @param config - the resolved plugin configuration (schemastery has filled every default).
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved = config as ResolvedConfig
|
||||
const childEnv = buildChildEnv(resolved.env)
|
||||
// Resolve the executable eagerly so a misconfigured command fails at load, not on first query.
|
||||
const executable = resolveExecutable(resolved.command, childEnv)
|
||||
|
||||
const provider = new LocalLspProvider(resolved, childEnv, executable)
|
||||
ctx.effect(() => {
|
||||
const dispose = ctx.lsp.registerProvider(provider)
|
||||
return async () => {
|
||||
dispose()
|
||||
await provider.disposeAll()
|
||||
}
|
||||
}, 'lsp-local.registerProvider')
|
||||
}
|
||||
|
||||
/** A pooled generic provider: one server process per canonical workspace, created on demand. */
|
||||
class LocalLspProvider implements LspProvider {
|
||||
readonly id: LspProviderId
|
||||
readonly extensionToLanguage: Readonly<Record<string, string>>
|
||||
/** Single-flight map: canonical workspace realpath → the (pending) instance for it. */
|
||||
private readonly instances = new Map<string, Promise<LspInstance>>()
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
private readonly config: ResolvedConfig,
|
||||
private readonly childEnv: Record<string, string>,
|
||||
private readonly executable: string,
|
||||
) {
|
||||
this.id = LspProviderId(config.providerId)
|
||||
this.extensionToLanguage = config.extensionToLanguage
|
||||
}
|
||||
|
||||
async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
/* v8 ignore next -- the seam unregisters this provider on dispose, so a query never reaches a disposed provider; defensive. */
|
||||
if (this.disposed) throw new Error('lsp-local provider is disposed')
|
||||
const workspace = await canonicalizeWorkspace(request.workspaceRoot)
|
||||
const instance = await this.instanceFor(workspace)
|
||||
try {
|
||||
return await instance.query(request, signal)
|
||||
} finally {
|
||||
// A crashed/closed process must not be reused: drop its slot so the next query starts fresh,
|
||||
// but only if the slot still holds THIS instance (a concurrent replacement must survive).
|
||||
if (instance.dead) {
|
||||
const slot = this.instances.get(workspace)
|
||||
/* v8 ignore next -- the slot-undefined arm needs a concurrent eviction of the same slot; defensive. */
|
||||
if (slot !== undefined && (await settledInstance(slot)) === instance) {
|
||||
this.instances.delete(workspace)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Single-flight one instance per canonical workspace; a rejected creation clears the slot. */
|
||||
private instanceFor(workspace: string): Promise<LspInstance> {
|
||||
const existing = this.instances.get(workspace)
|
||||
if (existing !== undefined) return existing
|
||||
const created = Promise.resolve().then(() => this.createInstance(workspace))
|
||||
this.instances.set(workspace, created)
|
||||
/* v8 ignore next 3 -- createInstance (the LspInstance constructor) does not throw; spawn failures
|
||||
surface asynchronously through the instance, so this creation-rejection cleanup is defensive. */
|
||||
created.catch(() => {
|
||||
if (this.instances.get(workspace) === created) this.instances.delete(workspace)
|
||||
})
|
||||
return created
|
||||
}
|
||||
|
||||
private createInstance(workspace: string): LspInstance {
|
||||
const spec: InstanceSpec = {
|
||||
command: this.executable,
|
||||
args: this.config.args,
|
||||
cwd: workspace,
|
||||
env: this.childEnv,
|
||||
configuration: this.config.configuration,
|
||||
initializationOptions: this.config.initializationOptions,
|
||||
maxMessageBytes: this.config.maxMessageBytes,
|
||||
maxStderrBytes: this.config.maxStderrBytes,
|
||||
maxDocumentBytes: this.config.maxDocumentBytes,
|
||||
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
|
||||
killGraceMs: this.config.killGraceMs,
|
||||
}
|
||||
return new LspInstance(spec)
|
||||
}
|
||||
|
||||
/** Dispose every live instance and block further queries. */
|
||||
async disposeAll(): Promise<void> {
|
||||
this.disposed = true
|
||||
const pending = [...this.instances.values()]
|
||||
this.instances.clear()
|
||||
await Promise.all(pending.map(async (entry) => {
|
||||
try {
|
||||
const instance = await entry
|
||||
await instance.dispose()
|
||||
} catch {
|
||||
// A never-initialized instance already rejected; nothing to tear down.
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a slot promise to its instance for identity comparison, tolerating a pending rejection. */
|
||||
async function settledInstance(slot: Promise<LspInstance>): Promise<LspInstance | undefined> {
|
||||
try {
|
||||
return await slot
|
||||
} catch {
|
||||
/* v8 ignore next -- a slot promise only rejects if createInstance throws, which it never does; defensive. */
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** The ambient env minus credential-shaped vars, plus the config's explicit env. */
|
||||
function buildChildEnv(extra: Record<string, string>): Record<string, string> {
|
||||
const scrubbed = Object.entries(process.env).filter(
|
||||
([key, value]) => value !== undefined && !SENSITIVE_ENV_PATTERN.test(key),
|
||||
) as [string, string][]
|
||||
return { ...Object.fromEntries(scrubbed), ...extra }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the server executable to an absolute path: an absolute command is verified directly; a
|
||||
* bare command is looked up on the child's PATH. Fails loudly when nothing is executable.
|
||||
*/
|
||||
function resolveExecutable(command: string, childEnv: Record<string, string>): string {
|
||||
if (isAbsolute(command)) {
|
||||
return command
|
||||
}
|
||||
/* v8 ignore next -- buildChildEnv always sets PATH from the ambient env; the further fallbacks are defensive. */
|
||||
const pathValue = childEnv.PATH ?? process.env.PATH ?? ''
|
||||
for (const dir of pathValue.split(delimiter)) {
|
||||
if (dir === '') continue
|
||||
const candidate = join(dir, command)
|
||||
if (isExecutableSync(candidate)) return candidate
|
||||
}
|
||||
throw new Error(`lsp-local: command "${command}" was not found on PATH`)
|
||||
}
|
||||
|
||||
/** Synchronous executable check used only at load-time resolution. */
|
||||
function isExecutableSync(path: string): boolean {
|
||||
try {
|
||||
accessSync(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* One language-server instance: a connection plus the initialize handshake, the serialized abortable
|
||||
* query queue, the transient `didOpen`→request→`didClose` lifecycle, and bounded teardown. One
|
||||
* instance owns one `(provider id, canonical workspace)` process. Queries serialize through a single
|
||||
* queue so a cancellation that fails to stop the server can terminate it without killing unrelated
|
||||
* work; distinct instances run in parallel.
|
||||
* @module @deepseek-ai/dsh-lsp-local/instance
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type {
|
||||
LspOperation,
|
||||
LspProviderQuery,
|
||||
LspQueryResult,
|
||||
} from '@deepseek-ai/dsh-lsp'
|
||||
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { LspConnection } from './connection.ts'
|
||||
import type { ConnectionSpec } from './connection.ts'
|
||||
import { readHostSource } from './host.ts'
|
||||
import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
|
||||
import {
|
||||
negotiatePositionEncoding,
|
||||
normalizeHover,
|
||||
normalizeLocations,
|
||||
requestMethod,
|
||||
supportsOperation,
|
||||
supportsTransientOpen,
|
||||
} from './translate.ts'
|
||||
|
||||
/** Everything an instance needs beyond the connection spec. */
|
||||
export interface InstanceSpec extends ConnectionSpec {
|
||||
/** Static `initialize` options forwarded to the server. */
|
||||
readonly initializationOptions: unknown
|
||||
/** Largest source file this host will open (bytes). */
|
||||
readonly maxDocumentBytes: number
|
||||
/** Graceful `shutdown`/`exit` budget before escalation (ms). */
|
||||
readonly shutdownTimeoutMs: number
|
||||
/** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */
|
||||
readonly killGraceMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A single initialized server process. Not exported as a provider — the provider single-flights and
|
||||
* pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down.
|
||||
*/
|
||||
export class LspInstance {
|
||||
private readonly connection: LspConnection
|
||||
private capabilities: WireServerCapabilities | undefined
|
||||
/** The serialization tail: each query awaits the prior one, so lifecycles never interleave. */
|
||||
private queue: Promise<unknown> = Promise.resolve()
|
||||
private disposed = false
|
||||
/** Set once the process closes, so the pool can synchronously skip a dead instance. */
|
||||
private processClosed = false
|
||||
/** Populated once `initialize` succeeds; a failed handshake rejects every query. */
|
||||
private readonly ready: Promise<void>
|
||||
|
||||
/**
|
||||
* @param spec - the launch, initialize, and teardown parameters.
|
||||
*/
|
||||
constructor(private readonly spec: InstanceSpec) {
|
||||
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params))
|
||||
this.ready = this.initialize()
|
||||
// A handshake rejection must not surface as an unhandled rejection before the first query awaits
|
||||
// it; queries attach the real handler.
|
||||
this.ready.catch(() => {})
|
||||
void this.connection.closed.then(() => { this.processClosed = true })
|
||||
}
|
||||
|
||||
/** Synchronous liveness check: true once the process has closed or the instance was disposed. */
|
||||
get dead(): boolean {
|
||||
return this.processClosed || this.disposed
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one query through the serialized queue.
|
||||
* @param request - the resolved provider query.
|
||||
* @param signal - optional cancellation for this query's full lifecycle.
|
||||
* @returns the normalized result.
|
||||
*/
|
||||
query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
const run = this.queue.then(() => this.runQuery(request, signal))
|
||||
// Keep the tail alive regardless of this query's outcome so the next caller still serializes.
|
||||
this.queue = run.then(() => undefined, () => undefined)
|
||||
return run
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
const initializeResult = await this.connection.request('initialize', {
|
||||
processId: process.pid,
|
||||
rootUri: pathToFileURL(this.spec.cwd).href,
|
||||
workspaceFolders: [{ uri: pathToFileURL(this.spec.cwd).href, name: 'workspace' }],
|
||||
capabilities: CLIENT_CAPABILITIES,
|
||||
initializationOptions: this.spec.initializationOptions,
|
||||
}) as WireInitializeResult
|
||||
const capabilities = initializeResult.capabilities
|
||||
// An omitted encoding defaults to utf-16; any other value is a protocol error we reject here.
|
||||
negotiatePositionEncoding(capabilities.positionEncoding)
|
||||
this.capabilities = capabilities
|
||||
this.connection.notify('initialized', {})
|
||||
}
|
||||
|
||||
private async runQuery(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
if (this.disposed) throw new Error('LSP instance was disposed')
|
||||
if (signal?.aborted) throw abortError(signal)
|
||||
await this.ready
|
||||
const capabilities = this.capabilities
|
||||
/* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */
|
||||
if (capabilities === undefined) throw new Error('LSP instance is not initialized')
|
||||
if (!supportsOperation(capabilities, request.operation)) {
|
||||
throw new Error(`server does not support ${request.operation}`)
|
||||
}
|
||||
if (!supportsTransientOpen(capabilities.textDocumentSync)) {
|
||||
throw new Error('server does not support the transient textDocument/didOpen this host requires')
|
||||
}
|
||||
|
||||
const source = await readHostSource(request.filePath, this.spec.cwd, this.spec.maxDocumentBytes)
|
||||
const uri = pathToFileURL(source.canonicalPath).href
|
||||
let opened = false
|
||||
try {
|
||||
if (signal?.aborted) throw abortError(signal)
|
||||
this.connection.notify('textDocument/didOpen', {
|
||||
textDocument: { uri, languageId: request.languageId, version: 1, text: source.text },
|
||||
})
|
||||
opened = true
|
||||
const payload = await this.sendRequest(request.operation, uri, request.position, signal)
|
||||
return this.normalize(request.operation, payload)
|
||||
} finally {
|
||||
if (opened) {
|
||||
try {
|
||||
this.connection.notify('textDocument/didClose', { textDocument: { uri } })
|
||||
} catch (error) {
|
||||
/* v8 ignore start -- stdin write errors surface asynchronously via the swallowed 'error'
|
||||
listener, so a synchronous didClose write failure is a defensive path. */
|
||||
// A close-write failure does not replace the settled result/error, but the instance can no
|
||||
// longer be trusted: invalidate it and await bounded process termination.
|
||||
this.disposed = true
|
||||
void this.tearDown(error instanceof Error ? error : new Error(String(error)))
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async sendRequest(
|
||||
operation: LspOperation,
|
||||
uri: string,
|
||||
position: LspProviderQuery['position'],
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
const params = {
|
||||
textDocument: { uri },
|
||||
position: { line: position.line, character: position.character },
|
||||
// references always includes declarations: the caller gets no flag and impact analysis never
|
||||
// omits the defining site.
|
||||
...(operation === 'references' ? { context: { includeDeclaration: true } } : {}),
|
||||
}
|
||||
const requestId = this.connection.peekNextId()
|
||||
const send = this.connection.request(requestMethod(operation), params)
|
||||
if (signal === undefined) return send
|
||||
return this.raceAbort(send, requestId, signal)
|
||||
}
|
||||
|
||||
/** Race a pending request against abort; on abort, send `$/cancelRequest` and reject. */
|
||||
private async raceAbort(send: Promise<unknown>, requestId: number, signal: AbortSignal): Promise<unknown> {
|
||||
const abort = new Promise<never>((_, reject) => {
|
||||
const onAbort = (): void => { reject(abortError(signal)) }
|
||||
/* v8 ignore next -- runQuery checks signal.aborted before sending, so it is not yet aborted here; defensive. */
|
||||
if (signal.aborted) { onAbort(); return }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
// Remove the abort listener once the request settles either way; the finally-promise inherits
|
||||
// send's rejection, so catch it to avoid an unhandled rejection when abort already won.
|
||||
send.finally(() => { signal.removeEventListener('abort', onAbort) }).catch(() => {})
|
||||
})
|
||||
try {
|
||||
return await Promise.race([send, abort])
|
||||
} catch (error) {
|
||||
if (signal.aborted) this.connection.cancel(requestId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private normalize(operation: LspOperation, payload: unknown): LspQueryResult {
|
||||
if (operation === 'hover') {
|
||||
return { kind: 'hover', hover: normalizeHover(payload) }
|
||||
}
|
||||
return { kind: 'locations', locations: normalizeLocations(payload) }
|
||||
}
|
||||
|
||||
private answerServerRequest(method: string, params: unknown): Promise<unknown> {
|
||||
if (method === 'workspace/configuration') {
|
||||
// Answer every requested item with the one static configuration value.
|
||||
const record = params as { items?: unknown[] } | null
|
||||
/* v8 ignore next -- a configuration request always carries an items array; the empty fallback is defensive. */
|
||||
const items = Array.isArray(record?.items) ? record.items : []
|
||||
return Promise.resolve(items.map(() => this.spec.configuration))
|
||||
}
|
||||
if (LIFECYCLE_NOOP_METHODS.has(method)) {
|
||||
// Accept lifecycle bookkeeping requests with an empty result; we register nothing dynamic.
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (method === 'workspace/applyEdit') {
|
||||
// This host never applies edits or runs commands.
|
||||
return Promise.reject(new Error('workspace/applyEdit is not permitted by this host'))
|
||||
}
|
||||
return Promise.reject(new Error(`unsupported server request: ${method}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject queued work, attempt graceful `shutdown`/`exit`, then escalate SIGTERM→SIGKILL, awaiting
|
||||
* process close so nothing outlives disposal.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
await this.connection.closed
|
||||
return
|
||||
}
|
||||
this.disposed = true
|
||||
await this.tearDown(new Error('LSP instance disposed'))
|
||||
}
|
||||
|
||||
private async tearDown(_reason: Error): Promise<void> {
|
||||
try {
|
||||
using shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN')
|
||||
await this.gracefulShutdown(shutdownDeadline.signal)
|
||||
} catch {
|
||||
// Graceful shutdown failed or timed out: fall through to signal escalation.
|
||||
}
|
||||
await this.forceTerminate()
|
||||
}
|
||||
|
||||
/** Best-effort LSP `shutdown` request then `exit` notification, bounded by `signal`. */
|
||||
private async gracefulShutdown(signal: AbortSignal): Promise<void> {
|
||||
const shutdown = this.connection.request('shutdown', null)
|
||||
await Promise.race([
|
||||
shutdown,
|
||||
new Promise<never>((_, reject) => {
|
||||
/* v8 ignore next -- the shutdown deadline signal is freshly armed and not yet aborted here; defensive. */
|
||||
if (signal.aborted) { reject(abortError(signal)); return }
|
||||
signal.addEventListener('abort', () => { reject(abortError(signal)) }, { once: true })
|
||||
}),
|
||||
])
|
||||
this.connection.notify('exit', null)
|
||||
}
|
||||
|
||||
/** SIGTERM, wait `killGraceMs` for close, then SIGKILL; await full process close either way. */
|
||||
private async forceTerminate(): Promise<void> {
|
||||
this.connection.terminate()
|
||||
using graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE')
|
||||
const closedInTime = await Promise.race([
|
||||
this.connection.closed.then(() => true),
|
||||
new Promise<boolean>((resolve) => {
|
||||
/* v8 ignore next -- the kill-grace deadline signal is freshly armed and not yet aborted here; defensive. */
|
||||
if (graceDeadline.signal.aborted) { resolve(false); return }
|
||||
graceDeadline.signal.addEventListener('abort', () => { resolve(false) }, { once: true })
|
||||
}),
|
||||
])
|
||||
if (!closedInTime) this.connection.kill()
|
||||
await this.connection.closed
|
||||
}
|
||||
}
|
||||
|
||||
/** Server→client request methods this host acknowledges with an empty result (no dynamic registration). */
|
||||
const LIFECYCLE_NOOP_METHODS = new Set([
|
||||
'window/workDoneProgress/create',
|
||||
'client/registerCapability',
|
||||
'client/unregisterCapability',
|
||||
])
|
||||
|
||||
/** Build an abort Error carrying the signal's reason (preserving a timeout classification). */
|
||||
function abortError(signal: AbortSignal): Error {
|
||||
const timeout = timeoutOf(signal)
|
||||
if (timeout !== undefined) return timeout
|
||||
const reason: unknown = signal.reason
|
||||
if (reason instanceof Error) return reason
|
||||
return new Error('LSP query aborted')
|
||||
}
|
||||
|
||||
/**
|
||||
* The client capabilities advertised at `initialize`: UTF-16 positions, workspace folders and
|
||||
* configuration, markdown/plaintext hover, and link support for definition/implementation. No
|
||||
* dynamic registration; the server's returned capabilities are authoritative.
|
||||
*/
|
||||
const CLIENT_CAPABILITIES = {
|
||||
general: { positionEncodings: ['utf-16'] },
|
||||
workspace: { workspaceFolders: true, configuration: true },
|
||||
textDocument: {
|
||||
synchronization: { dynamicRegistration: false },
|
||||
hover: { contentFormat: ['markdown', 'plaintext'] },
|
||||
definition: { linkSupport: true },
|
||||
implementation: { linkSupport: true },
|
||||
references: {},
|
||||
},
|
||||
} as const
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* The subset of LSP wire types this generic host reads and writes: initialize capabilities, the four
|
||||
* request results (`Location`, `LocationLink`, `Hover`), and the `textDocumentSync` shapes used to
|
||||
* decide transient-open support. Types only. Fields absent from a real server payload stay optional;
|
||||
* the translation layer normalizes them into the seam's closed unions.
|
||||
* @module @deepseek-ai/dsh-lsp-local/protocol
|
||||
*/
|
||||
|
||||
/** A zero-based UTF-16 position on the wire (the protocol's `Position`). */
|
||||
export interface WirePosition {
|
||||
readonly line: number
|
||||
readonly character: number
|
||||
}
|
||||
|
||||
/** A wire range (`Range`). */
|
||||
export interface WireRange {
|
||||
readonly start: WirePosition
|
||||
readonly end: WirePosition
|
||||
}
|
||||
|
||||
/** A `Location`: a document URI plus a range. */
|
||||
export interface WireLocation {
|
||||
readonly uri: string
|
||||
readonly range: WireRange
|
||||
}
|
||||
|
||||
/** A `LocationLink`: the target uri plus the selection range to focus. */
|
||||
export interface WireLocationLink {
|
||||
readonly targetUri: string
|
||||
readonly targetSelectionRange: WireRange
|
||||
readonly targetRange?: WireRange
|
||||
}
|
||||
|
||||
/** A `MarkupContent` hover body (`markdown` or `plaintext`). */
|
||||
export interface WireMarkupContent {
|
||||
readonly kind: 'markdown' | 'plaintext'
|
||||
readonly value: string
|
||||
}
|
||||
|
||||
/** A `MarkedString` object form (`{ language, value }`); the string form is a bare `string`. */
|
||||
export interface WireMarkedStringObject {
|
||||
readonly language: string
|
||||
readonly value: string
|
||||
}
|
||||
|
||||
/** One `MarkedString`: a raw string or a language-tagged code block. */
|
||||
export type WireMarkedString = string | WireMarkedStringObject
|
||||
|
||||
/** A `Hover`: contents in any of the protocol's three encodings, plus an optional range. */
|
||||
export interface WireHover {
|
||||
readonly contents: WireMarkupContent | WireMarkedString | readonly WireMarkedString[]
|
||||
readonly range?: WireRange
|
||||
}
|
||||
|
||||
/** The legacy enum form of `textDocumentSync` (`0` None, `1` Full, `2` Incremental). */
|
||||
export type WireTextDocumentSyncKind = 0 | 1 | 2
|
||||
|
||||
/** The options form of `textDocumentSync` (`{ openClose, change }`). */
|
||||
export interface WireTextDocumentSyncOptions {
|
||||
readonly openClose?: boolean
|
||||
readonly change?: WireTextDocumentSyncKind
|
||||
}
|
||||
|
||||
/** A `ServerCapabilities.provider` slot: a boolean or an options object (both mean "supported"). */
|
||||
export type WireProviderCapability = boolean | Record<string, unknown> | undefined
|
||||
|
||||
/** The `ServerCapabilities` fields this host inspects. */
|
||||
export interface WireServerCapabilities {
|
||||
readonly positionEncoding?: string
|
||||
readonly textDocumentSync?: WireTextDocumentSyncKind | WireTextDocumentSyncOptions
|
||||
readonly definitionProvider?: WireProviderCapability
|
||||
readonly referencesProvider?: WireProviderCapability
|
||||
readonly implementationProvider?: WireProviderCapability
|
||||
readonly hoverProvider?: WireProviderCapability
|
||||
}
|
||||
|
||||
/** The `initialize` result envelope. */
|
||||
export interface WireInitializeResult {
|
||||
readonly capabilities: WireServerCapabilities
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Pure protocol translation for the local host: what the server's capabilities allow, and how its
|
||||
* `Location`/`LocationLink`/`Hover` payloads normalize into the seam's closed result unions. No I/O
|
||||
* or process state — every function here is a pure transform, which the fake-stdio tests pin exactly.
|
||||
* @module @deepseek-ai/dsh-lsp-local/translate
|
||||
*/
|
||||
|
||||
import type {
|
||||
LspHover,
|
||||
LspLocation,
|
||||
LspOperation,
|
||||
LspRange,
|
||||
} from '@deepseek-ai/dsh-lsp'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
WireHover,
|
||||
WireLocation,
|
||||
WireLocationLink,
|
||||
WireMarkedString,
|
||||
WireProviderCapability,
|
||||
WireRange,
|
||||
WireServerCapabilities,
|
||||
WireTextDocumentSyncKind,
|
||||
WireTextDocumentSyncOptions,
|
||||
} from './protocol.ts'
|
||||
|
||||
/**
|
||||
* The `textDocument/*` request method for each seam operation.
|
||||
* @param operation - the seam operation to map.
|
||||
* @returns the LSP request method name.
|
||||
*/
|
||||
export function requestMethod(operation: LspOperation): string {
|
||||
switch (operation) {
|
||||
case 'definition': return 'textDocument/definition'
|
||||
case 'references': return 'textDocument/references'
|
||||
case 'implementation': return 'textDocument/implementation'
|
||||
case 'hover': return 'textDocument/hover'
|
||||
/* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */
|
||||
default: return assertNever(operation, 'requestMethod')
|
||||
}
|
||||
}
|
||||
|
||||
/** The `ServerCapabilities` provider field backing each operation. */
|
||||
function capabilityValue(capabilities: WireServerCapabilities, operation: LspOperation): WireProviderCapability {
|
||||
switch (operation) {
|
||||
case 'definition': return capabilities.definitionProvider
|
||||
case 'references': return capabilities.referencesProvider
|
||||
case 'implementation': return capabilities.implementationProvider
|
||||
case 'hover': return capabilities.hoverProvider
|
||||
/* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */
|
||||
default: return assertNever(operation, 'capabilityValue')
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider capability is present when the server sent `true` or an options object (not `false`/absent). */
|
||||
function supportsCapability(value: WireProviderCapability): boolean {
|
||||
if (value === undefined) return false
|
||||
if (typeof value === 'boolean') return value
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the server advertises the requested operation.
|
||||
* @param capabilities - the server's `initialize` capabilities.
|
||||
* @param operation - the seam operation to check.
|
||||
* @returns true when the corresponding provider capability is present.
|
||||
*/
|
||||
export function supportsOperation(capabilities: WireServerCapabilities, operation: LspOperation): boolean {
|
||||
return supportsCapability(capabilityValue(capabilities, operation))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `textDocumentSync` value permits the transient `didOpen`/`didClose` this host relies on.
|
||||
* @param sync - the server's advertised `textDocumentSync` capability.
|
||||
* @returns true when transient open/close is supported.
|
||||
*/
|
||||
export function supportsTransientOpen(sync: WireServerCapabilities['textDocumentSync']): boolean {
|
||||
if (sync === undefined) return false
|
||||
if (typeof sync === 'number') return isOpenCloseKind(sync)
|
||||
return sync.openClose === true || (sync.openClose === undefined && changeAllowsOpenClose(sync))
|
||||
}
|
||||
|
||||
/** Legacy enum: `Full` (1) or `Incremental` (2) imply open/close support; `None` (0) does not. */
|
||||
function isOpenCloseKind(kind: WireTextDocumentSyncKind): boolean {
|
||||
return kind === 1 || kind === 2
|
||||
}
|
||||
|
||||
/** Options without an explicit `openClose` fall back to the legacy `change` enum's implication. */
|
||||
function changeAllowsOpenClose(sync: WireTextDocumentSyncOptions): boolean {
|
||||
return sync.change !== undefined && isOpenCloseKind(sync.change)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the negotiated position encoding. An omitted encoding defaults to `utf-16`; any value
|
||||
* other than `utf-16` is a protocol error this host does not support.
|
||||
* @param encoding - the server's advertised `positionEncoding`, if any.
|
||||
* @returns the string `'utf-16'`.
|
||||
* @throws Error for any non-`utf-16` encoding.
|
||||
*/
|
||||
export function negotiatePositionEncoding(encoding: string | undefined): 'utf-16' {
|
||||
if (encoding === undefined || encoding === 'utf-16') return 'utf-16'
|
||||
throw new Error(`server negotiated unsupported position encoding "${encoding}"; this host requires utf-16`)
|
||||
}
|
||||
|
||||
/** Convert a wire range to the seam's range (structurally identical, but re-shaped as `readonly`). */
|
||||
function toRange(range: WireRange): LspRange {
|
||||
return {
|
||||
start: { line: range.start.line, character: range.start.character },
|
||||
end: { line: range.end.line, character: range.end.character },
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a record is a `LocationLink` (has `targetUri` + `targetSelectionRange`). */
|
||||
function isLocationLink(value: Record<string, unknown>): boolean {
|
||||
return typeof value.targetUri === 'string' && isRange(value.targetSelectionRange)
|
||||
}
|
||||
|
||||
/** Whether a record is a `Location` (has string `uri` + a range). */
|
||||
function isLocation(value: Record<string, unknown>): boolean {
|
||||
return typeof value.uri === 'string' && isRange(value.range)
|
||||
}
|
||||
|
||||
/** Structural range guard used by both location shapes. */
|
||||
function isRange(value: unknown): value is WireRange {
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
const range = value as Record<string, unknown>
|
||||
return isPosition(range.start) && isPosition(range.end)
|
||||
}
|
||||
|
||||
/** Structural position guard. */
|
||||
function isPosition(value: unknown): boolean {
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
const position = value as Record<string, unknown>
|
||||
return typeof position.line === 'number' && typeof position.character === 'number'
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a navigation result (`Location`, `Location[]`, `LocationLink[]`, or `null`) to the seam's
|
||||
* locations. `Location` maps directly; `LocationLink` maps `targetUri` + `targetSelectionRange`.
|
||||
* @param payload - the raw `textDocument/definition|references|implementation` result.
|
||||
* @returns the normalized locations (empty for `null`/`[]`).
|
||||
* @throws Error when an element is neither a `Location` nor a `LocationLink`.
|
||||
*/
|
||||
export function normalizeLocations(payload: unknown): LspLocation[] {
|
||||
if (payload === null || payload === undefined) return []
|
||||
const elements = Array.isArray(payload) ? payload : [payload]
|
||||
const locations: LspLocation[] = []
|
||||
for (const element of elements) {
|
||||
if (element === null || typeof element !== 'object') {
|
||||
throw new Error('LSP navigation result contained a non-object entry')
|
||||
}
|
||||
const record = element as Record<string, unknown>
|
||||
if (isLocationLink(record)) {
|
||||
const link = record as unknown as WireLocationLink
|
||||
locations.push({ uri: link.targetUri, range: toRange(link.targetSelectionRange) })
|
||||
} else if (isLocation(record)) {
|
||||
const location = record as unknown as WireLocation
|
||||
locations.push({ uri: location.uri, range: toRange(location.range) })
|
||||
} else {
|
||||
throw new Error('LSP navigation result contained neither a Location nor a LocationLink')
|
||||
}
|
||||
}
|
||||
return locations
|
||||
}
|
||||
|
||||
/** Render one `MarkedString` (string form verbatim; object form as a language-tagged fenced block). */
|
||||
function renderMarkedString(value: WireMarkedString): string {
|
||||
if (typeof value === 'string') return value
|
||||
return `\`\`\`${value.language}\n${value.value}\n\`\`\``
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a `Hover` (or `null`) to the seam's hover. `MarkupContent` uses its `value`; a string
|
||||
* `MarkedString` is verbatim; a language-tagged `MarkedString` becomes a fenced code block; an array
|
||||
* joins its rendered parts with one blank line. `maxHoverChars` is NOT applied here — the tool caps.
|
||||
* @param payload - the raw `textDocument/hover` result.
|
||||
* @returns the normalized hover, or `null` when there is no content.
|
||||
* @throws Error when the payload is a non-null, non-object, or structurally invalid hover.
|
||||
*/
|
||||
export function normalizeHover(payload: unknown): LspHover | null {
|
||||
if (payload === null || payload === undefined) return null
|
||||
if (typeof payload !== 'object') throw new Error('LSP hover result was not an object')
|
||||
const hover = payload as unknown as WireHover
|
||||
const contents = renderHoverContents(hover.contents)
|
||||
if (contents === '') return null
|
||||
const range = hover.range
|
||||
return range !== undefined && isRange(range) ? { contents, range: toRange(range) } : { contents }
|
||||
}
|
||||
|
||||
/** Render the three `Hover.contents` encodings into one string (input is untrusted wire data). */
|
||||
function renderHoverContents(contents: unknown): string {
|
||||
if (contents === null || contents === undefined) {
|
||||
throw new Error('LSP hover result had no contents')
|
||||
}
|
||||
if (typeof contents === 'string') return contents
|
||||
if (Array.isArray(contents)) {
|
||||
return contents.map(renderMarkedString).join('\n\n')
|
||||
}
|
||||
if (typeof contents !== 'object') {
|
||||
throw new Error('LSP hover contents were not MarkupContent, MarkedString, or an array')
|
||||
}
|
||||
const record = contents as Record<string, unknown>
|
||||
if (record.kind === 'markdown' || record.kind === 'plaintext') {
|
||||
return typeof record.value === 'string' ? record.value : ''
|
||||
}
|
||||
if (typeof record.language === 'string' && typeof record.value === 'string') {
|
||||
return renderMarkedString({ language: record.language, value: record.value })
|
||||
}
|
||||
throw new Error('LSP hover contents were not MarkupContent, MarkedString, or an array')
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Keyless built-artifact smoke: plain Node imports `@deepseek-ai/dsh-lsp` and
|
||||
* `@deepseek-ai/dsh-lsp-local` by name through their exports maps, spawns the fixture server, runs
|
||||
* one query (exercising real `Content-Length` framing over `lib/index.js`), and disposes (exercising
|
||||
* subprocess cleanup). Unit tests use `src/`; this pins the downstream `lib/` path. Skips when `lib/`
|
||||
* is absent; CI runs it after the build.
|
||||
*/
|
||||
|
||||
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
|
||||
const seamLib = join(pkgDir, '../lsp/lib/index.js')
|
||||
const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib)
|
||||
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-built-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (root) await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
it('runs a query through lib/index.js and disposes cleanly, framing over the base protocol', async () => {
|
||||
const location = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
|
||||
const script = `
|
||||
const { Context } = await import('cordis')
|
||||
const { default: Lsp } = await import('@deepseek-ai/dsh-lsp')
|
||||
const LspLocal = await import('@deepseek-ai/dsh-lsp-local')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LspLocal, {
|
||||
providerId: 'fake',
|
||||
command: ${JSON.stringify(process.execPath)},
|
||||
args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}],
|
||||
env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
})
|
||||
const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} })
|
||||
console.log(JSON.stringify(result))
|
||||
await ctx.fiber.dispose()
|
||||
process.exit(0)
|
||||
`
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
|
||||
const exitCode = await new Promise<number | null>(resolve => child.on('close', resolve))
|
||||
|
||||
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
|
||||
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
|
||||
const result = JSON.parse(lastLine) as { kind: string; locations: unknown[] }
|
||||
expect(result.kind).toBe('locations')
|
||||
expect(result.locations).toHaveLength(1)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
/** A recorded server→client request the test's handler saw. */
|
||||
interface SeenRequest { method: string; params: unknown }
|
||||
|
||||
let open: LspConnection[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const conn of open) {
|
||||
conn.kill()
|
||||
await conn.closed
|
||||
}
|
||||
open = []
|
||||
})
|
||||
|
||||
/** Spawn the fixture as a raw connection, with a scripted server-request handler. */
|
||||
function connect(
|
||||
env: Record<string, string>,
|
||||
onServerRequest: (method: string, params: unknown) => Promise<unknown> = () => Promise.resolve(null),
|
||||
seen?: SeenRequest[],
|
||||
): LspConnection {
|
||||
const conn = new LspConnection({
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, fixtureServer],
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env as Record<string, string>, TSX_TSCONFIG_PATH: repoTsconfig, ...env },
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes: 100_000,
|
||||
configuration: { setting: 42 },
|
||||
}, (method, params) => {
|
||||
seen?.push({ method, params })
|
||||
return onServerRequest(method, params)
|
||||
})
|
||||
open.push(conn)
|
||||
return conn
|
||||
}
|
||||
|
||||
describe('LspConnection', () => {
|
||||
it('completes an initialize request/response round-trip and exposes a pid', async () => {
|
||||
const conn = connect({})
|
||||
const result = await conn.request('initialize', { capabilities: {} })
|
||||
expect(result).toMatchObject({ capabilities: { hoverProvider: true } })
|
||||
expect(conn.pid).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('rejects a request when the server replies with an error', async () => {
|
||||
const conn = connect({ LSP_FAKE_ERROR: '1' })
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/)
|
||||
})
|
||||
|
||||
it('answers a server workspace/configuration request from static config', async () => {
|
||||
const seen: SeenRequest[] = []
|
||||
const conn = connect(
|
||||
{ LSP_FAKE_ON_OPEN: 'configuration' },
|
||||
(method, params) => {
|
||||
if (method === 'workspace/configuration') {
|
||||
const items = (params as { items: unknown[] }).items
|
||||
return Promise.resolve(items.map(() => ({ setting: 42 })))
|
||||
}
|
||||
return Promise.resolve(null)
|
||||
},
|
||||
seen,
|
||||
)
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
|
||||
await waitFor(() => seen.some(s => s.method === 'workspace/configuration'))
|
||||
expect(seen[0]?.method).toBe('workspace/configuration')
|
||||
})
|
||||
|
||||
it('drops a server→client notification without replying', async () => {
|
||||
const conn = connect({ LSP_FAKE_ON_OPEN: 'notification' })
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
|
||||
// No throw and the connection stays usable.
|
||||
await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('sends an error response when the server-request handler rejects', async () => {
|
||||
const seen: SeenRequest[] = []
|
||||
const conn = connect(
|
||||
{ LSP_FAKE_ON_OPEN: 'applyEdit' },
|
||||
method => method === 'workspace/applyEdit' ? Promise.reject(new Error('not permitted')) : Promise.resolve(null),
|
||||
seen,
|
||||
)
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
|
||||
await waitFor(() => seen.some(s => s.method === 'workspace/applyEdit'))
|
||||
// The connection remains healthy after emitting the error response.
|
||||
await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('fails all pending requests and kills the process on a framing error', async () => {
|
||||
const conn = connect({ LSP_FAKE_GARBAGE: '1' })
|
||||
// The garbage byte precedes a valid initialize reply; unframed bytes are tolerated until a
|
||||
// Content-Length header, so initialize still resolves. This exercises the decoder's resilience.
|
||||
await expect(conn.request('initialize', { capabilities: {} })).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects a new request issued after the process closes', async () => {
|
||||
const conn = connect({})
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
conn.terminate()
|
||||
await conn.closed
|
||||
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/exited|closed/)
|
||||
})
|
||||
|
||||
it('cancel is a no-op-safe write after close', async () => {
|
||||
const conn = connect({})
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
conn.terminate()
|
||||
await conn.closed
|
||||
expect(() => { conn.cancel(1) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('caps the retained stderr tail', async () => {
|
||||
const conn = connect({})
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
expect(conn.stderrTail.length).toBeLessThanOrEqual(100_000)
|
||||
})
|
||||
})
|
||||
|
||||
/** Spawn a raw connection running an inline node script as the "server". */
|
||||
function connectScript(script: string, maxStderrBytes = 100_000): LspConnection {
|
||||
const conn = new LspConnection({
|
||||
command: process.execPath,
|
||||
args: ['-e', script],
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env as Record<string, string> },
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes,
|
||||
configuration: null,
|
||||
}, () => Promise.resolve(null))
|
||||
open.push(conn)
|
||||
return conn
|
||||
}
|
||||
|
||||
describe('LspConnection edge behavior', () => {
|
||||
it('fails a request when the command cannot be spawned', async () => {
|
||||
const conn = new LspConnection({
|
||||
command: '/definitely/not/a/real/binary/xyz',
|
||||
args: [],
|
||||
cwd: process.cwd(),
|
||||
env: {},
|
||||
maxMessageBytes: 1000,
|
||||
maxStderrBytes: 1000,
|
||||
configuration: null,
|
||||
}, () => Promise.resolve(null))
|
||||
open.push(conn)
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('kills the process and fails pending requests on a framing error', async () => {
|
||||
// Emit an invalid Content-Length header, corrupting the stream irrecoverably.
|
||||
const conn = connectScript('process.stdout.write("Content-Length: abc\\r\\n\\r\\n{}"); setInterval(()=>{}, 1000)')
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('ignores a framed non-object message', async () => {
|
||||
// Send a framed JSON number and a framed null (both non-objects) then a proper response to id 1.
|
||||
const script = 'let b=Buffer.alloc(0);'
|
||||
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdout.write(fr("42"));process.stdout.write(fr("null"));'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
|
||||
const conn = connectScript(script)
|
||||
await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('drops a response for an unknown id', async () => {
|
||||
// Emit a response for id 999 (never sent), then answer our real request.
|
||||
const script = 'let b=Buffer.alloc(0);'
|
||||
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:999,result:{stray:true}})));'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
|
||||
const conn = connectScript(script)
|
||||
await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('caps the retained stderr tail at maxStderrBytes across chunks', async () => {
|
||||
// Write stderr repeatedly so a later chunk arrives after the cap is already reached.
|
||||
const conn = connectScript('setInterval(()=>process.stderr.write("E".repeat(200)), 5); setInterval(()=>{}, 1000)', 100)
|
||||
await waitFor(() => conn.stderrTail.length >= 100)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 50))
|
||||
expect(conn.stderrTail.length).toBe(100)
|
||||
})
|
||||
|
||||
it('rejects with a fallback message when the error response has no message string', async () => {
|
||||
const script = 'let b=Buffer.alloc(0);'
|
||||
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,error:{code:-1}})));});'
|
||||
const conn = connectScript(script)
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow(/LSP error response/)
|
||||
})
|
||||
|
||||
it('rejects a pending request when the process exits mid-flight', async () => {
|
||||
// Never responds, then exits shortly: the pending request must reject on close.
|
||||
const conn = connectScript('setTimeout(()=>process.exit(0), 100)')
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/)
|
||||
})
|
||||
|
||||
it('ignores a frame that is neither a valid request nor a numeric-id response', async () => {
|
||||
// A frame with a string id and no method: not dispatchable; the client must ignore it and still
|
||||
// answer our real request.
|
||||
const script = 'let b=Buffer.alloc(0);'
|
||||
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:"str-id"})));'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
|
||||
const conn = connectScript(script)
|
||||
await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
/** Poll a predicate until it holds or a deadline elapses. */
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out')
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* A scriptable fake LSP server over stdio for lsp-local tests. It speaks the real
|
||||
* `Content-Length`-framed base protocol so it exercises the client's framing, initialize handshake,
|
||||
* transient open/close, request mapping, and teardown — without a real language server.
|
||||
*
|
||||
* Behavior is driven by env vars so one file backs many scenarios:
|
||||
* - LSP_FAKE_ENCODING: advertised positionEncoding (default utf-16; "utf-8" forces a mismatch).
|
||||
* - LSP_FAKE_SYNC: textDocumentSync value as JSON (default 1/Full).
|
||||
* - LSP_FAKE_CAPS: JSON of extra capability flags merged into the defaults.
|
||||
* - LSP_FAKE_DEF / LSP_FAKE_REFS / LSP_FAKE_IMPL / LSP_FAKE_HOVER: JSON result per request.
|
||||
* - LSP_FAKE_HANG: "1" makes textDocument/* requests never respond (for abort/timeout tests).
|
||||
* - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test).
|
||||
* - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
|
||||
* - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of
|
||||
* "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr.
|
||||
* - LSP_FAKE_ERROR: "1" answers textDocument/* requests with a JSON-RPC error response.
|
||||
* - LSP_FAKE_GARBAGE: "1" emits an unframed garbage byte before the initialize reply.
|
||||
*
|
||||
* Run: node --import tsx fixture-server.ts
|
||||
*/
|
||||
|
||||
const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16'
|
||||
const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1
|
||||
const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {}
|
||||
const hang = process.env.LSP_FAKE_HANG === '1'
|
||||
const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1'
|
||||
const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1'
|
||||
const onOpen = process.env.LSP_FAKE_ON_OPEN
|
||||
const errorReply = process.env.LSP_FAKE_ERROR === '1'
|
||||
const garbage = process.env.LSP_FAKE_GARBAGE === '1'
|
||||
|
||||
let serverRequestId = 10_000
|
||||
const pendingServerRequests = new Map<number, string>()
|
||||
|
||||
function resultFor(method: string): unknown {
|
||||
switch (method) {
|
||||
case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null)
|
||||
case 'textDocument/references': return envJson('LSP_FAKE_REFS', null)
|
||||
case 'textDocument/implementation': return envJson('LSP_FAKE_IMPL', null)
|
||||
case 'textDocument/hover': return envJson('LSP_FAKE_HOVER', null)
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function envJson(name: string, fallback: unknown): unknown {
|
||||
const raw = process.env[name]
|
||||
return raw === undefined ? fallback : JSON.parse(raw)
|
||||
}
|
||||
|
||||
let buffer = Buffer.alloc(0)
|
||||
process.stdin.on('data', (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk])
|
||||
for (;;) {
|
||||
const sep = buffer.indexOf('\r\n\r\n')
|
||||
if (sep < 0) break
|
||||
const header = buffer.toString('ascii', 0, sep)
|
||||
const match = /content-length:\s*(\d+)/i.exec(header)
|
||||
if (!match) { buffer = buffer.subarray(sep + 4); continue }
|
||||
const length = Number(match[1])
|
||||
const start = sep + 4
|
||||
if (buffer.length < start + length) break
|
||||
const body = buffer.toString('utf8', start, start + length)
|
||||
buffer = buffer.subarray(start + length)
|
||||
handle(JSON.parse(body) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown })
|
||||
}
|
||||
})
|
||||
|
||||
function handle(message: { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown }): void {
|
||||
const { id, method } = message
|
||||
// A frame with an id but no method is the client's REPLY to a server→client request; log it.
|
||||
if (method === undefined && id !== undefined && pendingServerRequests.has(id)) {
|
||||
const kind = pendingServerRequests.get(id)
|
||||
pendingServerRequests.delete(id)
|
||||
process.stderr.write(`REPLY ${kind} ${JSON.stringify({ result: message.result, error: message.error })}\n`)
|
||||
return
|
||||
}
|
||||
if (method === 'initialize') {
|
||||
if (garbage) process.stdout.write('this is not a framed message\r\n')
|
||||
send({
|
||||
id,
|
||||
result: {
|
||||
capabilities: {
|
||||
positionEncoding: enc,
|
||||
textDocumentSync: sync,
|
||||
definitionProvider: true,
|
||||
referencesProvider: true,
|
||||
implementationProvider: true,
|
||||
hoverProvider: true,
|
||||
...(extraCaps as Record<string, unknown>),
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (method === 'shutdown') {
|
||||
if (noShutdown) return
|
||||
send({ id, result: null })
|
||||
return
|
||||
}
|
||||
if (method === 'exit') {
|
||||
process.exit(0)
|
||||
}
|
||||
if (method === 'textDocument/didOpen') {
|
||||
if (crashOnOpen) process.exit(1)
|
||||
if (onOpen !== undefined) emitServerRequest(onOpen)
|
||||
return
|
||||
}
|
||||
if (method === 'textDocument/didClose' || method === 'initialized') return
|
||||
if (method?.startsWith('textDocument/')) {
|
||||
if (hang) return
|
||||
if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }); return }
|
||||
send({ id, result: resultFor(method) })
|
||||
return
|
||||
}
|
||||
// Unknown request with an id: answer null so the client never stalls.
|
||||
if (id !== undefined) send({ id, result: null })
|
||||
}
|
||||
|
||||
/** Emit a server→client request and log the client's reply to stderr for the test to assert. */
|
||||
function emitServerRequest(kind: string): void {
|
||||
if (kind === 'notification') {
|
||||
send({ method: 'window/logMessage', params: { type: 3, message: 'hello' } })
|
||||
return
|
||||
}
|
||||
const id = serverRequestId++
|
||||
const method = kind === 'configuration'
|
||||
? 'workspace/configuration'
|
||||
: kind === 'applyEdit'
|
||||
? 'workspace/applyEdit'
|
||||
: kind === 'lifecycle'
|
||||
? 'client/registerCapability'
|
||||
: 'window/showMessageRequest'
|
||||
const params = kind === 'configuration' ? { items: [{ section: 'a' }, { section: 'b' }] } : {}
|
||||
pendingServerRequests.set(id, method)
|
||||
send({ id, method, params })
|
||||
}
|
||||
|
||||
function send(message: Record<string, unknown>): void {
|
||||
const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message }), 'utf8')
|
||||
process.stdout.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]))
|
||||
}
|
||||
|
||||
// Keep the event loop alive.
|
||||
process.stdin.resume()
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { encodeMessage, MessageDecoder } from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
/** Frame a message the way a server would, for decoder round-trips. */
|
||||
function frame(body: string): Buffer {
|
||||
return Buffer.concat([Buffer.from(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`, 'ascii'), Buffer.from(body, 'utf8')])
|
||||
}
|
||||
|
||||
describe('encodeMessage', () => {
|
||||
it('prefixes a Content-Length header with the utf-8 byte length', () => {
|
||||
const buffer = encodeMessage({ jsonrpc: '2.0', method: 'x', params: { s: 'é' } })
|
||||
const text = buffer.toString('utf8')
|
||||
const body = '{"jsonrpc":"2.0","method":"x","params":{"s":"é"}}'
|
||||
expect(text).toBe(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageDecoder', () => {
|
||||
it('decodes a single framed message', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
expect(decoder.push(frame('{"id":1,"result":42}'))).toEqual([{ id: 1, result: 42 }])
|
||||
})
|
||||
|
||||
it('decodes multiple messages arriving in one chunk', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const chunk = Buffer.concat([frame('{"a":1}'), frame('{"b":2}')])
|
||||
expect(decoder.push(chunk)).toEqual([{ a: 1 }, { b: 2 }])
|
||||
})
|
||||
|
||||
it('reassembles a message split across chunks', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const full = frame('{"hello":"world"}')
|
||||
expect(decoder.push(full.subarray(0, 10))).toEqual([])
|
||||
expect(decoder.push(full.subarray(10))).toEqual([{ hello: 'world' }])
|
||||
})
|
||||
|
||||
it('handles a header split from its body', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const body = '{"x":1}'
|
||||
expect(decoder.push(Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'))).toEqual([])
|
||||
expect(decoder.push(Buffer.from(body, 'utf8'))).toEqual([{ x: 1 }])
|
||||
})
|
||||
|
||||
it('reads a case-insensitive header and ignores other headers', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const body = '{"ok":true}'
|
||||
const chunk = Buffer.from(`content-length: ${body.length}\r\nContent-Type: x\r\n\r\n${body}`, 'utf8')
|
||||
expect(decoder.push(chunk)).toEqual([{ ok: true }])
|
||||
})
|
||||
|
||||
it('rejects a body over the size limit', () => {
|
||||
const decoder = new MessageDecoder(4)
|
||||
expect(() => decoder.push(frame('{"big":true}'))).toThrow(/exceeds the 4-byte limit/)
|
||||
})
|
||||
|
||||
it('rejects a missing Content-Length header', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
expect(() => decoder.push(Buffer.from('X: 1\r\n\r\n{}', 'utf8'))).toThrow(/missing Content-Length/)
|
||||
})
|
||||
|
||||
it('rejects a non-numeric Content-Length', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
expect(() => decoder.push(Buffer.from('Content-Length: abc\r\n\r\n{}', 'utf8'))).toThrow(/invalid Content-Length/)
|
||||
})
|
||||
|
||||
it('rejects a header block that never terminates', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const huge = Buffer.alloc((1 << 16) + 1, 0x41)
|
||||
expect(() => decoder.push(huge)).toThrow(/exceeded .* bytes without a terminator/)
|
||||
})
|
||||
|
||||
it('rejects a non-JSON body', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-host-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const BIG = 1_000_000
|
||||
|
||||
describe('canonicalizeWorkspace', () => {
|
||||
it('returns the realpath of a directory', async () => {
|
||||
expect(await canonicalizeWorkspace(ws)).toBe(ws)
|
||||
})
|
||||
|
||||
it('resolves a symlinked workspace to its target so aliases share identity', async () => {
|
||||
const link = join(root, 'ws-link')
|
||||
await symlink(ws, link)
|
||||
expect(await canonicalizeWorkspace(link)).toBe(ws)
|
||||
})
|
||||
|
||||
it('rejects a missing workspace', async () => {
|
||||
await expect(canonicalizeWorkspace(join(root, 'nope'))).rejects.toThrow(/cannot be resolved/)
|
||||
})
|
||||
|
||||
it('rejects a non-directory workspace', async () => {
|
||||
const file = join(root, 'file.txt')
|
||||
await writeFile(file, 'x')
|
||||
await expect(canonicalizeWorkspace(file)).rejects.toThrow(/not a directory/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readHostSource', () => {
|
||||
it('reads a relative path against the workspace', async () => {
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
|
||||
const source = await readHostSource('a.ts', ws, BIG)
|
||||
expect(source.canonicalPath).toBe(join(ws, 'a.ts'))
|
||||
expect(source.text).toBe('const x = 1\n')
|
||||
})
|
||||
|
||||
it('reads an absolute path inside the workspace', async () => {
|
||||
const abs = join(ws, 'b.ts')
|
||||
await writeFile(abs, 'b')
|
||||
const source = await readHostSource(abs, ws, BIG)
|
||||
expect(source.canonicalPath).toBe(abs)
|
||||
})
|
||||
|
||||
it('accepts a source reached through a symlink that stays inside the workspace', async () => {
|
||||
await mkdir(join(ws, 'real'))
|
||||
await writeFile(join(ws, 'real', 'c.ts'), 'c')
|
||||
await symlink(join(ws, 'real'), join(ws, 'linked'))
|
||||
const source = await readHostSource('linked/c.ts', ws, BIG)
|
||||
expect(source.canonicalPath).toBe(join(ws, 'real', 'c.ts'))
|
||||
})
|
||||
|
||||
it('rejects a source whose canonical path escapes the workspace via symlink', async () => {
|
||||
const outside = join(root, 'outside.ts')
|
||||
await writeFile(outside, 'secret')
|
||||
await symlink(outside, join(ws, 'escape.ts'))
|
||||
await expect(readHostSource('escape.ts', ws, BIG)).rejects.toThrow(/outside the workspace/)
|
||||
})
|
||||
|
||||
it('rejects an absolute source outside the workspace', async () => {
|
||||
const outside = join(root, 'out.ts')
|
||||
await writeFile(outside, 'x')
|
||||
await expect(readHostSource(outside, ws, BIG)).rejects.toThrow(/outside the workspace/)
|
||||
})
|
||||
|
||||
it('rejects a missing source', async () => {
|
||||
await expect(readHostSource('nope.ts', ws, BIG)).rejects.toThrow(/cannot be resolved/)
|
||||
})
|
||||
|
||||
it('rejects a non-regular source (directory)', async () => {
|
||||
await mkdir(join(ws, 'dir'))
|
||||
await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/)
|
||||
})
|
||||
|
||||
it('treats the workspace root itself as inside, then rejects it as non-regular', async () => {
|
||||
// filePath '.' canonicalizes to the workspace dir: isInside's identity branch is taken, and the
|
||||
// directory then fails the regular-file check.
|
||||
await expect(readHostSource('.', ws, BIG)).rejects.toThrow(/not a regular file/)
|
||||
})
|
||||
|
||||
it('rejects an oversized source', async () => {
|
||||
await writeFile(join(ws, 'big.ts'), 'x'.repeat(100))
|
||||
await expect(readHostSource('big.ts', ws, 10)).rejects.toThrow(/over the 10-byte limit/)
|
||||
})
|
||||
|
||||
it('rejects a non-UTF-8 source', async () => {
|
||||
await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00]))
|
||||
await expect(readHostSource('bin.ts', ws, BIG)).rejects.toThrow(/not valid UTF-8/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
import { LspInstance } from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
|
||||
import type { LspProviderQuery } from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
let live: LspInstance[] = []
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-inst-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const instance of live) await instance.dispose()
|
||||
live = []
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function makeInstance(env: Record<string, string> = {}, overrides: Partial<InstanceSpec> = {}): LspInstance {
|
||||
const instance = new LspInstance({
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, fixtureServer],
|
||||
cwd: ws,
|
||||
env: { ...process.env as Record<string, string>, TSX_TSCONFIG_PATH: repoTsconfig, ...env },
|
||||
configuration: { setting: 42 },
|
||||
initializationOptions: { init: true },
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes: 100_000,
|
||||
maxDocumentBytes: 4_000_000,
|
||||
shutdownTimeoutMs: 200,
|
||||
killGraceMs: 200,
|
||||
...overrides,
|
||||
})
|
||||
live.push(instance)
|
||||
return instance
|
||||
}
|
||||
|
||||
function query(operation: LspProviderQuery['operation'] = 'definition'): LspProviderQuery {
|
||||
return { operation, filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ws, languageId: 'typescript' }
|
||||
}
|
||||
|
||||
/** Build an instance whose "server" is an inline node script (for teardown-escalation control). */
|
||||
function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}): LspInstance {
|
||||
const instance = new LspInstance({
|
||||
command: process.execPath,
|
||||
args: ['-e', script],
|
||||
cwd: ws,
|
||||
env: { ...process.env as Record<string, string> },
|
||||
configuration: null,
|
||||
initializationOptions: null,
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes: 100_000,
|
||||
maxDocumentBytes: 4_000_000,
|
||||
shutdownTimeoutMs: 150,
|
||||
killGraceMs: 150,
|
||||
...overrides,
|
||||
})
|
||||
live.push(instance)
|
||||
return instance
|
||||
}
|
||||
|
||||
/** An inline server that answers initialize + definition and echoes a location. */
|
||||
const RESPONDING_SERVER =
|
||||
'let b=Buffer.alloc(0);'
|
||||
+ 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
|
||||
+ 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
|
||||
+ 'else if(m.method==="textDocument/definition")process.stdout.write(fr({id:m.id,result:null}));'
|
||||
+ '}});'
|
||||
|
||||
const locJson = () => JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
|
||||
|
||||
describe('LspInstance server-request handling', () => {
|
||||
it('answers workspace/configuration with the static config per item', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'configuration', LSP_FAKE_DEF: locJson() })
|
||||
// The query drives didOpen, which makes the fake emit workspace/configuration; a healthy answer
|
||||
// keeps the query working.
|
||||
await expect(instance.query(query('definition'))).resolves.toMatchObject({ kind: 'locations' })
|
||||
})
|
||||
|
||||
it('accepts a lifecycle client/registerCapability request', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' })
|
||||
await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] })
|
||||
})
|
||||
|
||||
it('rejects a workspace/applyEdit request but keeps serving', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' })
|
||||
await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] })
|
||||
})
|
||||
|
||||
it('rejects an unknown server request but keeps serving', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' })
|
||||
await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('LspInstance query and abort', () => {
|
||||
it('sends includeDeclaration for references', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) })
|
||||
await expect(instance.query(query('references'))).resolves.toMatchObject({ kind: 'locations' })
|
||||
})
|
||||
|
||||
it('rejects a query aborted before it starts', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('pre-abort'))
|
||||
await expect(instance.query(query('definition'), controller.signal)).rejects.toThrow(/pre-abort/)
|
||||
})
|
||||
|
||||
it('cancels an in-flight request on abort and rejects', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_HANG: '1' })
|
||||
const controller = new AbortController()
|
||||
// Warm the instance first so the abort lands during the hanging request, not during startup.
|
||||
const pending = instance.query(query('definition'), controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
||||
controller.abort(new Error('mid-flight'))
|
||||
await expect(pending).rejects.toThrow(/mid-flight/)
|
||||
})
|
||||
|
||||
it('rejects when the server lacks the operation capability', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' })
|
||||
await expect(instance.query(query('definition'))).rejects.toThrow(/does not support definition/)
|
||||
})
|
||||
|
||||
it('propagates a server error response even when a signal is supplied (not an abort)', async () => {
|
||||
// A live signal is passed, but the request fails for a server reason; the catch must rethrow
|
||||
// without treating it as an abort.
|
||||
const instance = makeInstance({ LSP_FAKE_ERROR: '1' })
|
||||
const controller = new AbortController()
|
||||
await expect(instance.query(query('definition'), controller.signal)).rejects.toThrow(/server refused/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LspInstance disposal', () => {
|
||||
it('is idempotent — a second dispose awaits close without error', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await instance.query(query('definition'))
|
||||
await instance.dispose()
|
||||
await expect(instance.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a query after disposal', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await instance.query(query('definition'))
|
||||
await instance.dispose()
|
||||
await expect(instance.query(query('definition'))).rejects.toThrow(/disposed/)
|
||||
})
|
||||
|
||||
it('reports dead after the process closes', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await instance.query(query('definition'))
|
||||
await instance.dispose()
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
|
||||
it('escalates to SIGKILL when the server ignores shutdown and SIGTERM', async () => {
|
||||
// Server answers initialize, ignores shutdown, and traps SIGTERM so only SIGKILL stops it.
|
||||
const script = RESPONDING_SERVER + 'process.on("SIGTERM",()=>{});'
|
||||
const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
await instance.query(query('definition'))
|
||||
await expect(instance.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries a non-Error abort reason as a generic aborted error', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_HANG: '1' })
|
||||
const controller = new AbortController()
|
||||
const pending = instance.query(query('definition'), controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 200))
|
||||
controller.abort('a string reason, not an Error')
|
||||
await expect(pending).rejects.toThrow(/aborted/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { Config } from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-local-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\nconst y = x\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Mount the real seam + lsp-local plugin driving the fake server with the given env. */
|
||||
async function mount(fakeEnv: Record<string, string> = {}, overrides: Partial<Config> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LspLocal, {
|
||||
providerId: 'fake',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, fixtureServer],
|
||||
env: { TSX_TSCONFIG_PATH: repoTsconfig, ...fakeEnv },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
...overrides,
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
function query(operation: LspQueryRequest['operation'], filePath = 'a.ts'): LspQueryRequest {
|
||||
return { operation, filePath, position: { line: 0, character: 6 }, workspaceRoot: ws }
|
||||
}
|
||||
|
||||
/** A single Location JSON pointing into the workspace. */
|
||||
function locationJson(line: number): unknown {
|
||||
return { uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line, character: 0 }, end: { line, character: 3 } } }
|
||||
}
|
||||
|
||||
describe('lsp-local end to end over a fake server', () => {
|
||||
it('resolves definition to normalized locations', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
|
||||
const result = await ctx.lsp.query(query('definition'))
|
||||
expect(result).toEqual<LspQueryResult>({
|
||||
kind: 'locations',
|
||||
locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }],
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('maps a LocationLink for implementation', async () => {
|
||||
const link = { targetUri: pathToFileURL(join(ws, 'a.ts')).href, targetSelectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 2 } } }
|
||||
const ctx = await mount({ LSP_FAKE_IMPL: JSON.stringify([link]) })
|
||||
const result = await ctx.lsp.query(query('implementation'))
|
||||
expect(result).toMatchObject({ kind: 'locations', locations: [{ range: { start: { line: 1, character: 0 } } }] })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('returns references (server includes the declaration)', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_REFS: JSON.stringify([locationJson(0), locationJson(1)]) })
|
||||
const result = await ctx.lsp.query(query('references'))
|
||||
expect(result).toMatchObject({ kind: 'locations' })
|
||||
if (result.kind !== 'locations') throw new Error('expected locations')
|
||||
expect(result.locations).toHaveLength(2)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('normalizes a hover MarkupContent', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_HOVER: JSON.stringify({ contents: { kind: 'markdown', value: 'docs' } }) })
|
||||
const result = await ctx.lsp.query(query('hover'))
|
||||
expect(result).toEqual({ kind: 'hover', hover: { contents: 'docs' } })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('returns an empty locations result for a null definition', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('returns a null hover for a null result', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_HOVER: 'null' })
|
||||
expect(await ctx.lsp.query(query('hover'))).toEqual({ kind: 'hover', hover: null })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a non-utf-16 position encoding at initialize', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a server without transient-open sync (None)', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/transient textDocument\/didOpen/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('accepts openClose options sync', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' })
|
||||
expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails a query for an unsupported operation', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_CAPS: JSON.stringify({ hoverProvider: false }), LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query(query('hover'))).rejects.toThrow(/does not support hover/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a source outside the workspace before startup', async () => {
|
||||
const outside = join(root, 'out.ts')
|
||||
await writeFile(outside, 'x')
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query({ ...query('definition'), filePath: outside })).rejects.toThrow(/outside the workspace/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('serializes queries through one instance and runs them in order', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
|
||||
const results = await Promise.all([
|
||||
ctx.lsp.query(query('definition')),
|
||||
ctx.lsp.query(query('definition')),
|
||||
ctx.lsp.query(query('definition')),
|
||||
])
|
||||
for (const result of results) expect(result).toMatchObject({ kind: 'locations' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('aborts an in-flight query when the signal fires', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_HANG: '1' })
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.lsp.query(query('definition'), controller.signal)
|
||||
controller.abort(new Error('caller cancelled'))
|
||||
await expect(pending).rejects.toThrow(/cancelled/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('classifies a timeout deadline as the abort reason', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_HANG: '1' })
|
||||
using d = deadline(undefined, 50, 'TEST_TIMEOUT')
|
||||
await expect(ctx.lsp.query(query('definition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails the active query when the server crashes on open, and replaces it next query', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_CRASH_ON_OPEN: '1', LSP_FAKE_DEF: 'null' }, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow()
|
||||
// A later query starts a fresh process; still crashes, but proves the slot was replaced (no hang).
|
||||
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('runs distinct workspaces in parallel instances', async () => {
|
||||
const ws2 = join(root, 'ws2')
|
||||
await mkdir(ws2)
|
||||
await writeFile(join(ws2, 'a.ts'), 'const z = 2\n')
|
||||
const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
|
||||
const [r1, r2] = await Promise.all([
|
||||
ctx.lsp.query({ ...query('definition'), workspaceRoot: ws }),
|
||||
ctx.lsp.query({ ...query('definition'), workspaceRoot: ws2 }),
|
||||
])
|
||||
expect(r1).toMatchObject({ kind: 'locations' })
|
||||
expect(r2).toMatchObject({ kind: 'locations' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('disposes cleanly, terminating a server that ignores shutdown', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_NO_SHUTDOWN: '1', LSP_FAKE_DEF: 'null' }, { killGraceMs: 100, shutdownTimeoutMs: 100 })
|
||||
await ctx.lsp.query(query('definition'))
|
||||
await expect(ctx.fiber.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects at load when the command is not found', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
providerId: 'missing',
|
||||
command: 'definitely-not-a-real-lsp-binary-xyz',
|
||||
args: [],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
})).rejects.toThrow(/was not found on PATH/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-prov-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function query(): LspQueryRequest {
|
||||
return { operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws }
|
||||
}
|
||||
|
||||
describe('lsp-local provider resolution', () => {
|
||||
it('resolves a bare command on the child PATH and registers the provider', async () => {
|
||||
// A tiny executable script placed on a custom PATH dir: the load-time resolver must find it.
|
||||
const bin = join(root, 'bin')
|
||||
await mkdir(bin)
|
||||
const exe = join(bin, 'fake-lsp')
|
||||
await writeFile(exe, '#!/bin/sh\nexit 0\n')
|
||||
await chmod(exe, 0o755)
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
providerId: 'onpath',
|
||||
command: 'fake-lsp',
|
||||
args: [],
|
||||
env: { PATH: bin },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
})).resolves.toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('skips empty PATH segments and fails when the command is absent', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
providerId: 'nope',
|
||||
command: 'fake-lsp',
|
||||
args: [],
|
||||
env: { PATH: `::${join(root, 'empty')}` },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
})).rejects.toThrow(/was not found on PATH/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a query after the provider is disposed', async () => {
|
||||
// Use a server that never emits results and dispose the plugin, then confirm queries are refused.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
// Grab the provider instance by registering, then dispose the whole plugin fiber.
|
||||
const lsp = ctx.lsp
|
||||
const fiber = await ctx.plugin(LspLocal, {
|
||||
providerId: 'disp',
|
||||
command: process.execPath,
|
||||
args: ['-e', 'setInterval(()=>{},1000)'],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
})
|
||||
await fiber.dispose()
|
||||
// After disposal the provider unregistered from the seam, so selection fails as unavailable.
|
||||
await expect(lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
negotiatePositionEncoding,
|
||||
normalizeHover,
|
||||
normalizeLocations,
|
||||
requestMethod,
|
||||
supportsOperation,
|
||||
supportsTransientOpen,
|
||||
} from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { WireServerCapabilities } from '@deepseek-ai/dsh-lsp-local/src/protocol.ts'
|
||||
|
||||
const RANGE = { start: { line: 1, character: 2 }, end: { line: 1, character: 5 } }
|
||||
|
||||
describe('requestMethod', () => {
|
||||
it('maps each operation to its textDocument request', () => {
|
||||
expect(requestMethod('definition')).toBe('textDocument/definition')
|
||||
expect(requestMethod('references')).toBe('textDocument/references')
|
||||
expect(requestMethod('implementation')).toBe('textDocument/implementation')
|
||||
expect(requestMethod('hover')).toBe('textDocument/hover')
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsOperation', () => {
|
||||
it('reads the provider slot for each operation (boolean and options forms)', () => {
|
||||
const caps: WireServerCapabilities = {
|
||||
definitionProvider: true,
|
||||
referencesProvider: { workDoneProgress: true },
|
||||
implementationProvider: false,
|
||||
}
|
||||
expect(supportsOperation(caps, 'definition')).toBe(true)
|
||||
expect(supportsOperation(caps, 'references')).toBe(true)
|
||||
expect(supportsOperation(caps, 'implementation')).toBe(false)
|
||||
expect(supportsOperation(caps, 'hover')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsTransientOpen', () => {
|
||||
it('accepts legacy Full and Incremental enums, rejects None and absent', () => {
|
||||
expect(supportsTransientOpen(1)).toBe(true)
|
||||
expect(supportsTransientOpen(2)).toBe(true)
|
||||
expect(supportsTransientOpen(0)).toBe(false)
|
||||
expect(supportsTransientOpen(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts options with openClose:true and rejects openClose:false', () => {
|
||||
expect(supportsTransientOpen({ openClose: true })).toBe(true)
|
||||
expect(supportsTransientOpen({ openClose: false, change: 2 })).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the change enum when openClose is omitted', () => {
|
||||
expect(supportsTransientOpen({ change: 1 })).toBe(true)
|
||||
expect(supportsTransientOpen({ change: 0 })).toBe(false)
|
||||
expect(supportsTransientOpen({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('negotiatePositionEncoding', () => {
|
||||
it('defaults an omitted encoding to utf-16', () => {
|
||||
expect(negotiatePositionEncoding(undefined)).toBe('utf-16')
|
||||
expect(negotiatePositionEncoding('utf-16')).toBe('utf-16')
|
||||
})
|
||||
|
||||
it('rejects any other encoding', () => {
|
||||
expect(() => negotiatePositionEncoding('utf-8')).toThrow(/unsupported position encoding/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeLocations', () => {
|
||||
it('returns empty for null and undefined', () => {
|
||||
expect(normalizeLocations(null)).toEqual([])
|
||||
expect(normalizeLocations(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('maps a single Location', () => {
|
||||
expect(normalizeLocations({ uri: 'file:///a', range: RANGE })).toEqual([{ uri: 'file:///a', range: RANGE }])
|
||||
})
|
||||
|
||||
it('maps an array of Locations', () => {
|
||||
const result = normalizeLocations([{ uri: 'file:///a', range: RANGE }, { uri: 'file:///b', range: RANGE }])
|
||||
expect(result.map(l => l.uri)).toEqual(['file:///a', 'file:///b'])
|
||||
})
|
||||
|
||||
it('maps a LocationLink from targetUri + targetSelectionRange', () => {
|
||||
const link = { targetUri: 'file:///c', targetSelectionRange: RANGE, targetRange: RANGE }
|
||||
expect(normalizeLocations([link])).toEqual([{ uri: 'file:///c', range: RANGE }])
|
||||
})
|
||||
|
||||
it('rejects a non-object entry', () => {
|
||||
expect(() => normalizeLocations([42])).toThrow(/non-object/)
|
||||
})
|
||||
|
||||
it('rejects an entry that is neither a Location nor a LocationLink', () => {
|
||||
expect(() => normalizeLocations([{ nope: true }])).toThrow(/neither a Location nor a LocationLink/)
|
||||
})
|
||||
|
||||
it('rejects a Location whose range is not an object', () => {
|
||||
expect(() => normalizeLocations([{ uri: 'file:///a', range: 'nope' }])).toThrow(/neither a Location/)
|
||||
})
|
||||
|
||||
it('rejects a Location whose range positions are malformed', () => {
|
||||
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: null, end: null } }])).toThrow(/neither a Location/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeHover', () => {
|
||||
it('returns null for null', () => {
|
||||
expect(normalizeHover(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('reads MarkupContent value and keeps a range', () => {
|
||||
expect(normalizeHover({ contents: { kind: 'markdown', value: '# H' }, range: RANGE }))
|
||||
.toEqual({ contents: '# H', range: RANGE })
|
||||
})
|
||||
|
||||
it('keeps a bare string MarkedString verbatim', () => {
|
||||
expect(normalizeHover({ contents: 'plain text' })).toEqual({ contents: 'plain text' })
|
||||
})
|
||||
|
||||
it('renders a language-tagged MarkedString object as a fenced code block', () => {
|
||||
expect(normalizeHover({ contents: { language: 'ts', value: 'const x = 1' } }))
|
||||
.toEqual({ contents: '```ts\nconst x = 1\n```' })
|
||||
})
|
||||
|
||||
it('joins a MarkedString array with one blank line', () => {
|
||||
expect(normalizeHover({ contents: ['a', { language: 'ts', value: 'b' }] }))
|
||||
.toEqual({ contents: 'a\n\n```ts\nb\n```' })
|
||||
})
|
||||
|
||||
it('drops an empty-contents hover to null', () => {
|
||||
expect(normalizeHover({ contents: { kind: 'plaintext', value: '' } })).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a MarkupContent with a non-string value as empty (null)', () => {
|
||||
expect(normalizeHover({ contents: { kind: 'markdown', value: 42 } })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a non-object payload', () => {
|
||||
expect(() => normalizeHover(42)).toThrow(/was not an object/)
|
||||
})
|
||||
|
||||
it('rejects malformed contents', () => {
|
||||
expect(() => normalizeHover({ contents: { weird: true } })).toThrow(/were not MarkupContent/)
|
||||
expect(() => normalizeHover({ contents: 42 })).toThrow(/were not MarkupContent/)
|
||||
})
|
||||
|
||||
it('rejects a hover with no contents field', () => {
|
||||
expect(() => normalizeHover({ range: RANGE })).toThrow(/no contents/)
|
||||
})
|
||||
|
||||
it('ignores a malformed range and keeps the contents', () => {
|
||||
expect(normalizeHover({ contents: 'x', range: { start: { line: 1 } } })).toEqual({ contents: 'x' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Keyless real-server e2e: drives the real `typescript-language-server` through the full
|
||||
* `ctx.lsp` → `dsh-lsp-local` stack over the base protocol, exercising all four operations. No API
|
||||
* key needed — the server is a local dev dependency. This establishes one compatibility floor
|
||||
* (TypeScript), not a cross-language claim.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
// The server binary is a dev dependency of this package; resolve its pnpm-hoisted .bin path.
|
||||
const serverBin = join(
|
||||
new URL('..', import.meta.url).pathname,
|
||||
'node_modules',
|
||||
'.bin',
|
||||
'typescript-language-server',
|
||||
)
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
let ctx: Context
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-ts-e2e-')))
|
||||
ws = join(root, 'proj')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'tsconfig.json'), JSON.stringify({ compilerOptions: { strict: true, module: 'nodenext' } }))
|
||||
// A small program with a definition, a reference, an interface + implementation, and a typed value.
|
||||
await writeFile(join(ws, 'shapes.ts'), [
|
||||
'export interface Shape {',
|
||||
' area(): number',
|
||||
'}',
|
||||
'',
|
||||
'export class Circle implements Shape {',
|
||||
' constructor(private r: number) {}',
|
||||
' area(): number { return Math.PI * this.r * this.r }',
|
||||
'}',
|
||||
'',
|
||||
'export function describe(s: Shape): string {',
|
||||
' return `area=${s.area()}`',
|
||||
'}',
|
||||
'',
|
||||
'const c = new Circle(2)',
|
||||
'export const text = describe(c)',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LspLocal, {
|
||||
providerId: 'typescript',
|
||||
command: serverBin,
|
||||
args: ['--stdio'],
|
||||
extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' },
|
||||
})
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
if (ctx) await ctx.fiber.dispose()
|
||||
if (root) await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** One-based helper mirroring the model contract, converted to the seam's zero-based position. */
|
||||
function at(operation: LspQueryRequest['operation'], line1: number, char1: number, filePath = 'shapes.ts'): LspQueryRequest {
|
||||
return { operation, filePath, position: { line: line1 - 1, character: char1 - 1 }, workspaceRoot: ws }
|
||||
}
|
||||
|
||||
function locations(result: LspQueryResult): readonly { uri: string }[] {
|
||||
if (result.kind !== 'locations') throw new Error(`expected locations, got ${result.kind}`)
|
||||
return result.locations
|
||||
}
|
||||
|
||||
describe('real typescript-language-server', () => {
|
||||
it('resolves the definition of a call site to its declaration', async () => {
|
||||
// `export const text = describe(c)` (line 15): `describe` begins at column 21.
|
||||
const result = await ctx.lsp.query(at('definition', 15, 22))
|
||||
const locs = locations(result)
|
||||
expect(locs.length).toBeGreaterThanOrEqual(1)
|
||||
expect(locs.some(l => l.uri.endsWith('shapes.ts'))).toBe(true)
|
||||
}, 60_000)
|
||||
|
||||
it('finds references to a symbol including its declaration', async () => {
|
||||
// References to `describe` from its declaration (line 10, col 17).
|
||||
const result = await ctx.lsp.query(at('references', 10, 17))
|
||||
const locs = locations(result)
|
||||
// At least the declaration plus the call site.
|
||||
expect(locs.length).toBeGreaterThanOrEqual(2)
|
||||
}, 60_000)
|
||||
|
||||
it('resolves implementations of an interface', async () => {
|
||||
// Implementations of `Shape` (line 1, col 18) → Circle.
|
||||
const result = await ctx.lsp.query(at('implementation', 1, 18))
|
||||
const locs = locations(result)
|
||||
expect(locs.length).toBeGreaterThanOrEqual(1)
|
||||
}, 60_000)
|
||||
|
||||
it('returns hover information for a typed symbol', async () => {
|
||||
// Hover on `Circle` in `new Circle(2)` (line 14, col 15).
|
||||
const result = await ctx.lsp.query(at('hover', 14, 15))
|
||||
expect(result.kind).toBe('hover')
|
||||
if (result.kind === 'hover') {
|
||||
expect(result.hover).not.toBeNull()
|
||||
expect(result.hover?.contents).toContain('Circle')
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../lsp"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# @deepseek-ai/dsh-lsp
|
||||
|
||||
The **LSP capability seam**: an abstract `LspService` (`ctx.lsp`) defining WHAT semantic code navigation the harness has — go to definition, find references, find implementations, hover — over language-server providers, without binding the model contract to local subprocesses.
|
||||
|
||||
This package is the interface third of the LSP capability:
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-lsp` (this) | the interface: the service, provider registry keyed by branded id + extension mapping, per-query selection, request/result vocabulary, the `LspError` taxonomy |
|
||||
| `@deepseek-ai/dsh-lsp-local` | a generic stdio language-server provider |
|
||||
| `@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`.
|
||||
|
||||
## Service API (`ctx.lsp`)
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `registerProvider(provider)` | Register a backend, atomically reserving its branded `id` and every normalized file extension. Any invalid input or conflict publishes nothing and throws `LspError` (`LSP_INVALID_PROVIDER` / `LSP_CONFLICT`). Returns a disposer releasing all reservations. Disposed with the calling fiber. |
|
||||
| `query(request, signal?)` | Select the provider by the file's final extension, derive the `languageId` from that provider's mapping, and run one query. No match throws `LspError` `LSP_UNAVAILABLE`. |
|
||||
|
||||
Selection is per query and order-independent: a provider owns a set of extensions exclusively, so registration and HMR order never change routing. Extension keys normalize to lowercase, leading-dot form; the `languageId` only synchronizes the transient document, never participates in selection. The first version has no glob, language-id, or explicit route selector.
|
||||
|
||||
Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner of the model-facing name, description, prompt guidance, schema, and presentation.
|
||||
|
||||
## 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 }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-lsp`, which owns the model-facing `lsp` schema, prompt, and rendered results while this registry contributes no prompt or schema itself.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Exclusive extension ownership within one runtime** — two providers cannot both claim `.ts`, even with different language ids; overlaps fail registration. The intended extension is a deployment-configured selector above registrations, which can relax exclusive reservation without adding provider choice to model input ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)).
|
||||
- **Four operations only** — symbols and call hierarchy are deferred (they need different schemas); diagnostics need separate freshness/accumulation rules; mutations (rename, code actions, formatting) require separate tools with preview, permission, and write-policy integration.
|
||||
- **No observation surface** — availability is observed only by running `query()` and routing the thrown `LspError` codes; there is no provider-change event or capability-status query.
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-lsp",
|
||||
"description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* dsh-lsp's owned branded id: {@link LspProviderId}, the opaque identity a provider reserves on
|
||||
* `ctx.lsp`. The `Branded<B>` primitive lives in `@deepseek-ai/dsh-brand`; keeping the type and its
|
||||
* factory together here lets `index.ts` re-export both under one name.
|
||||
* @module @deepseek-ai/dsh-lsp/brand
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Opaque provider identity, reserved atomically with its extension mappings at registration. */
|
||||
export type LspProviderId = Branded<'LspProviderId'>
|
||||
|
||||
/**
|
||||
* Brand a string as an {@link LspProviderId}. No validation — the registry rejects an empty id at
|
||||
* registration.
|
||||
* @param id - the provider's stable identifier.
|
||||
* @returns the same string, branded.
|
||||
*/
|
||||
export function LspProviderId(id: string): LspProviderId {
|
||||
return id as LspProviderId
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* The LSP capability seam (`ctx.lsp`): a language-server provider registry and per-query,
|
||||
* order-independent selection over normalized definition/references/implementation/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
|
||||
* invalid or conflicting registration publishes nothing, and its disposer releases every
|
||||
* reservation together. Selection routes a query by the file's final extension; it never depends on
|
||||
* registration order. The seam exposes exactly the four operations and no JSON-RPC escape hatch.
|
||||
* @module @deepseek-ai/dsh-lsp
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { LspProviderId } from './brand.ts'
|
||||
import type {
|
||||
LspProvider,
|
||||
LspQueryRequest,
|
||||
LspQueryResult,
|
||||
LspService,
|
||||
} from './types.ts'
|
||||
|
||||
export { LspProviderId } from './brand.ts'
|
||||
export type {
|
||||
LspHover,
|
||||
LspLocation,
|
||||
LspOperation,
|
||||
LspPosition,
|
||||
LspProvider,
|
||||
LspProviderQuery,
|
||||
LspQueryRequest,
|
||||
LspQueryResult,
|
||||
LspRange,
|
||||
LspService,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
lsp: LspService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
export class LspError extends HarnessError {}
|
||||
|
||||
/**
|
||||
* Extract a file's final extension as a normalized, lowercase, leading-dot key (e.g. `Foo.TS` →
|
||||
* `.ts`, `foo.d.ts` → `.ts`). Returns `''` for a name with no extension or a leading-dot dotfile
|
||||
* (`.bashrc`), which no route ever matches. Splits on both `/` and `\` so a caller's path separator
|
||||
* does not change the result.
|
||||
* @param filePath - the source path to inspect.
|
||||
* @returns the normalized extension, or `''` when there is none.
|
||||
*/
|
||||
export function finalExtension(filePath: string): string {
|
||||
const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
|
||||
const base = lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath
|
||||
const dot = base.lastIndexOf('.')
|
||||
// dot <= 0 covers both "no dot" (-1) and a leading-dot dotfile (0): neither has an extension.
|
||||
if (dot <= 0) return ''
|
||||
return base.slice(dot).toLowerCase()
|
||||
}
|
||||
|
||||
/** A well-formed normalized extension: a dot followed by one or more non-dot, non-separator chars. */
|
||||
const EXTENSION_PATTERN = /^\.[^./\\]+$/
|
||||
|
||||
/** One selection route: the provider to run plus the language id to synchronize the document with. */
|
||||
interface Route {
|
||||
readonly provider: LspProvider
|
||||
readonly languageId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.lsp`. Holds the id reservations and the extension→route table; both are populated and cleared
|
||||
* together per provider so a route always has a live provider.
|
||||
*/
|
||||
export class Lsp extends Service implements LspService {
|
||||
private readonly providerIds = new Set<LspProviderId>()
|
||||
private readonly routes = new Map<string, Route>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'lsp')
|
||||
}
|
||||
|
||||
registerProvider(provider: LspProvider): () => void {
|
||||
// Validate and conflict-check everything BEFORE any mutation: an invalid or conflicting
|
||||
// registration must publish nothing (fail-loud, all-or-nothing).
|
||||
const id = provider.id
|
||||
if (id.trim() === '') {
|
||||
throw new LspError('an LSP provider id must be a non-empty string', 'LSP_INVALID_PROVIDER')
|
||||
}
|
||||
if (this.providerIds.has(id)) {
|
||||
throw new LspError(`an LSP provider with id "${id}" is already registered`, 'LSP_CONFLICT')
|
||||
}
|
||||
|
||||
const entries = Object.entries(provider.extensionToLanguage)
|
||||
if (entries.length === 0) {
|
||||
throw new LspError(`LSP provider "${id}" registers no file extensions`, 'LSP_INVALID_PROVIDER')
|
||||
}
|
||||
|
||||
// Normalize into this provider's route set, catching intra-provider duplicates (e.g. `.TS` and
|
||||
// `.ts`) before checking cross-provider conflicts.
|
||||
const pending = new Map<string, Route>()
|
||||
for (const [rawExt, languageId] of entries) {
|
||||
const ext = normalizeExtension(rawExt)
|
||||
if (!EXTENSION_PATTERN.test(ext)) {
|
||||
throw new LspError(`LSP provider "${id}" maps an invalid extension "${rawExt}"`, 'LSP_INVALID_PROVIDER')
|
||||
}
|
||||
if (languageId.trim() === '') {
|
||||
throw new LspError(`LSP provider "${id}" maps extension "${ext}" to an empty language id`, 'LSP_INVALID_PROVIDER')
|
||||
}
|
||||
if (pending.has(ext)) {
|
||||
throw new LspError(`LSP provider "${id}" maps extension "${ext}" more than once`, 'LSP_INVALID_PROVIDER')
|
||||
}
|
||||
pending.set(ext, { provider, languageId })
|
||||
}
|
||||
for (const ext of pending.keys()) {
|
||||
if (this.routes.has(ext)) {
|
||||
throw new LspError(`extension "${ext}" is already handled by another LSP provider`, 'LSP_CONFLICT')
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed: reserve id and every extension in one lifecycle controller so disposal
|
||||
// releases them together.
|
||||
const dispose = this.ctx.effect(function* (this: Lsp) {
|
||||
this.providerIds.add(id)
|
||||
for (const [ext, route] of pending) this.routes.set(ext, route)
|
||||
yield () => {
|
||||
this.providerIds.delete(id)
|
||||
for (const ext of pending.keys()) this.routes.delete(ext)
|
||||
}
|
||||
}.bind(this), 'lsp.registerProvider()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is synchronous
|
||||
// fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
async query(request: LspQueryRequest, signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
const route = this.routes.get(finalExtension(request.filePath))
|
||||
if (route === undefined) {
|
||||
throw new LspError(`no LSP provider handles "${request.filePath}"`, 'LSP_UNAVAILABLE')
|
||||
}
|
||||
return route.provider.query({ ...request, languageId: route.languageId }, signal)
|
||||
}
|
||||
}
|
||||
|
||||
/** Lowercase an extension and ensure it carries a leading dot; `EXTENSION_PATTERN` rejects the rest. */
|
||||
function normalizeExtension(ext: string): string {
|
||||
const lower = ext.toLowerCase()
|
||||
return lower.startsWith('.') ? lower : `.${lower}`
|
||||
}
|
||||
|
||||
export default Lsp
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* LSP seam vocabulary: the normalized request, provider, and result contracts. Types only — the
|
||||
* {@link LspError} taxonomy and the {@link LspProviderId} brand factory are runtime and live in
|
||||
* `index.ts`. Positions and ranges are zero-based UTF-16, matching the protocol; the model-facing
|
||||
* tool owns the one-based cursor convention. The seam exposes no protocol types, process or document
|
||||
* controls, or generic JSON-RPC escape hatch — only the four semantic operations.
|
||||
* @module @deepseek-ai/dsh-lsp/types
|
||||
*/
|
||||
|
||||
import type { LspProviderId } from './brand.ts'
|
||||
|
||||
/**
|
||||
* The four semantic queries the seam and model expose. A closed union: adding an operation is a
|
||||
* 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'
|
||||
|
||||
/** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */
|
||||
export interface LspPosition {
|
||||
/** Zero-based line. */
|
||||
readonly line: number
|
||||
/** Zero-based UTF-16 code-unit offset within the line. */
|
||||
readonly character: number
|
||||
}
|
||||
|
||||
/** A zero-based UTF-16 half-open range `[start, end)`. */
|
||||
export interface LspRange {
|
||||
readonly start: LspPosition
|
||||
readonly end: LspPosition
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller's normalized query. Every field is required: `workspaceRoot` is caller-supplied,
|
||||
* `languageId` comes from the provider registration (not here), and consumers own timeouts and
|
||||
* result limits — so no field needs implementation defaulting and there is no `resolve()` step.
|
||||
*/
|
||||
export interface LspQueryRequest {
|
||||
/** Which semantic query to run. */
|
||||
readonly operation: LspOperation
|
||||
/** The source file to query (relative to `workspaceRoot` or absolute; the provider canonicalizes). */
|
||||
readonly filePath: string
|
||||
/** The zero-based UTF-16 cursor position to query at. */
|
||||
readonly position: LspPosition
|
||||
/** The workspace root the provider resolves against and indexes; required, never defaulted. */
|
||||
readonly workspaceRoot: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A request as a provider receives it: the caller's {@link LspQueryRequest} plus the `languageId`
|
||||
* the seam derived from the provider's extension mapping. The language id only synchronizes the
|
||||
* transient document; it does not participate in selection.
|
||||
*/
|
||||
export interface LspProviderQuery extends LspQueryRequest {
|
||||
/** The LSP language id for `filePath`, from this provider's extension mapping. */
|
||||
readonly languageId: string
|
||||
}
|
||||
|
||||
/** One resolved location: a document URI and the range within it. */
|
||||
export interface LspLocation {
|
||||
/** The target document URI (`file:` or otherwise), verbatim from the server. */
|
||||
readonly uri: string
|
||||
/** The range within the target document. */
|
||||
readonly range: LspRange
|
||||
}
|
||||
|
||||
/** Normalized hover content, or `null` for no hover at the position. */
|
||||
export interface LspHover {
|
||||
/** The normalized hover text (markdown or plaintext, provider-joined). */
|
||||
readonly contents: string
|
||||
/** The range the hover applies to, when the server supplied one. */
|
||||
readonly range?: LspRange
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export type LspQueryResult =
|
||||
| { readonly kind: 'locations'; readonly locations: readonly LspLocation[] }
|
||||
| { readonly kind: 'hover'; readonly hover: LspHover | null }
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export interface LspProvider {
|
||||
/** Stable provider identity, reserved atomically with the extension mappings. */
|
||||
readonly id: LspProviderId
|
||||
/** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */
|
||||
readonly extensionToLanguage: Readonly<Record<string, string>>
|
||||
/**
|
||||
* Run one query. The seam has already selected this provider and derived `languageId`.
|
||||
* @param request - the resolved provider query (caller request + derived language id).
|
||||
* @param signal - optional cancellation; the provider stops its own work when it aborts.
|
||||
* @returns the normalized, closed-union result.
|
||||
*/
|
||||
query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* The LSP capability seam (`ctx.lsp`). Owns provider registration/selection and normalized query
|
||||
* execution; exposes exactly the four operations and no protocol escape hatch.
|
||||
*/
|
||||
export interface LspService {
|
||||
/**
|
||||
* Register a provider, atomically reserving its id and every normalized extension. Any conflict
|
||||
* or invalid input publishes nothing and throws `LspError`; the returned disposer releases all
|
||||
* reservations. Disposed with the calling fiber.
|
||||
* @param provider - the backend to register.
|
||||
* @returns a synchronous disposer releasing the id and all extension reservations.
|
||||
*/
|
||||
registerProvider(provider: LspProvider): () => void
|
||||
/**
|
||||
* Select a provider by the file's extension and run one query. Selection is per-query and
|
||||
* order-independent; no match throws `LspError` `LSP_UNAVAILABLE`.
|
||||
* @param request - the normalized query.
|
||||
* @param signal - optional cancellation forwarded to the selected provider.
|
||||
* @returns the normalized, closed-union result.
|
||||
*/
|
||||
query(request: LspQueryRequest, signal?: AbortSignal): Promise<LspQueryResult>
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Lsp, {
|
||||
finalExtension,
|
||||
LspError,
|
||||
LspProviderId,
|
||||
type LspProvider,
|
||||
type LspProviderQuery,
|
||||
type LspQueryResult,
|
||||
} from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
/** A scripted provider that records the queries it receives. */
|
||||
function makeProvider(
|
||||
id: string,
|
||||
extensionToLanguage: Record<string, string>,
|
||||
result: LspQueryResult = { kind: 'locations', locations: [] },
|
||||
): LspProvider & { seen: LspProviderQuery[]; seenSignals: (AbortSignal | undefined)[] } {
|
||||
const seen: LspProviderQuery[] = []
|
||||
const seenSignals: (AbortSignal | undefined)[] = []
|
||||
return {
|
||||
id: LspProviderId(id),
|
||||
extensionToLanguage,
|
||||
seen,
|
||||
seenSignals,
|
||||
query(request, signal) {
|
||||
seen.push(request)
|
||||
seenSignals.push(signal)
|
||||
return Promise.resolve(result)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount an Lsp service on a fresh root context. */
|
||||
async function mountLsp(): Promise<{ ctx: Context; lsp: Lsp }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
return { ctx, lsp: ctx.lsp as Lsp }
|
||||
}
|
||||
|
||||
const hover: LspQueryResult = { kind: 'hover', hover: { contents: 'x' } }
|
||||
|
||||
function query(filePath: string, operation: LspProviderQuery['operation'] = 'definition'): Parameters<Lsp['query']>[0] {
|
||||
return { operation, filePath, position: { line: 0, character: 0 }, workspaceRoot: '/ws' }
|
||||
}
|
||||
|
||||
describe('finalExtension', () => {
|
||||
it('lowercases and keeps only the final extension', () => {
|
||||
expect(finalExtension('src/Foo.TS')).toBe('.ts')
|
||||
expect(finalExtension('a/b/foo.d.ts')).toBe('.ts')
|
||||
expect(finalExtension('C:\\proj\\Main.CS')).toBe('.cs')
|
||||
})
|
||||
|
||||
it('returns empty for no extension or a leading-dot dotfile', () => {
|
||||
expect(finalExtension('Makefile')).toBe('')
|
||||
expect(finalExtension('.bashrc')).toBe('')
|
||||
expect(finalExtension('dir.d/file')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Lsp registration', () => {
|
||||
it('registers a provider and routes a query to it, then releases on dispose', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
const provider = makeProvider('ts', { '.ts': 'typescript' })
|
||||
const dispose = lsp.registerProvider(provider)
|
||||
|
||||
await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [] })
|
||||
expect(provider.seen[0]).toMatchObject({ filePath: 'a.ts', languageId: 'typescript' })
|
||||
|
||||
dispose()
|
||||
await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
})
|
||||
|
||||
it('normalizes extension keys to lowercase leading-dot and derives the language id', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
const provider = makeProvider('ts', { TS: 'typescript' })
|
||||
lsp.registerProvider(provider)
|
||||
await lsp.query(query('a.ts'))
|
||||
expect(provider.seen[0]?.languageId).toBe('typescript')
|
||||
})
|
||||
|
||||
it('rejects an empty provider id (LSP_INVALID_PROVIDER)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
expect(() => lsp.registerProvider(makeProvider(' ', { '.ts': 'typescript' })))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
|
||||
})
|
||||
|
||||
it('rejects a provider with no extensions (LSP_INVALID_PROVIDER)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
expect(() => lsp.registerProvider(makeProvider('ts', {})))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
|
||||
})
|
||||
|
||||
it('rejects an invalid extension mapping (LSP_INVALID_PROVIDER)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
expect(() => lsp.registerProvider(makeProvider('ts', { '.tar.gz': 'archive' })))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
|
||||
})
|
||||
|
||||
it('rejects an empty language id (LSP_INVALID_PROVIDER)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
expect(() => lsp.registerProvider(makeProvider('ts', { '.ts': ' ' })))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
|
||||
})
|
||||
|
||||
it('rejects an extension mapped twice within one provider (LSP_INVALID_PROVIDER)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
expect(() => lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript', TS: 'ts2' })))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
|
||||
})
|
||||
|
||||
it('rejects a duplicate provider id (LSP_CONFLICT)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
|
||||
expect(() => lsp.registerProvider(makeProvider('ts', { '.tsx': 'typescriptreact' })))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
|
||||
})
|
||||
|
||||
it('rejects an extension already owned by another provider (LSP_CONFLICT)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
|
||||
expect(() => lsp.registerProvider(makeProvider('other', { '.ts': 'other-lang' })))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
|
||||
})
|
||||
|
||||
it('publishes nothing when a later extension conflicts (atomic reservation)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
|
||||
// This provider's `.py` is free but `.ts` conflicts: the whole registration must roll back.
|
||||
expect(() => lsp.registerProvider(makeProvider('py-ts', { '.py': 'python', '.ts': 'x' })))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
|
||||
// `.py` must NOT have been reserved.
|
||||
await expect(lsp.query(query('a.py'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
})
|
||||
|
||||
it('releases every extension and the id together on dispose', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
const dispose = lsp.registerProvider(makeProvider('multi', { '.ts': 'typescript', '.tsx': 'typescriptreact' }))
|
||||
dispose()
|
||||
await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
await expect(lsp.query(query('a.tsx'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
// The id is free again after release.
|
||||
expect(() => lsp.registerProvider(makeProvider('multi', { '.ts': 'typescript' }))).not.toThrow()
|
||||
})
|
||||
|
||||
it('selection is order-independent across two providers', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
const ts = makeProvider('ts', { '.ts': 'typescript' }, hover)
|
||||
const py = makeProvider('py', { '.py': 'python' })
|
||||
lsp.registerProvider(ts)
|
||||
lsp.registerProvider(py)
|
||||
await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [] })
|
||||
await expect(lsp.query(query('a.ts', 'hover'))).resolves.toEqual(hover)
|
||||
})
|
||||
|
||||
it('forwards the abort signal verbatim to the provider', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
const provider = makeProvider('ts', { '.ts': 'typescript' })
|
||||
lsp.registerProvider(provider)
|
||||
const controller = new AbortController()
|
||||
await lsp.query(query('a.ts'), controller.signal)
|
||||
expect(provider.seenSignals[0]).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('fails LSP_UNAVAILABLE when no provider handles the extension', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
|
||||
await expect(lsp.query(query('a.py'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
})
|
||||
|
||||
it('disposes provider registrations when the contributing fiber is disposed (HMR safety)', async () => {
|
||||
const { ctx, lsp } = await mountLsp()
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
|
||||
}, { inject: ['lsp'] }))
|
||||
await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [] })
|
||||
await fiber.dispose()
|
||||
await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
})
|
||||
|
||||
it('LspError carries its structured code', () => {
|
||||
expect(new LspError('m', 'LSP_UNAVAILABLE').code).toBe('LSP_UNAVAILABLE')
|
||||
})
|
||||
|
||||
it('brands a provider id without altering the string', () => {
|
||||
expect(LspProviderId('ts')).toBe('ts')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# @deepseek-ai/dsh-tool-lsp
|
||||
|
||||
The model-facing **`lsp` tool** over `ctx.lsp`: one read-only tool with four operations for precise code navigation. It owns the model schema, prompt guidance, coordinate conversion, result limits and formatting, and ACP presentation; it imports no provider.
|
||||
|
||||
Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). Injects `tools`, `lsp`, and `systemPrompt`.
|
||||
|
||||
## The tool
|
||||
|
||||
`lsp` accepts `operation` (`definition` | `references` | `implementation` | `hover`), `file_path`, `line`, and `character`. `line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based positions and converts rendered locations back. `references` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable stay outside model input.
|
||||
|
||||
The tool requires the workspace root from the session `header.cwd`, with no fallback: absence fails as `LSP_WORKSPACE_REQUIRED` before querying. Locations render as stable, file-grouped `path:line:character` entries; a `file:` URI becomes a workspace-relative path (inside) or absolute path (outside), and any other URI stays verbatim. Empty locations and `null` hover are successful no-result responses; malformed provider payloads remain structured errors.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `maxLocations` | `100` | Largest number of rendered locations before an omission marker. |
|
||||
| `maxHoverChars` | `16000` | Largest hover length in characters, applied after normalization. |
|
||||
| `timeoutMs` | `60000` | Tool-call timeout budget, enforced by `dsh-timeout-policy`; covers the complete queued open/query/close lifecycle and is not model-configurable. |
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Prompt guidance
|
||||
|
||||
**What the model sees**: One system-prompt section (order 112) positioning LSP as a precision aid, plus the tool schema below.
|
||||
|
||||
**Token effect**: Fixed — the verbatim prose below is contributed once per request while the tool is enabled.
|
||||
|
||||
#### Verbatim text for this context surface
|
||||
|
||||
```markdown
|
||||
Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. references always includes the declaration.
|
||||
```
|
||||
|
||||
### Tool schema
|
||||
|
||||
**What the model sees**: The model sees the generated [`lsp` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-lsp).
|
||||
|
||||
**Token effect**: Fixed per request while enabled; the `timeoutMs` budget is never sent to the model.
|
||||
|
||||
### Results
|
||||
|
||||
**What the model sees**: File-grouped `path:line:character` location lines, or normalized hover text; capped by `maxLocations` / `maxHoverChars` with an omission marker when truncated, and distinct `No results.` / `No hover information.` lines for empty results.
|
||||
|
||||
**Token effect**: Capped by the two limits above.
|
||||
|
||||
### ACP presentation
|
||||
|
||||
**What the model sees**: A generic search card — `{ card: 'generic', kind: 'search', title, locations: [{ path, line }] }` — whose args-derived title carries the operation and one-based cursor; follow-along focuses the queried line while the title preserves the column. Rendered by the client, not sent to the model.
|
||||
|
||||
**Token effect**: Zero direct token effect (client-side rendering only).
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **UTF-16 cursor coordinates** — columns are exact for the protocol but hard for a model to count around non-BMP characters; an off-symbol position may return empty results, so the prompt explains the convention without encouraging broad LSP use ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)).
|
||||
- **No cross-server completeness promise** — supported servers may return empty or partial results depending on indexing readiness; the tool promises no completeness across languages or servers.
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-lsp",
|
||||
"description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with definition/references/implementation/hover operations, one-based UTF-16 cursor coordinates, workspace-grouped location rendering, and hover normalization",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-lsp": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-lsp": "workspace:^",
|
||||
"@deepseek-ai/dsh-lsp-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Model-facing `lsp` tool over `ctx.lsp`. One read-only tool with four operations
|
||||
* (`definition`/`references`/`implementation`/`hover`); it converts one-based UTF-16 cursor
|
||||
* coordinates to the seam's zero-based positions, requires the session workspace with no fallback,
|
||||
* caps and renders results, and attaches a configurable timeout budget for `dsh-timeout-policy` to
|
||||
* enforce. It runtime-injects only `tools`, `lsp`, and `systemPrompt` and imports no provider.
|
||||
*
|
||||
* Namespace plugin (named exports, no default export).
|
||||
* @module @deepseek-ai/dsh-tool-lsp
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { LspError } from '@deepseek-ai/dsh-lsp'
|
||||
import type {} from '@deepseek-ai/dsh-lsp'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
DEFAULT_MAX_HOVER_CHARS,
|
||||
DEFAULT_MAX_LOCATIONS,
|
||||
formatHover,
|
||||
formatLocations,
|
||||
LSP_OPERATIONS,
|
||||
parseLspArgs,
|
||||
presentLspCall,
|
||||
} from './render.ts'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
|
||||
export {
|
||||
DEFAULT_MAX_HOVER_CHARS,
|
||||
DEFAULT_MAX_LOCATIONS,
|
||||
formatHover,
|
||||
formatLocations,
|
||||
LSP_OPERATIONS,
|
||||
parseLspArgs,
|
||||
presentLspCall,
|
||||
renderUri,
|
||||
} from './render.ts'
|
||||
export { sessionCwd } from './session-cwd.ts'
|
||||
|
||||
/** Cordis plugin name for loader diagnostics. */
|
||||
export const name = 'tool-lsp'
|
||||
|
||||
/** Services required by this plugin. */
|
||||
export const inject = ['tools', 'lsp', 'systemPrompt']
|
||||
|
||||
/** Default tool-call timeout budget (ms), covering the queued open/query/close lifecycle. */
|
||||
export const DEFAULT_LSP_TOOL_TIMEOUT_MS = 60_000
|
||||
|
||||
/** The stable system-prompt guidance positioning LSP as a precision aid. */
|
||||
export const LSP_PROMPT_TEXT =
|
||||
'Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. references always includes the declaration.'
|
||||
|
||||
/** Plugin configuration: result caps and the timeout budget. */
|
||||
export interface Config {
|
||||
/** Largest number of rendered locations before an omission marker (default 100). */
|
||||
maxLocations?: number
|
||||
/** Largest hover length in characters after normalization (default 16000). */
|
||||
maxHoverChars?: number
|
||||
/** Tool-call timeout budget in ms (default 60000). */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
maxLocations: z.number().default(DEFAULT_MAX_LOCATIONS),
|
||||
maxHoverChars: z.number().default(DEFAULT_MAX_HOVER_CHARS),
|
||||
timeoutMs: z.number().default(DEFAULT_LSP_TOOL_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/**
|
||||
* Register the `lsp` tool and its system-prompt guidance.
|
||||
* @param ctx - the plugin context (must inject `tools`, `lsp`, `systemPrompt`).
|
||||
* @param config - the resolved plugin configuration.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveInteger('maxLocations', resolved.maxLocations)
|
||||
assertPositiveInteger('maxHoverChars', resolved.maxHoverChars)
|
||||
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
|
||||
|
||||
ctx.systemPrompt.section({ name: 'tool:lsp', order: 112, text: LSP_PROMPT_TEXT })
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'lsp',
|
||||
description:
|
||||
'Query a language server for precise code navigation. operation is one of definition, references, implementation, hover. line and character are one-based UTF-16 cursor coordinates. references includes the declaration.',
|
||||
parameters: {
|
||||
operation: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: [...LSP_OPERATIONS],
|
||||
description: 'definition, references, implementation, or hover.',
|
||||
},
|
||||
file_path: { type: 'string', required: true, description: 'The source file to query, relative to the workspace or absolute.' },
|
||||
line: { type: 'number', required: true, description: 'One-based line of the cursor.' },
|
||||
character: { type: 'number', required: true, description: 'One-based UTF-16 column of the cursor.' },
|
||||
},
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseLspArgs(args)
|
||||
const workspaceRoot = sessionCwd(exec)
|
||||
if (workspaceRoot === undefined) {
|
||||
throw new LspError('the lsp tool requires a session workspace cwd', 'LSP_WORKSPACE_REQUIRED')
|
||||
}
|
||||
const result = await ctx.lsp.query({
|
||||
operation: input.operation,
|
||||
filePath: input.filePath,
|
||||
position: input.position,
|
||||
workspaceRoot,
|
||||
}, exec.signal)
|
||||
switch (result.kind) {
|
||||
case 'locations':
|
||||
return [{ type: 'text', text: formatLocations(result.locations, workspaceRoot, resolved.maxLocations) }]
|
||||
case 'hover':
|
||||
return [{ type: 'text', text: formatHover(result.hover, resolved.maxHoverChars) }]
|
||||
}
|
||||
},
|
||||
presentCall: presentLspCall,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Reject a non-positive-integer config value at load, so misconfiguration fails loud. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`tool-lsp: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Pure formatting and coordinate conversion for the `lsp` tool: one-based↔zero-based UTF-16 cursor
|
||||
* conversion, workspace-grouped location rendering with `file:`-URI resolution, hover capping, and
|
||||
* ACP presentation. No I/O — a UI may call the presenter on live streaming and on replay, so it
|
||||
* depends only on the tool arguments.
|
||||
* @module @deepseek-ai/dsh-tool-lsp/render
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { isAbsolute, relative, sep } from 'node:path'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { LspHover, LspLocation, LspOperation, LspPosition } from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
/** The four operations the tool exposes, as a runtime tuple for schema enum + validation. */
|
||||
export const LSP_OPERATIONS: readonly LspOperation[] = ['definition', 'references', 'implementation', 'hover']
|
||||
|
||||
/** Default cap on rendered locations before an omission marker is appended. */
|
||||
export const DEFAULT_MAX_LOCATIONS = 100
|
||||
|
||||
/** Default cap on hover characters (applied after normalization) before truncation is marked. */
|
||||
export const DEFAULT_MAX_HOVER_CHARS = 16_000
|
||||
|
||||
/** Validated `lsp` arguments after coordinate checks. */
|
||||
export interface LspToolInput {
|
||||
readonly operation: LspOperation
|
||||
readonly filePath: string
|
||||
/** Zero-based UTF-16 position converted from the one-based model coordinates. */
|
||||
readonly position: LspPosition
|
||||
}
|
||||
|
||||
/** The raw, schema-typed argument shape. */
|
||||
export interface LspToolArgs {
|
||||
readonly operation: string
|
||||
readonly file_path: string
|
||||
readonly line: number
|
||||
readonly character: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and convert model arguments: `operation` must be one of the four; `line`/`character` are
|
||||
* positive one-based integers converted to the seam's zero-based position.
|
||||
* @param args - the schema-validated raw arguments.
|
||||
* @returns the validated input with a zero-based position.
|
||||
* @throws Error when the operation is unknown or a coordinate is not a positive integer.
|
||||
*/
|
||||
export function parseLspArgs(args: LspToolArgs): LspToolInput {
|
||||
if (!isOperation(args.operation)) {
|
||||
throw new Error(`operation must be one of ${LSP_OPERATIONS.join(', ')}`)
|
||||
}
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
const line = oneBased(args.line, 'line')
|
||||
const character = oneBased(args.character, 'character')
|
||||
return {
|
||||
operation: args.operation,
|
||||
filePath: args.file_path,
|
||||
// The model counts from 1; the seam (and protocol) count from 0.
|
||||
position: { line: line - 1, character: character - 1 },
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a string is one of the four operations. */
|
||||
function isOperation(value: string): value is LspOperation {
|
||||
return (LSP_OPERATIONS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/** Validate a one-based coordinate is a positive integer. */
|
||||
function oneBased(value: number, name: string): number {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`${name} must be a positive integer (one-based)`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a locations result grouped by file, converting each zero-based location back to a one-based
|
||||
* `path:line:character` entry. A `file:` URI inside the workspace becomes a workspace-relative path;
|
||||
* outside it, an absolute path; a non-`file:` URI is kept verbatim. Applies `maxLocations` and
|
||||
* appends an omission marker when it truncates.
|
||||
* @param locations - the seam's locations (possibly empty).
|
||||
* @param workspaceRoot - the canonical workspace root for relativizing `file:` paths.
|
||||
* @param maxLocations - the cap before truncation.
|
||||
* @returns the rendered text; a distinct no-result line when there are none.
|
||||
*/
|
||||
export function formatLocations(
|
||||
locations: readonly LspLocation[],
|
||||
workspaceRoot: string,
|
||||
maxLocations: number,
|
||||
): string {
|
||||
if (locations.length === 0) return 'No results.'
|
||||
const shown = locations.slice(0, maxLocations)
|
||||
const omitted = locations.length - shown.length
|
||||
const grouped = new Map<string, string[]>()
|
||||
for (const location of shown) {
|
||||
const path = renderUri(location.uri, workspaceRoot)
|
||||
const line = location.range.start.line + 1
|
||||
const character = location.range.start.character + 1
|
||||
const entries = grouped.get(path) ?? []
|
||||
entries.push(`${path}:${line}:${character}`)
|
||||
grouped.set(path, entries)
|
||||
}
|
||||
const lines: string[] = []
|
||||
for (const entries of grouped.values()) lines.push(...entries)
|
||||
if (omitted > 0) {
|
||||
lines.push(`… ${omitted} more location${omitted === 1 ? '' : 's'} omitted (limit ${maxLocations}).`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a hover result, applying `maxHoverChars` last and marking truncation.
|
||||
* @param hover - the normalized hover, or `null` for no hover.
|
||||
* @param maxHoverChars - the cap applied after normalization.
|
||||
* @returns the rendered hover text; a distinct no-result line for `null`.
|
||||
*/
|
||||
export function formatHover(hover: LspHover | null, maxHoverChars: number): string {
|
||||
if (hover === null) return 'No hover information.'
|
||||
const contents = hover.contents
|
||||
if (contents.length <= maxHoverChars) return contents
|
||||
return `${contents.slice(0, maxHoverChars)}\n… hover truncated (limit ${maxHoverChars} characters).`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a location URI to a display path. A `file:` URI accepted by Node becomes workspace-relative
|
||||
* (inside) or absolute (outside); any other URI is returned verbatim.
|
||||
* @param uri - the target URI from the seam.
|
||||
* @param workspaceRoot - the canonical workspace root.
|
||||
* @returns the display path or the verbatim URI.
|
||||
*/
|
||||
export function renderUri(uri: string, workspaceRoot: string): string {
|
||||
if (!uri.startsWith('file:')) return uri
|
||||
let absolute: string
|
||||
try {
|
||||
absolute = fileURLToPath(uri)
|
||||
} catch {
|
||||
// A malformed file: URI is not a path we can resolve; show it verbatim.
|
||||
return uri
|
||||
}
|
||||
const rel = relative(workspaceRoot, absolute)
|
||||
if (rel === '') return '.'
|
||||
const outside = rel.startsWith('..') || isAbsolute(rel)
|
||||
return outside ? absolute : rel.split(sep).join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP presentation for a pending `lsp` call. Uses a generic search card; the title carries the
|
||||
* operation and one-based cursor, and `locations` focuses the queried line (ACP `FileLocation` has
|
||||
* no character, so the title preserves the column).
|
||||
* @param args - the raw tool arguments.
|
||||
* @returns the generic call view.
|
||||
*/
|
||||
export function presentLspCall(args: LspToolArgs): GenericCallView {
|
||||
return {
|
||||
card: 'generic',
|
||||
kind: 'search',
|
||||
title: `LSP ${args.operation} ${args.file_path}:${args.line}:${args.character}`,
|
||||
locations: [{ path: args.file_path, line: args.line }],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Derive the workspace root an `lsp` call resolves against: the calling agent's per-session
|
||||
* workspace (`exec.agent.session.header.cwd`), mirroring how the filesystem tools resolve paths.
|
||||
* Unlike those tools, LSP has NO provider fallback — a missing cwd fails the call as
|
||||
* `LSP_WORKSPACE_REQUIRED`, because the local provider must canonicalize a real workspace before it
|
||||
* can start a server.
|
||||
* @module @deepseek-ai/dsh-tool-lsp/session-cwd
|
||||
*/
|
||||
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/**
|
||||
* The session workspace cwd for this call, or `undefined` when none applies.
|
||||
* @param exec - the tool-execution context; only its optional `agent` is read.
|
||||
* @returns the calling agent's session cwd, or undefined for a non-agent caller.
|
||||
*/
|
||||
export function sessionCwd(exec: ToolExecution): string | undefined {
|
||||
return exec.agent?.session.header.cwd
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import Lsp from '@deepseek-ai/dsh-lsp'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
|
||||
import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
|
||||
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
|
||||
|
||||
/**
|
||||
* Real-composition integration: the model-facing `lsp` tool over the real seam, the real
|
||||
* `dsh-lsp-local` provider (driving an inline stdio server), and the real `dsh-timeout-policy`, all
|
||||
* driven only through `ctx.tools.execute()`. Pins that a query round-trips end to end and that the
|
||||
* policy's `TOOL_TIMEOUT` budget wins when the server hangs.
|
||||
*/
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-tool-int-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** An inline stdio server that answers initialize + definition; `hang` makes textDocument/* stall. */
|
||||
function serverScript(hang: boolean): string {
|
||||
const definition = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
|
||||
return 'let b=Buffer.alloc(0);'
|
||||
+ `const DEF=${definition};`
|
||||
+ 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
|
||||
+ 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
|
||||
+ `else if(m.method==="textDocument/definition"){${hang ? '' : 'process.stdout.write(fr({id:m.id,result:DEF}));'}}`
|
||||
+ 'else if(m.method==="shutdown")process.stdout.write(fr({id:m.id,result:null}));'
|
||||
+ 'else if(m.method==="exit")process.exit(0);'
|
||||
+ '}});'
|
||||
}
|
||||
|
||||
async function mount(hang: boolean, timeoutMs?: number): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LspLocal, {
|
||||
providerId: 'inline',
|
||||
command: process.execPath,
|
||||
args: ['-e', serverScript(hang)],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
shutdownTimeoutMs: 200,
|
||||
killGraceMs: 200,
|
||||
})
|
||||
await ctx.plugin(TimeoutPolicy)
|
||||
await ctx.plugin(ToolLsp, timeoutMs !== undefined ? { timeoutMs } : {})
|
||||
return ctx
|
||||
}
|
||||
|
||||
let seq = 0
|
||||
function call(ctx: Context, args: unknown) {
|
||||
return ctx.tools.execute({
|
||||
callId: `int-${++seq}` as never,
|
||||
name: 'lsp',
|
||||
arguments: args,
|
||||
agent: { session: { header: { cwd: ws } } } as never,
|
||||
})
|
||||
}
|
||||
|
||||
describe('tool-lsp real composition', () => {
|
||||
it('round-trips a definition query through the real provider and renders a location', async () => {
|
||||
const ctx = await mount(false)
|
||||
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 7 })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
|
||||
await ctx.fiber.dispose()
|
||||
}, 30_000)
|
||||
|
||||
it('enforces the TOOL_TIMEOUT budget when the server hangs', async () => {
|
||||
const ctx = await mount(true, 300)
|
||||
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 7 })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('TOOL_TIMEOUT')
|
||||
await ctx.fiber.dispose()
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Real-load-path guard for @deepseek-ai/dsh-tool-lsp. It is a NAMESPACE plugin with `inject`, so a
|
||||
* stray `export default apply` would make the Loader's `unwrapExports` collapse the module to the
|
||||
* bare `apply`, dropping `inject` (postmortem 0001). This unwraps through the REAL
|
||||
* `Loader.prototype.unwrapExports` and verifies the namespace shape survives.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import * as toolLsp from '@deepseek-ai/dsh-tool-lsp'
|
||||
|
||||
describe('dsh-tool-lsp real-load-path guard', () => {
|
||||
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
|
||||
expect('default' in toolLsp).toBe(false)
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(toolLsp) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(toolLsp)
|
||||
expect(unwrapped.name).toBe('tool-lsp')
|
||||
expect(unwrapped.inject).toEqual(['tools', 'lsp', 'systemPrompt'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
DEFAULT_MAX_HOVER_CHARS,
|
||||
DEFAULT_MAX_LOCATIONS,
|
||||
formatHover,
|
||||
formatLocations,
|
||||
LSP_OPERATIONS,
|
||||
parseLspArgs,
|
||||
presentLspCall,
|
||||
renderUri,
|
||||
} from '@deepseek-ai/dsh-tool-lsp'
|
||||
import type { LspLocation } from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
const WS = '/home/u/proj'
|
||||
|
||||
function loc(uri: string, line: number, character = 0): LspLocation {
|
||||
return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } }
|
||||
}
|
||||
|
||||
describe('parseLspArgs', () => {
|
||||
it('accepts the four operations and converts one-based to zero-based', () => {
|
||||
for (const operation of LSP_OPERATIONS) {
|
||||
const input = parseLspArgs({ operation, file_path: 'a.ts', line: 3, character: 5 })
|
||||
expect(input.operation).toBe(operation)
|
||||
expect(input.position).toEqual({ line: 2, character: 4 })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an unknown operation', () => {
|
||||
expect(() => parseLspArgs({ operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }))
|
||||
.toThrow(/operation must be one of/)
|
||||
})
|
||||
|
||||
it('rejects a blank file_path', () => {
|
||||
expect(() => parseLspArgs({ operation: 'hover', file_path: ' ', line: 1, character: 1 }))
|
||||
.toThrow(/file_path/)
|
||||
})
|
||||
|
||||
it('rejects non-positive or non-integer coordinates', () => {
|
||||
expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 0, character: 1 })).toThrow(/line/)
|
||||
expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1, character: 0 })).toThrow(/character/)
|
||||
expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1.5, character: 1 })).toThrow(/line/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderUri', () => {
|
||||
it('relativizes a file: URI inside the workspace with forward slashes', () => {
|
||||
const uri = pathToFileURL(join(WS, 'src', 'a.ts')).href
|
||||
expect(renderUri(uri, WS)).toBe('src/a.ts')
|
||||
})
|
||||
|
||||
it('returns an absolute path for a file: URI outside the workspace', () => {
|
||||
const uri = pathToFileURL('/other/lib/b.ts').href
|
||||
expect(renderUri(uri, WS)).toBe('/other/lib/b.ts')
|
||||
})
|
||||
|
||||
it('renders the workspace root itself as "."', () => {
|
||||
expect(renderUri(pathToFileURL(WS).href, WS)).toBe('.')
|
||||
})
|
||||
|
||||
it('keeps a non-file URI verbatim', () => {
|
||||
expect(renderUri('untitled:Untitled-1', WS)).toBe('untitled:Untitled-1')
|
||||
expect(renderUri('jdt://contents/Foo.class', WS)).toBe('jdt://contents/Foo.class')
|
||||
})
|
||||
|
||||
it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
|
||||
// A file: URI with a host that fileURLToPath rejects falls through to the verbatim path.
|
||||
expect(renderUri('file://host/notlocal', WS)).toBe('file://host/notlocal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatLocations', () => {
|
||||
it('renders a no-result line for an empty list', () => {
|
||||
expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS)).toBe('No results.')
|
||||
})
|
||||
|
||||
it('renders one-based path:line:character grouped by file', () => {
|
||||
const a = pathToFileURL(join(WS, 'a.ts')).href
|
||||
const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS, DEFAULT_MAX_LOCATIONS)
|
||||
expect(text).toBe('a.ts:1:1\na.ts:5:3')
|
||||
})
|
||||
|
||||
it('caps at maxLocations and marks the omission', () => {
|
||||
const a = pathToFileURL(join(WS, 'a.ts')).href
|
||||
const many = Array.from({ length: 5 }, (_, i) => loc(a, i))
|
||||
const text = formatLocations(many, WS, 2)
|
||||
expect(text).toContain('a.ts:1:1')
|
||||
expect(text).toContain('3 more locations omitted (limit 2).')
|
||||
})
|
||||
|
||||
it('uses the singular omission marker for exactly one extra', () => {
|
||||
const a = pathToFileURL(join(WS, 'a.ts')).href
|
||||
const text = formatLocations([loc(a, 0), loc(a, 1)], WS, 1)
|
||||
expect(text).toContain('1 more location omitted (limit 1).')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatHover', () => {
|
||||
it('renders a no-result line for null', () => {
|
||||
expect(formatHover(null, DEFAULT_MAX_HOVER_CHARS)).toBe('No hover information.')
|
||||
})
|
||||
|
||||
it('returns short hover verbatim', () => {
|
||||
expect(formatHover({ contents: '```ts\nx: number\n```' }, DEFAULT_MAX_HOVER_CHARS)).toBe('```ts\nx: number\n```')
|
||||
})
|
||||
|
||||
it('caps hover at maxHoverChars and marks truncation', () => {
|
||||
const text = formatHover({ contents: 'a'.repeat(50) }, 10)
|
||||
expect(text.startsWith('aaaaaaaaaa\n')).toBe(true)
|
||||
expect(text).toContain('hover truncated (limit 10 characters).')
|
||||
})
|
||||
})
|
||||
|
||||
describe('presentLspCall', () => {
|
||||
it('is a generic search card with an operation/cursor title and a line location', () => {
|
||||
expect(presentLspCall({ operation: 'references', file_path: 'a.ts', line: 3, character: 7 })).toEqual({
|
||||
card: 'generic',
|
||||
kind: 'search',
|
||||
title: 'LSP references a.ts:3:7',
|
||||
locations: [{ path: 'a.ts', line: 3 }],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import Lsp, { LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
|
||||
import { DEFAULT_LSP_TOOL_TIMEOUT_MS, LSP_PROMPT_TEXT } from '@deepseek-ai/dsh-tool-lsp'
|
||||
|
||||
/** A scripted provider recording queries; `respond` yields the result or throws. */
|
||||
function stubProvider(
|
||||
respond: (request: LspProviderQuery) => LspQueryResult,
|
||||
extensionToLanguage: Record<string, string> = { '.ts': 'typescript' },
|
||||
): LspProvider & { seen: LspProviderQuery[] } {
|
||||
const seen: LspProviderQuery[] = []
|
||||
return {
|
||||
id: LspProviderId('stub'),
|
||||
extensionToLanguage,
|
||||
seen,
|
||||
query(request) {
|
||||
seen.push(request)
|
||||
return Promise.resolve(respond(request))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount the real tool stack over a real seam plus one stub provider. */
|
||||
async function mount(
|
||||
provider?: LspProvider,
|
||||
config: ToolLsp.Config = {},
|
||||
): Promise<{ ctx: Context }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(Lsp)
|
||||
if (provider) (ctx.lsp as Lsp).registerProvider(provider)
|
||||
await ctx.plugin(ToolLsp, config)
|
||||
return { ctx }
|
||||
}
|
||||
|
||||
let seq = 0
|
||||
/** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */
|
||||
function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
|
||||
return ctx.tools.execute({
|
||||
callId: `c-${++seq}` as never,
|
||||
name: 'lsp',
|
||||
arguments: args,
|
||||
...cwd !== null ? { agent: { session: { header: { cwd } } } as never } : {},
|
||||
})
|
||||
}
|
||||
|
||||
const okLocations: LspQueryResult = {
|
||||
kind: 'locations',
|
||||
locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
}
|
||||
|
||||
describe('tool-lsp registration', () => {
|
||||
it('registers the lsp tool and its prompt section', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
expect(ctx.tools.get('lsp')).toBeDefined()
|
||||
const prompt = await ctx.systemPrompt.assemble()
|
||||
const text = prompt.sections.map(s => s.text).join('\n')
|
||||
expect(text).toContain(LSP_PROMPT_TEXT)
|
||||
})
|
||||
|
||||
it('attaches the default timeout budget to the tool definition', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
expect(ctx.tools.get('lsp')?.timeoutMs).toBe(DEFAULT_LSP_TOOL_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it('honors a configured timeout override', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations), { timeoutMs: 5000 })
|
||||
expect(ctx.tools.get('lsp')?.timeoutMs).toBe(5000)
|
||||
})
|
||||
|
||||
it('exposes exactly the four operations in the schema enum', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const schema = ctx.tools.get('lsp')?.parameters as { properties: { operation: { enum: string[] } } }
|
||||
expect(schema.properties.operation.enum).toEqual(['definition', 'references', 'implementation', 'hover'])
|
||||
})
|
||||
|
||||
it('has no default export (namespace plugin shape)', () => {
|
||||
expect((ToolLsp as { default?: unknown }).default).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a non-positive config value at load', async () => {
|
||||
await expect(mount(stubProvider(() => okLocations), { maxLocations: 0 })).rejects.toThrow(/maxLocations/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-lsp execution', () => {
|
||||
it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => {
|
||||
const provider = stubProvider(() => okLocations)
|
||||
const { ctx } = await mount(provider)
|
||||
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 3, character: 5 }, '/ws')
|
||||
expect(result.isError).toBe(false)
|
||||
expect(provider.seen[0]).toMatchObject({
|
||||
operation: 'definition',
|
||||
filePath: 'a.ts',
|
||||
position: { line: 2, character: 4 },
|
||||
workspaceRoot: '/ws',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders locations relative to the workspace', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const result = await call(ctx, { operation: 'references', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
|
||||
})
|
||||
|
||||
it('renders hover content', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } })))
|
||||
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'number' })
|
||||
})
|
||||
|
||||
it('fails LSP_WORKSPACE_REQUIRED without a session cwd', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, null)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('LSP_WORKSPACE_REQUIRED')
|
||||
})
|
||||
|
||||
it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' }))
|
||||
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('LSP_UNAVAILABLE')
|
||||
})
|
||||
|
||||
it('returns a structured INVALID_ARGS on a bad operation', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('INVALID_ARGS')
|
||||
})
|
||||
|
||||
it('forwards exec.signal to the seam query', async () => {
|
||||
const seen: (AbortSignal | undefined)[] = []
|
||||
const provider: LspProvider = {
|
||||
id: LspProviderId('sig'),
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
query(_request, signal) {
|
||||
seen.push(signal)
|
||||
return Promise.resolve(okLocations)
|
||||
},
|
||||
}
|
||||
const { ctx } = await mount(provider)
|
||||
await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
// The timeout policy is not mounted here, so the signal is whatever the registry passes (may be
|
||||
// undefined); the point is the tool threads it through without throwing.
|
||||
expect(seen).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('presentCall renders the pending card from args', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const view = ctx.tools.get('lsp')?.presentCall?.({ operation: 'hover', file_path: 'a.ts', line: 2, character: 3 })
|
||||
expect(view).toEqual({
|
||||
card: 'generic',
|
||||
kind: 'search',
|
||||
title: 'LSP hover a.ts:2:3',
|
||||
locations: [{ path: 'a.ts', line: 2 }],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../lsp"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+146
-1
@@ -724,6 +724,80 @@ importers:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/lsp/lsp:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/lsp/lsp-local:
|
||||
dependencies:
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-lsp':
|
||||
specifier: workspace:^
|
||||
version: link:../lsp
|
||||
'@deepseek-ai/dsh-timeout':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/timeout
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
typescript:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
typescript-language-server:
|
||||
specifier: ^5.0.0
|
||||
version: 5.3.0
|
||||
|
||||
packages/lsp/tool-lsp:
|
||||
dependencies:
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-lsp':
|
||||
specifier: workspace:^
|
||||
version: link:../lsp
|
||||
'@deepseek-ai/dsh-lsp-local':
|
||||
specifier: workspace:^
|
||||
version: link:../lsp-local
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
'@deepseek-ai/dsh-timeout-policy':
|
||||
specifier: workspace:^
|
||||
version: link:../../timeout/timeout-policy
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/mcp/mcp-client:
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk':
|
||||
@@ -1171,7 +1245,7 @@ importers:
|
||||
devDependencies:
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/support/subagent-mock:
|
||||
dependencies:
|
||||
@@ -3612,6 +3686,18 @@ packages:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
cordis@4.0.0-rc.6:
|
||||
resolution: {integrity: sha512-GzUv7zCKh3FlgM3/Ad2S03UpYO3v4u1GcKa7ig4K2je4lCrgJ/S64ziiZI6XNyKEa1tZwdzj4oBQrhYDLgfEiA==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@cordisjs/plugin-include': ^1.0.4
|
||||
'@cordisjs/plugin-loader': ^1.0.0-rc.4
|
||||
peerDependenciesMeta:
|
||||
'@cordisjs/plugin-include':
|
||||
optional: true
|
||||
'@cordisjs/plugin-loader':
|
||||
optional: true
|
||||
|
||||
cordis@4.0.0-rc.7:
|
||||
resolution: {integrity: sha512-5nm6ehrSfJhEUV659CctEvyNuBY/AXapw8+ZEw7YENztdzpiT+Ha8nIfkyhfyAgPtJns9aB5On5nzl9Sm6zHeQ==}
|
||||
hasBin: true
|
||||
@@ -5330,6 +5416,11 @@ packages:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
typescript-language-server@5.3.0:
|
||||
resolution: {integrity: sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
typescript@6.0.3:
|
||||
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -5471,6 +5562,20 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
vscode-jsonrpc@5.0.1:
|
||||
resolution: {integrity: sha512-JvONPptw3GAQGXlVV2utDcHx0BiY34FupW/kI6mZ5x06ER5DdPG/tXWMVHjTNULF5uKPOUUD0SaXg5QaubJL0A==}
|
||||
engines: {node: '>=8.0.0 || >=10.0.0'}
|
||||
|
||||
vscode-jsonrpc@9.0.1:
|
||||
resolution: {integrity: sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
vscode-languageserver-protocol@3.18.2:
|
||||
resolution: {integrity: sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==}
|
||||
|
||||
vscode-languageserver-types@3.18.0:
|
||||
resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5876,6 +5981,14 @@ snapshots:
|
||||
|
||||
'@chevrotain/types@11.1.2': {}
|
||||
|
||||
'@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.6)':
|
||||
dependencies:
|
||||
'@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.6)(node-addon-require-builtin@0.1.0)
|
||||
cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
cosmokit: 1.8.1
|
||||
js-yaml: 4.2.0
|
||||
optional: true
|
||||
|
||||
'@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7)':
|
||||
dependencies:
|
||||
'@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0)
|
||||
@@ -5891,6 +6004,14 @@ snapshots:
|
||||
js-yaml: 4.2.0
|
||||
optional: true
|
||||
|
||||
'@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.6)(node-addon-require-builtin@0.1.0)':
|
||||
dependencies:
|
||||
cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
cosmokit: 1.8.1
|
||||
optionalDependencies:
|
||||
node-addon-require-builtin: 0.1.0
|
||||
optional: true
|
||||
|
||||
'@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0)':
|
||||
dependencies:
|
||||
cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
@@ -7037,6 +7158,14 @@ snapshots:
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
cordis@4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5):
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
cosmokit: 1.8.1
|
||||
optionalDependencies:
|
||||
'@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.6)
|
||||
'@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.6)(node-addon-require-builtin@0.1.0)
|
||||
|
||||
cordis@4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5):
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
@@ -9038,6 +9167,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
typescript-language-server@5.3.0:
|
||||
dependencies:
|
||||
vscode-jsonrpc: 5.0.1
|
||||
vscode-languageserver-protocol: 3.18.2
|
||||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
unbash@3.0.0: {}
|
||||
@@ -9182,6 +9316,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
vscode-jsonrpc@5.0.1: {}
|
||||
|
||||
vscode-jsonrpc@9.0.1: {}
|
||||
|
||||
vscode-languageserver-protocol@3.18.2:
|
||||
dependencies:
|
||||
vscode-jsonrpc: 9.0.1
|
||||
vscode-languageserver-types: 3.18.0
|
||||
|
||||
vscode-languageserver-types@3.18.0: {}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
@@ -26,6 +26,8 @@ import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import Lsp from '@deepseek-ai/dsh-lsp'
|
||||
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
|
||||
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
|
||||
@@ -147,6 +149,20 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
note:
|
||||
'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-lsp',
|
||||
dir: 'tool-lsp',
|
||||
source: 'packages/lsp/tool-lsp/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.lsp', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The tool registers from the seam alone; the schema does not depend on any provider.
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(ToolLsp)
|
||||
},
|
||||
note:
|
||||
'The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-skill',
|
||||
dir: 'tool-skill',
|
||||
|
||||
@@ -47,6 +47,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
|
||||
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
|
||||
'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' },
|
||||
'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' },
|
||||
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
|
||||
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
|
||||
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"./packages/bash/*/src",
|
||||
"./packages/code-runtime/*/src",
|
||||
"./packages/fs/*/src",
|
||||
"./packages/lsp/*/src",
|
||||
"./packages/skill/*/src",
|
||||
"./packages/compact/*/src",
|
||||
"./packages/context/*/src",
|
||||
|
||||
+4
-1
@@ -83,6 +83,9 @@
|
||||
{ "path": "./packages/hooks/hook-protocol" },
|
||||
{ "path": "./packages/hooks/hooks-claude" },
|
||||
{ "path": "./packages/hooks/hooks-codex" },
|
||||
{ "path": "./packages/mcp/mcp-client" }
|
||||
{ "path": "./packages/mcp/mcp-client" },
|
||||
{ "path": "./packages/lsp/lsp" },
|
||||
{ "path": "./packages/lsp/lsp-local" },
|
||||
{ "path": "./packages/lsp/tool-lsp" }
|
||||
]
|
||||
}
|
||||
+4
-1
@@ -94,6 +94,9 @@
|
||||
{ "path": "./packages/hooks/hook-protocol" },
|
||||
{ "path": "./packages/hooks/hooks-claude" },
|
||||
{ "path": "./packages/hooks/hooks-codex" },
|
||||
{ "path": "./packages/mcp/mcp-client" }
|
||||
{ "path": "./packages/mcp/mcp-client" },
|
||||
{ "path": "./packages/lsp/lsp" },
|
||||
{ "path": "./packages/lsp/lsp-local" },
|
||||
{ "path": "./packages/lsp/tool-lsp" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user