@moonquake2004/dsh-doctor 0.7.1 → 0.8.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 CHANGED
@@ -16,6 +16,83 @@
16
16
 
17
17
  ---
18
18
 
19
+ ## [0.8.0] — 2026-09-16
20
+
21
+ ### 结构性修复:假绿灯的**类**被消灭,而不是又一个实例
22
+
23
+ 用户指出:**刚复查过又犯错,说明规则本身有缺陷**。复查确认了缺陷的性质——**R3 的强制点选错了**:
24
+ 它被写在 `report()` 里,而判定并不都在那里产生。机械枚举(而非凭记忆)发现 **21 处代码位置手写 ✓/⊖/✗**,
25
+ 其中我只加固过 3 处;另外两处从未查过:
26
+
27
+ | 位置 | 修前行为 |
28
+ |---|---|
29
+ | `--boot-check` | 零对象仍打印"✓ 所有可探测 entry 均可导入" |
30
+ | `--safe-add` | 零对象 `ok:true, stage:'verified'` **并写"已知良好"快照**(把"未验证"固化成"已验证") |
31
+ | `--post-upgrade` | 同样打印"✓ 装载模拟通过"并写快照 |
32
+ | **顶层聚合** | 极简 PATH 下 18 skip / 2 pass,仍打印 **"✓ 全部通过"**(从未查过) |
33
+ | **JSON 形态** | `ok: true` 只要求"无失败","全部跳过"也算通过 |
34
+
35
+ **改为收口点结构**(R10 边界律 + R11 闭集律):
36
+
37
+ - **`makeVerdict(state, opts)`** —— 唯一能构造 `pass` 的地方;`checked === 0` 时**构造期即抛错**(不是渲染期补救);
38
+ - **`printVerdict()` / `printItem()` / `VERDICT_MARKS`** —— **唯一**打印 `✓/⊖/✗` 的三个位置,
39
+ 调用点传**状态名**而不是符号(于是符号泄漏在结构上不可能);
40
+ - **`aggregateVerdict()`** —— 聚合判定如实给出 `verified / skipped / failed`:
41
+ 全部跳过时输出 `⊖ 没有任何检查实际验证过(N 项全部跳过)——这不等于通过`;
42
+ 有跳过时输出 `✓ N 项通过、M 项未检查(skip)——未检查的部分不代表通过`;
43
+ - **JSON 契约收紧**:`ok` 只有在**确实验证过(verified > 0)且无失败**时才为 true,并新增
44
+ `verified / skipped / failed` 三个字段。
45
+
46
+ ### Added — 审计与规则的机制化
47
+
48
+ - **`scripts/audit.sh`**(R12 可重审律 / R13 环境泛化律):一条命令、五个环境、结果可比——
49
+ 空环境 / 极简 PATH(无 dsh、pnpm、zstd)/ 畸形输入(BOM、截断 JSON)/ 清单一致性 / 闭集与全套测试。
50
+ 当前 **7/7 通过**。
51
+ - **闭集测试**(R11):断言源码中任何 `✓/⊖/✗` 都必须落在三个收口点内——**今后新加一处手写绿灯会直接红**。
52
+ - **规则补 4 条**(`docs/check-authoring-rules.md`):**R10 边界律**(每条规则必须指明强制点,且是所有同类输出的必经之路)、
53
+ **R11 闭集律**、**R12 可重审律**、**R13 环境泛化律**;§4 记录本次复查,包括**我在这轮审计里又犯的一次 R8**
54
+ (审计脚本第一版断言"空环境 verified 必须为 0",而空 profile 里 P15 确实验证了 3 个文件——断言强度超过了证据)。
55
+
56
+ 测试 124 → 127。
57
+
58
+ ## [0.7.2] — 2026-09-16
59
+
60
+ ### 用新规则复查旧错误 —— **确认重复犯了同一个错**
61
+
62
+ 按 `docs/check-authoring-rules.md` 的 R1–R9 重新审前面犯过错的案例(尤其审 0.7.0/0.7.1 这批新代码)。
63
+ 结论:**是的,重复犯了,而且是"治症状"的典型**——我修了 #6788 那个**具体形态**,没修**这一类**。
64
+
65
+ **① 同一个缺陷出现在三处输出路径**(R3 覆盖律只在 `report()` 里,而这些路径不走它):
66
+
67
+ | 路径 | 零对象时的行为(修前) |
68
+ |---|---|
69
+ | `--boot-check` | 打印"✓ 所有可探测 entry 均可导入",退出码 0 |
70
+ | `--safe-add` | `ok: true, stage: 'verified'` **并写入"已知良好"快照**——把"未验证"固化成"已验证" |
71
+ | `--post-upgrade` | 打印"✓ 装载模拟通过"并写快照 |
72
+
73
+ 改为**唯一判定入口 `bootVerdict()`**(三处统一):零对象一律 `skip`,**不写快照、不宣称通过**。
74
+ 按 R7 的问法——"如果它完全坏了,会打印什么?"——修前与健康时相同,即**不可证伪**;现在三态可区分。
75
+
76
+ **② 清单机制不够**(R1/R5):`docs/check-inventory.md` 曾**缺 8 个动态构造的 id**
77
+ (`E1-node`/`E1-pnpm`/`E1-zstd`/`E7-dsh-in-path`/`E8-npmrc-workspace-flag`/`E9-storages-json-valid`/
78
+ `E11-settings-writable`/`P6-patch-name-space`),即"查清单"可能查不到它们。现已补齐(**51 项、缺失 0**)。
79
+ **范围重叠仍无自动检测**——见下。
80
+
81
+ **③ 更正我自己的过度声称**(R8 的来历):0.7.1 的 release note 写"P22 与 P15 重复那件事**由此可被拦住**",
82
+ 但该机制只强制一次可见 diff,**并不检测重叠**。已在原处标注更正。
83
+
84
+ **④ R4 隔离律远未落地**:复查 profile/env 段的读取,**14 处疑似未加保护**,抽查 3 处
85
+ (`readFileSync(patchFile)`、`JSON.parse(readFileSync(.../package.json))`、`readFileSync(patchPath)`)
86
+ **全部确认在 try 之外**。此前只修了 manifest 那一处。
87
+
88
+ **⑤ 规则集本身缺两条,已补**(这是复查最有价值的产出):
89
+ - **R8 断言强度律**:断言的强度不得超过证据(复现过 > 读过代码 > 推断;一个样本 ≠ 全体;**机制存在 ≠ 机制足够**)。
90
+ **这是唯一一条没有机制的规则**,已明确标注为待补。
91
+ - **R9 版本漂移律**:宿主收紧前置条件后,先假设 **fixture 失真**而不是代码坏了
92
+ (0.1.6-alpha.1 收紧 loader 后,我们多个 fixture 悄悄变成不合法输入,测试还绿着)。
93
+
94
+ 测试 122 → 124。
95
+
19
96
  ## [0.7.1] — 2026-09-16
