@banbolee/dsh-lsp-diagnostics 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -34
- package/README.zh.md +144 -0
- package/coordinator.js +45 -20
- package/index.js +3 -5
- package/message-source.d.ts +23 -0
- package/package.json +28 -27
- package/runtime.js +100 -26
- package/tool.js +28 -3
- package/workspace-root.js +155 -0
package/README.md
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# @banbolee/dsh-lsp-diagnostics
|
|
2
2
|
|
|
3
|
+
**English** | [中文](./README.zh.md)
|
|
4
|
+
|
|
3
5
|
Host-plane Cordis bundle plugin for DeepSeek Harness: after an official
|
|
4
6
|
`write`/`edit`/`str_replace_editor` mutation lands inside the session
|
|
5
|
-
workspace,
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
workspace, or inside a marker-rooted sibling worktree/project, the tool result
|
|
8
|
+
still succeeds and the next model inference carries a single persistent LSP
|
|
9
|
+
diagnostics plugin notice. It also registers the model-callable
|
|
10
|
+
`lsp_diagnostics(file_path)` tool for explicit diagnosis.
|
|
8
11
|
|
|
9
12
|
The plugin is a named namespace function plugin: it exports `name`, `inject`,
|
|
10
13
|
`Config`, and `apply`, and has no default export. The real Loader reads the
|
|
@@ -16,7 +19,13 @@ monkey-patches official objects.
|
|
|
16
19
|
|
|
17
20
|
## Usage
|
|
18
21
|
|
|
19
|
-
Install
|
|
22
|
+
**Install from npm (recommended)** — no clone needed:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
dsh plugin --profile <name> add @banbolee/dsh-lsp-diagnostics
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
For local development, install the bundle into any DSH profile from the repository root. The
|
|
20
29
|
workspace-root flag `-w` is required: without it pnpm cannot resolve the local
|
|
21
30
|
package inside the profile's generated workspace.
|
|
22
31
|
|
|
@@ -59,20 +68,22 @@ deviation from the closed extension route fail loud at load time.
|
|
|
59
68
|
| `servers` | TypeScript + Go | Closed provider catalog; TypeScript and Go are enabled by default, while `clangd`, `rust`, and `python` are opt-in. |
|
|
60
69
|
|
|
61
70
|
The extension route is closed: `.ts` → typescript/typescript, `.tsx` →
|
|
62
|
-
typescript/typescriptreact,
|
|
63
|
-
`.
|
|
64
|
-
`.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
silently ignored.
|
|
71
|
+
typescript/typescriptreact, optional explicit `.js` → typescript/javascript,
|
|
72
|
+
`.go` → go/go, `.c` → clangd/c, `.cc`/`.cpp`/`.cxx` → clangd/cpp,
|
|
73
|
+
`.h`/`.hh`/`.hpp`/`.hxx` → clangd/cpp, `.rs` → rust/rust, and
|
|
74
|
+
`.py`/`.pyi` → python/python; any other extension key, missing canonical
|
|
75
|
+
provider entry, cross-provider collision, or rewritten language id fails loud at
|
|
76
|
+
load time, while other extensions at runtime are silently ignored.
|
|
68
77
|
|
|
69
78
|
The `servers` block is a provider-level partial overlay: omitting `servers` or
|
|
70
79
|
supplying `servers: {}` keeps exactly the TypeScript and Go defaults, while
|
|
71
80
|
supplying a known optional `clangd`, `rust`, or `python` key activates that
|
|
72
81
|
provider. For example, `servers: { go: { command: '/path/trae-gopls' } }`
|
|
73
82
|
changes only Go without requiring a TypeScript block. The legacy
|
|
74
|
-
`extensionToLanguage` field is accepted only when it
|
|
75
|
-
|
|
83
|
+
`extensionToLanguage` field is accepted only when it contains that provider's
|
|
84
|
+
canonical mapping and only supported provider-owned extras. `.js` is accepted
|
|
85
|
+
for compatibility with explicit TypeScript configs, but it is not enabled by
|
|
86
|
+
default.
|
|
76
87
|
|
|
77
88
|
Default commands are `typescript-language-server --stdio`, `gopls`, `clangd`,
|
|
78
89
|
`rust-analyzer`, and `pyright-langserver --stdio`. clangd, rust-analyzer, and
|
|
@@ -81,13 +92,16 @@ gopls receive no default arguments. Per-server `env`, `configuration`, and
|
|
|
81
92
|
|
|
82
93
|
## Behavior
|
|
83
94
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
`
|
|
95
|
+
A non-empty `session.header.cwd` that canonicalizes to a directory is required.
|
|
96
|
+
Automatic feedback first keeps the old behavior for targets contained by that
|
|
97
|
+
workspace; targets outside it are eligible only when a target-local `.git` file
|
|
98
|
+
or directory identifies their own git worktree root. Missing or empty cwd,
|
|
99
|
+
non-directory canonicalization, and outside targets without a marker-rooted
|
|
100
|
+
git worktree are silently ignored — never a `workspace unavailable` or
|
|
101
|
+
`outside workspace` notice. Workspace, cwd, and URI are always
|
|
102
|
+
derived through the public `ctx.fs` API (`resolve`/`stat`/`contains`/`targetKey`/
|
|
103
|
+
`processPath`/`fileUrl`), never from raw `displayPath` or the host
|
|
104
|
+
`process.cwd()`.
|
|
91
105
|
|
|
92
106
|
Fail-open is plugin-owned post-processing failure only: after the downstream
|
|
93
107
|
`await next()` decision succeeded, a diagnostics error, missing or crashed
|
|
@@ -187,13 +201,14 @@ natural-close wait → conditional `handle.terminate()` (only while still alive)
|
|
|
187
201
|
existing file that `ctx.fs` permits the session to read. It accepts a
|
|
188
202
|
workspace-relative or absolute path and uses every configured provider and the
|
|
189
203
|
closed extension route documented above. It has the same path authority as the
|
|
190
|
-
official `read` tool:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
204
|
+
official `read` tool: for files contained by `session.header.cwd`, that cwd
|
|
205
|
+
remains the LSP project root; for a readable file outside the session cwd, the
|
|
206
|
+
tool prefers the target's marker-rooted project/worktree root and then falls
|
|
207
|
+
back to the session cwd. It never creates, edits, or deletes a file. Invalid
|
|
208
|
+
requests fail explicitly with `file_path must be a non-empty string`, `session
|
|
209
|
+
workspace cwd`, `session workspace is not an existing directory`, `target does
|
|
210
|
+
not exist`, `target is not a regular file`, `no configured diagnostics
|
|
211
|
+
provider`, or `target changed during diagnosis`.
|
|
197
212
|
|
|
198
213
|
The direct tool has exactly three canonical outcomes: `diagnostics` with the
|
|
199
214
|
normalized list sorted by the same comparator as automatic rendering and sliced
|
|
@@ -231,13 +246,13 @@ unavailable`.
|
|
|
231
246
|
- shell redirection, `sed -i`, scripts, formatters, generators, and external
|
|
232
247
|
editors that bypass `ctx.fs`/`fs/observed` are out of scope for automatic
|
|
233
248
|
feedback; the model may call `lsp_diagnostics(file_path)` afterward. There is no
|
|
234
|
-
filesystem watcher
|
|
235
|
-
|
|
249
|
+
filesystem watcher; project-root probing is marker-based and only runs for
|
|
250
|
+
supported source files.
|
|
236
251
|
- For automatic feedback, agentless tool execution, missing/empty or
|
|
237
|
-
non-directory cwd, and outside
|
|
238
|
-
and never produce a notice. Direct calls use official
|
|
239
|
-
authority instead: a readable file outside the session
|
|
240
|
-
|
|
252
|
+
non-directory cwd, and outside targets without a marker-rooted git worktree are
|
|
253
|
+
silently out of scope and never produce a notice. Direct calls use official
|
|
254
|
+
`read`-equivalent path authority instead: a readable file outside the session
|
|
255
|
+
cwd is eligible, using its marker-rooted project/worktree root when available.
|
|
241
256
|
- The plugin does not install or download `typescript-language-server`,
|
|
242
257
|
`gopls`, `clangd`, `rust-analyzer`, or `pyright-langserver`; install requested
|
|
243
258
|
executables yourself and configure their paths. A missing server fails open
|
|
@@ -245,9 +260,9 @@ unavailable`.
|
|
|
245
260
|
- Portable deterministic tests use the repository fixture server and never use
|
|
246
261
|
a real server or the network. The separate explicit real-server lane is
|
|
247
262
|
opt-in, local-filesystem/process-only, and never installs or downloads tools.
|
|
248
|
-
- Implementation and tests use the `0.1.
|
|
263
|
+
- Implementation and tests use the `0.1.7-rc.2` dependency family; the peers
|
|
249
264
|
`@deepseek-ai/dsh-fs`, `@deepseek-ai/dsh-llm`, `@deepseek-ai/dsh-subprocess`,
|
|
250
|
-
and `@deepseek-ai/dsh-tools` are `^0.1.
|
|
265
|
+
and `@deepseek-ai/dsh-tools` are `^0.1.7-rc.2`.
|
|
251
266
|
|
|
252
267
|
## Verification
|
|
253
268
|
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# @banbolee/dsh-lsp-diagnostics
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | **中文**
|
|
4
|
+
|
|
5
|
+
DeepSeek Harness 的 host 面 Cordis bundle 插件:当一次官方 `write`/`edit`/`str_replace_editor` 变更落在 session workspace 内,或落在带 marker 根的兄弟 worktree/项目内时,工具结果仍然成功,而下一次模型推理会携带一条持久的 LSP diagnostics 插件 notice。它还注册了模型可调用的 `lsp_diagnostics(file_path)` 工具,用于显式诊断。
|
|
6
|
+
|
|
7
|
+
本插件是具名 namespace 函数插件:导出 `name`、`inject`、`Config` 和 `apply`,没有 default export。真实 Loader 读取模块 namespace(`exports.default ?? exports`),Cordis 从插件对象读取 `inject`/`Config`,因此每个公开元数据字段都能在安装后存活。插件从不修改 `deepseek-harness`,从不扩展 `ctx.lsp`,从不 import `@deepseek-ai/*/src/*`,也从不 monkey-patch 官方对象。
|
|
8
|
+
|
|
9
|
+
## 用法
|
|
10
|
+
|
|
11
|
+
**从 npm 安装(推荐)** —— 无需 clone:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
dsh plugin --profile <name> add @banbolee/dsh-lsp-diagnostics
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
本地开发时,可在仓库根目录把本 bundle 安装到任意 DSH profile。必须带上 workspace 根标志 `-w`:否则 pnpm 无法在 profile 生成的 workspace 内解析本地包。
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
dsh plugin --profile <profile> add -w ./plugins/dsh-lsp-diagnostics
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
若要用一条命令安装到隔离的 profile:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
DSH_HOME="$(mktemp -d)" scripts/sync-lsp-diagnostics-to-profile.sh <profile>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
sync 辅助脚本要求设置 `DSH_HOME`,这样测试与手工 QA 始终作用于隔离的 profile 状态,而绝不是用户的默认 Harness home。
|
|
30
|
+
|
|
31
|
+
插件注册一个真正的三参数 `tools/post-execute` waterfall 监听器 `(exec, _result, next)`:它恰好调用一次 `await next()`,且在任何插件 catch 之外,也从不消费 `_result` 占位符。
|
|
32
|
+
|
|
33
|
+
## 配置
|
|
34
|
+
|
|
35
|
+
所有开关都经由插件的 `Config` schema 与严格校验器。未知 key、不是合法 JSON 可表示的值、非法 timer/cap,或对封闭扩展路由的任何偏离,都会在加载时 fail loud。
|
|
36
|
+
|
|
37
|
+
| 配置项 | 默认值 | 含义 |
|
|
38
|
+
| --- | --- | --- |
|
|
39
|
+
| `enabled` | `true` | `enabled=false` 时插件不创建任何 collector、runtime、coordinator 或 tool,不注册任何 tools/listeners/effects,也不 spawn 任何进程。 |
|
|
40
|
+
| `timeoutMs` | `5000` | 单个 post-execute 聚合的不可延长 deadline。 |
|
|
41
|
+
| `settleMs` | `200` | 静默窗口;必须 `< timeoutMs`。 |
|
|
42
|
+
| `shutdownTimeoutMs` | `1000` | 优雅 `shutdown`/`exit` 的预算。 |
|
|
43
|
+
| `killGraceMs` | `500` | 进程树的 TERM→KILL 宽限期。 |
|
|
44
|
+
| `maxDocumentBytes` | `2097152` | 经 `didOpen` 发送的文档的硬性 UTF-8 字节上限。 |
|
|
45
|
+
| `maxMessageBytes` | `4194304` | LSP 消息体上限。 |
|
|
46
|
+
| `maxStderrBytes` | `16384` | 有界的 stderr 尾部上限。 |
|
|
47
|
+
| `maxDiagnostics` | `50` | 自动 diagnostic 行的全局上限,也是 canonical direct-tool diagnostics 数组的每次调用上限。 |
|
|
48
|
+
| `maxResultChars` | `8000` | 最终聚合文本的 Unicode 码点上限。 |
|
|
49
|
+
| `reportClean` | `true` | 显式空结果是否渲染为 `Status: clean`。 |
|
|
50
|
+
| `servers` | TypeScript + Go | 封闭的 provider catalog;TypeScript 和 Go 默认启用,而 `clangd`、`rust` 和 `python` 需显式开启。 |
|
|
51
|
+
|
|
52
|
+
扩展路由是封闭的:`.ts` → typescript/typescript,`.tsx` → typescript/typescriptreact,可选的显式 `.js` → typescript/javascript,`.go` → go/go,`.c` → clangd/c,`.cc`/`.cpp`/`.cxx` → clangd/cpp,`.h`/`.hh`/`.hpp`/`.hxx` → clangd/cpp,`.rs` → rust/rust,`.py`/`.pyi` → python/python;任何其他扩展名 key、缺失的 canonical provider 条目、跨 provider 冲突或被改写的 language id 都会在加载时 fail loud,而运行时的其他扩展名会被静默忽略。
|
|
53
|
+
|
|
54
|
+
`servers` 块是 provider 级别的部分 overlay:省略 `servers` 或提供 `servers: {}` 会精确保留 TypeScript 和 Go 默认值,而提供已知的可选 key `clangd`、`rust` 或 `python` 会激活对应 provider。例如 `servers: { go: { command: '/path/trae-gopls' } }` 只改动 Go,无需提供 TypeScript 块。legacy 的 `extensionToLanguage` 字段仅当它包含该 provider 的 canonical 映射且只包含受支持的 provider 自有扩展时才被接受。`.js` 为兼容显式 TypeScript 配置而被接受,但默认不启用。
|
|
55
|
+
|
|
56
|
+
默认命令为 `typescript-language-server --stdio`、`gopls`、`clangd`、`rust-analyzer` 和 `pyright-langserver --stdio`。clangd、rust-analyzer 和 gopls 不接受任何默认参数。per-server 的 `env`、`configuration` 和 `initializationOptions` 可以被覆盖,且必须是 JSON 可表示的。
|
|
57
|
+
|
|
58
|
+
## 行为
|
|
59
|
+
|
|
60
|
+
要求存在非空、且能 canonicalize 为目录的 `session.header.cwd`。自动反馈首先对该 workspace 包含的 target 保持旧行为;workspace 之外的 target 只有在 target 本地的 `.git` 文件或目录能标识其自身 git worktree 根时才符合条件。cwd 缺失或为空、canonicalize 结果不是目录,以及没有 marker 根 git worktree 的外部 target,都会被静默忽略 —— 永远不会出现 `workspace unavailable` 或 `outside workspace` notice。workspace、cwd 和 URI 始终通过公开的 `ctx.fs` API(`resolve`/`stat`/`contains`/`targetKey`/`processPath`/`fileUrl`)推导,绝不来自原始的 `displayPath` 或宿主的 `process.cwd()`。
|
|
61
|
+
|
|
62
|
+
Fail-open 仅限插件自身的后处理失败:在下游 `await next()` 决策已经成功之后,diagnostics 错误、服务端缺失或崩溃、协议失败、读取错误或超时都不会改变该决策或结果,也永远不会返回 `kind: 'block'`。调用方 abort 由 ToolRuntime 和下游监听器决定;本插件从不吞掉、改写调用方 abort,也不承诺把它变成成功。
|
|
63
|
+
|
|
64
|
+
新鲜度使用写入后的 `FsVersion`,加上一个独立的 per-target 单调 generation 计数器,其取值在一个插件生命周期内永不重用。`retireIfCurrent` 只移除匹配的 active marker(generation + version),从不触碰计数器。当 generation 计数器耗尽时,插件 fail safe,所有更旧的候选都保持 stale。
|
|
65
|
+
|
|
66
|
+
符合条件的 target 由唯一的共享 comparator `(renderPath, String(targetKey), canonicalUri)` 按 Unicode 码点排序,同一顺序同时驱动诊断调度和渲染出的 section —— 绝不使用原始 `displayPath` 元组、`localeCompare` 或默认的 UTF-16 排序。
|
|
67
|
+
|
|
68
|
+
一次 exec 最多产生一条聚合 notice,其中恰好有一个标题 `[LSP diagnostics after write]`,每个保留的文件一个 section,section 之间恰好一个空行,且没有开头或结尾换行。在全局 `maxDiagnostics` 数量上限之后,保留行为零的 diagnostics 文件会被整体省略,绝不留下空 section;clean 和 unavailable 的 section 不消耗该上限。仅当至少保留一行 diagnostic 时,才追加恰好一条全局 advisory,并以一个空行分隔。`maxResultChars` 上限只在完整的 canonical 聚合构建完成之后才应用:固定 marker `…(truncated)` 替换被截断的后缀,输出永不超过该上限。
|
|
69
|
+
|
|
70
|
+
文档在 `stat.size` 预检之后用有界的 `ctx.fs.readBytes(target, signal, maxDocumentBytes)` 读取,并采用严格 UTF-8 解码;绝不使用无界的 `readText`。已知大小超过上限,或未知大小的读取抛出 `code === 'FS_TOO_LARGE'` 的 `FsError`,都映射为 `document too large`;任何其他读取错误映射为 `diagnostics unavailable`。公开接缝保证成功的 `readBytes` 是完整的,且 `bytes.length <= maxDocumentBytes`;插件从不依赖或返回超过上限的字节。
|
|
71
|
+
|
|
72
|
+
`publishDiagnostics` 的关联顺序是精确 URI 优先,其次按文档 version;只有被消费的字段 `range`、`severity`、`code`、`source` 和 `message` 会被校验,而标准可选字段 `tags`、`relatedInformation`、`codeDescription`、`data` 以及未知扩展字段会被安全忽略,绝不进入规范化 schema 或 renderer。位置是基于 0 的 UTF-16,渲染为基于 1 的起止坐标。
|
|
73
|
+
|
|
74
|
+
coordinator 同时持有 active augment-operation registry 和 `retiredIo` late-final-stat registry。Unload 按精确顺序静默:停止 direct-tool 准入 → 停止 coordinator 准入(同时关闭 runtime 准入)→ offTool → offPost → offObserved → abort coordinator 操作 → abort direct-tool 操作 → await 所有 active augment promise → await 所有 active direct-tool promise → await 所有 `retiredIo` → `runtime.dispose()`。清理在单个失败之后仍继续并聚合这些失败;因此没有任何直接调用能与 runtime disposal 竞争。每个操作最外层的 `finally` 会清除 deadline timer 并移除 caller/cleanup relay 监听器,不留任何残留。
|
|
75
|
+
|
|
76
|
+
`stopAdmission()` 只关闭闸门和 runtime 自身的准入;它从不 abort 操作。Active 操作在更靠后的 abort 步骤被 abort,即在两个 listener disposer 都运行之后,与文档记载的顺序完全一致。已经进入但仍阻塞在下游 `next()` 中的监听器没有插件自有的取消接缝,因此清理会在 pending-invocation registry 中跟踪每一个这样的调用并等待其结算(它在下游结果或错误返回之前取走并 retire 该 exec 的候选,包括 `next()` 同步抛出的情况)。deadline 使用注入的单调时钟,绝不使用 `Date.now()`,因此墙钟回拨无法延长硬 deadline。
|
|
77
|
+
|
|
78
|
+
Session teardown 还负责协议写入的静默:每个在途的 server-request handler 和序列化的写入尾部都在 cleanup 返回前结算,关闭期间不会启动新的 server-request handler —— 因此 cleanup 完成后不可能再写入任何排队的帧(responses、`shutdown`、`exit`)。与 `didOpen` 写入竞争的匹配 `publishDiagnostics` 会被缓冲,直到 open 写入成功;只有成功写入的 open generation 才接受通知,而失败的 `didOpen` 写入仍允许被允许的 fresh-instance 重试。共享 runtime 采用通用的 correctness-over-performance 策略:它仍会把一个 provider/workspace session 跨不同的 canonical URI 池化,但在该 session 已打开过的 URI 被再次诊断之前,它会驱逐并异步 retire 该 session,然后在一个全新进程中以 version 1 打开该 URI;这避免了 provider VFS 缓存返回同一 URI 的陈旧 diagnostics,而 retired teardown 在 unload 时仍被跟踪并排空。
|
|
79
|
+
|
|
80
|
+
`timeoutMs` 是整个 post-execute 的单个不可延长 deadline;最后一轮 freshness 是一次性的 final stat/deadline/generation 闸门。direct tool 同样从其注入的单调时钟捕获一个绝对 deadline,并且当 `now() >= deadlineAt` 时每个闸门都转为 timeout,即使事件循环饥饿尚未运行已排定的 timer 回调。deadline/caller/cleanup 丢失时绝不等待迟到的 final stat:未结算的记录在决策返回前被 retire 进 `retiredIo`。Session teardown 是唯一顺序:`shutdown` 请求 → `exit` 通知/自然关闭等待 → 有条件的 `handle.terminate()`(仅当仍存活时)→ await `handle.done` 与 `waitForExit()` → `processLifetimeController.abort()`;`terminate()` 是唯一的硬停止。
|
|
81
|
+
|
|
82
|
+
## 模型体验
|
|
83
|
+
|
|
84
|
+
`lsp_diagnostics(file_path)` 是模型可调用的只读工具,作用于一个已存在、且 `ctx.fs` 允许该 session 读取的文件。它接受 workspace 相对路径或绝对路径,并使用所有已配置的 provider 以及上文记载的封闭扩展路由。它具有与官方 `read` 工具相同的路径权限:对于被 `session.header.cwd` 包含的文件,该 cwd 仍是 LSP 项目根;对于 session cwd 之外的可读文件,工具优先使用 target 的 marker 根项目/worktree 根,然后回退到 session cwd。它从不创建、编辑或删除文件。非法请求会显式失败,并给出 `file_path must be a non-empty string`、`session workspace cwd`、`session workspace is not an existing directory`、`target does not exist`、`target is not a regular file`、`no configured diagnostics provider` 或 `target changed during diagnosis`。
|
|
85
|
+
|
|
86
|
+
direct tool 恰好有三种 canonical 结果:`diagnostics`,其规范化列表按与自动渲染相同的 comparator 排序,并在 ToolRuntime/PTC 收到之前被切片到 `maxDiagnostics`,外加一个始终存在的非负整数 `omitted_diagnostics`;`no_diagnostics`,渲染为 `No diagnostics reported for this file snapshot.`;以及 `unavailable`,带一个封闭 reason。renderer 会保留来自该 canonical 计数的省略 marker。当你需要显式诊断一个已存在的文件时调用它,尤其是在 shell 命令、formatter 或 generator 绕过了自动 `fs/observed` 反馈之后;当已有新鲜的自动反馈时,请避免冗余调用。在受支持的 write/edit 变更之后,自动的写入后反馈仍是默认行为,而 direct tool 是刻意调用的,并且会报告路径/workspace 错误,而不是把它们静默当作不符合条件。
|
|
87
|
+
|
|
88
|
+
从模型的角度看,每个变更工具(`write`、`edit`、`str_replace_editor` 的 create/str_replace/insert)都原样返回其结果;插件为下一次推理向 `decision.additionalContexts` 追加一条有界聚合 notice。notice 包含:
|
|
89
|
+
|
|
90
|
+
- 每个保留的文件一个 `File: <renderPath>` section —— diagnostics 为 `- <severity> <startLine>:<startCharacter>-<endLine>:<endCharacter> source=... code=... message` 行,或 `Status: clean`,或 `Status: diagnostics unavailable (<reason>)` —— 按共享的码点顺序排列;
|
|
91
|
+
- 当至少保留一行 diagnostic 时,恰好一条全局 advisory;
|
|
92
|
+
- 对不支持的扩展名、不符合条件的 workspace、`enabled=false` 或没有 workspace 的 session,则不产生任何内容。
|
|
93
|
+
|
|
94
|
+
Unavailable reason 是封闭的:`server not found`、`server crashed`、`timeout`、`malformed response`、`document too large`、`diagnostics unavailable`。
|
|
95
|
+
|
|
96
|
+
## 已知限制与待办工作
|
|
97
|
+
|
|
98
|
+
- 绕过 `ctx.fs`/`fs/observed` 的 shell 重定向、`sed -i`、脚本、formatter、generator 以及外部编辑器不在自动反馈的范围内;模型可以随后调用 `lsp_diagnostics(file_path)`。没有 filesystem watcher;项目根探测基于 marker,且只对受支持的源文件运行。
|
|
99
|
+
- 对于自动反馈,无 agent 的工具执行、cwd 缺失/为空或不是目录,以及没有 marker 根 git worktree 的外部 target,都被静默排除在范围之外,永不产生 notice。直接调用则改用与官方 `read` 等价的路径权限:session cwd 之外的可读文件符合条件,并在可用时使用其 marker 根项目/worktree 根。
|
|
100
|
+
- 插件不安装也不下载 `typescript-language-server`、`gopls`、`clangd`、`rust-analyzer` 或 `pyright-langserver`;请自行安装所需可执行文件并配置其路径。服务端缺失时 fail open,给出 `diagnostics unavailable (server not found)`。
|
|
101
|
+
- 可移植的确定性测试使用仓库内的 fixture 服务端,绝不使用真实服务端或网络。独立的显式 real-server lane 需显式开启,只使用本地文件系统/进程,且从不安装或下载工具。
|
|
102
|
+
- 实现与测试使用 `0.1.7-rc.2` 依赖族;peer 依赖 `@deepseek-ai/dsh-fs`、`@deepseek-ai/dsh-llm`、`@deepseek-ai/dsh-subprocess` 和 `@deepseek-ai/dsh-tools` 为 `^0.1.7-rc.2`。
|
|
103
|
+
|
|
104
|
+
## 验证
|
|
105
|
+
|
|
106
|
+
确定性的包内测试(fixture 服务端、无网络、无真实 LSP 服务端、隔离的临时 workspace/profile):
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pnpm exec vitest run plugins/dsh-lsp-diagnostics tests/package-shape.spec.ts
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
文档形态测试(必需章节与契约字符串):
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pnpm exec vitest run tests/docs-shape-lsp-diagnostics.spec.ts
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
类型检查与 sync 脚本帮助:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
pnpm dlx --package=typescript@6.0.3 tsc -p plugins/dsh-lsp-diagnostics/tsconfig.json --noEmit
|
|
122
|
+
bash scripts/sync-lsp-diagnostics-to-profile.sh --help
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
显式的 real-server 验证与可移植覆盖是分开的。它要求 `RUN_REAL_LSP_SERVERS=1`、非空的 `REAL_LSP_PROVIDERS` 列表、每个被请求 provider 的显式可执行文件路径,以及一个证据路径:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
RUN_REAL_LSP_SERVERS=1 \
|
|
129
|
+
REAL_LSP_PROVIDERS=rust \
|
|
130
|
+
REAL_LSP_RUST_COMMAND=/data00/home/lixingxin/.cargo/bin/rust-analyzer \
|
|
131
|
+
REAL_LSP_EVIDENCE_PATH=/tmp/dsh-real-rust.json \
|
|
132
|
+
pnpm exec vitest run tests/composition/lsp-real-servers.spec.ts
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
operator wrapper 执行同样的检查:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
node scripts/qa/run-lsp-real-servers.mjs \
|
|
139
|
+
--providers rust \
|
|
140
|
+
--rust-command /data00/home/lixingxin/.cargo/bin/rust-analyzer \
|
|
141
|
+
--evidence /tmp/dsh-real-rust.json
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
每个被请求的 provider 都必须完成 bad → diagnostic → repair → clean。该 JSON 文件是机器可读的证据;被请求的可执行文件缺失时会记录 `status: "blocked"` 并以非零码退出,而不是跳过或通过。
|
package/coordinator.js
CHANGED
|
@@ -15,15 +15,17 @@
|
|
|
15
15
|
* @module @banbolee/dsh-lsp-diagnostics/coordinator
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
/// <reference path="./message-source.d.ts" />
|
|
18
19
|
import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
19
20
|
import { compareEligibleTargets, renderDiagnostics, sanitizeDisplayPath } from './render.js'
|
|
21
|
+
import { resolveWorkspaceRoot } from './workspace-root.js'
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Loose structural seams, mirroring the runtime's own public shapes. The
|
|
23
25
|
* coordinator only consumes the documented surface (`take/isCurrent/
|
|
24
26
|
* retireIfCurrent`, `diagnose/stopAdmission/dispose`, `resolve/stat/
|
|
25
|
-
* contains/fileUrl`); keeping these structural keeps both the real
|
|
26
|
-
* and the deterministic test fakes assignable.
|
|
27
|
+
* contains/fileUrl/processPath`); keeping these structural keeps both the real
|
|
28
|
+
* services and the deterministic test fakes assignable.
|
|
27
29
|
* @typedef {object} CollectorSeam
|
|
28
30
|
* @property {(exec: any, target: any, observation: any) => void} observe
|
|
29
31
|
* @property {(exec: any) => import('./collector.js').MutationCandidate[]} take
|
|
@@ -44,6 +46,7 @@ import { compareEligibleTargets, renderDiagnostics, sanitizeDisplayPath } from '
|
|
|
44
46
|
* @property {(target: any, signal?: AbortSignal) => Promise<{ version: string, type: string, size?: number } | undefined>} stat
|
|
45
47
|
* @property {(parent: any, child: any) => boolean} contains
|
|
46
48
|
* @property {(target: any) => string} fileUrl
|
|
49
|
+
* @property {(target: any) => string} processPath
|
|
47
50
|
*/
|
|
48
51
|
|
|
49
52
|
/**
|
|
@@ -382,10 +385,12 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
|
|
|
382
385
|
retireAll()
|
|
383
386
|
return decision
|
|
384
387
|
}
|
|
385
|
-
// 2.
|
|
388
|
+
// 2. Canonicalize the session workspace once. Files inside it keep the
|
|
389
|
+
// original behavior; files outside it may still resolve to their own
|
|
390
|
+
// project/worktree root.
|
|
386
391
|
const execRecord = /** @type {{ agent?: { session?: { header?: { cwd?: unknown } } } }} */ (exec)
|
|
387
|
-
const
|
|
388
|
-
if (typeof
|
|
392
|
+
const sessionRoot = execRecord?.agent?.session?.header?.cwd
|
|
393
|
+
if (typeof sessionRoot !== 'string' || sessionRoot.length === 0) {
|
|
389
394
|
retireAll()
|
|
390
395
|
return decision
|
|
391
396
|
}
|
|
@@ -393,10 +398,9 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
|
|
|
393
398
|
retireAll()
|
|
394
399
|
return decision
|
|
395
400
|
}
|
|
396
|
-
|
|
397
|
-
let workspaceTarget
|
|
401
|
+
let sessionWorkspace
|
|
398
402
|
try {
|
|
399
|
-
|
|
403
|
+
sessionWorkspace = await fs.resolve(sessionRoot, { signal })
|
|
400
404
|
} catch {
|
|
401
405
|
retireAll()
|
|
402
406
|
return decision
|
|
@@ -405,9 +409,9 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
|
|
|
405
409
|
retireAll()
|
|
406
410
|
return decision
|
|
407
411
|
}
|
|
408
|
-
let
|
|
412
|
+
let sessionWorkspaceInfo
|
|
409
413
|
try {
|
|
410
|
-
|
|
414
|
+
sessionWorkspaceInfo = await fs.stat(sessionWorkspace, signal)
|
|
411
415
|
} catch {
|
|
412
416
|
retireAll()
|
|
413
417
|
return decision
|
|
@@ -416,26 +420,43 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
|
|
|
416
420
|
retireAll()
|
|
417
421
|
return decision
|
|
418
422
|
}
|
|
419
|
-
if (
|
|
423
|
+
if (sessionWorkspaceInfo === undefined || sessionWorkspaceInfo.type !== 'directory') {
|
|
420
424
|
retireAll()
|
|
421
425
|
return decision
|
|
422
426
|
}
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
/** @type {{ candidate: import('./collector.js').MutationCandidate, renderPath: string, targetKey: string, canonicalUri: string }[]} */
|
|
427
|
+
// 3. Per-target eligibility: use session cwd when it contains the target;
|
|
428
|
+
// otherwise resolve the LSP workspace from target-local markers
|
|
429
|
+
// (go.mod/.git/package markers). Then freeze renderPath + canonicalUri.
|
|
430
|
+
/** @type {{ candidate: import('./collector.js').MutationCandidate, workspaceTarget: any, renderPath: string, targetKey: string, canonicalUri: string }[]} */
|
|
427
431
|
const eligible = []
|
|
428
432
|
for (const route of routed) {
|
|
429
433
|
const { candidate, canonicalUri } = route
|
|
430
434
|
if (signal.aborted) break
|
|
431
435
|
let contained = false
|
|
432
436
|
try {
|
|
433
|
-
contained = fs.contains(
|
|
437
|
+
contained = fs.contains(sessionWorkspace, candidate.target) === true
|
|
434
438
|
} catch {
|
|
435
439
|
contained = false
|
|
436
440
|
}
|
|
437
441
|
if (signal.aborted) break
|
|
438
|
-
|
|
442
|
+
let workspaceTarget = contained ? sessionWorkspace : undefined
|
|
443
|
+
if (workspaceTarget === undefined) {
|
|
444
|
+
try {
|
|
445
|
+
workspaceTarget = await resolveWorkspaceRoot({
|
|
446
|
+
fs,
|
|
447
|
+
target: candidate.target,
|
|
448
|
+
extension: extensionOf(canonicalUri),
|
|
449
|
+
sessionRoot,
|
|
450
|
+
signal,
|
|
451
|
+
sessionFallback: 'never',
|
|
452
|
+
markerMode: 'git',
|
|
453
|
+
})
|
|
454
|
+
} catch {
|
|
455
|
+
workspaceTarget = undefined
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (signal.aborted) break
|
|
459
|
+
if (workspaceTarget === undefined) {
|
|
439
460
|
collector.retireIfCurrent(candidate)
|
|
440
461
|
continue
|
|
441
462
|
}
|
|
@@ -448,6 +469,7 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
|
|
|
448
469
|
}
|
|
449
470
|
eligible.push({
|
|
450
471
|
candidate,
|
|
472
|
+
workspaceTarget,
|
|
451
473
|
renderPath,
|
|
452
474
|
targetKey: String(candidate.target.targetKey ?? ''),
|
|
453
475
|
canonicalUri,
|
|
@@ -470,7 +492,7 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
|
|
|
470
492
|
/** @type {import('./runtime.js').DiagnosisOutcome} */
|
|
471
493
|
let outcome
|
|
472
494
|
try {
|
|
473
|
-
outcome = await runtime.diagnose(target.candidate, workspaceTarget, target.canonicalUri, signal)
|
|
495
|
+
outcome = await runtime.diagnose(target.candidate, target.workspaceTarget, target.canonicalUri, signal)
|
|
474
496
|
} catch (error) {
|
|
475
497
|
if (signal.aborted) break
|
|
476
498
|
// Plugin-owned failure after eligibility: bounded unavailable with
|
|
@@ -609,9 +631,12 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
|
|
|
609
631
|
if (rendered.text !== null) {
|
|
610
632
|
const message = createUserMessage({
|
|
611
633
|
content: [{ type: 'text', text: rendered.text }],
|
|
634
|
+
// 0.1.7-rc.2 vocabulary: the producer declares its own
|
|
635
|
+
// `MessageSourceMap` kind (message-source.d.ts) and owns its bounded
|
|
636
|
+
// one-line `notice` account; there is no shared catch-all `plugin`
|
|
637
|
+
// kind and no producer-name field.
|
|
612
638
|
source: {
|
|
613
|
-
kind: '
|
|
614
|
-
plugin: '@banbolee/dsh-lsp-diagnostics',
|
|
639
|
+
kind: 'lsp-diagnostics',
|
|
615
640
|
form: 'notice',
|
|
616
641
|
summary: boundContextSummary(rendered.text),
|
|
617
642
|
},
|
package/index.js
CHANGED
|
@@ -90,6 +90,7 @@ const DEFAULT_ENABLED_PROVIDERS = Object.freeze(['typescript', 'go'])
|
|
|
90
90
|
* @type {Readonly<Record<string, Readonly<{ provider: ProviderId, language: string }>>>}
|
|
91
91
|
*/
|
|
92
92
|
const CANONICAL_ROUTE = Object.freeze({
|
|
93
|
+
'.js': Object.freeze({ provider: 'typescript', language: 'javascript' }),
|
|
93
94
|
'.ts': Object.freeze({ provider: 'typescript', language: 'typescript' }),
|
|
94
95
|
'.tsx': Object.freeze({ provider: 'typescript', language: 'typescriptreact' }),
|
|
95
96
|
'.go': Object.freeze({ provider: 'go', language: 'go' }),
|
|
@@ -290,11 +291,8 @@ function assertExtensionToLanguage(map, provider) {
|
|
|
290
291
|
if (defaults === undefined) fail(`provider ${provider} has no catalog defaults`)
|
|
291
292
|
const expected = defaults.extensionToLanguage
|
|
292
293
|
const expectedExtensions = Object.keys(expected)
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|| !expectedExtensions.every((extension) => routes.get(extension) === expected[extension])
|
|
296
|
-
) {
|
|
297
|
-
fail(`servers.${provider}.extensionToLanguage must equal the canonical provider mapping`)
|
|
294
|
+
if (!expectedExtensions.every((extension) => routes.get(extension) === expected[extension])) {
|
|
295
|
+
fail(`servers.${provider}.extensionToLanguage must include the canonical provider mapping`)
|
|
298
296
|
}
|
|
299
297
|
return Object.fromEntries(routes)
|
|
300
298
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin-owned `MessageSourceMap` entry this plugin's aggregate diagnostics
|
|
3
|
+
* notice needs.
|
|
4
|
+
*
|
|
5
|
+
* `@deepseek-ai/dsh-llm` 0.1.7-rc.2 removed the shared catch-all
|
|
6
|
+
* `MessageSourceMap['plugin']` kind: the map is merge-extensible, so every
|
|
7
|
+
* producer declares its own `kind` in its own module (as `dsh-agent` does for
|
|
8
|
+
* `model-selection` and `dsh-tools` for `tool-registry`). A `declare module`
|
|
9
|
+
* augmentation cannot live in a checked JavaScript file (TS8006), so it lives
|
|
10
|
+
* here and `coordinator.js` pulls it into the program with
|
|
11
|
+
* `/// <reference path="./message-source.d.ts" />`.
|
|
12
|
+
*
|
|
13
|
+
* @module @banbolee/dsh-lsp-diagnostics/message-source
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
|
|
17
|
+
|
|
18
|
+
declare module '@deepseek-ai/dsh-llm' {
|
|
19
|
+
interface MessageSourceMap {
|
|
20
|
+
/** One aggregate post-write diagnostics notice produced by this plugin. */
|
|
21
|
+
'lsp-diagnostics': { kind: 'lsp-diagnostics' } & ContextFormed
|
|
22
|
+
}
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@banbolee/dsh-lsp-diagnostics",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Automatic post-write and model-callable read-only LSP diagnostics for existing files in DeepSeek Harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
"tool.js",
|
|
17
17
|
"render.js",
|
|
18
18
|
"coordinator.js",
|
|
19
|
+
"message-source.d.ts",
|
|
20
|
+
"workspace-root.js",
|
|
19
21
|
"cordis.patch.yml",
|
|
20
|
-
"README.md"
|
|
22
|
+
"README.md",
|
|
23
|
+
"README.zh.md"
|
|
21
24
|
],
|
|
22
25
|
"dsh": {
|
|
23
26
|
"bundle": {
|
|
@@ -25,33 +28,31 @@
|
|
|
25
28
|
}
|
|
26
29
|
},
|
|
27
30
|
"peerDependencies": {
|
|
28
|
-
"@deepseek-ai/cordis": "
|
|
29
|
-
"@deepseek-ai/dsh-fs": "^0.1.
|
|
30
|
-
"@deepseek-ai/dsh-llm": "^0.1.
|
|
31
|
-
"@deepseek-ai/dsh-subprocess": "^0.1.
|
|
32
|
-
"@deepseek-ai/dsh-tools": "^0.1.
|
|
31
|
+
"@deepseek-ai/cordis": "~4.0.4",
|
|
32
|
+
"@deepseek-ai/dsh-fs": "^0.1.7-rc.2",
|
|
33
|
+
"@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
|
|
34
|
+
"@deepseek-ai/dsh-subprocess": "^0.1.7-rc.2",
|
|
35
|
+
"@deepseek-ai/dsh-tools": "^0.1.7-rc.2"
|
|
33
36
|
},
|
|
34
37
|
"devDependencies": {
|
|
35
|
-
"@deepseek-ai/cordis": "
|
|
36
|
-
"@deepseek-ai/dsh-agent": "^0.1.
|
|
37
|
-
"@deepseek-ai/dsh-agent-loop": "^0.1.
|
|
38
|
-
"@deepseek-ai/dsh-attachment": "^0.1.
|
|
39
|
-
"@deepseek-ai/dsh-
|
|
40
|
-
"@deepseek-ai/dsh-
|
|
41
|
-
"@deepseek-ai/dsh-
|
|
42
|
-
"@deepseek-ai/dsh-
|
|
43
|
-
"@deepseek-ai/dsh-
|
|
44
|
-
"@deepseek-ai/dsh-
|
|
45
|
-
"@deepseek-ai/dsh-
|
|
46
|
-
"@deepseek-ai/dsh-
|
|
47
|
-
"@deepseek-ai/dsh-
|
|
48
|
-
"@deepseek-ai/dsh-
|
|
49
|
-
"@deepseek-ai/dsh-
|
|
50
|
-
"@deepseek-ai/dsh-
|
|
51
|
-
"@deepseek-ai/dsh-
|
|
52
|
-
"@deepseek-ai/dsh-
|
|
53
|
-
"@deepseek-ai/dsh-tools": "^0.1.5-rc.1",
|
|
54
|
-
"@deepseek-ai/dsh-user-approval": "^0.1.5-rc.1"
|
|
38
|
+
"@deepseek-ai/cordis": "~4.0.4",
|
|
39
|
+
"@deepseek-ai/dsh-agent": "^0.1.7-rc.2",
|
|
40
|
+
"@deepseek-ai/dsh-agent-loop": "^0.1.7-rc.2",
|
|
41
|
+
"@deepseek-ai/dsh-attachment": "^0.1.7-rc.2",
|
|
42
|
+
"@deepseek-ai/dsh-fs": "^0.1.7-rc.2",
|
|
43
|
+
"@deepseek-ai/dsh-fs-local": "^0.1.7-rc.2",
|
|
44
|
+
"@deepseek-ai/dsh-invariants": "^0.1.7-rc.2",
|
|
45
|
+
"@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
|
|
46
|
+
"@deepseek-ai/dsh-scope": "^0.1.7-rc.2",
|
|
47
|
+
"@deepseek-ai/dsh-session": "^0.1.7-rc.2",
|
|
48
|
+
"@deepseek-ai/dsh-session-persistence": "^0.1.7-rc.2",
|
|
49
|
+
"@deepseek-ai/dsh-subprocess": "^0.1.7-rc.2",
|
|
50
|
+
"@deepseek-ai/dsh-subprocess-local": "^0.1.7-rc.2",
|
|
51
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.7-rc.2",
|
|
52
|
+
"@deepseek-ai/dsh-tool-fs": "^0.1.7-rc.2",
|
|
53
|
+
"@deepseek-ai/dsh-tool-str-replace-editor": "^0.1.7-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-tools": "^0.1.7-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-user-approval": "^0.1.7-rc.2"
|
|
55
56
|
},
|
|
56
57
|
"keywords": [
|
|
57
58
|
"dsh",
|
package/runtime.js
CHANGED
|
@@ -473,7 +473,27 @@ export class DiagnosticsRuntime {
|
|
|
473
473
|
for (const session of sessions) this.startSessionTeardown(session)
|
|
474
474
|
/** @type {unknown[]} */
|
|
475
475
|
const errors = []
|
|
476
|
-
|
|
476
|
+
// Bounded for the same reason every wait inside `teardown` is: these are the
|
|
477
|
+
// serialized write queues, and a queue whose peer is gone never settles. The
|
|
478
|
+
// sessions' own teardowns are already bounded, but this await is a SECOND,
|
|
479
|
+
// independent one on the same tails — bounding it inside `teardown` does not
|
|
480
|
+
// reach it, and an unbounded await here keeps `dispose()` from ever
|
|
481
|
+
// resolving, which is what leaves the fiber alive and the plugin never
|
|
482
|
+
// reporting itself disposed.
|
|
483
|
+
const queues$ = new AbortController()
|
|
484
|
+
const queuesTimer = setTimeout(() => queues$.abort(), this.config.shutdownTimeoutMs)
|
|
485
|
+
/** @type {PromiseSettledResult<unknown>[]} */
|
|
486
|
+
let queueResults = []
|
|
487
|
+
try {
|
|
488
|
+
queueResults = await /** @type {Promise<PromiseSettledResult<unknown>[]>} */ (
|
|
489
|
+
abortable(Promise.allSettled(queues), queues$.signal)
|
|
490
|
+
)
|
|
491
|
+
} catch (error) {
|
|
492
|
+
if (!(error instanceof Error && error.name === 'AbortError')) errors.push(error)
|
|
493
|
+
queueResults = []
|
|
494
|
+
} finally {
|
|
495
|
+
clearTimeout(queuesTimer)
|
|
496
|
+
}
|
|
477
497
|
for (const result of queueResults) {
|
|
478
498
|
if (result.status === 'rejected') errors.push(result.reason)
|
|
479
499
|
}
|
|
@@ -1505,17 +1525,37 @@ class LspSession {
|
|
|
1505
1525
|
}
|
|
1506
1526
|
|
|
1507
1527
|
/**
|
|
1508
|
-
* The unique teardown sequence:
|
|
1509
|
-
* graceful budget, exit notification plus
|
|
1510
|
-
* tree liveness probe (bounded by the same
|
|
1511
|
-
* `terminate()` (the only hard-stop, exactly once,
|
|
1512
|
-
* `handle.done` and `waitForExit()`, and only then the
|
|
1513
|
-
* controller abort. Terminate is decided by whole-tree
|
|
1514
|
-
* direct-process `done` alone, so a root that exits while
|
|
1515
|
-
* alive still escalates and the teardown cannot wait forever.
|
|
1528
|
+
* The unique teardown sequence: own the session's startup, then shutdown
|
|
1529
|
+
* request within an independent graceful budget, exit notification plus
|
|
1530
|
+
* natural-close wait, whole-process-tree liveness probe (bounded by the same
|
|
1531
|
+
* budget), conditional `terminate()` (the only hard-stop, exactly once,
|
|
1532
|
+
* tree-scoped), then `handle.done` and `waitForExit()`, and only then the
|
|
1533
|
+
* process-lifetime controller abort. Terminate is decided by whole-tree
|
|
1534
|
+
* liveness, never by direct-process `done` alone, so a root that exits while
|
|
1535
|
+
* a helper remains alive still escalates and the teardown cannot wait forever.
|
|
1516
1536
|
* @returns {Promise<void>}
|
|
1517
1537
|
*/
|
|
1518
1538
|
async teardown() {
|
|
1539
|
+
// A session can be retired while its bootstrap is still running: the pool
|
|
1540
|
+
// publishes the session before its process exists. Acting on the process
|
|
1541
|
+
// now would find no stdin and no handle, so the sequence below would
|
|
1542
|
+
// complete as a no-op while the process the bootstrap spawns afterwards
|
|
1543
|
+
// stays owned by nobody — never sent shutdown/exit and never terminated —
|
|
1544
|
+
// and dispose() would have reported a finished teardown. Own the startup
|
|
1545
|
+
// first, so the single transaction always runs against the process this
|
|
1546
|
+
// session actually has. This wait is deliberately not charged to the
|
|
1547
|
+
// graceful budget, which bounds the shutdown handshake against a live but
|
|
1548
|
+
// hung server: the bootstrap cannot outlive the admitted operation's signal
|
|
1549
|
+
// (cleanup aborts it before disposing) and its pending initialize request
|
|
1550
|
+
// was already rejected by `startTeardown`, so it settles without a process
|
|
1551
|
+
// or with the one the sequence must tear down.
|
|
1552
|
+
if (this.handle === undefined && this.ready !== undefined) {
|
|
1553
|
+
try {
|
|
1554
|
+
await this.ready
|
|
1555
|
+
} catch {
|
|
1556
|
+
// the bootstrap failed without spawning; the transaction continues
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1519
1559
|
/** @type {unknown[]} */
|
|
1520
1560
|
const errors = []
|
|
1521
1561
|
const budget = new AbortController()
|
|
@@ -1565,31 +1605,65 @@ class LspSession {
|
|
|
1565
1605
|
errors.push(error)
|
|
1566
1606
|
}
|
|
1567
1607
|
}
|
|
1568
|
-
if (this.handle !== undefined) {
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1608
|
+
if (this.handle !== undefined) {
|
|
1609
|
+
// These two waits must be BOUNDED. `terminate()` above is the hard stop —
|
|
1610
|
+
// once it has been issued there is nothing left to decide, only a process
|
|
1611
|
+
// tree to observe going away. On a platform whose containment never
|
|
1612
|
+
// reports the tree as exited (a systemd scope whose direct runner dies
|
|
1613
|
+
// while the scope lives, say), `waitForExit()` never resolves, and an
|
|
1614
|
+
// unbounded await here deadlocks the teardown BEFORE the lifetime abort at
|
|
1615
|
+
// the end of this method — the one action that would actually tear the
|
|
1616
|
+
// subprocess down. The method's own contract says it "cannot wait
|
|
1617
|
+
// forever"; this is where that promise is kept.
|
|
1618
|
+
//
|
|
1619
|
+
// A fresh graceful budget is used rather than the one above: that one
|
|
1620
|
+
// bounded the shutdown handshake, which is a different question from how
|
|
1621
|
+
// long to wait for a killed tree to disappear.
|
|
1622
|
+
const settle = new AbortController()
|
|
1623
|
+
const settleTimer = setTimeout(() => settle.abort(), this.config.shutdownTimeoutMs)
|
|
1574
1624
|
try {
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1625
|
+
try {
|
|
1626
|
+
await abortable(this.handle.done, settle.signal)
|
|
1627
|
+
} catch (error) {
|
|
1628
|
+
if (!(error instanceof Error && error.name === 'AbortError')) errors.push(error)
|
|
1629
|
+
}
|
|
1630
|
+
try {
|
|
1631
|
+
await this.handle.waitForExit(settle.signal)
|
|
1632
|
+
} catch (error) {
|
|
1633
|
+
if (!(error instanceof Error && error.name === 'AbortError')) errors.push(error)
|
|
1634
|
+
}
|
|
1635
|
+
} finally {
|
|
1636
|
+
clearTimeout(settleTimer)
|
|
1578
1637
|
}
|
|
1579
1638
|
}
|
|
1580
1639
|
// Own the protocol write quiescence: every server-request handler and the
|
|
1581
1640
|
// serialized write tail must settle before cleanup returns, so no queued
|
|
1582
1641
|
// frame (responses, shutdown, exit) can be written after dispose resolves.
|
|
1583
|
-
// No new server-request handlers start while closing, so this terminates
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1642
|
+
// No new server-request handlers start while closing, so this terminates —
|
|
1643
|
+
// EXCEPT when the peer is gone and a write never settles, which is precisely
|
|
1644
|
+
// the case teardown exists to survive. The loop is therefore bounded by the
|
|
1645
|
+
// same graceful budget as the waits above: quiescence is a cleanliness
|
|
1646
|
+
// guarantee, not something worth hanging the whole teardown for. An expired
|
|
1647
|
+
// budget is not an error — the lifetime abort below is the lever.
|
|
1648
|
+
const quiesce = new AbortController()
|
|
1649
|
+
const quiesceTimer = setTimeout(() => quiesce.abort(), this.config.shutdownTimeoutMs)
|
|
1650
|
+
try {
|
|
1651
|
+
for (;;) {
|
|
1652
|
+
while (this.serverRequests.size > 0) {
|
|
1653
|
+
await Promise.allSettled([...this.serverRequests])
|
|
1654
|
+
if (quiesce.signal.aborted) break
|
|
1655
|
+
}
|
|
1656
|
+
const tail = this.writeTail
|
|
1657
|
+
await abortable(tail, quiesce.signal).catch((error) => {
|
|
1658
|
+
if (!(error instanceof Error && error.name === 'AbortError')) errors.push(error)
|
|
1659
|
+
})
|
|
1660
|
+
if (quiesce.signal.aborted) break
|
|
1661
|
+
if (this.writeTail === tail && this.serverRequests.size === 0) break
|
|
1587
1662
|
}
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
if (this.writeTail === tail && this.serverRequests.size === 0) break
|
|
1663
|
+
} finally {
|
|
1664
|
+
clearTimeout(quiesceTimer)
|
|
1591
1665
|
}
|
|
1592
|
-
this.lifetimeController.abort()
|
|
1666
|
+
this.lifetimeController.abort()
|
|
1593
1667
|
this.detachStreams()
|
|
1594
1668
|
if (errors.length === 1) throw errors[0]
|
|
1595
1669
|
if (errors.length > 1) {
|
package/tool.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import { compareDiagnostics, sanitizeDisplayPath } from './render.js'
|
|
3
|
+
import { resolveWorkspaceRoot } from './workspace-root.js'
|
|
3
4
|
|
|
4
5
|
/** Closed runtime reasons exposed by the canonical tool output. */
|
|
5
6
|
const UNAVAILABLE_REASONS = /** @type {const} */ ([
|
|
@@ -105,7 +106,9 @@ const OUTPUT_SCHEMA = /** @type {const} */ ({
|
|
|
105
106
|
* @typedef {object} FsSeam
|
|
106
107
|
* @property {(path: string, opts?: { cwd?: string, signal?: AbortSignal }) => Promise<import('@deepseek-ai/dsh-fs').FsTarget>} resolve
|
|
107
108
|
* @property {(target: import('@deepseek-ai/dsh-fs').FsTarget, signal?: AbortSignal) => Promise<import('@deepseek-ai/dsh-fs').FsInfo | undefined>} stat
|
|
109
|
+
* @property {(parent: import('@deepseek-ai/dsh-fs').FsTarget, child: import('@deepseek-ai/dsh-fs').FsTarget) => boolean} contains
|
|
108
110
|
* @property {(target: import('@deepseek-ai/dsh-fs').FsTarget) => string} fileUrl
|
|
111
|
+
* @property {(target: import('@deepseek-ai/dsh-fs').FsTarget) => string} processPath
|
|
109
112
|
*/
|
|
110
113
|
|
|
111
114
|
/**
|
|
@@ -351,13 +354,13 @@ export function createDiagnosticsTool({ fs, runtime, config, now = () => perform
|
|
|
351
354
|
let early = abortOutcome(operation)
|
|
352
355
|
if (early !== undefined) return early
|
|
353
356
|
const signal = operation.controller.signal
|
|
354
|
-
const
|
|
357
|
+
const sessionWorkspace = await fs.resolve(workspaceRoot, { signal })
|
|
355
358
|
early = abortOutcome(operation)
|
|
356
359
|
if (early !== undefined) return early
|
|
357
|
-
const
|
|
360
|
+
const sessionWorkspaceInfo = await fs.stat(sessionWorkspace, signal)
|
|
358
361
|
early = abortOutcome(operation)
|
|
359
362
|
if (early !== undefined) return early
|
|
360
|
-
if (
|
|
363
|
+
if (sessionWorkspaceInfo === undefined || sessionWorkspaceInfo.type !== 'directory') {
|
|
361
364
|
throw new Error('lsp_diagnostics: session workspace is not an existing directory')
|
|
362
365
|
}
|
|
363
366
|
|
|
@@ -379,6 +382,28 @@ export function createDiagnosticsTool({ fs, runtime, config, now = () => perform
|
|
|
379
382
|
const label = extension.length === 0 ? '(none)' : extension
|
|
380
383
|
throw new Error(`lsp_diagnostics: no configured diagnostics provider for extension ${label}`)
|
|
381
384
|
}
|
|
385
|
+
/** @type {import('@deepseek-ai/dsh-fs').FsTarget | undefined} */
|
|
386
|
+
let workspace = sessionWorkspace
|
|
387
|
+
try {
|
|
388
|
+
if (fs.contains(sessionWorkspace, target) !== true) workspace = undefined
|
|
389
|
+
} catch {
|
|
390
|
+
workspace = undefined
|
|
391
|
+
}
|
|
392
|
+
if (workspace === undefined) {
|
|
393
|
+
workspace = await resolveWorkspaceRoot({
|
|
394
|
+
fs,
|
|
395
|
+
target,
|
|
396
|
+
extension,
|
|
397
|
+
sessionRoot: workspaceRoot,
|
|
398
|
+
signal,
|
|
399
|
+
sessionFallback: 'always',
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
early = abortOutcome(operation)
|
|
403
|
+
if (early !== undefined) return early
|
|
404
|
+
if (workspace === undefined) {
|
|
405
|
+
throw new Error('lsp_diagnostics: workspace root could not be resolved')
|
|
406
|
+
}
|
|
382
407
|
|
|
383
408
|
const outcome = await runtime.diagnoseTarget(
|
|
384
409
|
target,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { dirname, isAbsolute, join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
/** @typedef {'always' | 'if-contained' | 'never'} SessionFallback */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} FsSeam
|
|
7
|
+
* @property {(path: string, opts?: { cwd?: string, signal?: AbortSignal }) => Promise<any>} resolve
|
|
8
|
+
* @property {(statTarget: any, signal?: AbortSignal) => Promise<{ version: string, type: string, size?: number } | undefined>} stat
|
|
9
|
+
* @property {(parentTarget: any, childTarget: any) => boolean} contains
|
|
10
|
+
* @property {(processTarget: any) => string} processPath
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const GO_MARKERS = /** @type {const} */ (['go.work', 'go.mod', '.git'])
|
|
14
|
+
const TYPESCRIPT_MARKERS = /** @type {const} */ (['tsconfig.json', 'jsconfig.json', 'package.json', '.git'])
|
|
15
|
+
const DEFAULT_MARKERS = /** @type {const} */ (['.git'])
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Return project markers for the already-routed source extension.
|
|
19
|
+
* @param {string} extension
|
|
20
|
+
* @returns {readonly string[]}
|
|
21
|
+
*/
|
|
22
|
+
function markersForExtension(extension) {
|
|
23
|
+
if (extension === '.go') return GO_MARKERS
|
|
24
|
+
if (extension === '.ts' || extension === '.tsx') return TYPESCRIPT_MARKERS
|
|
25
|
+
return DEFAULT_MARKERS
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Stat one marker path. Missing or inaccessible markers are simply not roots.
|
|
30
|
+
* @param {FsSeam} fs
|
|
31
|
+
* @param {string} markerPath
|
|
32
|
+
* @param {AbortSignal | undefined} signal
|
|
33
|
+
* @returns {Promise<boolean>}
|
|
34
|
+
*/
|
|
35
|
+
async function markerExists(fs, markerPath, signal) {
|
|
36
|
+
try {
|
|
37
|
+
if (signal?.aborted) return false
|
|
38
|
+
const marker = await fs.resolve(markerPath, { signal })
|
|
39
|
+
if (signal?.aborted) return false
|
|
40
|
+
const info = await fs.stat(marker, signal)
|
|
41
|
+
if (signal?.aborted) return false
|
|
42
|
+
return info?.type === 'file' || info?.type === 'directory'
|
|
43
|
+
} catch {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve an existing directory root candidate.
|
|
50
|
+
* @param {FsSeam} fs
|
|
51
|
+
* @param {string} directory
|
|
52
|
+
* @param {AbortSignal | undefined} signal
|
|
53
|
+
* @returns {Promise<any | undefined>}
|
|
54
|
+
*/
|
|
55
|
+
async function resolveDirectory(fs, directory, signal) {
|
|
56
|
+
try {
|
|
57
|
+
if (signal?.aborted) return undefined
|
|
58
|
+
const root = await fs.resolve(directory, { signal })
|
|
59
|
+
if (signal?.aborted) return undefined
|
|
60
|
+
const info = await fs.stat(root, signal)
|
|
61
|
+
if (signal?.aborted) return undefined
|
|
62
|
+
return info?.type === 'directory' ? root : undefined
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Walk from the target's directory to the filesystem root looking for project markers.
|
|
70
|
+
* @param {FsSeam} fs
|
|
71
|
+
* @param {string} startDirectory
|
|
72
|
+
* @param {readonly string[]} markers
|
|
73
|
+
* @param {AbortSignal | undefined} signal
|
|
74
|
+
* @returns {Promise<any | undefined>}
|
|
75
|
+
*/
|
|
76
|
+
async function nearestMarkedDirectory(fs, startDirectory, markers, signal) {
|
|
77
|
+
let directory = startDirectory
|
|
78
|
+
for (;;) {
|
|
79
|
+
for (const marker of markers) {
|
|
80
|
+
if (await markerExists(fs, join(directory, marker), signal)) {
|
|
81
|
+
return resolveDirectory(fs, directory, signal)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const parent = dirname(directory)
|
|
85
|
+
if (parent === directory) return undefined
|
|
86
|
+
directory = parent
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the session root fallback and optionally require it to contain target.
|
|
92
|
+
* @param {FsSeam} fs
|
|
93
|
+
* @param {string} sessionRoot
|
|
94
|
+
* @param {any} target
|
|
95
|
+
* @param {SessionFallback} fallback
|
|
96
|
+
* @param {AbortSignal | undefined} signal
|
|
97
|
+
* @returns {Promise<any | undefined>}
|
|
98
|
+
*/
|
|
99
|
+
async function resolveSessionFallback(fs, sessionRoot, target, fallback, signal) {
|
|
100
|
+
if (fallback === 'never') return undefined
|
|
101
|
+
if (typeof sessionRoot !== 'string' || sessionRoot.trim().length === 0) return undefined
|
|
102
|
+
const workspace = await resolveDirectory(fs, sessionRoot, signal)
|
|
103
|
+
if (workspace === undefined) return undefined
|
|
104
|
+
if (fallback === 'always') return workspace
|
|
105
|
+
try {
|
|
106
|
+
return fs.contains(workspace, target) === true ? workspace : undefined
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the LSP workspace for a target. Prefer a project marker rooted at the
|
|
114
|
+
* target itself (so sibling git worktrees work), then fall back to the session
|
|
115
|
+
* workspace according to the caller's policy.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} input
|
|
118
|
+
* @param {FsSeam} input.fs
|
|
119
|
+
* @param {any} input.target
|
|
120
|
+
* @param {string} input.extension
|
|
121
|
+
* @param {string} input.sessionRoot
|
|
122
|
+
* @param {AbortSignal} [input.signal]
|
|
123
|
+
* @param {SessionFallback} [input.sessionFallback]
|
|
124
|
+
* @param {'language' | 'git'} [input.markerMode]
|
|
125
|
+
* @returns {Promise<any | undefined>}
|
|
126
|
+
*/
|
|
127
|
+
export async function resolveWorkspaceRoot({
|
|
128
|
+
fs,
|
|
129
|
+
target,
|
|
130
|
+
extension,
|
|
131
|
+
sessionRoot,
|
|
132
|
+
signal,
|
|
133
|
+
sessionFallback = 'if-contained',
|
|
134
|
+
markerMode = 'language',
|
|
135
|
+
}) {
|
|
136
|
+
if (sessionFallback === 'if-contained') {
|
|
137
|
+
const sessionWorkspace = await resolveSessionFallback(fs, sessionRoot, target, sessionFallback, signal)
|
|
138
|
+
if (sessionWorkspace !== undefined) return sessionWorkspace
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
if (signal?.aborted) return undefined
|
|
143
|
+
const targetPath = fs.processPath(target)
|
|
144
|
+
if (typeof targetPath === 'string' && isAbsolute(targetPath)) {
|
|
145
|
+
const markers = markerMode === 'git' ? DEFAULT_MARKERS : markersForExtension(extension)
|
|
146
|
+
const marked = await nearestMarkedDirectory(fs, dirname(targetPath), markers, signal)
|
|
147
|
+
if (marked !== undefined) return marked
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Fall through to the configured session fallback.
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (sessionFallback === 'if-contained') return undefined
|
|
154
|
+
return resolveSessionFallback(fs, sessionRoot, target, sessionFallback, signal)
|
|
155
|
+
}
|