@moonquake2004/dsh-doctor 0.8.0 → 0.8.2

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,68 @@
16
16
 
17
17
  ---
18
18
 
19
+ ## [0.8.2] — 2026-09-16
20
+
21
+ **红队审计(fresh-context 证伪者)在"已自审三轮"的版本里找出 6 个真漏洞**,其中 2 个推翻了我刚声称"已验证"的断言。
22
+ 完整档案:`docs/redteam-2026-09-16.md`;未修项见 `docs/check-authoring-rules.md` §5。
23
+
24
+ ### Fixed
25
+
26
+ - **目录检查把"跳过"报成 pass,并伪造覆盖量**(最严重):`runCatalogCheck` 自报 `skipped` 被记成 `ok:true`;
27
+ `report()` 的 `examined` 回退到段级占位值 1,零对象永不降级。实测 `✓ 未发现 DSH 环境…跳过 dsh 的 PATH 检查`;
28
+ profile 目录**整个不存在**时 P6 仍 pass。
29
+ - 自报 `skipped` → `reportSkip`;探测原语的"目标不存在"分支补 `skipped: true`(根因是**分支里没设标记、文案却写着"跳过"**);
30
+ 保留"文案含跳过 ⇒ skip"作兜底。空环境下被伪造的 `verified` 由 11 降到 8。
31
+ - **闭集检测有盲点**:`⚠` 也是判定符号却裸写在收口点外,而闭集正则只有 `/[✓⊖✗]/`。纳入 `⚠` 后**立刻又暴露 3 处**,已全部收口;
32
+ 判定符号正则集中为 `VERDICT_GLYPH_RE`;**收口处剥掉数据里的判定符号**(远程目录数据不得伪造判定上屏)。
33
+ - **`reportSkip` 缺 `phase`** → 补上("单位属于求值阶段"这条断言此前在 skip 上无法核对)。
34
+
35
+ ### Changed — 契约级决策,**未擅改**
36
+
37
+ - **退出码随输出格式分叉**(`--json` 1 / `--envelope` 2 / 文本 1):契约 `doctor-contract.md:32` 规定
38
+ `0 全通过 / 1 任何 WARN / 2 任何 FAIL` → **envelope 是对的一边,主路径违反契约**。
39
+ 我一度把 envelope 改成 1(**统一到违反契约的一边**),已回滚;分歧现由测试钉住并列为待决策 D1。
40
+
41
+ ### Infra
42
+
43
+ - `scripts/audit.sh` 的畸形-manifest 断言由 `n > 1` 收紧为 `n >= 15`,并把当前不达标**如实计为"已知缺口"**(自证太弱是 R8 的实例)。
44
+ - 新增 `docs/redteam-2026-09-16.md`(逐条反例 + 处置 + 红队未能验证项)。
45
+
46
+ 测试 141 → 144。
47
+
48
+ ## [0.8.1] — 2026-09-16
49
+
50
+ ### 0.8.0 的回归审计 —— **修一个引入一个,被查出来了四个**
51
+
52
+ 对 0.8.0 做回归审计(R12/R13),确认它**引入了新问题**:
53
+
54
+ | 引入的问题 | 现象 | 修法 |
55
+ |---|---|---|
56
+ | **覆盖量上下文跨段泄漏** | 会话段的 S6/S14 号称"检查了 **16 个 bundle 条目**",而它们实际展开的是 **11352 个事件** —— 伪造的数字比没有数字更危险,因为它看起来可信 | 段边界 `resetCoverage()` + **单位属于求值阶段**(新增 `phase` 字段);回归测试断言"单位不得串台" |
57
+ | **目录段继承会话单位** | 目录检查 `P6-patch-name-space`(section=profile)声称"检查了会话日志" | catalog 阶段独立单位(`目录检查项`) |
58
+ | **R6 只校验类型、不校验个数** | JS 静默忽略多余参数 → 我给 S11 补覆盖量时多传一个 `undefined`,**没人报错**(类型守卫也抓不到) | `report()` 增加**参数个数守卫**(>7 即抛) |
59
+ | **两处测试自伤** | 抽取 `report` 的测试缺 `currentPhase` 桩;清单需重新生成 | 已修 |
60
+
61
+ ### Added — 论坛病例语料(`plugin/test/forum-cases.test.mjs`)
62
+
63
+ 把社区**实际报告的问题**做成回归用例(R2 样本律),每例都配**健康对照**,同时防漏报与误报:
64
+
65
+ | 病例 | 出处 | 结果 |
66
+ |---|---|---|
67
+ | 缺 `dsh.bundle` → `--boot-check` 必须失败 | #6788 | ✓ 通过(含对照) |
68
+ | manifest 带 BOM → P15 报出且其余检查仍运行 | #6758 | ✓ 通过(含对照) |
69
+ | 迁移后缺 `data.message` → S14 报出 | #6686 | ✓ 通过(含对照) |
70
+ | 裸引用 `harness`/`styles` → P21 失败 | #6693 规则2/4 | ✓ 通过(含注释/字符串对照) |
71
+ | client 产物写成裸 ESM → P20 报出 | #6693 规则3 | ✓ 通过(含对照) |
72
+ | `apply()` 期抛错 | #6693 规则1 | **已知不能检出**:我们只到 import 层,判定仍是 loadable —— 作为**边界用例**钉在测试里,防止把"P20/P21 通过"误读成"一定能起来" |
73
+ | 损坏的 `.zstd` → 判为损坏而非"读取不稳" | #6739 | ✓ 通过。**仅损坏一侧可验证**:报告者的"失败帧偏移每次不同"(读取路径不稳)无法在测试里忠实复现,已在用例注释里写明 |
74
+
75
+ **写这个语料时我又犯了一次 R9**:病例 G 的 fixture 我写成两层目录,而真实布局是三层
76
+ (`sessions/<项目>/<会话>/<日志>`)→ 定位器找不到 → S11 报"未发现会话日志"。**那是 fixture 失真,不是漏报** ——
77
+ 修成真实布局后,S11 正确报出"1 个损坏会话…3 次尝试均失败 → 倾向于文件本身损坏"。
78
+
79
+ 测试 129 → 141(+12 论坛病例);审计 **8/8**。
80
+
19
81
  ## [0.8.0] — 2026-09-16