20
97
 
21
98
  把"事后总结"变成**规则 + 机制**(`docs/check-authoring-rules.md`)。起因是用户指出:一次次的修复是头疼医头,
@@ -33,7 +110,10 @@
33
110
  **落地当天它就抓出了我自己另外 5 处同类错误**——这一类从此可由机制消灭,不必靠小心。
34
111
  - **R5 唯一归属律 / R1 来源律**:新增 `scripts/gen-check-inventory.mjs` 生成 `docs/check-inventory.md`
35
112
  (43 项,含段、范围、来源),并加 CI 断言"清单与代码一致"。
36
- 于是"加检查前先查清单"不再是自觉,而是**一次可见的 diff**——P22 与 P15 重复那件事由此可被拦住。
113
+ 于是"加检查前先查清单"不再是自觉,而是**一次可见的 diff**。
114
+ ⚠️ **更正(同日复查)**:我最初写的是"P22 与 P15 重复那件事**由此可被拦住**"——**这是过度声称**:
115
+ 该机制只强制一次可见 diff,**并不检测范围重叠**;而且清单当时还**缺 8 个动态构造的 id**。
116
+ 现已补齐清单(51 项、缺失 0),并把"机制存在 ≠ 机制足够"写成了规则 R8。
37
117
  清单中**尚有 8 项未标注权威来源(R1 欠账)**,如实列出。
