@moonquake2004/dsh-doctor 0.4.4 → 0.4.6
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 +8 -3
- package/README.zh.md +8 -3
- package/dsh-doctor.mjs +553 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dsh-doctor
|
|
2
2
|
|
|
3
|
+
**Verified against DSH `0.1.5-rc.1`** (session format v3, `dsh-session` located across npx / global / profile layouts; legacy v0 events read through the installed `dsh-session-format-*` migration chain).
|
|
4
|
+
|
|
3
5
|
> [中文版 README](README.zh.md) · English
|
|
4
6
|
|
|
5
7
|
Offline diagnostic for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — run it **before** boot or before installing plugins, and it tells you which of the failure classes this community has been reporting will bite.
|
|
@@ -24,7 +26,7 @@ node dsh-doctor.mjs --json --envelope # v1 doctor-contract envelope (lowercas
|
|
|
24
26
|
|
|
25
27
|
Exit codes (default mode): `0` = all pass · `1` = problems found (built-in checks + catalog `severity: error`) · warn-level catalog failures don't flip the exit code.
|
|
26
28
|
|
|
27
|
-
With `--envelope` (doctor-contract mode): `0` = all pass · `1` = any WARN · `2` = any FAIL. The envelope follows the shared `dsh-doctor/v1` schema (`{ schema, generatedAt, profile, exitCode, summary, ok, checks:[{name,status,detail}] }`) so implementations are interchangeable for CI/marketplace use. Installed via npm, the CLI is also available as the `dsh-doctor` bin.
|
|
29
|
+
With `--envelope` (doctor-contract mode): `0` = all pass · `1` = any WARN · `2` = any FAIL. The envelope follows the shared `dsh-doctor/v1` schema (`{ schema, generatedAt, profile, exitCode, summary, ok, checks:[{name,status,detail}] }`) so implementations are interchangeable for CI/marketplace use. Installed via npm, the CLI is also available as the `dsh-doctor` bin. `--remediation` (with `--json --envelope`) adds the opt-in v1.1 `remediation` array — ordered `[check-name] fix` lines for failing checks, absent otherwise so r5 consumers stay byte-stable.
|
|
28
30
|
|
|
29
31
|
## Checks (28 built-in + 5 catalog = 33)
|
|
30
32
|
|
|
@@ -36,7 +38,7 @@ With `--envelope` (doctor-contract mode): `0` = all pass · `1` = any WARN · `2
|
|
|
36
38
|
| E3 | node version / `--expose-internals` reachability | [#113](https://github.com/deepseek-ai/deepseek-harness/discussions/113), [#1313](https://github.com/deepseek-ai/deepseek-harness/discussions/1313) |
|
|
37
39
|
| E4 | node-pty native binary present (`prebuilds/<platform>-<arch>/pty.node`) | [#1219](https://github.com/deepseek-ai/deepseek-harness/discussions/1219) |
|
|
38
40
|
| E5 | storage JSON files valid (strict UTF-8 + parse) | [#1357](https://github.com/deepseek-ai/deepseek-harness/discussions/1357) |
|
|
39
|
-
| E6 | anchor tripwire:
|
|
41
|
+
| E6 | anchor tripwire: the S6/S7/S10 contracts still exist wherever the current release keeps them (npx / global / profile layouts; `dsh-session` or the `dsh-session-format-*` migration chain) | [anti-rot idea](https://github.com/deepseek-ai/deepseek-harness/discussions/1534) |
|
|
40
42
|
| E10 | web port 3080 availability before launch (dsh web itself = OK; other process = FAIL; `DSH_DOCTOR_PORT` override) | [#1719](https://github.com/deepseek-ai/deepseek-harness/discussions/1719) |
|
|
41
43
|
|
|
42
44
|
### profile
|
|
@@ -50,6 +52,8 @@ With `--envelope` (doctor-contract mode): `0` = all pass · `1` = any WARN · `2
|
|
|
50
52
|
| P12 | profile-installed bundle version vs running CLI (emits vocabulary name `installed_bundle`, #1719 v1.1: skip when unlisted / warn on manifest-lies or divergence / pass when equal; the web "Doctor" panel / `/dsh-doctor/run` API run the bundle) | [#1719](https://github.com/deepseek-ai/deepseek-harness/discussions/1719) |
|
|
51
53
|
| P13 | client-half `provide` service name clashes with core client services (`chatFileMentions` etc. from `@deepseek-ai/dsh-client-*`, warn) or cross-bundle same-name grabs (browser-side "service already registered" → UI white screen, server logs see nothing) | [#2752](https://github.com/deepseek-ai/deepseek-harness/discussions/2752) |
|
|
52
54
|
| P14 | declared `bin` executability (target file present + shebang required for text `bin`; exec-bit alone does not identify the interpreter → ENOEXEC on direct run, #1846) | [#1846](https://github.com/deepseek-ai/deepseek-harness/discussions/1846) |
|
|
55
|
+
| P16 | plugin imports a named export the installed package does not provide (warn; boot fails hard on one bad entry) | [#5864](https://github.com/deepseek-ai/deepseek-harness/discussions/5864) |
|
|
56
|
+
| P17 | client-side `require()` of a specifier the host module table cannot serve (platform seeds ∪ installed graph rows ∪ declared externals) — warn-only; catches the browser `Failed to load plugins` white screen | [#5719](https://github.com/deepseek-ai/deepseek-harness/discussions/5719) |
|
|
53
57
|
|
|
54
58
|
### session
|
|
55
59
|
| ID | Checks | Discussion |
|
|
@@ -58,10 +62,11 @@ With `--envelope` (doctor-contract mode): `0` = all pass · `1` = any WARN · `2
|
|
|
58
62
|
| S2 | unclosed turns (session stuck "running") | [#466](https://github.com/deepseek-ai/deepseek-harness/discussions/466), [#1265](https://github.com/deepseek-ai/deepseek-harness/discussions/1265) |
|
|
59
63
|
| S6 | `seq == index` contiguity (official semantics, chunk rows expanded like `expandRow`) | [#1333](https://github.com/deepseek-ai/deepseek-harness/discussions/1333), [#1452](https://github.com/deepseek-ai/deepseek-harness/discussions/1452), [#1469](https://github.com/deepseek-ai/deepseek-harness/discussions/1469) |
|
|
60
64
|
| S7 | post-`end-seed` replay (replayed committed tail) | [#1497](https://github.com/deepseek-ai/deepseek-harness/discussions/1497) |
|
|
61
|
-
| S8 | unknown event types without `ignorable` (wholesale refusal) | [#1538](https://github.com/deepseek-ai/deepseek-harness/discussions/1538) |
|
|
65
|
+
| S8 | unknown event types without `ignorable` (wholesale refusal) — readable set = current table ∪ legacy types recognized by the installed `dsh-session-format-*` migrators | [#1538](https://github.com/deepseek-ai/deepseek-harness/discussions/1538) |
|
|
62
66
|
| S9 | zstd container frame count (single-frame logs → `session.list` 500) | [#1043](https://github.com/deepseek-ai/deepseek-harness/discussions/1043) |
|
|
63
67
|
| S10 | `sourceEventSeqs` referencing non-earlier events | [#1469](https://github.com/deepseek-ai/deepseek-harness/discussions/1469) |
|
|
64
68
|
| S11 | whole-session scan: corrupt → quarantine suggestion; oversized / workspace estimated-heap (max(events×600B, bytes×6), default 1GiB, `DSH_DOCTOR_HEAP_MB`) → cold-start stall risk | [#1550](https://github.com/deepseek-ai/deepseek-harness/discussions/1550) |
|
|
69
|
+
| S12 | migration-refusal pre-flight: session logs the installed `dsh-session-format-*` chain will refuse (v0 `subagent/descriptor` version gate; v2→v3 source-kind whitelist) — sessions that look present but cannot be opened | [#6045](https://github.com/deepseek-ai/deepseek-harness/discussions/6045), [#6328](https://github.com/deepseek-ai/deepseek-harness/discussions/6328), [#6311](https://github.com/deepseek-ai/deepseek-harness/discussions/6311) |
|
|
65
70
|
|
|
66
71
|
## Notes
|
|
67
72
|
|
package/README.zh.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dsh-doctor
|
|
2
2
|
|
|
3
|
+
**已针对 DSH `0.1.5-rc.1` 验证**(会话格式 v3;`dsh-session` 定位覆盖 npx / 全局 / profile 三种布局;v0 旧事件按已装 `dsh-session-format-*` 迁移链读取)。
|
|
4
|
+
|
|
3
5
|
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的离线诊断工具——在**启动前**或**安装插件前**跑一次,它会告诉你社区反复报告的哪几类故障会在你机器上咬人。
|
|
4
6
|
|
|
5
7
|
零 npm 依赖。单文件。任何有 `node` 的环境都能跑(`zstd` 只在扫 `.zstd` 会话日志时需要,E1 会检查它)。
|
|
@@ -31,7 +33,7 @@ node dsh-doctor.mjs --no-catalog # 不拉远程目录(只用内置副
|
|
|
31
33
|
| E3 | node 版本 / `--expose-internals` 可及性 | [#113](https://github.com/deepseek-ai/deepseek-harness/discussions/113), [#1313](https://github.com/deepseek-ai/deepseek-harness/discussions/1313) |
|
|
32
34
|
| E4 | node-pty 原生二进制在位(`prebuilds/<platform>-<arch>/pty.node`) | [#1219](https://github.com/deepseek-ai/deepseek-harness/discussions/1219) |
|
|
33
35
|
| E5 | 存储 JSON 文件合法(严格 UTF-8 + 可解析) | [#1357](https://github.com/deepseek-ai/deepseek-harness/discussions/1357) |
|
|
34
|
-
| E6 | 锚点 tripwire:S6/S7/S10
|
|
36
|
+
| E6 | 锚点 tripwire:S6/S7/S10 依赖的契约仍在**当前版本存放它的位置**(npx / 全局 / profile 三布局;`dsh-session` 或 `dsh-session-format-*` 迁移链) | [anti-rot idea](https://github.com/deepseek-ai/deepseek-harness/discussions/1534) |
|
|
35
37
|
| E10 | 启动前 Web 端口 3080 可用性(dsh web 自身占用=正常;其他进程=FAIL;`DSH_DOCTOR_PORT` 可覆盖) | [#1719](https://github.com/deepseek-ai/deepseek-harness/discussions/1719) |
|
|
36
38
|
|
|
37
39
|
### profile
|
|
@@ -45,6 +47,8 @@ node dsh-doctor.mjs --no-catalog # 不拉远程目录(只用内置副
|
|
|
45
47
|
| P12 | profile 内 bundle 版本 vs 运行 CLI(发词汇名 `installed_bundle`,#1719 v1.1:未声明=skip / manifest 撒谎或分歧=warn / 一致=pass;web「诊断」面板 / `/dsh-doctor/run` API 跑的是 bundle) | [#1719](https://github.com/deepseek-ai/deepseek-harness/discussions/1719) |
|
|
46
48
|
| P13 | client 半 `provide` 服务名抢注核心客户端服务(`chatFileMentions` 等 `@deepseek-ai/dsh-client-*`,warn)或跨 bundle 同名(浏览器端 service already registered → UI 白屏、服务端日志无感知) | [#2752](https://github.com/deepseek-ai/deepseek-harness/discussions/2752) |
|
|
47
49
|
| P14 | 声明 `bin` 可执行性(目标文件在位 + 文本 bin 必须带 shebang;仅可执行位不识别解释器 → 直接执行 ENOEXEC,#1846) | [#1846](https://github.com/deepseek-ai/deepseek-harness/discussions/1846) |
|
|
50
|
+
| P16 | 插件导入了已装包未提供的命名导出(warn;单条坏 entry 即 boot 硬失败) | [#5864](https://github.com/deepseek-ai/deepseek-harness/discussions/5864) |
|
|
51
|
+
| P17 | client 端 `require()` 的模块宿主模块表无法服务(平台种子 ∪ 已装图行 ∪ 声明的 external)——warn 级;抓浏览器 `Failed to load plugins` 白屏 | [#5719](https://github.com/deepseek-ai/deepseek-harness/discussions/5719) |
|
|
48
52
|
|
|
49
53
|
### session
|
|
50
54
|
| ID | 检查 | 对应讨论 |
|
|
@@ -53,10 +57,11 @@ node dsh-doctor.mjs --no-catalog # 不拉远程目录(只用内置副
|
|
|
53
57
|
| S2 | 未闭合 turn(会话卡"运行中") | [#466](https://github.com/deepseek-ai/deepseek-harness/discussions/466), [#1265](https://github.com/deepseek-ai/deepseek-harness/discussions/1265) |
|
|
54
58
|
| S6 | `seq == index` 连续性(官方语义,chunk 行按 `expandRow` 展开) | [#1333](https://github.com/deepseek-ai/deepseek-harness/discussions/1333), [#1452](https://github.com/deepseek-ai/deepseek-harness/discussions/1452), [#1469](https://github.com/deepseek-ai/deepseek-harness/discussions/1469) |
|
|
55
59
|
| S7 | `end-seed` 后重放(重放已提交尾部) | [#1497](https://github.com/deepseek-ai/deepseek-harness/discussions/1497) |
|
|
56
|
-
| S8 | 未知事件类型且无 `ignorable
|
|
60
|
+
| S8 | 未知事件类型且无 `ignorable`(整包拒绝)——可读集 = 当前表 ∪ 已装 `dsh-session-format-*` 迁移包认的旧类型 | [#1538](https://github.com/deepseek-ai/deepseek-harness/discussions/1538) |
|
|
57
61
|
| S9 | zstd 容器帧数(单帧日志 → `session.list` 整体 500) | [#1043](https://github.com/deepseek-ai/deepseek-harness/discussions/1043) |
|
|
58
62
|
| S10 | `sourceEventSeqs` 引用非更早事件 | [#1469](https://github.com/deepseek-ai/deepseek-harness/discussions/1469) |
|
|
59
63
|
| S11 | 全会话扫描:损坏 → 隔离建议;超大 / 工作区估算物化堆(max(事件×600B, 字节×6),默认 1GiB,`DSH_DOCTOR_HEAP_MB`)→ 冷启动卡顿风险 | [#1550](https://github.com/deepseek-ai/deepseek-harness/discussions/1550) |
|
|
64
|
+
| S12 | 迁移拒载预检:会被已装 `dsh-session-format-*` 迁移链拒绝的会话日志(v0 `subagent/descriptor` 版本门;v2→v3 source.kind 白名单)——列表里看着在、点开却打不开 | [#6045](https://github.com/deepseek-ai/deepseek-harness/discussions/6045), [#6328](https://github.com/deepseek-ai/deepseek-harness/discussions/6328), [#6311](https://github.com/deepseek-ai/deepseek-harness/discussions/6311) |
|
|
60
65
|
|
|
61
66
|
## 备注
|
|
62
67
|
|
|
@@ -95,7 +100,7 @@ If you're coming from a symptom (rather than from the machine), these are the ch
|
|
|
95
100
|
| approval internals | S2 | ⚠️ runtime policy; only the turn-level effect |
|
|
96
101
|
| credentials internals | E2, E5 | ⚠️ file-level only |
|
|
97
102
|
|
|
98
|
-
The `dsh-doctor/v1` envelope (`--json --envelope`) is the machine-readable form of any of these runs, so a symptom tool can consume the verdict directly.
|
|
103
|
+
The `dsh-doctor/v1` envelope (`--json --envelope`) is the machine-readable form of any of these runs, so a symptom tool can consume the verdict directly. `--remediation`(配合 `--json --envelope`)输出 v1.1 可选 `remediation` 数组——失败项的有序 `[检查名] 修复` 行;不带该 flag 时字段不存在,r5 消费者字节稳定。
|
|
99
104
|
|
|
100
105
|
## 自更新检查(v0.2.1,层 B)
|
|
101
106
|
|
package/dsh-doctor.mjs
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* P10 inject 引用客户端专属服务(#1947:@deepseek-ai/dsh-client-* 服务端永不提供 → Fiber 永久 PENDING → web boot 失败)
|
|
16
16
|
* P11 已装 bundle 的 main 入口产物缺失(#1965:市场装未构建源码树 → ERR_MODULE_NOT_FOUND → boot 崩)
|
|
17
17
|
* P13 client 端 provide 服务名抢注核心客户端服务 / 跨 bundle 同名(#2752:浏览器端 service already registered → UI 白屏,服务端日志无感知)
|
|
18
|
+
* P17 client 端 require 不在宿主模块表(#5719:warn 级,种子表自省自 web-frontend 产物)
|
|
19
|
+
* P16 命名导入的导出缺失(#5864:warn 级,静态自省已装包导出面)
|
|
18
20
|
* P14 declared bin 可执行性(#1846:打包成功但 bin 缺 shebang/产物 → 直接执行 ENOEXEC;与 P11 互补)
|
|
19
21
|
* P12 `installed_bundle`(#1719 v1.1 词汇:profile 内 bundle 版本 vs 运行 CLI 版本——web 面板/API 跑的是 profile 里装的 bundle,可与独立 CLI 版本不一致)
|
|
20
22
|
* [session]
|
|
@@ -24,7 +26,8 @@
|
|
|
24
26
|
* S7 end-seed 后重放已提交尾部(#1497:种子末尾之后出现更低 seq)
|
|
25
27
|
* S9 zstd 容器结构(#1043:单帧容器 → session.list 整体 500,侧边栏全消失)
|
|
26
28
|
* S10 sourceEventSeqs 悬空引用(#1469:压缩未重映射溯源 → history unavailable)
|
|
27
|
-
* S8 未知事件类型且无 ignorable(#1538
|
|
29
|
+
* S8 未知事件类型且无 ignorable(#1538:harness 读不了 → 整包拒绝;可读集 = 安装的 dsh-session 当前表 ∪ dsh-session-format-* 迁移包旧类型,0.1.5-rc.1 回退)
|
|
30
|
+
* S12 迁移拒载预检(#6045/#6328/#6311:规则自省自已装 dsh-session-format-* 迁移包)
|
|
28
31
|
* S11 全会话扫描(#1550:损坏会话 → 隔离建议;超大会话/工作区估算物化堆 → 冷启动风险警告;估算堆=解码MB×6+事件×200B,阈值默认 1GB,可设 DSH_DOCTOR_HEAP_MB)
|
|
29
32
|
* [env]
|
|
30
33
|
* E1 关键命令不在 PATH(#1270:node/pnpm/zstd)
|
|
@@ -32,7 +35,7 @@
|
|
|
32
35
|
* E3 node 版本 / --expose-internals 可及性(#113/#1313,headless/HMR 场景)
|
|
33
36
|
* E4 node-pty 原生模块完整性(#1219:pty.node 缺失 → dsh web 启动失败)
|
|
34
37
|
* E5 存储 JSON 文件合法性(#1357:并发写 workspace.json 乱码 → 工作区列表消失)
|
|
35
|
-
* E6 锚点元检查(tripwire:S6 的
|
|
38
|
+
* E6 锚点元检查(tripwire:S6 的 v0 chunk 展开契约、S7 的 session/end-seed、S10 的 sourceEventSeqs 是否仍在安装的 dsh-session/迁移包中;定位覆盖 npx/全局/profile 三布局)
|
|
36
39
|
* E10 3080 Web 端口可用性(#1719:启动 dsh web 前检查;dsh web 自身占用=正常,其他程序占用=FAIL;DSH_DOCTOR_PORT 可覆盖)
|
|
37
40
|
* (P6 Windows 空格参数 lint,#1420 —— 待实现)
|
|
38
41
|
*
|
|
@@ -54,7 +57,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
|
|
|
54
57
|
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
55
58
|
import { createRequire } from 'node:module';
|
|
56
59
|
import net from 'node:net';
|
|
57
|
-
import { basename, delimiter as PATH_DELIM, dirname, join, resolve } from 'node:path';
|
|
60
|
+
import { basename, delimiter as PATH_DELIM, dirname, join, relative, resolve } from 'node:path';
|
|
58
61
|
import { homedir } from 'node:os';
|
|
59
62
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
60
63
|
|
|
@@ -67,37 +70,148 @@ const only = process.argv
|
|
|
67
70
|
.map((a) => a.startsWith('--') ? a.slice(2) : a);
|
|
68
71
|
const wants = (s) => only.length === 0 || only.includes(s) || only.includes(s.charAt(0).toUpperCase() + s.slice(1));
|
|
69
72
|
|
|
70
|
-
// S8:官方 KNOWN_SESSION_EVENT_TYPES
|
|
73
|
+
// S8:官方 KNOWN_SESSION_EVENT_TYPES(回退表对齐 0.1.5-rc.1;优先从安装的 dsh-session 动态解析)
|
|
71
74
|
const KNOWN_SESSION_EVENT_TYPES_FALLBACK = new Set([
|
|
72
|
-
'agent-preset/selected', 'agent/inbox/spliced', 'approval/asked', 'approval/decided',
|
|
73
|
-
'
|
|
74
|
-
'
|
|
75
|
-
'
|
|
76
|
-
'
|
|
77
|
-
'
|
|
78
|
-
'
|
|
79
|
-
'
|
|
80
|
-
'
|
|
75
|
+
'agent-preset/selected', 'agent/inbox/spliced', 'approval/asked', 'approval/decided',
|
|
76
|
+
'approval/policy', 'assistant/attempt', 'assistant/message', 'command/done',
|
|
77
|
+
'command/run', 'compaction/end', 'compaction/prune', 'compaction/start',
|
|
78
|
+
'compaction/summary', 'feedback/message-delete', 'feedback/message-put', 'feedback/record',
|
|
79
|
+
'goal/change', 'hook/invoked', 'hook/result', 'llm/retry',
|
|
80
|
+
'llm/retry-started', 'model/selection', 'permission/preset', 'plan/mode',
|
|
81
|
+
'request/context', 'request/header', 'sandbox/mode', 'schedule/change',
|
|
82
|
+
'session-log-deepseek/delivery-accepted', 'session/end-seed', 'session/title', 'session/title-llm-request',
|
|
83
|
+
'step/end', 'step/start', 'subagent/descriptor', 'subagent/model-selection-policy',
|
|
84
|
+
'system/message', 'team/member', 'team/message/delivered', 'team/message/queued',
|
|
85
|
+
'team/task', 'todo/write', 'tool-workflow/agent-end', 'tool-workflow/agent-start',
|
|
86
|
+
'tool-workflow/run-end', 'tool-workflow/run-start', 'tool/call', 'tool/ptc-dispatch',
|
|
87
|
+
'tool/ptc-dispatch-start', 'tool/result', 'turn/end', 'turn/start',
|
|
88
|
+
'user/message', 'web/deepseek-search-llm-request'
|
|
81
89
|
]);
|
|
82
90
|
// 存储行类型与 header,不属于事件门禁
|
|
83
91
|
const STORAGE_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks', 'session']);
|
|
84
|
-
|
|
92
|
+
/** 定位已安装的 dsh-session lib/index.js —— 覆盖三种安装形态(0.1.5 起 dsh 转全局安装,旧的 npx-only 查找会静默回退):
|
|
93
|
+
* ① npx/pnpm:<root>/node_modules/.bin/dsh → <root>/node_modules/@deepseek-ai/dsh-session
|
|
94
|
+
* ② 全局 npm:<prefix>/bin/dsh → <prefix>/lib/node_modules/@deepseek-ai/dsh[/node_modules]/@deepseek-ai/dsh-session
|
|
95
|
+
* ③ profile:$DSH_HOME/profiles/<name>/node_modules/@deepseek-ai/dsh-session(含 .pnpm store)
|
|
96
|
+
* 找不到返回 null —— 调用方回退内置假设并**声明**(不静默)。 */
|
|
97
|
+
function findSessionLibs() {
|
|
98
|
+
const REL = join('@deepseek-ai', 'dsh-session', 'lib', 'index.js');
|
|
99
|
+
const cands = [];
|
|
85
100
|
for (const p of (process.env.PATH || '').split(PATH_DELIM)) {
|
|
86
|
-
if (!p
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
if (!p || !existsSync(join(p, 'dsh'))) continue;
|
|
102
|
+
cands.push(join(dirname(p), REL)); // ① npx/pnpm 布局
|
|
103
|
+
let real = null;
|
|
104
|
+
try { real = realpathSync(join(p, 'dsh')); } catch { /* 忽略 */ }
|
|
105
|
+
if (real) {
|
|
106
|
+
let d = dirname(real);
|
|
107
|
+
for (let i = 0; i < 5; i++) {
|
|
108
|
+
cands.push(join(d, 'node_modules', REL)); // ② <pkg>/node_modules/…
|
|
109
|
+
cands.push(join(d, REL)); // ② hoisted 到 <dir>/@deepseek-ai/
|
|
110
|
+
const up = dirname(d);
|
|
111
|
+
if (up === d) break;
|
|
112
|
+
d = up;
|
|
93
113
|
}
|
|
94
|
-
}
|
|
95
|
-
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const profiles = join(HOME, 'profiles'); // ③
|
|
117
|
+
if (existsSync(profiles)) {
|
|
118
|
+
for (const name of readdirSync(profiles)) {
|
|
119
|
+
const nm = join(profiles, name, 'node_modules');
|
|
120
|
+
cands.push(join(nm, REL));
|
|
121
|
+
const store = join(nm, '.pnpm');
|
|
122
|
+
if (existsSync(store)) {
|
|
123
|
+
for (const d of readdirSync(store)) {
|
|
124
|
+
if (d.startsWith('@deepseek-ai+dsh-session@')) cands.push(join(store, d, 'node_modules', REL));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return [...new Set(cands.filter((c) => existsSync(c)))];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 机器上可能同时装着多份 dsh(全局 + 多个 npx checkout)——取版本最高的一份做"当前契约",
|
|
133
|
+
* 可读类型则取所有安装的并集(任一安装能读的旧类型都不该判"读不了")。 */
|
|
134
|
+
function pickNewestSessionLib(libs) {
|
|
135
|
+
let best = null; let bestV = null;
|
|
136
|
+
for (const lib of libs) {
|
|
137
|
+
let v = null;
|
|
138
|
+
try { v = JSON.parse(readFileSync(join(dirname(dirname(lib)), 'package.json'), 'utf8')).version; } catch { /* 忽略 */ }
|
|
139
|
+
if (!v) { if (!best) best = lib; continue; }
|
|
140
|
+
const key = v.split(/[.-]/).map((x) => (/^\d+$/.test(x) ? Number(x) : 0));
|
|
141
|
+
if (!bestV || key.some((n, i) => n !== (bestV[i] ?? 0) && n > (bestV[i] ?? 0) && bestV.slice(0, i).every((m, j) => m === key[j]))) { best = lib; bestV = key; }
|
|
142
|
+
else if (!best) { best = lib; bestV = key; }
|
|
143
|
+
}
|
|
144
|
+
return best;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 单份安装的事件类型表(解析失败返回 null)。 */
|
|
148
|
+
function sessionTableFrom(lib) {
|
|
149
|
+
try {
|
|
150
|
+
const s = readFileSync(lib, 'utf8');
|
|
151
|
+
const m = /const KNOWN_SESSION_EVENT_TYPES = new Set\(\[(.*?)\]\);/.exec(s);
|
|
152
|
+
if (!m) return null;
|
|
153
|
+
const items = [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
|
|
154
|
+
return items.length ? new Set(items) : null;
|
|
155
|
+
} catch { return null; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 某份安装同级的会话格式迁移包(v0→v1→v2→v3)所认的旧类型。 */
|
|
159
|
+
function migratorTypesFor(lib) {
|
|
160
|
+
const out = [];
|
|
161
|
+
const nm = lib.slice(0, lib.indexOf(join('@deepseek-ai', 'dsh-session')));
|
|
162
|
+
const libs = [];
|
|
163
|
+
const scope = join(nm, '@deepseek-ai');
|
|
164
|
+
if (existsSync(scope)) {
|
|
165
|
+
for (const d of readdirSync(scope)) if (d.startsWith('dsh-session-format-')) libs.push(join(scope, d, 'lib', 'index.js'));
|
|
166
|
+
}
|
|
167
|
+
const store = join(nm, '.pnpm');
|
|
168
|
+
if (existsSync(store)) {
|
|
169
|
+
for (const d of readdirSync(store)) {
|
|
170
|
+
if (!d.startsWith('@deepseek-ai+dsh-session-format-')) continue;
|
|
171
|
+
const inner = join(store, d, 'node_modules', '@deepseek-ai');
|
|
172
|
+
if (!existsSync(inner)) continue;
|
|
173
|
+
for (const p of readdirSync(inner)) if (p.startsWith('dsh-session-format-')) libs.push(join(inner, p, 'lib', 'index.js'));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const f of libs) {
|
|
177
|
+
try {
|
|
178
|
+
const s = readFileSync(f, 'utf8');
|
|
179
|
+
for (const m of s.matchAll(/"([a-z][a-z0-9-]*\/[a-z0-9-]+)"/g)) out.push(m[1]);
|
|
180
|
+
} catch { /* 忽略单个包 */ }
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const SESSION_LIBS = findSessionLibs();
|
|
186
|
+
/** 定位安装的 dsh-session(新→旧取最高版本;找不到返回 null)。 */
|
|
187
|
+
function findSessionLib() { return pickNewestSessionLib(SESSION_LIBS) || null; }
|
|
188
|
+
|
|
189
|
+
function knownSessionEventTypes() {
|
|
190
|
+
const lib = findSessionLib();
|
|
191
|
+
if (lib) {
|
|
192
|
+
const t = sessionTableFrom(lib);
|
|
193
|
+
if (t) return t;
|
|
96
194
|
}
|
|
97
195
|
return KNOWN_SESSION_EVENT_TYPES_FALLBACK;
|
|
98
196
|
}
|
|
197
|
+
|
|
99
198
|
const KNOWN = knownSessionEventTypes();
|
|
100
199
|
|
|
200
|
+
/** 可读类型集 = 当前 KNOWN ∪ 已装 dsh-session-format-* 迁移包认的旧类型(0.1.5 起会话格式有 v0→v1→v2→v3 迁移链)。
|
|
201
|
+
* 只比对当前表会把**可迁移的旧会话**误判为"harness 读不了"(#1538 的语义是"读不了",迁移得了就不算)。
|
|
202
|
+
* 旧类型在磁盘日志里真实存在(v0 日志含 assistant/chunk、tool/code-dispatch 等),迁移包负责转换。 */
|
|
203
|
+
function readableSessionEventTypes() {
|
|
204
|
+
const set = new Set(KNOWN);
|
|
205
|
+
for (const lib of SESSION_LIBS) {
|
|
206
|
+
const t = sessionTableFrom(lib);
|
|
207
|
+
if (t) for (const x of t) set.add(x);
|
|
208
|
+
for (const x of migratorTypesFor(lib)) set.add(x);
|
|
209
|
+
}
|
|
210
|
+
return set;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const READABLE = readableSessionEventTypes();
|
|
214
|
+
|
|
101
215
|
function report(section, id, ok, detail, fix, src) {
|
|
102
216
|
results.push({ section, id, ok, detail, fix, src: src ?? 'builtin' });
|
|
103
217
|
}
|
|
@@ -193,27 +307,31 @@ function checkEnv() {
|
|
|
193
307
|
|
|
194
308
|
// E6:锚点元检查(tripwire)——我们 S6/S7/S10 依赖的契约是否仍在安装的 dsh-session 里
|
|
195
309
|
// 上游改名/重构会让我们的离线结论静默腐烂(boyin111-1 的 --verify-anchors 同款思路)
|
|
196
|
-
|
|
197
|
-
for (const p of (process.env.PATH || '').split(PATH_DELIM)) {
|
|
198
|
-
if (p.endsWith('node_modules/.bin') && existsSync(join(p, 'dsh'))) {
|
|
199
|
-
const lib = join(dirname(p), '@deepseek-ai', 'dsh-session', 'lib', 'index.js');
|
|
200
|
-
if (existsSync(lib)) { sessionLib = lib; break; }
|
|
201
|
-
}
|
|
202
|
-
}
|
|
310
|
+
const sessionLib = findSessionLib();
|
|
203
311
|
if (!sessionLib) {
|
|
204
|
-
|
|
312
|
+
// r5 语义:锚点校验在此上下文"不适用"(找不到安装)——用 skip 而非静默 pass,理由写进 detail
|
|
313
|
+
reportSkip('env', 'E6', '未定位到 dsh-session(npx/全局/profile 三种布局都没有)——锚点未校验,S6/S7/S10 结论回退内置假设', undefined);
|
|
205
314
|
} else {
|
|
206
315
|
const src = readFileSync(sessionLib, 'utf8');
|
|
316
|
+
// 0.1.5 起 expandRow 从 dsh-session 移到会话格式迁移包(v0→v1→v2→v3),锚点随之迁移:
|
|
317
|
+
// S6 读的是磁盘上的 v0/v1 日志,其 chunk 展开契约由迁移链承载。
|
|
318
|
+
const migratorLib = (() => {
|
|
319
|
+
const nm = sessionLib.slice(0, sessionLib.indexOf(join('@deepseek-ai', 'dsh-session')));
|
|
320
|
+
const p = join(nm, '@deepseek-ai', 'dsh-session-format-v0-to-v1', 'lib', 'index.js');
|
|
321
|
+
return existsSync(p) ? p : null;
|
|
322
|
+
})();
|
|
323
|
+
const expandOk = /function expandRow[\s\S]*?row\.seq0/.test(src)
|
|
324
|
+
|| (migratorLib ? /"assistant\/chunk":\s*disposition\(/.test(readFileSync(migratorLib, 'utf8')) : false);
|
|
207
325
|
const anchors = [
|
|
208
|
-
['
|
|
209
|
-
['session/end-seed 字面量(S7 依赖)', /"session\/end-seed"
|
|
210
|
-
['sourceEventSeqs 字段(S10 依赖)', /sourceEventSeqs
|
|
326
|
+
['v0 chunk 展开契约(S6 依赖;dsh-session 的 expandRow 或迁移包 disposition)', expandOk],
|
|
327
|
+
['session/end-seed 字面量(S7 依赖)', /"session\/end-seed"/.test(src)],
|
|
328
|
+
['sourceEventSeqs 字段(S10 依赖)', /sourceEventSeqs/.test(src)],
|
|
211
329
|
];
|
|
212
|
-
const missing = anchors.filter(([,
|
|
330
|
+
const missing = anchors.filter(([, okFlag]) => !okFlag);
|
|
213
331
|
if (missing.length) {
|
|
214
332
|
report('env', 'E6', false, `锚点缺失(上游可能改了契约,S6/S7/S10 结论需人工复核): ${missing.map(([n]) => n).join('; ')}(${sessionLib.slice(-60)})`, '对照上游变更更新 dsh-doctor 的对应检查');
|
|
215
333
|
} else {
|
|
216
|
-
report('env', 'E6', true, `锚点齐全(${anchors.length}/3:
|
|
334
|
+
report('env', 'E6', true, `锚点齐全(${anchors.length}/3: v0 chunk 展开 / session/end-seed / sourceEventSeqs)`, undefined);
|
|
217
335
|
}
|
|
218
336
|
}
|
|
219
337
|
}
|
|
@@ -450,6 +568,71 @@ function checkProfile(name) {
|
|
|
450
568
|
else report('profile', 'P7', true, 'cordis.patch.yml 结构正常(无 tab / 无 ~ insert / 无映射-序列混排)', undefined);
|
|
451
569
|
|
|
452
570
|
// P8/P9 需要扫描 bundle 构建产物:收集目录下有限深度的 .js 文件(lib/dist/根 + main 入口,跳过 node_modules)
|
|
571
|
+
|
|
572
|
+
/** 从 fromDir 向上找 node_modules 里的裸包目录(也查 profile 与 CLI 安装);找不到返回 null。 */
|
|
573
|
+
/** 用户 patch 中标记 `disabled: true` 的 entry id 集合(P16 用:区分"已禁用不会崩"与"启用即崩")。 */
|
|
574
|
+
function disabledPatchIds() {
|
|
575
|
+
const set = new Set();
|
|
576
|
+
const f = join(HOME, 'profiles', 'web', 'cordis.patch.yml');
|
|
577
|
+
if (!existsSync(f)) return set;
|
|
578
|
+
let text;
|
|
579
|
+
try { text = readFileSync(f, 'utf8'); } catch { return set; }
|
|
580
|
+
const lines = text.split('\n');
|
|
581
|
+
for (let i = 0; i < lines.length; i++) {
|
|
582
|
+
const m = /^\s*-?\s*id:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(lines[i]);
|
|
583
|
+
if (!m) continue;
|
|
584
|
+
for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
|
|
585
|
+
if (/^\s*-?\s*id:/.test(lines[j])) break;
|
|
586
|
+
if (/^\s*disabled:\s*true\s*$/.test(lines[j])) { set.add(m[1]); break; }
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return set;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function resolveInstalledPackage(spec, fromDir) {
|
|
593
|
+
const seg = spec.split('/');
|
|
594
|
+
const name = spec.startsWith('@') ? seg.slice(0, 2).join('/') : seg[0];
|
|
595
|
+
const cands = [];
|
|
596
|
+
let d = fromDir;
|
|
597
|
+
for (let i = 0; i < 6; i++) {
|
|
598
|
+
cands.push(join(d, 'node_modules', name));
|
|
599
|
+
const up = dirname(d);
|
|
600
|
+
if (up === d) break;
|
|
601
|
+
d = up;
|
|
602
|
+
}
|
|
603
|
+
const profiles = join(HOME, 'profiles');
|
|
604
|
+
if (existsSync(profiles)) for (const p of readdirSync(profiles)) cands.push(join(profiles, p, 'node_modules', name));
|
|
605
|
+
for (const lib of SESSION_LIBS) {
|
|
606
|
+
const nm = lib.slice(0, lib.indexOf(join('@deepseek-ai', 'dsh-session')));
|
|
607
|
+
cands.push(join(nm, name));
|
|
608
|
+
}
|
|
609
|
+
return cands.find((c) => existsSync(join(c, 'package.json'))) || null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** 包入口的命名导出集合;**静态不可确定时返回 null**(CJS 入口 / `export *` / 找不到入口)。 */
|
|
613
|
+
function packageNamedExports(pkgDir) {
|
|
614
|
+
let pkg;
|
|
615
|
+
try { pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')); } catch { return null; }
|
|
616
|
+
let rel = null;
|
|
617
|
+
const pick = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' ? (pick(v.import) ?? pick(v.default) ?? pick(v.require) ?? null) : null));
|
|
618
|
+
if (pkg.exports) rel = pick(typeof pkg.exports === 'object' && pkg.exports['.'] !== undefined ? pkg.exports['.'] : pkg.exports);
|
|
619
|
+
if (!rel) rel = pick(pkg.module) ?? pick(pkg.main) ?? 'index.js';
|
|
620
|
+
let code;
|
|
621
|
+
try { code = readFileSync(join(pkgDir, rel), 'utf8'); } catch { return null; }
|
|
622
|
+
if (/export\s*\*/.test(code)) return null; // 星号再导出 → 导出面不确定
|
|
623
|
+
const named = new Set();
|
|
624
|
+
for (const m of code.matchAll(/export\s*\{([^}]*)\}/g)) {
|
|
625
|
+
for (const x of m[1].split(',')) {
|
|
626
|
+
const n = x.trim().replace(/^type[ \t]+/, '').split(/[ \t]+as[ \t]+/).pop().trim();
|
|
627
|
+
if (n) named.add(n);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
for (const m of code.matchAll(/export\s+(?:async\s+)?(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/g)) named.add(m[1]);
|
|
631
|
+
if (/export\s+default/.test(code)) named.add('default');
|
|
632
|
+
if (named.size === 0) return null; // 没有任何 ESM 导出语法 → 视为 CJS,不判
|
|
633
|
+
return named;
|
|
634
|
+
}
|
|
635
|
+
|
|
453
636
|
const bundleDirs = new Map(); // bundle 名 → 目录(可解析的)
|
|
454
637
|
for (const b of bundles) {
|
|
455
638
|
const d = findPkg(b);
|
|
@@ -737,6 +920,110 @@ function checkProfile(name) {
|
|
|
737
920
|
} else {
|
|
738
921
|
report('profile', 'P15', true, '关键文件无 BOM 头', undefined);
|
|
739
922
|
}
|
|
923
|
+
|
|
924
|
+
/* P16:插件命名导入的导出缺失检测(#5864:一个缺失导出 → 整棵插件树 boot 崩溃循环、
|
|
925
|
+
* 启动器每 ~11s 重启一次)。静态校验 `import { A } from 'pkg'` 的 A 是否存在于已装 pkg 的命名导出。
|
|
926
|
+
* **能不确定就不判**(本检查 warn 级,宁可漏报不误报):解析不到包 / 入口含 `export *` / CJS 入口 /
|
|
927
|
+
* 条件导出取不到 ESM 入口 / type-only 导入 / 非裸说明符 —— 全部跳过,且只在"确定不存在"时报。 */
|
|
928
|
+
const exportIssues = [];
|
|
929
|
+
const disabledIds = disabledPatchIds();
|
|
930
|
+
for (const [b, d] of bundleDirs) {
|
|
931
|
+
const seen = new Map(); // "pkg → 缺失符号" → 证据文件
|
|
932
|
+
for (const f of collectJsFiles(d)) {
|
|
933
|
+
const code = readJs(f);
|
|
934
|
+
for (const m of code.matchAll(/(?:^|\n)[ \t]*(?:import|export)[ \t]*\{([^}]*)\}[ \t]*from[ \t]*['"]([^'"]+)['"]/g)) {
|
|
935
|
+
const names = m[1].split(',').map((x) => x.trim().replace(/^type[ \t]+/, '').split(/[ \t]+as[ \t]+/)[0].trim()).filter((x) => x && x !== 'default' && /^[A-Za-z_$][\w$]*$/.test(x));
|
|
936
|
+
const spec = m[2];
|
|
937
|
+
if (!names.length || !/^[@A-Za-z]/.test(spec) || spec.startsWith('node:')) continue; // 只查裸包说明符
|
|
938
|
+
// 带子路径的说明符(pkg/sub、pkg/a/b)走各自的 exports 入口,静态判定不可靠 → 跳过(防误报)
|
|
939
|
+
const segs = spec.split('/');
|
|
940
|
+
if (spec.startsWith('@') ? segs.length > 2 : segs.length > 1) continue;
|
|
941
|
+
const pkgDir = resolveInstalledPackage(spec, d);
|
|
942
|
+
if (!pkgDir) continue; // 解析不到 → 不判
|
|
943
|
+
const exports = packageNamedExports(pkgDir);
|
|
944
|
+
if (!exports) continue; // 静态不可确定 → 不判
|
|
945
|
+
const miss = names.filter((n) => !exports.has(n));
|
|
946
|
+
if (miss.length) seen.set(`${spec} → ${miss.join(', ')}`, relative(d, f));
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
if (seen.size) {
|
|
950
|
+
// patch 的 id 可能是裸名(dsh-noema)而 bundle 名是 scoped(@zseven-w/dsh-noema)→ 去 scope 归一化比对
|
|
951
|
+
const bare = (n) => (n.startsWith('@') ? n.split('/').slice(1).join('/') : n);
|
|
952
|
+
const isDisabled = disabledIds.has(b) || disabledIds.has(bare(b));
|
|
953
|
+
exportIssues.push(`${b}(${[...seen].map(([k, f]) => `${k} [${f}]`).join('; ')})${isDisabled ? '〔当前已禁用,重新启用会 boot 失败〕' : '〔已启用 → boot 会失败〕'}`);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (exportIssues.length) {
|
|
957
|
+
report('profile', 'P16', false, `插件导入了已装包未提供的导出(#5864:整棵插件树 boot 崩溃循环,启动器会反复重启): ${exportIssues.join('; ')}`, '把该插件升到与所装 @deepseek-ai/* 版本匹配的版本(或降级/移除);这类错误在 boot 期是硬失败,单条 entry 会拖垮整棵树');
|
|
958
|
+
} else {
|
|
959
|
+
report('profile', 'P16', true, '插件命名导入均在已装包的导出里(静态可判定的部分)', undefined);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/* P17:client 端 require 的 specifier 不在宿主模块表(#5719:dsh-client-modules 的 makeRequire 硬 throw
|
|
963
|
+
* → 浏览器端 Failed to load plugins / 白屏,服务端 HTTP 200 且日志零感知)。
|
|
964
|
+
* 可服务 ⟺ 平台种子 ∪ 图行(已装包同时有 dsh.client 与 exports["./client"])∪ 该包声明的 external/inject。
|
|
965
|
+
* 防误报(#5719 实测得出):必须先剥注释(JSDoc 里的 require("picomatch") 示例会误报)、只认双引号形态、
|
|
966
|
+
* 排除模板插值/相对路径/Node 内置、归一 /client 后缀、跳过自引用。warn 级(静态近似,宁可漏报不误报)。 */
|
|
967
|
+
const p17Issues = [];
|
|
968
|
+
const composedRows = new Set();
|
|
969
|
+
{
|
|
970
|
+
const roots = [];
|
|
971
|
+
try { roots.push(join(resolveProfile(profileArg), 'node_modules')); } catch { /* 无 profile */ }
|
|
972
|
+
for (const lib of SESSION_LIBS) roots.push(lib.slice(0, lib.indexOf(join('@deepseek-ai', 'dsh-session'))));
|
|
973
|
+
for (const nm of roots) {
|
|
974
|
+
if (!existsSync(nm)) continue;
|
|
975
|
+
const pkgs = [];
|
|
976
|
+
for (const d of readdirSync(nm)) {
|
|
977
|
+
if (d.startsWith('.')) continue;
|
|
978
|
+
if (d.startsWith('@')) { // scoped:@scope/name 两层
|
|
979
|
+
const scopeDir = join(nm, d);
|
|
980
|
+
try { for (const n of readdirSync(scopeDir)) pkgs.push(join(scopeDir, n)); } catch { /* 忽略 */ }
|
|
981
|
+
} else pkgs.push(join(nm, d));
|
|
982
|
+
}
|
|
983
|
+
for (const dir of pkgs) {
|
|
984
|
+
const pj = join(dir, 'package.json');
|
|
985
|
+
if (!existsSync(pj)) continue;
|
|
986
|
+
try {
|
|
987
|
+
const pkg = JSON.parse(readFileSync(pj, 'utf8'));
|
|
988
|
+
if (pkg.name && pkg.dsh?.client && pkg.exports?.['./client']) { composedRows.add(pkg.name); composedRows.add(`${pkg.name}/client`); }
|
|
989
|
+
} catch { /* 忽略 */ }
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
const stripClientSuffix = (s) => s.replace(/\/client$/, '');
|
|
994
|
+
for (const [b, d] of bundleDirs) {
|
|
995
|
+
const declared = new Set();
|
|
996
|
+
try {
|
|
997
|
+
const pkg = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8'));
|
|
998
|
+
for (const k of ['external', 'inject']) {
|
|
999
|
+
const v = pkg.dsh?.client?.[k];
|
|
1000
|
+
if (Array.isArray(v)) for (const x of v) if (typeof x === 'string') declared.add(x);
|
|
1001
|
+
}
|
|
1002
|
+
} catch { /* 无 manifest */ }
|
|
1003
|
+
const misses = new Map();
|
|
1004
|
+
for (const f of collectClientJsFiles(d)) {
|
|
1005
|
+
let code = readJs(f);
|
|
1006
|
+
code = code.replace(/\/\*[\s\S]*?\*\//g, ''); // 块注释(JSDoc 示例会误报)
|
|
1007
|
+
code = code.replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // 行注释(避开 https://)
|
|
1008
|
+
for (const m of code.matchAll(/require\(\s*"([^"]+)"\s*\)/g)) { // 打包产物用双引号;单引号多为文档示例
|
|
1009
|
+
const spec = m[1];
|
|
1010
|
+
if (!spec || spec.includes('${') || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('node:')) continue;
|
|
1011
|
+
if (NODE_BUILTINS.has(spec)) continue;
|
|
1012
|
+
const id = stripClientSuffix(spec);
|
|
1013
|
+
if (CLIENT_SEEDS.has(spec) || CLIENT_SEEDS.has(id)) continue;
|
|
1014
|
+
if (composedRows.has(spec) || composedRows.has(id)) continue;
|
|
1015
|
+
if (declared.has(spec) || declared.has(id)) continue;
|
|
1016
|
+
if (id === b || spec === b) continue; // 自引用 → 打包内联
|
|
1017
|
+
misses.set(spec, relative(d, f));
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (misses.size) p17Issues.push(`${b}(${[...misses].map(([s, f]) => `require("${s}") [${f}]`).join('; ')})`);
|
|
1021
|
+
}
|
|
1022
|
+
if (p17Issues.length) {
|
|
1023
|
+
report('profile', 'P17', false, `client 端 require 的模块不在宿主模块表(#5719:makeRequire 硬 throw → 浏览器白屏且服务端无感知): ${p17Issues.join('; ')}`, `改用宿主提供的模块名;若确由宿主提供,在本包 package.json 的 dsh.client.external/inject 里声明;平台种子当前 ${CLIENT_SEEDS.size} 项、已装图行 ${composedRows.size} 项`);
|
|
1024
|
+
} else {
|
|
1025
|
+
report('profile', 'P17', true, `client 端 require 的 specifier 均可服务(平台种子 ${CLIENT_SEEDS.size} 项 + 已装图行 ${composedRows.size} 项)`, undefined);
|
|
1026
|
+
}
|
|
740
1027
|
}
|
|
741
1028
|
|
|
742
1029
|
/* ================= session ================= */
|
|
@@ -787,7 +1074,7 @@ function checkSession(targetPath) {
|
|
|
787
1074
|
let d; try { d = JSON.parse(line); } catch { continue; }
|
|
788
1075
|
const seq = d.seq; if (typeof seq === 'number' && seq > maxSeq) maxSeq = seq;
|
|
789
1076
|
// S8:未知事件类型且未标 ignorable(#1538:harness 整包拒绝)
|
|
790
|
-
if (!STORAGE_ROW_TYPES.has(d.type) && !
|
|
1077
|
+
if (!STORAGE_ROW_TYPES.has(d.type) && !READABLE.has(d.type) && d.ignorable !== true) {
|
|
791
1078
|
s8Violations.push(`"${d.type}"`);
|
|
792
1079
|
}
|
|
793
1080
|
// S6(官方版):按 decodeStorageRecord 语义展开 chunk 行,构建 seq==index 事件流
|
|
@@ -857,7 +1144,7 @@ function checkSession(targetPath) {
|
|
|
857
1144
|
const seen = [...new Set(s8Violations)].slice(0, 5).join(', ');
|
|
858
1145
|
report('session', 'S8', false, `未知事件类型且无 ignorable 标记(#1538,harness 将整包拒绝): ${seen}${new Set(s8Violations).size > 5 ? ` 等 ${new Set(s8Violations).size} 种` : ''}`, '该日志由更新版本/外部插件写入,当前 harness 无法读取;升级 harness 或标记 ignorable');
|
|
859
1146
|
} else {
|
|
860
|
-
report('session', 'S8', true,
|
|
1147
|
+
report('session', 'S8', true, `所有事件类型均可读(当前表 ${KNOWN.size} 种 + 迁移包旧类型,共 ${READABLE.size} 种)`, undefined);
|
|
861
1148
|
}
|
|
862
1149
|
|
|
863
1150
|
// S7:end-seed 之后出现低于种子末尾 seq 的事件(#1497:已提交尾部被重放)
|
|
@@ -878,6 +1165,201 @@ function checkSession(targetPath) {
|
|
|
878
1165
|
}
|
|
879
1166
|
}
|
|
880
1167
|
|
|
1168
|
+
/* S12:迁移拒载预检(#6045/#6328/#6311)——升级/打开前列出会被"会话格式迁移链"拒绝的会话。
|
|
1169
|
+
* 规则不硬编码:从已装 dsh-session-format-* 迁移包自省(v0→v1 的 descriptor 版本门 + v2→v3 的 source.kind 白名单);
|
|
1170
|
+
* 定位不到迁移包 → skip(不猜)。surface 事件集合照抄 v2→v3 的 assertSource 调用点(user/assistant/tool/inbox/title-llm-request)。 */
|
|
1171
|
+
const SURFACE_SOURCE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result', 'agent/inbox/spliced', 'session/title-llm-request']);
|
|
1172
|
+
|
|
1173
|
+
function findPkgLib(nm, name) {
|
|
1174
|
+
const a = join(nm, '@deepseek-ai', name, 'lib', 'index.js');
|
|
1175
|
+
if (existsSync(a)) return a;
|
|
1176
|
+
const store = join(nm, '.pnpm');
|
|
1177
|
+
if (existsSync(store)) {
|
|
1178
|
+
for (const d of readdirSync(store)) {
|
|
1179
|
+
if (!d.startsWith(`@deepseek-ai+${name}@`)) continue;
|
|
1180
|
+
const b = join(store, d, 'node_modules', '@deepseek-ai', name, 'lib', 'index.js');
|
|
1181
|
+
if (existsSync(b)) return b;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
return null;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function migrationRules() {
|
|
1188
|
+
const out = { descriptorVersion: null, kinds: null, from: null };
|
|
1189
|
+
for (const lib of SESSION_LIBS) {
|
|
1190
|
+
const nm = lib.slice(0, lib.indexOf(join('@deepseek-ai', 'dsh-session')));
|
|
1191
|
+
if (out.descriptorVersion === null) {
|
|
1192
|
+
const v0 = findPkgLib(nm, 'dsh-session-format-v0-to-v1');
|
|
1193
|
+
if (v0) {
|
|
1194
|
+
try {
|
|
1195
|
+
const m = /data\["version"\]\s*!==\s*(\d+)/.exec(readFileSync(v0, 'utf8'));
|
|
1196
|
+
if (m) { out.descriptorVersion = Number(m[1]); out.from = nm; }
|
|
1197
|
+
} catch { /* 忽略 */ }
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
if (out.kinds === null) {
|
|
1201
|
+
const v3 = findPkgLib(nm, 'dsh-session-format-v2-to-v3');
|
|
1202
|
+
if (v3) {
|
|
1203
|
+
try {
|
|
1204
|
+
const m = /const SOURCE_KINDS = new Set\(\[(.*?)\]\);/s.exec(readFileSync(v3, 'utf8'));
|
|
1205
|
+
if (m) {
|
|
1206
|
+
const items = [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
|
|
1207
|
+
if (items.length) { out.kinds = new Set(items); out.from = nm; }
|
|
1208
|
+
}
|
|
1209
|
+
} catch { /* 忽略 */ }
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
if (out.descriptorVersion !== null && out.kinds !== null) break;
|
|
1213
|
+
}
|
|
1214
|
+
return out;
|
|
1215
|
+
}
|
|
1216
|
+
const MIGRATION_RULES = migrationRules();
|
|
1217
|
+
|
|
1218
|
+
/** 加载已装的会话格式目录(真实迁移链入口)。找不到/加载失败返回 null —— 调用方回退启发式规则或 skip。 */
|
|
1219
|
+
function loadFormatCatalog() {
|
|
1220
|
+
for (const lib of SESSION_LIBS) {
|
|
1221
|
+
const nm = lib.slice(0, lib.indexOf(join('@deepseek-ai', 'dsh-session')));
|
|
1222
|
+
const cands = [join(nm, '@deepseek-ai', 'dsh-session-format-catalog', 'lib', 'index.js')];
|
|
1223
|
+
const store = join(nm, '.pnpm');
|
|
1224
|
+
if (existsSync(store)) {
|
|
1225
|
+
for (const d of readdirSync(store)) {
|
|
1226
|
+
if (d.startsWith('@deepseek-ai+dsh-session-format-catalog@')) {
|
|
1227
|
+
cands.push(join(store, d, 'node_modules', '@deepseek-ai', 'dsh-session-format-catalog', 'lib', 'index.js'));
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
for (const c of cands) {
|
|
1232
|
+
if (!existsSync(c)) continue;
|
|
1233
|
+
try {
|
|
1234
|
+
const req = createRequire(c);
|
|
1235
|
+
const m = req(c);
|
|
1236
|
+
if (m?.sessionFormatCatalog?.createRestore) return m.sessionFormatCatalog;
|
|
1237
|
+
} catch { /* 试下一个 */ }
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
return null;
|
|
1241
|
+
}
|
|
1242
|
+
const FORMAT_CATALOG = loadFormatCatalog();
|
|
1243
|
+
|
|
1244
|
+
/* P17:平台种子表 + Node 内置模块(#5719)
|
|
1245
|
+
* 种子 = 浏览器端 require 的"平台种子词",由宿主 web-frontend 构建产物决定;自省失败回退常量。 */
|
|
1246
|
+
const CLIENT_PLATFORM_SEEDS_FALLBACK = new Set([
|
|
1247
|
+
'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', '@deepseek-ai/cordis',
|
|
1248
|
+
'@deepseek-ai/dsh-client-store', '@deepseek-ai/dsh-client-ui-slots',
|
|
1249
|
+
'@deepseek-ai/dsh-client-ui-primitives', '@deepseek-ai/dsh-client-ui-dockkit',
|
|
1250
|
+
]);
|
|
1251
|
+
function clientPlatformSeeds() {
|
|
1252
|
+
for (const lib of SESSION_LIBS) {
|
|
1253
|
+
const nm = lib.slice(0, lib.indexOf(join('@deepseek-ai', 'dsh-session')));
|
|
1254
|
+
const assets = join(nm, '@deepseek-ai', 'dsh-web-frontend', 'dist', 'assets');
|
|
1255
|
+
if (!existsSync(assets)) continue;
|
|
1256
|
+
for (const f of readdirSync(assets)) {
|
|
1257
|
+
if (!/^index-.*\.js$/.test(f) && !/\.js$/.test(f)) continue;
|
|
1258
|
+
try {
|
|
1259
|
+
const s = readFileSync(join(assets, f), 'utf8');
|
|
1260
|
+
const i = s.indexOf('dsh-client-ui-dockkit');
|
|
1261
|
+
if (i < 0) continue;
|
|
1262
|
+
const win = s.slice(Math.max(0, i - 1400), i + 60);
|
|
1263
|
+
const keys = [...win.matchAll(/["']?([A-Za-z@][^"':,{}]*)["']?\s*:\s*[A-Za-z_$][\w$]*/g)]
|
|
1264
|
+
.map((m) => m[1].trim()).filter((k) => /^(react|react-dom|@deepseek-ai\/)/.test(k));
|
|
1265
|
+
if (keys.length >= 8) return new Set(keys);
|
|
1266
|
+
} catch { /* 试下一个资产 */ }
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return CLIENT_PLATFORM_SEEDS_FALLBACK;
|
|
1270
|
+
}
|
|
1271
|
+
const CLIENT_SEEDS = clientPlatformSeeds();
|
|
1272
|
+
const NODE_BUILTINS = new Set(['url','path','fs','util','events','stream','buffer','crypto','os','zlib','assert','worker_threads','perf_hooks','querystring','string_decoder','timers','tty','net','http','https','child_process','process','module','v8','vm','tls','dns','readline','repl','cluster','constants','domain','punycode','sys','timers/promises','fs/promises','stream/web','stream/promises','util/types','dns/promises']);
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
/** 用**真实迁移链**在内存里跑一遍:返回 null = 可恢复,否则返回拒载原因(截断)。
|
|
1276
|
+
* 比复刻规则更准(上游每条 fail-closed 规则都覆盖,且自动跟随版本变化)。 */
|
|
1277
|
+
function realChainRefusal(text) {
|
|
1278
|
+
if (!FORMAT_CATALOG) return undefined; // undefined = 链不可用(区别于 null = 可恢复)
|
|
1279
|
+
const lines = text.split('\n').filter((l) => l.trim());
|
|
1280
|
+
if (!lines.length) return null;
|
|
1281
|
+
try {
|
|
1282
|
+
const header = JSON.parse(lines[0]);
|
|
1283
|
+
const r = FORMAT_CATALOG.createRestore(header, { validation: 'current', recovery: 'strict' });
|
|
1284
|
+
for (let i = 1; i < lines.length; i++) r.decodeRow(JSON.parse(lines[i]));
|
|
1285
|
+
r.finish();
|
|
1286
|
+
return null;
|
|
1287
|
+
} catch (e) {
|
|
1288
|
+
return String(e?.message ?? e).replace(/\s+/g, ' ').slice(0, 140);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
/** 单个会话文本 → 会被迁移链拒绝的理由(空数组 = 可读)。 */
|
|
1294
|
+
function migrationRefusals(text) {
|
|
1295
|
+
const reasons = [];
|
|
1296
|
+
const { descriptorVersion, kinds } = MIGRATION_RULES;
|
|
1297
|
+
let version = null;
|
|
1298
|
+
for (const ln of text.split('\n')) {
|
|
1299
|
+
if (!ln.trim()) continue;
|
|
1300
|
+
let d; try { d = JSON.parse(ln); } catch { continue; }
|
|
1301
|
+
if (version === null && d.type === 'session' && typeof d.version === 'number') { version = d.version; continue; }
|
|
1302
|
+
if (version === 0 && descriptorVersion !== null && d.type === 'subagent/descriptor' && d.data?.version !== descriptorVersion) {
|
|
1303
|
+
reasons.push(`subagent/descriptor version ${d.data?.version} 会被 v0→v1 拒载(该迁移只接受 version ${descriptorVersion})`);
|
|
1304
|
+
}
|
|
1305
|
+
if (version === 2 && kinds && SURFACE_SOURCE_TYPES.has(d.type)) {
|
|
1306
|
+
const s = d.data?.source ?? d.data?.message?.source;
|
|
1307
|
+
if (s && typeof s.kind === 'string' && !kinds.has(s.kind)) reasons.push(`source.kind "${s.kind}" 会被 v2→v3 拒载(不在 ${kinds.size} 项白名单内)`);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
return [...new Set(reasons)];
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* 全会话库迁移拒载预检(#6045:某真实库 321 个日志中 282 个中招;#6328:一个坏产物拖垮搜索索引)。
|
|
1315
|
+
* 在升级/打开前给出"哪些会话将会打不开",避免列表里看着在、点开就报错。
|
|
1316
|
+
*/
|
|
1317
|
+
function scanMigrationRefusals() {
|
|
1318
|
+
if (!wants('session')) return;
|
|
1319
|
+
if (MIGRATION_RULES.descriptorVersion === null && !MIGRATION_RULES.kinds) {
|
|
1320
|
+
reportSkip('session', 'S12', '未定位到 dsh-session-format-* 迁移包,拒载规则不可自省——预检跳过(不猜)', undefined);
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
const root = join(HOME, 'sessions');
|
|
1324
|
+
if (!existsSync(root)) { reportSkip('session', 'S12', '无会话目录,迁移拒载预检不适用', undefined); return; }
|
|
1325
|
+
const files = [];
|
|
1326
|
+
for (const u of readdirSync(root)) {
|
|
1327
|
+
const sd = join(root, u);
|
|
1328
|
+
if (!existsSync(sd)) continue;
|
|
1329
|
+
for (const s of readdirSync(sd)) {
|
|
1330
|
+
const f = existsSync(join(sd, s, 'session.jsonl.zstd')) ? join(sd, s, 'session.jsonl.zstd') : join(sd, s, 'session.jsonl');
|
|
1331
|
+
if (existsSync(f)) files.push(f);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
if (files.length === 0) { reportSkip('session', 'S12', '未发现会话日志', undefined); return; }
|
|
1335
|
+
const refused = [];
|
|
1336
|
+
let viaChain = 0;
|
|
1337
|
+
for (const f of files) {
|
|
1338
|
+
let text;
|
|
1339
|
+
try { text = f.endsWith('.zstd') ? execFileSync('zstd', ['-dc', f], { maxBuffer: 512 * 1024 * 1024 }).toString('utf8') : readFileSync(f, 'utf8'); }
|
|
1340
|
+
catch { continue; } // 解压失败由 S11 报,不在 S12 重复
|
|
1341
|
+
const chain = realChainRefusal(text);
|
|
1342
|
+
if (chain === undefined) {
|
|
1343
|
+
// 真实链不可用 → 回退启发式规则(覆盖子集,明确标注)
|
|
1344
|
+
const rs = migrationRefusals(text);
|
|
1345
|
+
if (rs.length) refused.push(`${basename(dirname(f))}(${rs[0]})`);
|
|
1346
|
+
} else if (chain !== null) {
|
|
1347
|
+
viaChain++;
|
|
1348
|
+
refused.push(`${basename(dirname(f))}(${chain})`);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
const rule = FORMAT_CATALOG
|
|
1352
|
+
? `判定方式:真实迁移链(format-catalog ${FORMAT_CATALOG.currentVersion ? `v${FORMAT_CATALOG.currentVersion}` : ''},内存试迁移)`
|
|
1353
|
+
: `判定方式:启发式规则(descriptor v${MIGRATION_RULES.descriptorVersion ?? '?'} / source.kind ${MIGRATION_RULES.kinds?.size ?? '?'} 项;真实链不可用,覆盖子集)`;
|
|
1354
|
+
if (refused.length) {
|
|
1355
|
+
report('session', 'S12', false,
|
|
1356
|
+
`迁移拒载预检:${refused.length}/${files.length} 个会话会被当前迁移链拒绝(#6045/#6328/#6311——列表里看着在、点开即报 cannot safely transform / unsupported descriptor version): ${refused.slice(0, 5).join('; ')}${refused.length > 5 ? ` 等 ${refused.length} 个` : ''}`,
|
|
1357
|
+
`① 先备份这些会话目录(勿删);② 等上游放宽版本门(#6045 已报);③ 应急:把日志里 subagent/descriptor 的 version 改为 ${MIGRATION_RULES.descriptorVersion ?? 3} 再迁移(改前务必备份)`);
|
|
1358
|
+
} else {
|
|
1359
|
+
report('session', 'S12', true, `迁移拒载预检:${files.length} 个会话均可被当前迁移链读取(${rule})`, undefined);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
881
1363
|
/* S11:全会话扫描 —— 损坏 → 隔离建议;超大 → 冷打开物化风险(#1550:一个坏/超大会话拖垮整个服务器) */
|
|
882
1364
|
function scanAllSessions() {
|
|
883
1365
|
if (!wants('session')) return;
|
|
@@ -915,7 +1397,7 @@ function scanAllSessions() {
|
|
|
915
1397
|
for (let li = 0; li < lines.length; li++) {
|
|
916
1398
|
const ln = lines[li]; if (!ln.trim()) continue;
|
|
917
1399
|
let d; try { d = JSON.parse(ln); } catch { problems.push(`行 ${li + 1} 无法解析`); continue; }
|
|
918
|
-
if (!STORAGE_ROW_TYPES.has(d.type) && !
|
|
1400
|
+
if (!STORAGE_ROW_TYPES.has(d.type) && !READABLE.has(d.type) && d.ignorable !== true) problems.push(`未知类型 ${d.type}`);
|
|
919
1401
|
if (d.type === 'session/end-seed' && typeof d.seq === 'number') { lastSeed = d.seq; seedIdx = posList.length; }
|
|
920
1402
|
const t = d.type;
|
|
921
1403
|
if (t === 'text-chunks' || t === 'reasoning-chunks' || t === 'tool-call-chunks') {
|
|
@@ -976,6 +1458,8 @@ catalogSeverity.set('installed_bundle', 'warn');
|
|
|
976
1458
|
catalogSeverity.set('P13', 'warn');
|
|
977
1459
|
catalogSeverity.set('P14', 'warn');
|
|
978
1460
|
catalogSeverity.set('P15', 'error');
|
|
1461
|
+
catalogSeverity.set('P16', 'warn');
|
|
1462
|
+
catalogSeverity.set('P17', 'warn');
|
|
979
1463
|
|
|
980
1464
|
function bundledCatalog() {
|
|
981
1465
|
const p = new URL('./checks.json', import.meta.url);
|
|
@@ -1206,6 +1690,26 @@ export function localVersion() {
|
|
|
1206
1690
|
}
|
|
1207
1691
|
|
|
1208
1692
|
/** 返回 { current, latest, available };latest=null 表示无法确认(离线且无缓存)。 */
|
|
1693
|
+
|
|
1694
|
+
/** 简易 semver 比较(支持 x.y.z-预发布):a>b 返回 1,a<b 返回 -1,相等 0。
|
|
1695
|
+
* 用途:本地版本可能领先 npm(未发布的工作版本),只比"不相等"会误报"新版本可用"。 */
|
|
1696
|
+
function compareVersions(a, b) {
|
|
1697
|
+
const parse = (v) => {
|
|
1698
|
+
const [core, pre = ''] = String(v).split('-');
|
|
1699
|
+
const nums = core.split('.').map((x) => parseInt(x, 10) || 0);
|
|
1700
|
+
return { nums, pre };
|
|
1701
|
+
};
|
|
1702
|
+
const A = parse(a); const B = parse(b);
|
|
1703
|
+
for (let i = 0; i < 3; i++) {
|
|
1704
|
+
const x = A.nums[i] ?? 0; const y = B.nums[i] ?? 0;
|
|
1705
|
+
if (x !== y) return x > y ? 1 : -1;
|
|
1706
|
+
}
|
|
1707
|
+
if (A.pre === B.pre) return 0;
|
|
1708
|
+
if (!A.pre) return 1; // 正式版 > 预发布
|
|
1709
|
+
if (!B.pre) return -1;
|
|
1710
|
+
return A.pre > B.pre ? 1 : -1;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1209
1713
|
export async function checkForUpdate({ noRemote = false, fetchImpl, home = HOME } = {}) {
|
|
1210
1714
|
const current = localVersion();
|
|
1211
1715
|
if (noRemote || typeof fetchImpl !== 'function') return { current, latest: null, available: false };
|
|
@@ -1213,7 +1717,7 @@ export async function checkForUpdate({ noRemote = false, fetchImpl, home = HOME
|
|
|
1213
1717
|
const readCache = () => { try { const d = JSON.parse(readFileSync(cachePath, 'utf8')); return d && typeof d.latest === 'string' ? d : null; } catch { return null; } };
|
|
1214
1718
|
try {
|
|
1215
1719
|
const c = readCache();
|
|
1216
|
-
if (c && Date.now() - statSync(cachePath).mtimeMs < UPDATE_TTL_MS) return { current, latest: c.latest, available: c.latest !== current };
|
|
1720
|
+
if (c && Date.now() - statSync(cachePath).mtimeMs < UPDATE_TTL_MS) return { current, latest: c.latest, available: c.latest !== current && compareVersions(c.latest, current) > 0 };
|
|
1217
1721
|
} catch { /* 回退 */ }
|
|
1218
1722
|
try {
|
|
1219
1723
|
const ac = new AbortController();
|
|
@@ -1225,7 +1729,7 @@ export async function checkForUpdate({ noRemote = false, fetchImpl, home = HOME
|
|
|
1225
1729
|
const latest = data?.['dist-tags']?.latest;
|
|
1226
1730
|
if (typeof latest === 'string') {
|
|
1227
1731
|
try { mkdirSync(dirname(cachePath), { recursive: true }); writeFileSync(cachePath, JSON.stringify({ latest, checkedAt: new Date().toISOString() })); } catch { /* 缓存失败不影响 */ }
|
|
1228
|
-
return { current, latest, available: latest !== current };
|
|
1732
|
+
return { current, latest, available: latest !== current && compareVersions(latest, current) > 0 };
|
|
1229
1733
|
}
|
|
1230
1734
|
}
|
|
1231
1735
|
} catch { /* 离线/超时 → last-known-good */ }
|
|
@@ -1301,6 +1805,7 @@ async function run() {
|
|
|
1301
1805
|
try { checkProfile(profileArg); } catch (e) { report('profile', 'P0', false, `profile 检查异常: ${e.message.slice(0, 100)}`); }
|
|
1302
1806
|
try { checkSession(sessionArg); } catch (e) { report('session', 'S0', false, `session 检查异常: ${e.message.slice(0, 100)}`); }
|
|
1303
1807
|
try { scanAllSessions(); } catch (e) { report('session', 'S11', false, `全会话扫描异常: ${e.message.slice(0, 100)}`); }
|
|
1808
|
+
try { scanMigrationRefusals(); } catch (e) { report('session', 'S12', false, `迁移拒载预检异常: ${e.message.slice(0, 100)}`); }
|
|
1304
1809
|
}
|
|
1305
1810
|
|
|
1306
1811
|
// 远程检查目录(层 A):内置检查之后追加执行;--no-catalog 只走内置副本;--security-only 跳过
|
|
@@ -1428,6 +1933,12 @@ async function run() {
|
|
|
1428
1933
|
}
|
|
1429
1934
|
const baseExit = baseFail > 0 ? 2 : baseWarn > 0 ? 1 : 0;
|
|
1430
1935
|
const exitCode = Math.max(baseExit, secExit);
|
|
1936
|
+
// v1.1 remediation(#1719 ADOPTED:ciceroyang 提名、两位 reviewer +1):opt-in --remediation,
|
|
1937
|
+
// 顶层有序数组 ["[<checks[].name>] <fix>", ...],仅失败且有 fix 的项;键名取到首个 ']',']' 是唯一禁用字符。
|
|
1938
|
+
// 不带 --remediation 时字段不存在(r5 消费者字节稳定)。
|
|
1939
|
+
const remediation = process.argv.includes('--remediation')
|
|
1940
|
+
? results.filter((r) => !r.ok && r.fix && !r.id.includes(']')).map((r) => `[${r.id}] ${r.fix}`)
|
|
1941
|
+
: null;
|
|
1431
1942
|
const out = {
|
|
1432
1943
|
schema: 'dsh-doctor/v1',
|
|
1433
1944
|
tool: 'dsh-doctor',
|
|
@@ -1437,6 +1948,7 @@ async function run() {
|
|
|
1437
1948
|
summary,
|
|
1438
1949
|
ok: exitCode === 0,
|
|
1439
1950
|
checks,
|
|
1951
|
+
...(remediation ? { remediation } : {}),
|
|
1440
1952
|
};
|
|
1441
1953
|
if (securityMeta.enabled) {
|
|
1442
1954
|
out.security = { enabled: true, summary: securityMeta.summary, ...(securityMeta.error ? { error: securityMeta.error } : {}) };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moonquake2004/dsh-doctor",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "Offline diagnostic for DeepSeek Harness — 28 built-in + 5 catalog checks across env/profile/session (Layer A checks-as-data), self-update (Layer B), and a semi-automatic LLM observer (Layer C, --observe); Doctor panel in web UI settings.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"files": [
|