@moonquake2004/dsh-doctor 0.6.0 → 0.7.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/CHANGELOG.md +60 -0
- package/dsh-doctor.mjs +77 -26
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,66 @@
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
+
## [0.7.0] — 2026-09-16
|
|
20
|
+
|
|
21
|
+
一次**针对工具自身可信度**的加固,起因是连续四轮社区报告都照出了同一个病:**我们的结论比证据更自信**。
|
|
22
|
+
|
|
23
|
+
### Added — 覆盖不变量("没看" ≠ "通过")
|
|
24
|
+
|
|
25
|
+
实测:空环境(零 bundle、无会话库)下曾有 **18 项 pass,其中 14 项连一个数量都没给** —— "我什么都没看"与
|
|
26
|
+
"我全看过、很干净"打印出来完全一样。`--boot-check` 对"bundle 缺 `dsh.bundle`"(零 entry 可探)报
|
|
27
|
+
"✓ 全部可导入"(#6788 正是这一类)只是同一 bug 的一个实例。
|
|
28
|
+
|
|
29
|
+
现在:
|
|
30
|
+
- **`examined === 0` 的 `pass` 一律自动降为 `skip`**("无可检查对象,未做任何比较")——硬不变量,CI 有测试盯着;
|
|
31
|
+
- `pass` 需报告**检查了多少对象**(含 `examinedWhat`);未报告者带 `coverage: 'unreported'` 欠账标记,
|
|
32
|
+
可被测试度量。**profile 段的欠账已从 18 项清零**(上下文默认值 + 逐项复核单位不同的检查,如
|
|
33
|
+
`P12` 的对象是"已装候选包"、`P18` 是"manifest",都不是 bundle 列表长度)。
|
|
34
|
+
**env 段仍有 10 项未报告覆盖量**(`E1-*` / `E3-node` / `E4` / `E5` / `E6` / `E10` / `E12` / `E13`)——
|
|
35
|
+
这些探针各查一个对象(某个二进制/版本/端口),补起来是机械的,但**尚未补**,故在此如实列出,
|
|
36
|
+
而不是让 release note 显得比实际干净。
|
|
37
|
+
|
|
38
|
+
### Fixed — 故障隔离:一条正确的检查曾被无关崩溃静默
|
|
39
|
+
|
|
40
|
+
#6758 的 BOM 场景里,**P15 本来就能查、也查对了**;但在修复前,主读取点先抛错 →
|
|
41
|
+
`P0: profile 检查异常` → **P15 根本没机会执行**。真实失败不是"缺检查",而是**"正确的检查被上游故障静默了"**。
|
|
42
|
+
今天同类问题出现两次(另一处是变量作用域错误掐掉整个 profile 段)。现在外部读取一律不抛(带 BOM 感知的
|
|
43
|
+
读取已覆盖 manifest),并有两条回归测试钉住:**BOM 时 P15 仍报出、manifest 无法解析时其余检查仍给结论**。
|
|
44
|
+
|
|
45
|
+
### Removed — 撤销 `P22`(与 `P15` 重复)
|
|
46
|
+
|
|
47
|
+
`P15`(关键文件 BOM,源自 #5176)**早就扫描 profile 的 `package.json`** 并报出同一结论;我加 `P22` 之前
|
|
48
|
+
**没有查现有清单**。#6758 特有的信息(PowerShell 5.1 的 `-Encoding UTF8` 会写 BOM、`JSON.parse` 硬失败、
|
|
49
|
+
报错被 GBK 渲染成乱码)已并入 `P15`。**同一事实只应有一个归属。**
|
|
50
|
+
|
|
51
|
+
### 为什么这些错误能发生(写下来,供以后对照)
|
|
52
|
+
|
|
53
|
+
六处错误(S14 判错视图、SR5 成片误报、`--boot-check` 假绿灯、P22 重复、P18 矛盾结论、P15 被静默)**根因相同**:
|
|
54
|
+
**用一个"看起来合理"的模型,代替了权威来源**——来源分别是宿主代码路径、整库样本、loader 前置条件、
|
|
55
|
+
自家检查清单、宿主的解析代码、检查的执行顺序。
|
|
56
|
+
|
|
57
|
+
我们的原则(`unknown` 不得折叠、不适用就带理由 skip、"只在一个平台验证过 ≈ 没验证")此前只写在文档与
|
|
58
|
+
评审意见里,**没有变成引擎里的不变量**;而唯一一次我把原则写成机制(peer-range 一致性测试从**已发布源码**
|
|
59
|
+
抽函数来跑语料)恰恰没出问题。**原则不落成机制,就只剩运气。**
|
|
60
|
+
|
|
61
|
+
## [0.6.1] — 2026-09-15
|
|
62
|
+
|
|
63
|
+
### Fixed — `--boot-check` 在"bundle 缺 `dsh.bundle`"这一类上给假绿灯(社区 #6788)
|
|
64
|
+
|
|
65
|
+
#6788 报告:0.1.6-alpha.1 起 loader **严格要求** `dsh.profile.bundles` 里每个包声明 `dsh.bundle.patch`,
|
|
66
|
+
而 `@deepseek-ai/dsh-computer-use` 等包发布时**漏了这个字段** → 照文档 `dsh plugin add` 之后 profile **立刻起不来**。
|
|
67
|
+
|
|
68
|
+
这一类的特点是:**没有任何 entry 可探**(包没有 patch,自然没有 insert 条目),于是只做 import 的
|
|
69
|
+
`--boot-check` 会输出"✓ 所有可探测 entry 均可导入"——而用户恰恰是按我们的建议先跑它的。**假绿灯出现在
|
|
70
|
+
最该给出结论的时刻**,比不报更糟。
|
|
71
|
+
|
|
72
|
+
现在 `--boot-check`(及其同步版,供 `--safe-add` 使用)在探测 entry 之前先判 **bundle 级前置条件**:
|
|
73
|
+
列出的包若解析得到、却没有 `dsh.bundle.patch` → 直接判失败,给出 `missing-bundle-manifest`、
|
|
74
|
+
修复方向与隔离命令。宿主核心包(`dsh-base`/`dsh-web-app`,由 CLI 提供)照旧跳过。
|
|
75
|
+
|
|
76
|
+
(`P1` 早已覆盖同一类:`bundle 条目 X 存在但未声明 dsh.bundle`——本次修的是**我们最推荐的那条自救路径**
|
|
77
|
+
没有覆盖它。)
|
|
78
|
+
|
|
19
79
|
## [0.6.0] — 2026-09-15
|
|
20
80
|
|
|
21
81
|
### Added
|
package/dsh-doctor.mjs
CHANGED
|
@@ -221,8 +221,36 @@ function hasDshEnvironment(home = HOME) {
|
|
|
221
221
|
return existsSync(join(home, 'sessions')) || existsSync(join(home, 'settings.yaml'));
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
|
|
225
|
-
|
|
224
|
+
/**
|
|
225
|
+
* 记录一条检查结论。
|
|
226
|
+
*
|
|
227
|
+
* **覆盖不变量(2026-09 反思后加入)**:一条检查说"通过"时,必须能说出**它检查了多少东西**。
|
|
228
|
+
* 起因是一组实测:空环境(零 bundle、无会话库)下曾出现 **18 项 pass,其中 14 项连一个数量都没有** ——
|
|
229
|
+
* 也就是说"我什么都没看"与"我全看过、很干净"打印出来完全一样。`--boot-check` 对
|
|
230
|
+
* "bundle 缺 dsh.bundle"(零 entry 可探)给出"✓ 全部可导入"就是这个 bug 的一个实例(社区 #6788)。
|
|
231
|
+
*
|
|
232
|
+
* 规则:
|
|
233
|
+
* · `examined === 0` → **不得 pass**,自动降为 skip("无可检查对象")——硬不变量,CI 有测试盯着;
|
|
234
|
+
* · `examined` 未报告 → 结果带 `coverage: 'unreported'`(机器可读的欠账标记),可被测试度量并逐项清零。
|
|
235
|
+
*/
|
|
236
|
+
let coverageContext = null; // 当前检查段"检查了多少同类对象"的默认值(由 setCoverage 设置)
|
|
237
|
+
function setCoverage(n, what) { coverageContext = typeof n === 'number' ? { n, what } : null; }
|
|
238
|
+
function coverageNow() { return coverageContext; }
|
|
239
|
+
|
|
240
|
+
function report(section, id, ok, detail, fix, src, examined) {
|
|
241
|
+
const zeroCheck = typeof examined === 'number' ? examined : coverageNow()?.n;
|
|
242
|
+
if (ok === true && zeroCheck === 0) {
|
|
243
|
+
results.push({ section, id, ok: true, skip: true, coverage: 'none', detail: `${detail}(无可检查对象,未做任何比较)`, fix, src: src ?? 'builtin' });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const rec = { section, id, ok, detail, fix, src: src ?? 'builtin' };
|
|
247
|
+
if (ok === true) {
|
|
248
|
+
const ctx = coverageNow();
|
|
249
|
+
const n = typeof examined === 'number' ? examined : ctx?.n;
|
|
250
|
+
if (typeof n === 'number') { rec.examined = n; if (ctx?.what) rec.examinedWhat = ctx.what; }
|
|
251
|
+
else rec.coverage = 'unreported';
|
|
252
|
+
}
|
|
253
|
+
results.push(rec);
|
|
226
254
|
}
|
|
227
255
|
|
|
228
256
|
/** skip 状态(v1 词汇表 r5:#1719)——"不适用"而非"通过",必须带 reason(detail)。不计入 pass/fail,不翻退出码。 */
|
|
@@ -559,6 +587,7 @@ function checkProfile(name) {
|
|
|
559
587
|
}
|
|
560
588
|
const bundles = manifest.dsh?.profile?.bundles ?? [];
|
|
561
589
|
const deps = manifest.dependencies ?? {};
|
|
590
|
+
setCoverage(bundles.length, 'bundle 条目');
|
|
562
591
|
|
|
563
592
|
const installAnchor = (() => {
|
|
564
593
|
// 从 PATH 找 dsh 的安装目录(node_modules),用于 bundle 双锚点解析
|
|
@@ -692,7 +721,7 @@ function checkProfile(name) {
|
|
|
692
721
|
if (!profManifest || !(profManifest.dsh && profManifest.dsh.profile)) {
|
|
693
722
|
reportSkip('profile', 'P18', '未找到 profile manifest(无 dsh.profile),跳过 version 检查');
|
|
694
723
|
} else if (typeof profManifest.version === 'string' && profManifest.version.length > 0) {
|
|
695
|
-
report('profile', 'P18', true, `profile manifest 声明了 version(${profManifest.version}),不触发 #6667
|
|
724
|
+
report('profile', 'P18', true, `profile manifest 声明了 version(${profManifest.version}),不触发 #6667`, undefined, 'builtin', 1);
|
|
696
725
|
} else {
|
|
697
726
|
report('profile', 'P18', false,
|
|
698
727
|
`profile manifest 有 name(${profManifest.name})但**没有 version**——与 #6667 的条件一致:package inventory 在解析**游离本地模块**时会把该 manifest 当包处理并抛 "must declare non-empty name and version"(dsh-plugin-package-inventory-deepseek:34;其 allowAnonymous 只容忍缺 name),表现为 DeepSeek 请求 REQUEST_EXTENSION 失败`,
|
|
@@ -804,26 +833,8 @@ function checkProfile(name) {
|
|
|
804
833
|
}
|
|
805
834
|
}
|
|
806
835
|
|
|
807
|
-
// P22
|
|
808
|
-
//
|
|
809
|
-
// 而 GBK 控制台会把 BOM 三个字节渲染成乱码,用户完全看不出是编码问题。
|
|
810
|
-
// 这条判据零误报,且正是"报错不指向病因"要补的那一句:**是 BOM,不是 JSON 语法**。
|
|
811
|
-
{
|
|
812
|
-
const profRead22 = readJsonReportingBom(join(dir, 'package.json'));
|
|
813
|
-
if (!profRead22.exists) {
|
|
814
|
-
reportSkip('profile', 'P22', '无 profile manifest,跳过 BOM 检查');
|
|
815
|
-
} else if (profRead22.hadBom) {
|
|
816
|
-
report('profile', 'P22', false,
|
|
817
|
-
`profile manifest 带 **UTF-8 BOM**(EF BB BF)—— DSH 会直接 \`JSON.parse\` 该文件:`
|
|
818
|
-
+ `BOM 会让它抛 \`SyntaxError: Unexpected token '…' is not valid JSON\`(#6758)并**启动硬失败**;`
|
|
819
|
-
+ `而在 GBK 控制台上那三个字节会显示成乱码,报错里看不出是编码问题`,
|
|
820
|
-
'去掉 BOM(保留 UTF-8 无 BOM):PowerShell 5.1 的 `Set-Content -Encoding UTF8` 默认会写 BOM,改用 '
|
|
821
|
-
+ '`[IO.File]::WriteAllText($p, (Get-Content $p -Raw), (New-Object Text.UTF8Encoding $false))`,'
|
|
822
|
-
+ '或任何「UTF-8(无 BOM)」保存方式;本工具的其余检查已忽略 BOM 继续工作');
|
|
823
|
-
} else {
|
|
824
|
-
report('profile', 'P22', true, 'profile manifest 无 UTF-8 BOM(不会触发 #6758 的启动硬失败)');
|
|
825
|
-
}
|
|
826
|
-
}
|
|
836
|
+
// P22 已撤销:profile manifest 的 BOM 由 P15 覆盖(同一事实只应有一个归属)。
|
|
837
|
+
// 2026-09 反思的产物:加检查前必须先查清单——我当初没查,于是加了一条与 P15 重复的检查。
|
|
827
838
|
|
|
828
839
|
// P19:插件声明的 host peer 范围 vs 实际提供的 host 版本(社区 #6678 @ciceroyang 提案)
|
|
829
840
|
// 这是"升级后起不来"的常见原因之一:插件声明只支持某段 core 版本,而实际装的核心已在区间外,
|
|
@@ -1254,9 +1265,11 @@ function packageNamedExports(pkgDir) {
|
|
|
1254
1265
|
const bundleVersion = JSON.parse(readFileSync(bundlePkg, 'utf8')).version;
|
|
1255
1266
|
const cliVersion = localVersion();
|
|
1256
1267
|
const same = bundleVersion === cliVersion;
|
|
1268
|
+
// 显式覆盖量:P12 的比较对象是"已装的候选包"(1 个),不是 bundle 列表长度——
|
|
1269
|
+
// 这正是上下文默认值需要被逐项复核的地方(否则 0 个 bundle 的 profile 会被误降为 skip)。
|
|
1257
1270
|
report('profile', 'installed_bundle', same,
|
|
1258
1271
|
same ? `profile 内 bundle 版本 ${bundleVersion} 与运行 CLI ${cliVersion} 一致` : `profile 内 bundle 版本 ${bundleVersion} ≠ 运行 CLI ${cliVersion}(web 面板/API 跑的是 bundle,两边行为可能不一致;若刚发布过新版本,升级可能被 pnpm-workspace.yaml 的 minimumReleaseAgeExclude 年龄门暂缓,可次日重试)`,
|
|
1259
|
-
same ? undefined : `同步安装版本:dsh plugin --profile ${name} update ${selfName}(或让 CLI 与 bundle
|
|
1272
|
+
same ? undefined : `同步安装版本:dsh plugin --profile ${name} update ${selfName}(或让 CLI 与 bundle 走同一安装方式)`, undefined, 1);
|
|
1260
1273
|
}
|
|
1261
1274
|
} catch (e) {
|
|
1262
1275
|
report('profile', 'installed_bundle', false, `bundle 版本对比异常: ${e.message.slice(0, 60)}`, undefined);
|
|
@@ -1287,9 +1300,15 @@ function packageNamedExports(pkgDir) {
|
|
|
1287
1300
|
} catch { /* skip */ }
|
|
1288
1301
|
}
|
|
1289
1302
|
if (bomFiles.length > 0) {
|
|
1290
|
-
report('profile', 'P15', false,
|
|
1303
|
+
report('profile', 'P15', false,
|
|
1304
|
+
`检测到 BOM 头(#5176 / #6758:JSON/YAML 解析将失败): ${bomFiles.join(', ')}`
|
|
1305
|
+
+ `—— 若命中 profile 的 package.json,DSH 会直接 \`JSON.parse\` 它并抛 \`SyntaxError: Unexpected token '…' is not valid JSON\`,`
|
|
1306
|
+
+ `**启动硬失败**;而 GBK 控制台会把 BOM 三个字节渲染成乱码,报错里看不出是编码问题`,
|
|
1307
|
+
'删除首字符(BOM/U+FEFF)后保存;PowerShell 5.1 的 `Set-Content -Encoding UTF8` **默认会写 BOM**,'
|
|
1308
|
+
+ '改用 [IO.File]::WriteAllText($p, (Get-Content $p -Raw), (New-Object Text.UTF8Encoding $false));'
|
|
1309
|
+
+ '或 sed -i "" "1s/^\xEF\xBB\xBF//" <file>', undefined, 'builtin', bomTargets.length);
|
|
1291
1310
|
} else {
|
|
1292
|
-
report('profile', 'P15', true,
|
|
1311
|
+
report('profile', 'P15', true, `关键文件无 BOM 头(检查 ${bomTargets.length} 个)`, undefined, 'builtin', bomTargets.length);
|
|
1293
1312
|
}
|
|
1294
1313
|
|
|
1295
1314
|
/* P16:插件命名导入的导出缺失检测(#5864:一个缺失导出 → 整棵插件树 boot 崩溃循环、
|
|
@@ -2492,6 +2511,30 @@ function listProfilePackages(profileDir) {
|
|
|
2492
2511
|
return out;
|
|
2493
2512
|
}
|
|
2494
2513
|
|
|
2514
|
+
|
|
2515
|
+
/**
|
|
2516
|
+
* 列出 `dsh.profile.bundles` 里**能解析到、但没有 `dsh.bundle`** 的包。
|
|
2517
|
+
* 这类会让 loader 拒绝整个 profile(#6788/#1378),且**没有任何 entry 可探**——
|
|
2518
|
+
* 所以必须由 bundle 级前置条件来判,否则装载模拟会给出假绿灯。
|
|
2519
|
+
* 宿主核心包(如 @deepseek-ai/dsh-base / dsh-web-app)由 CLI 提供、不在 profile node_modules 里,跳过。
|
|
2520
|
+
*/
|
|
2521
|
+
function bundlesMissingManifest(profileDir) {
|
|
2522
|
+
const out = [];
|
|
2523
|
+
try {
|
|
2524
|
+
const read = readJsonReportingBom(join(profileDir, 'package.json'));
|
|
2525
|
+
const bundles = read.data?.dsh?.profile?.bundles ?? [];
|
|
2526
|
+
for (const b of bundles) {
|
|
2527
|
+
if (String(b).startsWith('@deepseek-ai/dsh-base') || String(b).startsWith('@deepseek-ai/dsh-web-app')) continue;
|
|
2528
|
+
const mf = join(profileDir, 'node_modules', String(b), 'package.json');
|
|
2529
|
+
if (!existsSync(mf)) continue; // 解析不到 → 由 P1 报(且可能是宿主提供的核心包)
|
|
2530
|
+
const pkg = readJsonReportingBom(mf).data;
|
|
2531
|
+
if (!pkg) continue;
|
|
2532
|
+
if (!pkg.dsh?.bundle?.patch) out.push({ id: String(b), bundle: String(b) });
|
|
2533
|
+
}
|
|
2534
|
+
} catch { /* 读不了就不判 */ }
|
|
2535
|
+
return out;
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2495
2538
|
/** 解析 profile 的启动列表与各 bundle 的 entry(含用户 patch 的 insert),跳过 disabled 与已隔离项。 */
|
|
2496
2539
|
function collectBootEntries(profileDir) {
|
|
2497
2540
|
const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'));
|
|
@@ -2678,6 +2721,10 @@ function safeAdd(profileArg, pkg) {
|
|
|
2678
2721
|
/** 同步版装载模拟(--safe-add 内部用;与 --boot-check 同一逻辑) */
|
|
2679
2722
|
function runBootCheckSync(profileDir) {
|
|
2680
2723
|
const out = [];
|
|
2724
|
+
// **bundle 级前置条件**(社区 #6788):loader 要求 `dsh.profile.bundles` 里每个包都声明
|
|
2725
|
+
// `dsh.bundle.patch`,否则**整场启动硬失败**。这类失败**没有任何 entry 可探**,于是只做 import 的
|
|
2726
|
+
// 探针会给出"全部可导入 ✓"的**假绿灯**——而用户是按我们的建议先跑 `--boot-check` 的,被误导的代价最大。
|
|
2727
|
+
for (const miss of bundlesMissingManifest(profileDir)) out.push({ id: miss.id, bundle: miss.bundle, spec: '(bundle 清单)', status: 'failed', kind: 'missing-bundle-manifest', hint: '该包未声明 dsh.bundle(loader 会拒绝整个 profile);升级/更换该包,或从 dsh.profile.bundles 移除', error: `bundle 条目 ${miss.bundle} 存在但未声明 dsh.bundle.patch(#1378/#6788)` });
|
|
2681
2728
|
for (const e of collectBootEntries(profileDir)) {
|
|
2682
2729
|
if (!e.name) continue;
|
|
2683
2730
|
const spec = e.name;
|
|
@@ -2741,6 +2788,10 @@ async function runBootCheck(profileDir) {
|
|
|
2741
2788
|
const entries = collectBootEntries(profileDir);
|
|
2742
2789
|
const targets = entries.filter((e) => e.name);
|
|
2743
2790
|
const results = [];
|
|
2791
|
+
// 同 runBootCheckSync:bundle 级前置条件必须一并判,否则这一类会得到假绿灯(#6788)
|
|
2792
|
+
for (const miss of bundlesMissingManifest(profileDir)) {
|
|
2793
|
+
results.push({ id: miss.id, bundle: miss.bundle, spec: '(bundle 清单)', status: 'failed', kind: 'missing-bundle-manifest', hint: '该包未声明 dsh.bundle(loader 会拒绝整个 profile);升级/更换该包,或从 dsh.profile.bundles 移除', error: `bundle 条目 ${miss.bundle} 存在但未声明 dsh.bundle.patch(#1378/#6788)` });
|
|
2794
|
+
}
|
|
2744
2795
|
for (const e of targets) {
|
|
2745
2796
|
const spec = e.name;
|
|
2746
2797
|
// 宿主内置与相对路径不做 import 探测(前者由宿主提供,后者依赖运行上下文)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moonquake2004/dsh-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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": [
|