38
118
  - **R3 收尾**:环境段补齐覆盖量(每个环境探针各检查一个对象),**全环境覆盖量欠账 18 → 0**;
39
119
  CI 断言"空环境下未报告覆盖量的 pass 必须为 0"(此前只是打印数字)。
package/dsh-doctor.mjs CHANGED
@@ -270,6 +270,71 @@ function reportSkip(section, id, detail, src) {
270
270
  results.push({ section, id, ok: true, skip: true, detail, src: src ?? 'builtin' });
271
271
  }
272
272
 
273
+
274
+ /* ================= 判定收口点(R10 边界律 + R11 闭集律) =================
275
+ * 2026-09 复查的结论:**"绿"此前可以在任意位置被手写出来**,所以 R3 的不变量只要放在
276
+ * report() 或某个输出路径里,别的路径就能绕过——修了三处、漏了顶层聚合与更新提示。
277
+ *
278
+ * 现在:**所有 ✓ / ⊖ / ✗ 只能由 printVerdict() 打印**,而"通过"这个状态只能由 makeVerdict() 构造,
279
+ * 且 `pass` 在 `checked === 0` 时**直接抛错**(构造期就挡住,而不是渲染期才补救)。
280
+ * 测试里有一条闭集断言:源码中任何出现 ✓/⊖/✗ 的代码行,都必须落在 printVerdict 的函数体内。
281
+ */
282
+
283
+ /**
284
+ * 构造判定。**唯一能产生 'pass' 的地方**。
285
+ * @param {'pass'|'fail'|'skip'|'action'} state
286
+ * @param {{checked?: number, detail?: string, reason?: string, totals?: {verified:number, skipped:number, failed:number}}} opts
287
+ */
288
+ function makeVerdict(state, opts = {}) {
289
+ if (state === 'pass') {
290
+ const n = opts.checked;
291
+ if (typeof n !== 'number' || n <= 0) {
292
+ throw new Error(`makeVerdict('pass') 需要 checked > 0:零检查对象不得构造为"通过"(R3 覆盖律;configured=${JSON.stringify(opts.checked)})`);
293
+ }
294
+ }
295
+ return { state, ...opts };
296
+ }
297
+
298
+ /**
299
+ * 条目行打印器:**判定之下的明细**(某条 entry 失败、某个会话损坏……)。
300
+ * 与 printVerdict 的分工:判定回答"整件事成不成立",条目回答"具体哪几条"。
301
+ * 两者构成本工具的**闭集**——闭集测试断言:源码里任何 ✓/⊖/✗ 都必须落在这两个函数体内。
302
+ */
303
+ const VERDICT_MARKS = { pass: '✓', fail: '✗', skip: '⊖', warn: '⚠', action: '✓' };
304
+
305
+ function printItem(state, text, indent = 2) {
306
+ console.log(`${' '.repeat(indent)}${VERDICT_MARKS[state] ?? '?'} ${text}`);
307
+ }
308
+
309
+ /** **唯一**打印 ✓ / ⊖ / ✗ 的**判定**的地方。任何新输出路径都必须走这里,否则闭集测试会红。 */
310
+ function printVerdict(v, opts = {}) {
311
+ const out = opts.stream === 'stderr' ? console.error : console.log;
312
+ const line = (state, text) => out(opts.indent ? ` ${VERDICT_MARKS[state]} ${text}` : `${VERDICT_MARKS[state]} ${text}`);
313
+ if (v.state === 'pass') return line('pass', v.detail ?? `通过(检查 ${v.checked} 个对象)`);
314
+ if (v.state === 'fail') return line('fail', v.detail ?? '失败');
315
+ if (v.state === 'skip') return line('skip', `${v.reason ?? '未检查'}——**这不等于通过**`);
316
+ return line('action', v.detail ?? ''); // action:动作确认(不是验证断言)
317
+ }
318
+
319
+ /**
320
+ * 报告级聚合判定:把"验证过多少 / 跳过多少"如实呈现。
321
+ * 修前它对"18 项 skip + 2 项 pass"照样打印 "✓ 全部通过"——断言的强度超过了证据(R8)。
322
+ */
323
+ function aggregateVerdict(records) {
324
+ const failed = records.filter((r) => !r.skip && !r.ok);
325
+ const verified = records.filter((r) => !r.skip && r.ok && (r.examined ?? 0) > 0).length;
326
+ const skipped = records.filter((r) => r.skip).length;
327
+ const totals = { verified, skipped, failed: failed.length };
328
+ if (failed.length) return makeVerdict('fail', { totals, detail: `${failed.length} 个问题` });
329
+ if (verified === 0) {
330
+ return makeVerdict('skip', { totals, reason: `没有任何检查实际验证过(${skipped} 项全部跳过)` });
331
+ }
332
+ if (skipped > 0) {
333
+ return makeVerdict('pass', { checked: verified, totals, detail: `${verified} 项通过、${skipped} 项未检查(skip)——**未检查的部分不代表通过**` });
334
+ }
335
+ return makeVerdict('pass', { checked: verified, totals, detail: `全部通过(${verified} 项,均已验证)` });
336
+ }
337
+
273
338
  /** 解析 --profile 参数:名字(如 web)→ $DSH_HOME/profiles/<name>;含路径分隔符/~/开头 → 直接当 profile 目录(契约 harness 传绝对路径)。 */
