fix(lsp): preserve execution-world URI semantics

This commit is contained in:
Tianyi Cui
2026-07-29 04:19:18 +08:00
parent 3537665806
commit 4997fb219e
24 changed files with 162 additions and 107 deletions

View File

@@ -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 packages/lsp/lsp/README.md
README.md: f96fc67ec8cb95f423eff9b312b7b591ec9d3008
README.zh.md: 9757147de692684747d9965efda6329b3bbe2638
README.md: 5c1044be50368acf13d8c36a15d5b2bd99d02701
README.zh.md: cc412333e63b9469319240d67269bf0192ad3858

View File

@@ -27,7 +27,7 @@ Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner
## Vocabulary
`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `findReferences` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceRoot }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceRoot` is the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to; a caller relativizing display paths uses it, not the (possibly symlinked) request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes, including `LSP_DISPOSED` and `LSP_MALFORMED_RESPONSE`.
`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `findReferences` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceUri }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceUri` is the provider's canonical workspace `file:` URI; callers relativize location URIs against it instead of applying host-platform path rules to the possibly symlinked request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes, including `LSP_DISPOSED` and `LSP_MALFORMED_RESPONSE`.
## Model Experience

View File

@@ -8,7 +8,7 @@
| 包 | 职责 |
|---|---|
| `@deepseek-ai/dsh-lsp`(本包) | 接口:服务、以带品牌类型的 id 和扩展名映射为键的提供方注册表、逐查询选择、请求/结果词汇、`LspError` 分类体系 |
| `@deepseek-ai/dsh-lsp`(本包) | 接口:服务、以品牌化 id + 扩展名映射为 key 的提供方注册表、逐查询选择、请求/结果词汇、`LspError` 分类体系 |
| `@deepseek-ai/dsh-lsp-local` | 通用本地后端,注册已配置的 stdio 语言服务器提供方 |
| `@deepseek-ai/dsh-tool-lsp` | 面向模型的 `lsp` 工具,基于 `ctx.lsp` |
@@ -18,16 +18,16 @@
| 成员 | 语义 |
|---|---|
| `registerProvider(provider)` | 注册后端,以原子方式保留其带品牌类型的 `id` 与每个规范化文件扩展名。任何无效输入或冲突都不会发布内容,并抛出 `LspError`(`LSP_INVALID_PROVIDER`/`LSP_CONFLICT`)。返回释放所有保留项的 disposer。随调用 fiber 一同 dispose(资源释放)。 |
| `registerProvider(provider)` | 注册后端,以原子方式保留其品牌化 `id` 与每个规范化文件扩展名。任何无效输入或冲突都不会发布内容,并抛出 `LspError`(`LSP_INVALID_PROVIDER`/`LSP_CONFLICT`)。返回释放所有保留项的 disposer。随调用 fiber 释放。 |
| `query(request, signal?)` | 按文件最终扩展名选择提供方,从该提供方的映射派生 `languageId`,并运行一次查询。没有匹配项时抛出 `LspError` `LSP_UNAVAILABLE`。 |
选择逐查询进行且与顺序无关:一个提供方独占一组扩展名,因此注册和 HMR(热模块替换)顺序绝不会改变路由。扩展名 key 规范化为小写且以点开头;`languageId` 只用于同步临时文档,绝不参与选择。第一版没有 glob、language-id 或显式路由 selector。
选择逐查询进行且与顺序无关:一个提供方独占一组扩展名,因此注册和 HMR 顺序绝不会改变路由。扩展名 key 规范化为小写且以点开头;`languageId` 只用于同步临时文档,绝不参与选择。第一版没有 glob、language-id 或显式路由 selector。
提供方注册的是**能力**,而非工具。`dsh-tool-lsp` 是面向模型名称、描述、提示词指引、schema 和呈现的唯一 owner。
提供方注册的是**能力** 而非工具。`dsh-tool-lsp` 是面向模型名称、描述、提示词指引、schema 和呈现的唯一 owner。
## 词汇
`LspQueryRequest`(`operation`、`filePath`、`position`、`workspaceRoot`):每个字段都必填,因此没有字段需要实现默认值,也不存在 `resolve()` 步骤。位置与范围使用从零开始的 UTF-16,与协议一致;工具负责从 1 开始的光标约定。`findReferences` 始终包含声明,提供方在内部强制执行,因此调用方没有 flag。`LspQueryResult` 是封闭的判别联合:导航使用 `{ kind: 'locations'; locations; resolvedWorkspaceRoot }`,悬停使用 `{ kind: 'hover'; hover }`(内容或 `null`);消费方通过 `switch` 实现穷尽检查,因此新增分支会使编译失败,直到完成处理。`resolvedWorkspaceRoot` 是提供方对请求 `workspaceRoot` 的规范形式,也是其 `file:` URI 所相对的根;调用方把显示路径相对化时使用该值,而非可能含符号链接的请求根。完整契约见 `src/types.ts`;`src/index.ts` 给出 `LspError` 代码,包括 `LSP_DISPOSED` 和 `LSP_MALFORMED_RESPONSE`。
`LspQueryRequest`(`operation`、`filePath`、`position`、`workspaceRoot`):每个字段都必填,因此没有字段需要实现默认值,也不存在 `resolve()` 步骤。位置与范围使用从零开始的 UTF-16,与协议一致;工具拥有从 1 开始的光标约定。`findReferences` 始终包含声明,提供方在内部强制执行,因此调用方没有 flag。`LspQueryResult` 是封闭的判别联合:导航使用 `{ kind: 'locations'; locations; resolvedWorkspaceUri }`,悬停使用 `{ kind: 'hover'; hover }`(内容或 `null`);消费方通过 `switch` 实现穷尽检查,因此新增分支会使编译失败,直到完成处理。`resolvedWorkspaceUri` 是提供方的规范工作区 `file:` URI;调用方相对化位置 URI 时以它为基准,而不是对可能含符号链接的请求根应用宿主平台路径规则。完整契约见 `src/types.ts`;`src/index.ts` 给出 `LspError` code,包括 `LSP_DISPOSED` 和 `LSP_MALFORMED_RESPONSE`。
## 模型体验
@@ -39,6 +39,6 @@
## 已知限制与暂缓事项
- **同一运行时内扩展名归属互斥**:两个提供方不能同时声明 `.ts`,即使语言 ID 不同;重叠会使注册失败。预期扩展是在注册之上增加部署配置的 selector;它可以放宽互斥保留,而无需把提供方选择加入模型输入(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。
- **同一运行时内扩展名归属互斥**:两个提供方不能同时声明 `.ts`,即使 language id 不同;重叠会使注册失败。预期扩展是在注册之上增加部署配置的 selector;它可以放宽互斥保留,而无需把提供方选择加入模型输入(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。
- **仅四种操作**:symbol 与 call hierarchy 暂缓(它们需要不同 schema);diagnostics 需要独立的新鲜度/累积规则;修改操作(rename、code action、formatting)需要独立工具,并集成预览、权限和写入策略。
- **没有观测接口**:可用性只能通过运行 `query()` 并按抛出的 `LspError` 代码进行路由来观测;没有提供方变更事件或能力状态查询。
- **没有观测表层**:可用性只能通过运行 `query()` 并按抛出的 `LspError` code 路由来观测;没有提供方变更事件或能力状态查询。

View File

@@ -77,13 +77,13 @@ export interface LspHover {
* `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`.
* Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled.
*
* The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the
* request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that
* relativizes display paths MUST use this, not the request's (possibly symlinked) `workspaceRoot`;
* otherwise a symlinked workspace misclassifies in-workspace results as external.
* The `locations` variant carries `resolvedWorkspaceUri`: the provider's canonical `file:` URI for
* the request's workspace root. A caller that relativizes location URIs MUST use this, not parse the
* request's possibly symlinked process path with host-platform rules; the execution platform may
* differ from the caller's.
*/
export type LspQueryResult =
| { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string }
| { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceUri: string }
| { readonly kind: 'hover'; readonly hover: LspHover | null }
/**

View File

@@ -13,7 +13,7 @@ import Lsp, {
function makeProvider(
id: string,
extensionToLanguage: Record<string, string>,
result: LspQueryResult = { kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' },
result: LspQueryResult = { kind: 'locations', locations: [], resolvedWorkspaceUri: 'file:///ws' },
): LspProvider & { seen: LspProviderQuery[]; seenSignals: (AbortSignal | undefined)[] } {
const seen: LspProviderQuery[] = []
const seenSignals: (AbortSignal | undefined)[] = []
@@ -63,7 +63,7 @@ describe('Lsp registration', () => {
const provider = makeProvider('ts', { '.ts': 'typescript' })
const dispose = lsp.registerProvider(provider)
await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' })
await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: 'file:///ws' })
expect(provider.seen[0]).toMatchObject({ filePath: 'a.ts', languageId: 'typescript' })
dispose()
@@ -148,7 +148,7 @@ describe('Lsp registration', () => {
const py = makeProvider('py', { '.py': 'python' })
lsp.registerProvider(ts)
lsp.registerProvider(py)
await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' })
await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: 'file:///ws' })
await expect(lsp.query(query('a.ts', 'hover'))).resolves.toEqual(hover)
})
@@ -172,7 +172,7 @@ describe('Lsp registration', () => {
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: [], resolvedWorkspaceRoot: '/ws' })
await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: 'file:///ws' })
await fiber.dispose()
await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
})