20
82
 
21
83
  ### 结构性修复:假绿灯的**类**被消灭,而不是又一个实例
package/dsh-doctor.mjs CHANGED
@@ -235,12 +235,24 @@ function hasDshEnvironment(home = HOME) {
235
235
  */
236
236
  let coverageContext = null; // 当前检查段"检查了多少同类对象"的默认值(由 setCoverage 设置)
237
237
  function setCoverage(n, what) { coverageContext = typeof n === 'number' ? { n, what } : null; }
238
+ /** 段边界重置:覆盖量上下文**绝不允许跨段继承**——否则会伪造出一个看起来可信的数字。
239
+ * 2026-09 回归审计实测:会话段的 S6/S14 曾号称"检查了 16 个 bundle 条目",而它们实际展开的是 11352 个事件。 */
240
+ function resetCoverage() { coverageContext = null; }
241
+ /** 求值阶段(env/profile/session/catalog)。覆盖量的**单位属于阶段**,不属于展示用的 section——
242
+ * 目录提供的检查会带 section:'env'/'profile',但它们在 catalog 阶段求值(回归审计实测)。 */
243
+ let currentPhase = null;
244
+ function setPhase(p) { currentPhase = p; }
238
245
  function coverageNow() { return coverageContext; }
239
246
 
240
247
  function report(section, id, ok, detail, fix, src, examined) {
241
248
  // R6 接口律:参数放错位置必须**立刻抛**,而不是变成一条静默的错误判定。
242
249
  // 来历:我曾在 fix 之后多插了两个参数,结果 'builtin' 落到了 examined 的位置(字符串)→
243
250
  // 回退到上下文 0 → 一条本该 pass 的检查被静默降级。凭记忆拼参数是可以通过机制消灭的。
251
+ // R6 扩展(2026-09 回归审计):**参数个数**守卫。JS 会静默忽略多余参数,而我只校验了类型——
252
+ // 于是"多传一个 undefined"这类错误(我给 S11 补覆盖量时就犯了)能一路走到运行时。
253
+ if (arguments.length > 7) {
254
+ throw new Error(`report(${id}): 最多 7 个参数 (section, id, ok, detail, fix, src, examined),收到 ${arguments.length} 个 —— 检查参数位置`);
255
+ }
244
256
  if (typeof examined !== 'number' && examined !== undefined) {
245
257
  throw new Error(`report(${id}): examined 必须是 number 或 undefined,收到 ${typeof examined}(${JSON.stringify(examined)})——检查参数位置`);
246
258
  }
@@ -252,10 +264,19 @@ function report(section, id, ok, detail, fix, src, examined) {
252
264
  }
253
265
  const zeroCheck = typeof examined === 'number' ? examined : coverageNow()?.n;
254
266
  if (ok === true && zeroCheck === 0) {
255
- results.push({ section, id, ok: true, skip: true, coverage: 'none', detail: `${detail}(无可检查对象,未做任何比较)`, fix, src: src ?? 'builtin' });
267
+ // 2026-09 语料发现的措辞缺陷:原实现把原判定文案原样留下 → 出现"结构正常(无可检查对象,未做任何比较)"
268
+ // 这种**自相矛盾**的句子(既宣称正常、又说没比较)。原文案改放 note(机器可读),人读文案只陈述事实。
269
+ results.push({ section, id, ok: true, skip: true, coverage: 'none',
270
+ // 措辞要求:**不得自相矛盾**(2026-09 语料曾产出"结构正常(无可检查对象,未做任何比较)"),
271
+ // 同时保留具体原因(否则用户与测试都只能看到一句通用话)。
272
+ detail: `无可检查对象、未做任何比较——不作为「通过」;原判定:${String(detail).replace(VERDICT_GLYPH_RE, '·')}`,
273
+ fix, src: src ?? 'builtin' });
256
274
  return;
257
275
  }
258
- const rec = { section, id, ok, detail, fix, src: src ?? 'builtin' };
276
+ // R6(红队):detail 可来自远程目录/checks.local.json,**数据不得能伪造判定符号**上屏——
277
+ // 源码里没有 ✓ 也能让 stdout 出现 ✓。故在收口处剥掉数据里的判定符号(含 ⚠)。
278
+ const safeDetail = String(detail).replace(VERDICT_GLYPH_RE, '·');
279
+ const rec = { section, id, ok, detail: safeDetail, fix, src: src ?? 'builtin', phase: currentPhase };
259
280
  if (ok === true) {
260
281
  const ctx = coverageNow();
261
282
  const n = typeof examined === 'number' ? examined : ctx?.n;
@@ -267,7 +288,8 @@ function report(section, id, ok, detail, fix, src, examined) {
267
288
 
268
289
  /** skip 状态(v1 词汇表 r5:#1719)——"不适用"而非"通过",必须带 reason(detail)。不计入 pass/fail,不翻退出码。 */
269
290
  function reportSkip(section, id, detail, src) {
270
- results.push({ section, id, ok: true, skip: true, detail, src: src ?? 'builtin' });
291
+ // R2(红队):skip 记录此前**没有 phase 字段**,于是"单位属于求值阶段"这条断言在 skip 上无法核对。
292
+ results.push({ section, id, ok: true, skip: true, detail, src: src ?? 'builtin', phase: currentPhase });
271
293
  }
272
294
 
273
295
 
@@ -286,6 +308,7 @@ function reportSkip(section, id, detail, src) {
286
308
  * @param {{checked?: number, detail?: string, reason?: string, totals?: {verified:number, skipped:number, failed:number}}} opts
287
309
  */
288
310
  function makeVerdict(state, opts = {}) {
311
+ if (!['pass', 'fail', 'skip', 'warn', 'action'].includes(state)) throw new Error(`makeVerdict: 未知状态 ${state}`);
289
312
  if (state === 'pass') {
290
313
  const n = opts.checked;
291
314
  if (typeof n !== 'number' || n <= 0) {
@@ -301,6 +324,9 @@ function makeVerdict(state, opts = {}) {
301
324
  * 两者构成本工具的**闭集**——闭集测试断言:源码里任何 ✓/⊖/✗ 都必须落在这两个函数体内。
302
325
  */
303
326
  const VERDICT_MARKS = { pass: '✓', fail: '✗', skip: '⊖', warn: '⚠', action: '✓' };
327
+ /** 判定符号集合(含 ⚠)——**唯一**用于"从数据里剥掉符号"的正则。
328
+ * 红队 R6:数据(远程目录/checks.local.json)不得能伪造判定符号上屏;符号只许收口点产出。 */
329
+ const VERDICT_GLYPH_RE = /[✓✔⊖✗✘⚠]/g;
304
330
 
305
331
  function printItem(state, text, indent = 2) {
306
332
  console.log(`${' '.repeat(indent)}${VERDICT_MARKS[state] ?? '?'} ${text}`);
@@ -378,6 +404,8 @@ function nodeInSupportedRange(v, range = NODE_RANGE_FALLBACK) {
378
404
  return peerRangeState(String(v).replace(/^v/, ''), range).state === 'satisfied';
379
405
  }
380
406
  function checkEnv() {
407
+ setPhase('env');
408
+ resetCoverage();
381
409
  if (!wants('env')) return;
382
410
  // R3 覆盖律:环境段的每个探针各检查**一个**对象(某个二进制/版本/端口)。
383
411
  // 单位不同的检查(如 E1 系列各自查一个可执行文件)如需别的数量应显式传入。
@@ -641,7 +669,7 @@ function checkPort3080() {
641
669
  `端口 ${port} 被 dsh web 实例占用(PID ${info.pid})${httpAlive === true ? '且 HTTP 有应答' : ''}——宿主自身或另一实例,正常`, undefined);
642
670
  }
643
671
  else if (info) report('env', 'E10-port-3080', false, `端口 ${port} 被其他程序占用(PID ${info.pid}: ${info.cmd}),dsh web 启动会 address in use(#1719)`, `关掉占用进程,或让 dsh web 用别的端口`);
644
- else report('env', 'E10-port-3080', true, `⚠ 端口 ${port} 被占用但无法识别占用者`, undefined);
672
+ else report('env', 'E10-port-3080', true, `端口 ${port} 被占用但无法识别占用者`, undefined);
645
673
  } else {
646
674
  report('env', 'E10-port-3080', false, `端口 ${port} 探测异常: ${e.message.slice(0, 60)}`, undefined);
647
675
  }
@@ -667,6 +695,8 @@ function checkProfile(name) {
667
695
  }
668
696
  const bundles = manifest.dsh?.profile?.bundles ?? [];
669
697
  const deps = manifest.dependencies ?? {};
698
+ setPhase('profile');
699
+ resetCoverage(); // 段边界:不得继承上一段的覆盖量
670
700
  setCoverage(bundles.length, 'bundle 条目');
671
701
 
672
702
  const installAnchor = (() => {
@@ -1565,6 +1595,9 @@ function latestSessionLog(root = join(HOME, 'sessions')) {
1565
1595
 
1566
1596
  /* ================= session ================= */
1567
1597
  function checkSession(targetPath) {
1598
+ setPhase('session');
1599
+ resetCoverage();
1600
+ setCoverage(1, '会话日志'); // 单位是这一份会话日志;事件数由各检查在 detail 里给出
1568
1601
  if (!wants('session')) return;
1569
1602
  const target = targetPath || latestSessionLog();
1570
1603
  if (!target || !existsSync(target)) { report('session', 'S0', true, '无会话日志,跳过单会话检查(可用 --session <path> 指定)', undefined); return; }
@@ -2073,7 +2106,7 @@ function scanAllSessions() {
2073
2106
  const heapLimit = Number(process.env.DSH_DOCTOR_HEAP_MB || 1024);
2074
2107
  const totalRisk = estHeapMB > heapLimit;
2075
2108
  if (quars.length) {
2076
- report('session', 'S11', false, `全会话扫描:${corrupt.length} 个损坏会话(#1550:冷打开会拖垮服务器): ${quars.join(' | ')}`, `隔离:把这些会话目录移出 ${join(HOME, 'sessions')}(如 mv 到备份目录)`);
2109
+ report('session', 'S11', false, `全会话扫描:${corrupt.length} 个损坏会话(#1550:冷打开会拖垮服务器): ${quars.join(' | ')}`, `隔离:把这些会话目录移出 ${join(HOME, 'sessions')}(如 mv 到备份目录)`, undefined, files.length);
2077
2110
  } else if (oversized.length || totalRisk || unstableReads.length) {
2078
2111
  const parts = [];
2079
2112
  // 间歇性读取失败:**不是文件损坏**(重试即成功、失败帧位置每次不同,见 #6739),
@@ -2082,12 +2115,12 @@ function scanAllSessions() {
2082
2115
  if (unstableReads.length) parts.push(`${unstableReads.length} 个会话**读取间歇性失败**(重试即成功 → 非损坏,指向存储/内存/驱动的偶发读错误,社区 #6739): ${unstableReads.slice(0, 3).map((u) => `${u.id}(${u.attempts} 次尝试)`).join(' | ')}`);
2083
2116
  if (oversized.length) parts.push(`${oversized.length} 个超大会话: ${oversized.map((o) => `${o.id}(${o.dsMB}MB/${o.events}事件)`).join(' | ')}`);
2084
2117
  if (totalRisk) parts.push(`工作区估算物化堆 ~${estHeapMB}MB(估算= max(${totalEvents}事件×600B, ${totalMB}MB×6),跨 ${files.length} 会话累积,#1550 场景;阈值 ${heapLimit}MB,可设 DSH_DOCTOR_HEAP_MB)`);
2085
- report('session', 'S11', true, `⚠ 全会话扫描:${parts.join(';')}(未损坏,可接受或归档)`,
2118
+ report('session', 'S11', true, `全会话扫描:${parts.join(';')}(未损坏,可接受或归档)`,
2086
2119
  unstableReads.length
2087
2120
  ? '间歇性读取失败不是日志问题:先重试读取;持续出现则排查存储健康(SMART)、内存与磁盘驱动(#6739 的证据是失败帧位置每次不同)'
2088
- : '冷启动会明显变慢;必要时压缩/归档历史会话');
2121
+ : '冷启动会明显变慢;必要时压缩/归档历史会话', undefined, files.length);
2089
2122
  } else {
2090
- report('session', 'S11', true, `全会话扫描:${clean.length} 个会话均健康(损坏 0 / 超大 0 / 估算物化堆 ${estHeapMB}MB)`, undefined);
2123
+ report('session', 'S11', true, `全会话扫描:${clean.length} 个会话均健康(损坏 0 / 超大 0 / 估算物化堆 ${estHeapMB}MB)`, undefined, undefined, files.length);
2091
2124
  }
2092
2125
  }
2093
2126
 
@@ -2255,13 +2288,13 @@ export function runCatalogCheck(check, ctx) {
2255
2288
  let ok = existsSync(fp);
2256
2289
  if (ok && probe.type === 'path-is-dir') ok = lstatSync(fp).isDirectory();
2257
2290
  if (ok && probe.type === 'path-is-file') ok = lstatSync(fp).isFile();
2258
- return ok ? { ok: true, detail: check.detailOk ?? `${fp} 存在` }
2291
+ return ok ? { ok: true, checked: 1, detail: check.detailOk ?? `${fp} 存在` }
2259
2292
  : { ok: false, detail: check.detailFail ?? `${fp} 不存在/类型不符` };
2260
2293
  }
2261
2294
  case 'json-valid': {
2262
2295
  const fp = p(probe.path);
2263
2296
  if (!existsSync(fp)) return probe.required === false
2264
- ? { ok: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` }
2297
+ ? { ok: true, skipped: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` }
2265
2298
  : { ok: false, detail: check.detailFail ?? `${fp} 缺失` };
2266
2299
  let utf8ok = true, jsonok = false;
2267
2300
  try { new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(fp)); } catch { utf8ok = false; }
@@ -2273,7 +2306,7 @@ export function runCatalogCheck(check, ctx) {
2273
2306
  case 'text-not-contains': {
2274
2307
  const fp = p(probe.path);
2275
2308
  if (!existsSync(fp)) return probe.required === false
2276
- ? { ok: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` }
2309
+ ? { ok: true, skipped: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` }
2277
2310
  : { ok: false, detail: check.detailFail ?? `${fp} 缺失` };
2278
2311
  let re;
2279
2312
  try { re = new RegExp(probe.pattern, probe.flags ?? ''); } catch (e) { return { ok: false, detail: `目录规则正则非法: ${e.message.slice(0, 60)}` }; }
@@ -2304,7 +2337,7 @@ export function runCatalogCheck(check, ctx) {
2304
2337
  case 'file-writable': {
2305
2338
  const fp = p(probe.path);
2306
2339
  if (!existsSync(fp)) return probe.required === false
2307
- ? { ok: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` }
2340
+ ? { ok: true, skipped: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` }
2308
2341
  : { ok: false, detail: check.detailFail ?? `${fp} 缺失` };
2309
2342
  let writable = false;
2310
2343
  try { const fd = openSync(fp, 'a'); closeSync(fd); writable = true; } catch { /* 只读/属主问题 */ }
@@ -2318,6 +2351,11 @@ export function runCatalogCheck(check, ctx) {
2318
2351
 
2319
2352
  /** 逐条执行目录检查,汇入统一 results 管线(src='catalog')。尊重 --profile/--env/--session 收窄。 */
2320
2353
  function checkCatalog(ctx, catalog) {
2354
+ setPhase('catalog');
2355
+ // 段边界:目录检查在会话段之后求值,若不清空上下文会**继承会话的单位**——
2356
+ // 回归审计实测到 `P6-patch-name-space`(profile) 声称"检查了会话日志"。目录检查各查一个目标,单位独立。
2357
+ resetCoverage();
2358
+ setCoverage(1, '目录检查项');
2321
2359
  const platform = process.platform;
2322
2360
  for (const check of catalog.checks ?? []) {
2323
2361
  if (!wants(check.section)) continue; // 与内置检查一致的 section 收窄
@@ -2326,7 +2364,14 @@ function checkCatalog(ctx, catalog) {
2326
2364
  if (check.section === 'profile' && !ctx.profileDir) continue; // profile 无效时跳过 profile 段
2327
2365
  let r;
2328
2366
  try { r = runCatalogCheck(check, ctx); } catch (e) { r = { ok: false, detail: `catalog 检查异常: ${e.message.slice(0, 80)}` }; }
2329
- if (r.skipped) { report(check.section, check.id, true, r.detail, undefined, 'catalog'); continue; }
2367
+ // 红队 R1(2026-09-16):`runCatalogCheck` 自报 `skipped`(原语不支持 / 无 DSH 环境 / 目标文件不存在)
2368
+ // 曾被当成 `ok:true`(pass)—— 于是出现"✓ 未发现 DSH 环境…跳过 dsh 的 PATH 检查"这种自相矛盾的行,
2369
+ // 而且它继承目录段的占位覆盖量 1,零对象永不降级。自报 skipped 就是 skip,覆盖量为 0。
2370
+ // 归一化(红队 R1 的第二半):探测原语里有多处文案写着"…不存在(跳过)"却返回 `ok: true`,
2371
+ // 于是"没有目标可查"被记成**通过**(实测:无 profile 目录时 P6 仍 pass)。判据用探测自己的话:
2372
+ // **凡自述跳过的一律是 skip** —— 这类"文案与状态不一致"只能从结构上消灭,不能靠逐个改分支。
2373
+ if (!r.skipped && r.ok === true && /跳过|不适用/.test(String(r.detail))) r.skipped = true;
2374
+ if (r.skipped) { reportSkip(check.section, check.id, r.detail, 'catalog'); continue; }
2330
2375
  const severity = check.severity ?? 'error';
2331
2376
  catalogSeverity.set(check.id, severity);
2332
2377
  report(check.section, check.id, r.ok, r.detail, r.ok ? undefined : check.fix, 'catalog');
@@ -3015,7 +3060,7 @@ async function run() {
3015
3060
  if (jsonOut) console.log(JSON.stringify(r, null, 2));
3016
3061
  else if (r.ok && r.stage === 'verified') printVerdict(makeVerdict('pass', { checked: r.checked, detail: `已安装并预检通过(${r.checked} 条 entry 均可导入)——重启 dsh 即可` }));
3017
3062
  else if (r.ok && r.stage === 'quarantined') {
3018
- console.log(`⚠ 已安装,但该插件的 entry 导入失败,已自动隔离以避免 dsh 起不来:`);
3063
+ printVerdict(makeVerdict('warn', { detail: '已安装,但该插件的 entry 导入失败,已自动隔离以避免 dsh 起不来:' }));
3019
3064
  for (const f of r.failures || []) console.log(` ${f}`);
3020
3065
  console.log(` 隔离项:${r.quarantined.join(', ')}(用 --unquarantine <包名> 放回,修好版本后再重试)`);
3021
3066
  console.log(` dsh 现在可以正常启动。`);
@@ -3131,6 +3176,13 @@ async function run() {
3131
3176
  try { await checkPort3080(); } catch (e) { report('env', 'E10-port-3080', false, `端口检查异常: ${e.message.slice(0, 60)}`); }
3132
3177
  try { checkProfile(profileArg); } catch (e) { report('profile', 'P0', false, `profile 检查异常: ${e.message.slice(0, 100)}`); }
3133
3178
  try { checkSession(sessionArg); } catch (e) { report('session', 'S0', false, `session 检查异常: ${e.message.slice(0, 100)}`); }
3179
+ // 段级覆盖(2026-09 语料发现):整段没有产出任何检查时,报告里会出现"整段消失"——
3180
+ // 用户看不出"会话检查根本没跑"。R3 的精神在**段**这一级同样成立:没看就必须说出来。
3181
+ for (const [sec, why] of [['session', '未选中任何会话(活跃会话在保护窗口内,或被 --session 过滤)']]) {
3182
+ if (wants(sec) && !results.some((r) => r.section === sec)) {
3183
+ reportSkip(sec, 'S0', `${why}——本段检查未运行,**这不代表通过**`);
3184
+ }
3185
+ }
3134
3186
  try { scanAllSessions(); } catch (e) { report('session', 'S11', false, `全会话扫描异常: ${e.message.slice(0, 100)}`); }
3135
3187
  try { scanMigrationRefusals(); } catch (e) { report('session', 'S12', false, `迁移拒载预检异常: ${e.message.slice(0, 100)}`); }
3136
3188
  }
@@ -3262,6 +3314,9 @@ async function run() {
3262
3314
  if (s === 'fail') baseFail++;
3263
3315
  else if (s === 'warn') baseWarn++;
3264
3316
  }
3317
+ // 契约(docs/doctor-contract.md:32):0 = 全通过 · **1 = 任何 WARN** · **2 = 任何 FAIL**。
3318
+ // 红队 R3 指出 envelope 与 json/文本路径退出码不一致——**方向是 json/文本错**,不是这里错。
3319
+ // 我一度把它改成 1,等于把一致性统一到违反契约的一边;已回滚。见 §"待决策"。
3265
3320
  const baseExit = baseFail > 0 ? 2 : baseWarn > 0 ? 1 : 0;
3266
3321
  const exitCode = Math.max(baseExit, secExit);
3267
3322
  // v1.1 remediation(#1719 ADOPTED:ciceroyang 提名、两位 reviewer +1):opt-in --remediation,
@@ -3318,7 +3373,7 @@ async function run() {
3318
3373
  if (!r.ok && r.fix) console.log(` ↳ 修复: ${r.fix}`);
3319
3374
  }
3320
3375
  if (updateInfo.available && !updateInfo.applied) {
3321
- console.log(`\n⚠ 新版本 ${updateInfo.latest} 可用(当前 ${updateInfo.current})→ 运行 \`dsh-doctor --update\` 或 \`dsh plugin update\``);
3376
+ console.log(''); printVerdict(makeVerdict('warn', { detail: `新版本 ${updateInfo.latest} 可用(当前 ${updateInfo.current})→ 运行 \`dsh-doctor --update\` 或 \`dsh plugin update\`` }));
3322
3377
  } else if (updateInfo.applied) {
3323
3378
  console.log(''); printVerdict(makeVerdict('action', { detail: updateInfo.applied }));
3324
3379
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moonquake2004/dsh-doctor",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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": [