274
339
  function resolveProfile(name) {
275
340
  if (!name) throw new Error('无效 profile 名');
@@ -2712,8 +2777,13 @@ function safeAdd(profileArg, pkg) {
2712
2777
  return { ok: false, stage: 'install', restored: true, exit: install.status };
2713
2778
  }
2714
2779
  const results = runBootCheckSync(profDir);
2715
- const failed = results.filter((r) => r.status === 'failed');
2716
- if (!failed.length) {
2780
+ const verdictAdd = bootVerdict(results);
2781
+ const failed = verdictAdd.failed;
2782
+ if (verdictAdd.state === 'skip') {
2783
+ // 零对象:**不写"已知良好"快照、不宣称已验证**——否则"未验证"会被固化成"已知良好"
2784
+ return { ok: true, stage: 'unchecked', checked: 0, failed: 0, reason: verdictAdd.reason };
2785
+ }
2786
+ if (verdictAdd.state === 'pass') {
2717
2787
  writeSnapshot(profDir, bootSnapshot(profDir));
2718
2788
  return { ok: true, stage: 'verified', checked: results.length, failed: 0 };
2719
2789
  }
@@ -2733,6 +2803,25 @@ function safeAdd(profileArg, pkg) {
2733
2803
  return { ok: true, stage: 'quarantined', quarantined, failures: failed.map((f) => `${f.bundle}/${f.id}: ${f.kind} ${f.error}`) };
2734
2804
  }
2735
2805
 
2806
+
2807
+ /**
2808
+ * **装载模拟的唯一判定入口**(R3 覆盖律的中央落实)。
2809
+ *
2810
+ * 来历:同一个缺陷曾出现在**三处**输出路径(--boot-check / --safe-add / --post-upgrade)——
2811
+ * "零 entry 可探"被当成"通过",其中 --safe-add 还会写"已知良好"快照。
2812
+ * 我最初只修了 --boot-check 那一个实例(#6788 的具体形态),没修这一类。
2813
+ * 所以判定必须只在一个地方:任何"探测了 0 个对象"的运行都是 **skip**,不是 pass;
2814
+ * 也不得据此写快照(否则"未验证"会被固化成"已知良好")。
2815
+ */
2816
+ function bootVerdict(results) {
2817
+ const failed = results.filter((r) => r.status === 'failed');
2818
+ if (failed.length) return { state: 'fail', failed, checked: results.length };
2819
+ if (results.length === 0) {
2820
+ return { state: 'skip', failed: [], checked: 0, reason: '启动列表里没有任何 entry 可探测,未做装载模拟(这不等于通过)' };
2821
+ }
2822
+ return { state: 'pass', failed: [], checked: results.length };
2823
+ }
2824
+
2736
2825
  /** 同步版装载模拟(--safe-add 内部用;与 --boot-check 同一逻辑) */
2737
2826
  function runBootCheckSync(profileDir) {
2738
2827
  const out = [];
@@ -2863,7 +2952,7 @@ async function run() {
2863
2952
  const base = writePreUpgrade(profDir);
2864
2953
  if (jsonOut) console.log(JSON.stringify({ ok: true, ...base }, null, 2));
2865
2954
  else {
2866
- console.log(`✓ 已记录升级前基线(${profDir})`);
2955
+ printVerdict(makeVerdict('action', { detail: `已记录升级前基线(${profDir})` }));
2867
2956
  console.log(` 核心版本: ${base.coreVersion ?? '(未能确定)'} | bundle ${base.bundleCount} 个 | entry ${Object.keys(base.entries).length} 条`);
2868
2957
  console.log(' 下一步:升级 dsh,然后运行');
2869
2958
  console.log(` npx @moonquake2004/dsh-doctor --post-upgrade --profile ${profileArg || 'web'}`);
@@ -2873,7 +2962,7 @@ async function run() {
2873
2962
  }
2874
2963
  const base = readPreUpgrade(profDir);
2875
2964
  if (!base) {
2876
- console.error(`✗ 没找到升级前基线(${join(profDir, PRE_UPGRADE_FILE)})——请先在升级前运行 --pre-upgrade`);
2965
+ printVerdict(makeVerdict('fail', { detail: `没找到升级前基线(${join(profDir, PRE_UPGRADE_FILE)})——请先在升级前运行 --pre-upgrade` }), { stream: 'stderr' }); // 错误路径:非判定
2877
2966
  process.exit(1);
2878
2967
  }
2879
2968
  const nowCore = coreVersion();
@@ -2894,11 +2983,14 @@ async function run() {
2894
2983
  }
2895
2984
  console.log(`升级后复检(基线取自 ${String(base.at).slice(0, 16)}):`);
2896
2985
  for (const l of lines) console.log(` · ${l}`);
2897
- if (!failed.length) {
2898
- console.log(' ✓ 装载模拟通过——升级未破坏任何可探测 entry');
2986
+ const verdictPost = bootVerdict(results);
2987
+ if (verdictPost.state === 'skip') {
2988
+ printVerdict(makeVerdict('skip', { reason: `${verdictPost.reason}——不改写已知良好基线` }), { indent: true });
2989
+ } else if (verdictPost.state === 'pass') {
2990
+ printVerdict(makeVerdict('pass', { checked: verdictPost.checked, detail: '装载模拟通过——升级未破坏任何可探测 entry' }), { indent: true });
2899
2991
  writeSnapshot(profDir, nowSnap);
2900
2992
  } else {
2901
- console.log(` ✗ 装载模拟失败 ${failed.length} 条(这就是"升级后起不来"的直接原因):`);
2993
+ printVerdict(makeVerdict('fail', { detail: `装载模拟失败 ${failed.length} 条(这就是"升级后起不来"的直接原因):` }), { indent: true });
2902
2994
  for (const f of failed) {
2903
2995
  console.log(` [${f.bundle}] ${f.id} → ${f.spec}`);
2904
2996
  console.log(` ${f.kind}: ${f.error}`);
@@ -2921,15 +3013,15 @@ async function run() {
2921
3013
  const profDir = resolveProfile(profileArg || 'web');
2922
3014
  const r = safeAdd(profileArg || 'web', safeAddArg);
2923
3015
  if (jsonOut) console.log(JSON.stringify(r, null, 2));
2924
- else if (r.ok && r.stage === 'verified') console.log(`✓ 已安装并预检通过(${r.checked} 条 entry 均可导入)——重启 dsh 即可`);
3016
+ else if (r.ok && r.stage === 'verified') printVerdict(makeVerdict('pass', { checked: r.checked, detail: `已安装并预检通过(${r.checked} 条 entry 均可导入)——重启 dsh 即可` }));
2925
3017
  else if (r.ok && r.stage === 'quarantined') {
2926
3018
  console.log(`⚠ 已安装,但该插件的 entry 导入失败,已自动隔离以避免 dsh 起不来:`);
2927
3019
  for (const f of r.failures || []) console.log(` ${f}`);
2928
3020
  console.log(` 隔离项:${r.quarantined.join(', ')}(用 --unquarantine <包名> 放回,修好版本后再重试)`);
2929
3021
  console.log(` dsh 现在可以正常启动。`);
2930
- } else if (r.stage === 'install') console.log(`✗ 安装命令本身失败(exit ${r.exit}),已还原 manifest`);
3022
+ } else if (r.stage === 'install') printVerdict(makeVerdict('fail', { detail: `安装命令本身失败(exit ${r.exit}),已还原 manifest` }));
2931
3023
  else {
2932
- console.log(`✗ 安装后无法启动,且隔离也救不回来 → 已整体回滚到安装前状态`);
3024
+ printVerdict(makeVerdict('fail', { detail: '安装后无法启动,且隔离也救不回来 → 已整体回滚到安装前状态' }));
2933
3025
  for (const f of r.failures || []) console.log(` ${f}`);
2934
3026
  }
2935
3027
  process.exit(r.ok ? 0 : 2);
@@ -2939,13 +3031,13 @@ async function run() {
2939
3031
  try {
2940
3032
  if (quarantineArg || unquarantineArg) {
2941
3033
  const r = quarantineBundle(profDir, quarantineArg || unquarantineArg, !!unquarantineArg);
2942
- if (!r.ok) { console.error(`✗ ${r.error}`); process.exit(1); }
3034
+ if (!r.ok) { printVerdict(makeVerdict('fail', { detail: r.error })); process.exit(1); }
2943
3035
  const payload = { ok: true, action: unquarantineArg ? 'unquarantine' : 'quarantine', ...r, next: '重启 dsh;随后用 --boot-check 复查,或用 --unquarantine 撤销' };
2944
3036
  if (jsonOut) console.log(JSON.stringify(payload, null, 2));
2945
3037
  else if (unquarantineArg) {
2946
- console.log(`✓ 已放回 ${unquarantineArg}(当前启动列表 ${r.bundles} 项)——重启 dsh 生效`);
3038
+ printVerdict(makeVerdict('action', { detail: `已放回 ${unquarantineArg}(当前启动列表 ${r.bundles} 项)——重启 dsh 生效` }));
2947
3039
  } else {
2948
- console.log(`✓ 已隔离 ${quarantineArg}(启动列表现为 ${r.bundles} 项;原始 package.json 已备份)`);
3040
+ printVerdict(makeVerdict('action', { detail: `已隔离 ${quarantineArg}(启动列表现为 ${r.bundles} 项;原始 package.json 已备份)` }));
2949
3041
  console.log(` 重启 dsh,然后用 --boot-check 复查;要放回:--unquarantine ${quarantineArg}`);
2950
3042
  }
2951
3043
  process.exit(0);
@@ -2968,16 +3060,31 @@ async function run() {
2968
3060
  console.log(' ——若本次启动失败,上面这些就是首要嫌疑。');
2969
3061
  }
2970
3062
  }
2971
- if (!failed.length) writeSnapshot(profDir, curSnap); // 只有通过时才更新"已知良好"快照
3063
+ // R3 覆盖律(2026-09 复查):**"零检查对象"不得等于"通过"**。
3064
+ // 这个输出路径不走 report(),所以 report() 里的不变量盖不到它——实测它曾对空启动列表打印
3065
+ // "✓ 所有可探测 entry 均可导入"。这正是 R7 要问的那句:"如果它完全坏了,会打印什么?"
3066
+ // 答案与健康时相同 ⇒ 不可证伪。故这里把"检查了 0 个对象"单列为 skip(不更新快照、不宣称通过)。
3067
+ const verdict = bootVerdict(results);
3068
+ const vacuous = verdict.state === 'skip';
3069
+ if (vacuous) {
3070
+ if (jsonOut) {
3071
+ console.log(JSON.stringify({ ok: true, skip: true, reason: '启动列表为空:没有任何 entry 可探测,未做装载模拟', profile: profDir, checked: 0, failed: 0, drift, results }, null, 2));
3072
+ } else {
3073
+ console.log(`装载模拟(${profDir}):`); printVerdict(makeVerdict('skip', { reason: '无可检查对象——启动列表为空,未做任何装载模拟' }));
3074
+ console.log(' 若你预期这里有插件:检查 profile 的 dsh.profile.bundles 是否为空,或插件是否装到了别的 profile。');
3075
+ }
3076
+ process.exit(0);
3077
+ }
3078
+ if (!failed.length) writeSnapshot(profDir, curSnap); // 只有**实际检查过且通过**时才更新"已知良好"快照
2972
3079
  if (jsonOut) {
2973
3080
  console.log(JSON.stringify({ ok: failed.length === 0, profile: profDir, checked: results.length, failed: failed.length, drift, results }, null, 2));
2974
3081
  } else {
2975
3082
  console.log(`装载模拟(${profDir}):检查 ${results.length} 条 entry,失败 ${failed.length} 条`);
2976
3083
  for (const r of results) {
2977
- if (r.status === 'failed') console.log(` ✗ [${r.bundle}] ${r.id} → ${r.spec}\n ${r.kind}: ${r.error}\n 修复方向:${r.hint}\n 先起来:npx @moonquake2004/dsh-doctor --quarantine ${r.bundle}`);
2978
- else if (r.status === 'skipped') console.log(` ⊖ ${r.id}(${r.reason})`);
3084
+ if (r.status === 'failed') printItem('fail', `[${r.bundle}] ${r.id} → ${r.spec}\n ${r.kind}: ${r.error}\n 修复方向:${r.hint}\n 先起来:npx @moonquake2004/dsh-doctor --quarantine ${r.bundle}`);
3085
+ else if (r.status === 'skipped') printItem('skip', `${r.id}(${r.reason})`);
2979
3086
  }
2980
- if (!failed.length) console.log(' ✓ 所有可探测 entry 均可导入——启动失败若仍发生,问题多在配置合并或原生环境,请贴 --json 输出');
3087
+ if (!failed.length) printVerdict(makeVerdict('pass', { checked: results.length, detail: '所有可探测 entry 均可导入——启动失败若仍发生,问题多在配置合并或原生环境,请贴 --json 输出' }), { indent: true });
2981
3088
  }
2982
3089
  process.exit(failed.length ? 2 : 0);
2983
3090
  } catch (e) {
@@ -3186,7 +3293,15 @@ async function run() {
3186
3293
  // 于是把 warn 当成误报。状态本就不该由消费者自行推导。
3187
3294
  const statusOf = (r) => (r.skip ? 'skip' : (!r.ok ? (((r.section === 'security') ? r.severity !== 'critical' : catalogSeverity.get(r.id) === 'warn') ? 'warn' : 'fail') : 'pass'));
3188
3295
  const checksWithStatus = results.map((r) => ({ ...r, status: statusOf(r) }));
3189
- console.log(JSON.stringify({ ok: bad.length === 0 && secExit === 0, checks: checksWithStatus, catalog: catalogMeta, update: updateInfo, ...(securityMeta.enabled ? { security: securityMeta } : {}) }, null, 2));
3296
+ const aggJson = aggregateVerdict(results);
3297
+ console.log(JSON.stringify({
3298
+ // ok 只有在"确实验证过且无失败"时才为 true —— 修前它只看 bad.length,于是"全部跳过"也会 ok:true
3299
+ ok: aggJson.state === 'pass',
3300
+ verified: aggJson.totals.verified,
3301
+ skipped: aggJson.totals.skipped,
3302
+ failed: aggJson.totals.failed,
3303
+ checks: checksWithStatus, catalog: catalogMeta, update: updateInfo, ...(securityMeta.enabled ? { security: securityMeta } : {}),
3304
+ }, null, 2));
3190
3305
  } else {
3191
3306
  const sectionOrder = { env: 0, profile: 1, session: 2, catalog: 3 };
3192
3307
  const ordered = [...results].sort((a, b) => (sectionOrder[a.section] ?? 9) - (sectionOrder[b.section] ?? 9));
@@ -3195,18 +3310,27 @@ async function run() {
3195
3310
  if (r.section !== lastSection) { console.log(`\n== ${r.section === 'security' ? '🔒 安全' : r.section.toUpperCase()} ==`); lastSection = r.section; }
3196
3311
  const sev = catalogSeverity.get(r.id);
3197
3312
  // 安全检查:skip 显示 ⊖;critical/high 失败 ✗;medium 及以下失败 ⚠(不影响退出码)
3198
- const mark = r.skip ? '⊖'
3199
- : (!r.ok ? (((r.section === 'security' && r.severity !== 'critical' && r.severity !== 'high') || sev === 'warn') ? '⚠' : '✗')
3200
- : '✓');
3201
- console.log(` ${mark} [${r.id}]${r.severity ? `(${r.severity}${r.skip ? '/skip' : ''})` : ''} ${r.detail}${r.src === 'catalog' ? ' [目录]' : ''}`);
3313
+ // 状态名(不是符号):符号只由 printItem/printVerdict 决定,闭集测试据此断言
3314
+ const mark = r.skip ? 'skip'
3315
+ : (!r.ok ? (((r.section === 'security' && r.severity !== 'critical' && r.severity !== 'high') || sev === 'warn') ? 'warn' : 'fail')
3316
+ : 'pass');
3317
+ printItem(mark, `[${r.id}]${r.severity ? `(${r.severity}${r.skip ? '/skip' : ''})` : ''} ${r.detail}${r.src === 'catalog' ? ' [目录]' : ''}`, 1);
3202
3318
  if (!r.ok && r.fix) console.log(` ↳ 修复: ${r.fix}`);
3203
3319
  }
3204
3320
  if (updateInfo.available && !updateInfo.applied) {
3205
3321
  console.log(`\n⚠ 新版本 ${updateInfo.latest} 可用(当前 ${updateInfo.current})→ 运行 \`dsh-doctor --update\` 或 \`dsh plugin update\``);
3206
3322
  } else if (updateInfo.applied) {
3207
- console.log(`\n✓ ${updateInfo.applied}`);
3323
+ console.log(''); printVerdict(makeVerdict('action', { detail: updateInfo.applied }));
3324
+ }
3325
+ const agg = aggregateVerdict(results.map((r) => ({ ...r, examined: r.examined })));
3326
+ if (agg.state === 'fail') {
3327
+ console.log('');
3328
+ printVerdict(makeVerdict('fail', { detail: `${bad.length} 个内置问题${secExit > 0 ? ` + 安全 ${secExit === 2 ? 'CRITICAL' : 'HIGH'} 级失败` : ''}(profile=${profileArg},目录=${catalogMeta.source},${catalogMeta.checks} 条)` }));
3329
+ } else {
3330
+ console.log('');
3331
+ printVerdict(agg);
3332
+ console.log(`(profile=${profileArg},目录=${catalogMeta.source},${catalogMeta.checks} 条)`);
3208
3333
  }
3209
- console.log(`\n${(bad.length === 0 && secExit === 0) ? '✓ 全部通过' : `✗ ${bad.length} 个内置问题${secExit > 0 ? ` + 安全 ${secExit === 2 ? 'CRITICAL' : 'HIGH'} 级失败` : ''}`}(profile=${profileArg},目录=${catalogMeta.source},${catalogMeta.checks} 条)`);
3210
3334
  }
3211
3335
  // 最终退出码:内置失败 → 1;安全 HIGH → 1、CRITICAL → 2(取 max)
3212
3336
  process.exit(Math.max(bad.length > 0 ? 1 : 0, secExit));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moonquake2004/dsh-doctor",
3
- "version": "0.7.1",
3
+ "version": "0.8.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": [