@moonquake2004/dsh-doctor 0.8.1 → 0.8.3

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,81 @@
16
16
 
17
17
  ---
18
18
 
19
+ ## [0.8.3] — 2026-09-16
20
+
21
+ **把"自造的对抗者"变成默认动作**(用户要求:红队入流程 + 不依赖外部报错也能自我进化)。
22
+
23
+ ### Added —— 变异测试:验证"守卫是否真的在守"
24
+
25
+ `scripts/mutation-test.mjs`:**故意破坏每个守卫,要求测试必须变红**;破坏后测试仍绿 = 那个守卫是装饰品。
26
+
27
+ - 首轮结果:**8 个变异中 4 个存活** —— 也就是说**一半守卫此前无人看守**:
28
+ `arity-guard`(没测)、`catalog-skip-as-pass`(没测)、`closure-symbol`(我的变异插错了位置)、
29
+ `coverage-reset-noop`(**守卫本身冗余**:各段都自己设了值,去掉重置没有任何测试变红)。
30
+ - 处置:补了两条缺失的测试(arity 守卫 / 目录检查 skip 语义,且**用例自控环境**:PATH 收窄到只有 node,
31
+ 否则 E7 在有 dsh 的机器上合法地 pass,测试就变成对环境的断言);把"覆盖量上下文"改为**绑定求值阶段**
32
+ (`coverageNow()` 只接受属于当前阶段的上下文),守卫才有牙齿;无牙齿的那条如实标为**等价变异**并记录。
33
+ - 终态:**杀死 7 / 存活 0 / 等价 1(已记录)**。
34
+
35
+ ### Added —— 发布闸门:红队不跑就不许发版
36
+
37
+ - `docs/redteam-brief.md`:红队行动简报(如何发起、攻击面、产出要求)。
38
+ - `scripts/release-check.sh`:五道闸门 —— 环境泛化审计 · **变异测试无存活** · 全套测试 ·
39
+ **本版本必须留下 `docs/redteam-<版本>.md`** · 待决策清单可见。缺一项即"不许发布"。
40
+ - 接入 CI(ubuntu × node24)。**闸门第一次运行就拦住了 0.8.3 自己**(缺红队档案)。
41
+
42
+ ### Added —— 缺陷台账:让"自我进化"可度量
43
+
44
+ `docs/defect-ledger.md`:20 条缺陷按**来源**分类(EXT 外部报告 / RT 红队 / MUT 变异 / AUDIT 自审),
45
+ 并给出**预发布发现率**。截至今日:EXT 6 · RT 6 · MUT 1 类 · AUDIT 7 → **预发布发现率 ≈ 70%**。
46
+ 台账里如实注明:**红队那 6 条是因为用户要求才跑的**,所以闸门(强制留下档案)才是把比率变成能力的那一步。
47
+
48
+ ## [0.8.2] — 2026-09-16
49
+
50
+ **红队审计(fresh-context 证伪者)在"已自审三轮"的版本里找出 6 个真漏洞**,其中 2 个推翻了我刚声称"已验证"的断言。
51
+ 完整档案:`docs/redteam-2026-09-16.md`;未修项见 `docs/check-authoring-rules.md` §5。
52
+
53
+ ### Fixed
54
+
55
+ - **目录检查把"跳过"报成 pass,并伪造覆盖量**(最严重):`runCatalogCheck` 自报 `skipped` 被记成 `ok:true`;
56
+ `report()` 的 `examined` 回退到段级占位值 1,零对象永不降级。实测 `✓ 未发现 DSH 环境…跳过 dsh 的 PATH 检查`;
57
+ profile 目录**整个不存在**时 P6 仍 pass。
58
+ - 自报 `skipped` → `reportSkip`;探测原语的"目标不存在"分支补 `skipped: true`(根因是**分支里没设标记、文案却写着"跳过"**);
59
+ 保留"文案含跳过 ⇒ skip"作兜底。空环境下被伪造的 `verified` 由 11 降到 8。
60
+ - **闭集检测有盲点**:`⚠` 也是判定符号却裸写在收口点外,而闭集正则只有 `/[✓⊖✗]/`。纳入 `⚠` 后**立刻又暴露 3 处**,已全部收口;
61
+ 判定符号正则集中为 `VERDICT_GLYPH_RE`;**收口处剥掉数据里的判定符号**(远程目录数据不得伪造判定上屏)。
62
+ - **`reportSkip` 缺 `phase`** → 补上("单位属于求值阶段"这条断言此前在 skip 上无法核对)。
63
+
64
+ ### Changed — 契约级决策,**未擅改**
65
+
66
+ - **退出码随输出格式分叉**(`--json` 1 / `--envelope` 2 / 文本 1):契约 `doctor-contract.md:32` 规定
67
+ `0 全通过 / 1 任何 WARN / 2 任何 FAIL` → **envelope 是对的一边,主路径违反契约**。
68
+ 我一度把 envelope 改成 1(**统一到违反契约的一边**),已回滚;分歧现由测试钉住并列为待决策 D1。
69
+
70
+ ### Infra
71
+
72
+ - `scripts/audit.sh` 的畸形-manifest 断言由 `n > 1` 收紧为 `n >= 15`,并把当前不达标**如实计为"已知缺口"**(自证太弱是 R8 的实例)。
73
+ - 新增 `docs/redteam-2026-09-16.md`(逐条反例 + 处置 + 红队未能验证项)。
74
+
75
+ ### 第二轮红队(攻击这批新机制本身)→ 本轮修复
76
+
77
+ 完整档案 `docs/redteam-0.8.3.md`。**8 条真反例**,其中 1 条推翻台账里"R1 已修 0.8.2"的结论:
78
+
79
+ - **F1(严重)**:0.8.2 只修了**目录段**的"跳过报成 pass";**profile 段的阶段占位值仍在伪造覆盖量** ——
80
+ 3 个**一个都没装**的幽灵 bundle 也能得到 `✓ 16 项通过 / ok=true / verified=16`,且 15 条记录带
81
+ `examined=3, examinedWhat="bundle 条目"`(P21 文案写"扫描 0 个 host 入口"却记 examined=3)。
82
+ **已修**:删除占位回退(`examined` 只能显式申报);未申报者标 `unreported` 并**单独计数、进 JSON**;
83
+ 补齐 P11/P15/P17/P19/P20/P21 的数量与单位;删除"数量巧合就继承单位"的回退(它会把 P15 误贴成"3 个 bundle 条目")。
84
+ - **F3/F4/F8(高)**:发布闸门可被空文件或任意版本号旁路;**闸门与变异清单此前根本未入库**;闸门诊断在 C locale 下崩溃且从不核对数量。已修并入库。
85
+ - **F5(高)**:测试**依赖网络**导致不可复现,进而让变异测试产生**假杀死**(环境红 = 记成 kill)。
86
+ 已加离线开关 `DSH_DOCTOR_OFFLINE=1`;变异判据收紧为"必须出现断言失败";每条变异先跑基线;超时单列。
87
+ - **F6(中)**:等价变异是自证后门(无预算、不进 `--json`)。已加预算(1)并在 JSON 暴露。
88
+ - **F7(中)**:台账计数与自己的 20 行不自洽 → 已按行重算并写明归属规则。
89
+ - **F2(严重,未修)**:闭集只是**文本律**(`String.fromCharCode` 可绕过;`client.js` 未被扫描)→ **待决策 D6**。
90
+ - 附注:`--no-catalog` 只是 `noRemote`,`checks.local.json` 仍会合并 → **待决策 D7**。
91
+
92
+ 测试 144 → **214**。
93
+
19
94
  ## [0.8.1] — 2026-09-16
20
95
 
21
96
  ### 0.8.0 的回归审计 —— **修一个引入一个,被查出来了四个**
package/dsh-doctor.mjs CHANGED
@@ -233,16 +233,21 @@ function hasDshEnvironment(home = HOME) {
233
233
  * · `examined === 0` → **不得 pass**,自动降为 skip("无可检查对象")——硬不变量,CI 有测试盯着;
234
234
  * · `examined` 未报告 → 结果带 `coverage: 'unreported'`(机器可读的欠账标记),可被测试度量并逐项清零。
235
235
  */
236
- let coverageContext = null; // 当前检查段"检查了多少同类对象"的默认值(由 setCoverage 设置)
237
- function setCoverage(n, what) { coverageContext = typeof n === 'number' ? { n, what } : null; }
236
+ let coverageContext = null; // 当前检查段"检查了多少同类对象"的默认值(由 setCoverage 设置,且**绑定阶段**)
237
+ let currentPhase = null; // 求值阶段:env / profile / session / catalog
238
+ function setCoverage(n, what) { coverageContext = typeof n === 'number' ? { n, what, phase: currentPhase } : null; }
238
239
  /** 段边界重置:覆盖量上下文**绝不允许跨段继承**——否则会伪造出一个看起来可信的数字。
239
240
  * 2026-09 回归审计实测:会话段的 S6/S14 曾号称"检查了 16 个 bundle 条目",而它们实际展开的是 11352 个事件。 */
240
241
  function resetCoverage() { coverageContext = null; }
241
242
  /** 求值阶段(env/profile/session/catalog)。覆盖量的**单位属于阶段**,不属于展示用的 section——
242
243
  * 目录提供的检查会带 section:'env'/'profile',但它们在 catalog 阶段求值(回归审计实测)。 */
243
- let currentPhase = null;
244
244
  function setPhase(p) { currentPhase = p; }
245
- function coverageNow() { return coverageContext; }
245
+ function coverageNow() {
246
+ // 只接受**属于当前阶段**的上下文:这样"忘记在段边界重置"不可能再伪造出跨段的数字
247
+ // (变异测试发现:只靠 resetCoverage 的版本里,去掉重置后没有任何测试变红——守卫是冗余的;
248
+ // 绑定阶段后,守卫才有牙齿。)
249
+ return coverageContext && coverageContext.phase === currentPhase ? coverageContext : null;
250
+ }
246
251
 
247
252
  function report(section, id, ok, detail, fix, src, examined) {
248
253
  // R6 接口律:参数放错位置必须**立刻抛**,而不是变成一条静默的错误判定。
@@ -250,8 +255,8 @@ function report(section, id, ok, detail, fix, src, examined) {
250
255
  // 回退到上下文 0 → 一条本该 pass 的检查被静默降级。凭记忆拼参数是可以通过机制消灭的。
251
256
  // R6 扩展(2026-09 回归审计):**参数个数**守卫。JS 会静默忽略多余参数,而我只校验了类型——
252
257
  // 于是"多传一个 undefined"这类错误(我给 S11 补覆盖量时就犯了)能一路走到运行时。
253
- if (arguments.length > 7) {
254
- throw new Error(`report(${id}): 最多 7 个参数 (section, id, ok, detail, fix, src, examined),收到 ${arguments.length} 个 —— 检查参数位置`);
258
+ if (arguments.length > 8) {
259
+ throw new Error(`report(${id}): 最多 8 个参数 (section, id, ok, detail, fix, src, examined, examinedWhat),收到 ${arguments.length} 个 —— 检查参数位置`);
255
260
  }
256
261
  if (typeof examined !== 'number' && examined !== undefined) {
257
262
  throw new Error(`report(${id}): examined 必须是 number 或 undefined,收到 ${typeof examined}(${JSON.stringify(examined)})——检查参数位置`);
@@ -262,16 +267,34 @@ function report(section, id, ok, detail, fix, src, examined) {
262
267
  if (typeof section !== 'string' || typeof id !== 'string' || typeof ok !== 'boolean' || typeof detail !== 'string') {
263
268
  throw new Error(`report(): 前四个参数必须是 (section:string, id:string, ok:boolean, detail:string)`);
264
269
  }
265
- const zeroCheck = typeof examined === 'number' ? examined : coverageNow()?.n;
270
+ // 红队 F1(2026-09-16):**不得**用阶段占位值顶替真实数量。
271
+ // 实测反例:3 个「幽灵 bundle」(一个都没装)时,15 条通过项带上 `examined=3, examinedWhat="bundle 条目"`,
272
+ // 其中 P21 的文案是"扫描 0 个 host 入口与 0 个 client 产物"——**文案与覆盖量自相矛盾**,
273
+ // 而 verified=16 让聚合层宣称"16 项通过"。占位值把"零对象不变量"整个架空了。
274
+ const zeroCheck = typeof examined === 'number' ? examined : undefined;
266
275
  if (ok === true && zeroCheck === 0) {
267
- results.push({ section, id, ok: true, skip: true, coverage: 'none', detail: `${detail}(无可检查对象,未做任何比较)`, fix, src: src ?? 'builtin' });
276
+ // 2026-09 语料发现的措辞缺陷:原实现把原判定文案原样留下 → 出现"结构正常(无可检查对象,未做任何比较)"
277
+ // 这种**自相矛盾**的句子(既宣称正常、又说没比较)。原文案改放 note(机器可读),人读文案只陈述事实。
278
+ results.push({ section, id, ok: true, skip: true, coverage: 'none',
279
+ // 措辞要求:**不得自相矛盾**(2026-09 语料曾产出"结构正常(无可检查对象,未做任何比较)"),
280
+ // 同时保留具体原因(否则用户与测试都只能看到一句通用话)。
281
+ detail: `无可检查对象、未做任何比较——不作为「通过」;原判定:${String(detail).replace(VERDICT_GLYPH_RE, '·')}`,
282
+ fix, src: src ?? 'builtin' });
268
283
  return;
269
284
  }
270
- const rec = { section, id, ok, detail, fix, src: src ?? 'builtin', phase: currentPhase };
285
+ // R6(红队):detail 可来自远程目录/checks.local.json,**数据不得能伪造判定符号**上屏——
286
+ // 源码里没有 ✓ 也能让 stdout 出现 ✓。故在收口处剥掉数据里的判定符号(含 ⚠)。
287
+ const safeDetail = String(detail).replace(VERDICT_GLYPH_RE, '·');
288
+ const rec = { section, id, ok, detail: safeDetail, fix, src: src ?? 'builtin', phase: currentPhase };
271
289
  if (ok === true) {
272
- const ctx = coverageNow();
273
- const n = typeof examined === 'number' ? examined : ctx?.n;
274
- if (typeof n === 'number') { rec.examined = n; if (ctx?.what) rec.examinedWhat = ctx.what; }
290
+ // 只有显式申报才算数;`examinedWhat` 也必须与**同一处**申报配套(由调用点传 what)
291
+ if (typeof examined === 'number') {
292
+ rec.examined = examined;
293
+ // 第 8 个参数是**覆盖量单位**(与 examined 配套申报,避免"数量对不上单位"的旧问题)
294
+ // 单位**只能**由第 8 个参数显式申报。绝不按"数量恰好相等"去继承——
295
+ // 那会让"检查了 3 个文件"的 P15 被贴上"3 个 bundle 条目"的标签(我第一版就这么错了)。
296
+ if (typeof arguments[7] === 'string') rec.examinedWhat = arguments[7];
297
+ }
275
298
  else rec.coverage = 'unreported';
276
299
  }
277
300
  results.push(rec);
@@ -279,7 +302,8 @@ function report(section, id, ok, detail, fix, src, examined) {
279
302
 
280
303
  /** skip 状态(v1 词汇表 r5:#1719)——"不适用"而非"通过",必须带 reason(detail)。不计入 pass/fail,不翻退出码。 */
281
304
  function reportSkip(section, id, detail, src) {
282
- results.push({ section, id, ok: true, skip: true, detail, src: src ?? 'builtin' });
305
+ // R2(红队):skip 记录此前**没有 phase 字段**,于是"单位属于求值阶段"这条断言在 skip 上无法核对。
306
+ results.push({ section, id, ok: true, skip: true, detail, src: src ?? 'builtin', phase: currentPhase });
283
307
  }
284
308
 
285
309
 
@@ -298,6 +322,7 @@ function reportSkip(section, id, detail, src) {
298
322
  * @param {{checked?: number, detail?: string, reason?: string, totals?: {verified:number, skipped:number, failed:number}}} opts
299
323
  */
300
324
  function makeVerdict(state, opts = {}) {
325
+ if (!['pass', 'fail', 'skip', 'warn', 'action'].includes(state)) throw new Error(`makeVerdict: 未知状态 ${state}`);
301
326
  if (state === 'pass') {
302
327
  const n = opts.checked;
303
328
  if (typeof n !== 'number' || n <= 0) {
@@ -313,6 +338,9 @@ function makeVerdict(state, opts = {}) {
313
338
  * 两者构成本工具的**闭集**——闭集测试断言:源码里任何 ✓/⊖/✗ 都必须落在这两个函数体内。
314
339
  */
315
340
  const VERDICT_MARKS = { pass: '✓', fail: '✗', skip: '⊖', warn: '⚠', action: '✓' };
341
+ /** 判定符号集合(含 ⚠)——**唯一**用于"从数据里剥掉符号"的正则。
342
+ * 红队 R6:数据(远程目录/checks.local.json)不得能伪造判定符号上屏;符号只许收口点产出。 */
343
+ const VERDICT_GLYPH_RE = /[✓✔⊖✗✘⚠]/g;
316
344
 
317
345
  function printItem(state, text, indent = 2) {
318
346
  console.log(`${' '.repeat(indent)}${VERDICT_MARKS[state] ?? '?'} ${text}`);
@@ -336,15 +364,19 @@ function aggregateVerdict(records) {
336
364
  const failed = records.filter((r) => !r.skip && !r.ok);
337
365
  const verified = records.filter((r) => !r.skip && r.ok && (r.examined ?? 0) > 0).length;
338
366
  const skipped = records.filter((r) => r.skip).length;
339
- const totals = { verified, skipped, failed: failed.length };
367
+ // 红队 F1:**未申报覆盖量的通过**既不算"已验证",也不能隐形——单独计数并进汇总
368
+ const unreported = records.filter((r) => !r.skip && r.ok && r.coverage === 'unreported').length;
369
+ const totals = { verified, skipped, failed: failed.length, unreported };
340
370
  if (failed.length) return makeVerdict('fail', { totals, detail: `${failed.length} 个问题` });
341
371
  if (verified === 0) {
342
372
  return makeVerdict('skip', { totals, reason: `没有任何检查实际验证过(${skipped} 项全部跳过)` });
343
373
  }
344
374
  if (skipped > 0) {
345
- return makeVerdict('pass', { checked: verified, totals, detail: `${verified} 项通过、${skipped} 项未检查(skip)——**未检查的部分不代表通过**` });
375
+ const unrep = unreported > 0 ? `;另有 ${unreported} 项通过但**未申报检查了多少**(不算已验证)` : '';
376
+ return makeVerdict('pass', { checked: verified, totals, detail: `${verified} 项通过、${skipped} 项未检查(skip)${unrep}` });
346
377
  }
347
- return makeVerdict('pass', { checked: verified, totals, detail: `全部通过(${verified} 项,均已验证)` });
378
+ const unrep2 = unreported > 0 ? `(其中 ${unreported} 项未申报覆盖量)` : '';
379
+ return makeVerdict('pass', { checked: verified, totals, detail: `全部通过(${verified} 项已验证${unrep2})` });
348
380
  }
349
381
 
350
382
  /** 解析 --profile 参数:名字(如 web)→ $DSH_HOME/profiles/<name>;含路径分隔符/~/开头 → 直接当 profile 目录(契约 harness 传绝对路径)。 */
@@ -655,7 +687,7 @@ function checkPort3080() {
655
687
  `端口 ${port} 被 dsh web 实例占用(PID ${info.pid})${httpAlive === true ? '且 HTTP 有应答' : ''}——宿主自身或另一实例,正常`, undefined);
656
688
  }
657
689
  else if (info) report('env', 'E10-port-3080', false, `端口 ${port} 被其他程序占用(PID ${info.pid}: ${info.cmd}),dsh web 启动会 address in use(#1719)`, `关掉占用进程,或让 dsh web 用别的端口`);
658
- else report('env', 'E10-port-3080', true, `⚠ 端口 ${port} 被占用但无法识别占用者`, undefined);
690
+ else report('env', 'E10-port-3080', true, `端口 ${port} 被占用但无法识别占用者`, undefined);
659
691
  } else {
660
692
  report('env', 'E10-port-3080', false, `端口 ${port} 探测异常: ${e.message.slice(0, 60)}`, undefined);
661
693
  }
@@ -871,7 +903,7 @@ function checkProfile(name) {
871
903
  + `systemd 健康、端口在听、页面永远空白(#6693 实测:整份 journal 里该错误计数为 0)`,
872
904
  '把 client 半边改成 `__ModuleLoader__.load({ id, factory })` 的 CJS 工厂形态(参照 dsh-better-sidebar / dsh-dream-skin)');
873
905
  } else {
874
- report('profile', 'P20', true, `client 产物均为 CJS 工厂形态(${p20scanned} 个)`);
906
+ report('profile', 'P20', true, `client 产物均为 CJS 工厂形态(${p20scanned} 个)`, undefined, undefined, p20scanned, 'client 产物');
875
907
  }
876
908
 
877
909
  // P21:**沙箱专属符号在普通插件里不存在**(host 与 client 两侧一律如此,同一根因两个面)
@@ -923,7 +955,7 @@ function checkProfile(name) {
923
955
  + `\n host 侧表现为 \`ReferenceError: harness is not defined\`(在 \`apply()\` 内同步抛出 → cordis 中断装载链 → Web UI 整体不可用)`,
924
956
  '移除这些引用或用宿主提供的等价能力;若确需守卫,必须写成 `typeof X !== \"undefined\"` **放在最前**——`X.foo && …` 挡不住(裸标识符先被解析)');
925
957
  } else if (notes21.length) {
926
- report('profile', 'P21', true, `未见裸引用沙箱专属符号(${notes21.length} 处有 typeof 守卫,未计入)`);
958
+ report('profile', 'P21', true, `未见裸引用沙箱专属符号(${notes21.length} 处有 typeof 守卫,未计入)`, undefined, undefined, hostFiles.length + clientFiles.length, 'host/client 文件');
927
959
  } else {
928
960
  report('profile', 'P21', true, `扫描 ${hostFiles.length} 个 host 入口与 ${clientFiles.length} 个 client 产物,未见沙箱专属符号引用`);
929
961
  }
@@ -983,7 +1015,7 @@ function checkProfile(name) {
983
1015
  report('profile', 'P19', true,
984
1016
  `核对 ${checked19} 条 host peer 声明,均可接受当前核心版本`
985
1017
  + (unknown19 ? `(${unknown19} 条无法判定,按未知处理未计入)` : '')
986
- + undeclaredNote.replace(/\n\s*/g, ' '));
1018
+ + undeclaredNote.replace(/\n\s*/g, ' '), undefined, undefined, checked19, 'host peer 声明');
987
1019
  }
988
1020
  }
989
1021
 
@@ -1213,7 +1245,7 @@ function packageNamedExports(pkgDir) {
1213
1245
  }
1214
1246
  }
1215
1247
  if (entryIssues.length) report('profile', 'P11', false, `已装 bundle 的 main 入口缺失(#1965:市场装源码不跑构建 → ERR_MODULE_NOT_FOUND → dsh web boot 崩溃): ${entryIssues.join('; ')}`, '在插件目录跑构建(pnpm install && pnpm run build 产出 main 指向的文件),或改用打包好的 npm 包安装;monorepo 插件需装子包(dsh-market #18 同族)');
1216
- else report('profile', 'P11', true, '已装 bundle 的 main 入口产物均在', undefined);
1248
+ else report('profile', 'P11', true, '已装 bundle 的 main 入口产物均在', undefined, undefined, bundleDirs.size, '已装 bundle');
1217
1249
 
1218
1250
  // P13:client 端服务名抢注核心客户端服务(#2752:ctx.provide("chatFileMentions") 撞核心 dsh-client-ui-deliverables
1219
1251
  // → 浏览器端 service already registered → Web UI 白屏,服务端日志无感知、报错无冲突来源)
@@ -1404,7 +1436,7 @@ function packageNamedExports(pkgDir) {
1404
1436
  + '改用 [IO.File]::WriteAllText($p, (Get-Content $p -Raw), (New-Object Text.UTF8Encoding $false));'
1405
1437
  + '或 sed -i "" "1s/^\xEF\xBB\xBF//" <file>', undefined, bomTargets.length);
1406
1438
  } else {
1407
- report('profile', 'P15', true, `关键文件无 BOM 头(检查 ${bomTargets.length} 个)`, undefined, undefined, bomTargets.length);
1439
+ report('profile', 'P15', true, `关键文件无 BOM 头(检查 ${bomTargets.length} 个)`, undefined, undefined, bomTargets.length, '关键文件');
1408
1440
  }
1409
1441
 
1410
1442
  /* P16:插件命名导入的导出缺失检测(#5864:一个缺失导出 → 整棵插件树 boot 崩溃循环、
@@ -1508,7 +1540,7 @@ function packageNamedExports(pkgDir) {
1508
1540
  if (p17Issues.length) {
1509
1541
  report('profile', 'P17', false, `client 端 require 的模块不在宿主模块表(#5719:makeRequire 硬 throw → 浏览器白屏且服务端无感知): ${p17Issues.join('; ')}`, `改用宿主提供的模块名;若确由宿主提供,在本包 package.json 的 dsh.client.external/inject 里声明;平台种子当前 ${CLIENT_SEEDS.size} 项、已装图行 ${composedRows.size} 项`);
1510
1542
  } else {
1511
- report('profile', 'P17', true, `client 端 require 的 specifier 均可服务(平台种子 ${CLIENT_SEEDS.size} 项 + 已装图行 ${composedRows.size} 项)`, undefined);
1543
+ report('profile', 'P17', true, `client 端 require 的 specifier 均可服务(平台种子 ${CLIENT_SEEDS.size} 项 + 已装图行 ${composedRows.size} 项)`, undefined, undefined, CLIENT_SEEDS.size + composedRows.size, 'client specifier 来源');
1512
1544
  }
1513
1545
  }
1514
1546
 
@@ -2101,7 +2133,7 @@ function scanAllSessions() {
2101
2133
  if (unstableReads.length) parts.push(`${unstableReads.length} 个会话**读取间歇性失败**(重试即成功 → 非损坏,指向存储/内存/驱动的偶发读错误,社区 #6739): ${unstableReads.slice(0, 3).map((u) => `${u.id}(${u.attempts} 次尝试)`).join(' | ')}`);
2102
2134
  if (oversized.length) parts.push(`${oversized.length} 个超大会话: ${oversized.map((o) => `${o.id}(${o.dsMB}MB/${o.events}事件)`).join(' | ')}`);
2103
2135
  if (totalRisk) parts.push(`工作区估算物化堆 ~${estHeapMB}MB(估算= max(${totalEvents}事件×600B, ${totalMB}MB×6),跨 ${files.length} 会话累积,#1550 场景;阈值 ${heapLimit}MB,可设 DSH_DOCTOR_HEAP_MB)`);
2104
- report('session', 'S11', true, `⚠ 全会话扫描:${parts.join(';')}(未损坏,可接受或归档)`,
2136
+ report('session', 'S11', true, `全会话扫描:${parts.join(';')}(未损坏,可接受或归档)`,
2105
2137
  unstableReads.length
2106
2138
  ? '间歇性读取失败不是日志问题:先重试读取;持续出现则排查存储健康(SMART)、内存与磁盘驱动(#6739 的证据是失败帧位置每次不同)'
2107
2139
  : '冷启动会明显变慢;必要时压缩/归档历史会话', undefined, files.length);
@@ -2150,6 +2182,8 @@ function validCatalog(data) {
2150
2182
 
2151
2183
  /** 拉取目录:新鲜缓存(≤TTL) → 远程(raw.githubusercontent,3s 超时) → 旧缓存(last-known-good) → 内置副本;末尾合并本地覆盖层。 */
2152
2184
  async function loadCatalog({ noRemote = false, fetchImpl, home = HOME, localPath } = {}) {
2185
+ // 离线开关(红队 F5:目录用例曾因取到远程目录而结果不可复现)——DSH_DOCTOR_OFFLINE=1 等价于 noRemote
2186
+ if (process.env.DSH_DOCTOR_OFFLINE === '1') noRemote = true;
2153
2187
  const bundled = bundledCatalog();
2154
2188
  let base;
2155
2189
  if (noRemote || typeof fetchImpl !== 'function') {
@@ -2274,13 +2308,13 @@ export function runCatalogCheck(check, ctx) {
2274
2308
  let ok = existsSync(fp);
2275
2309
  if (ok && probe.type === 'path-is-dir') ok = lstatSync(fp).isDirectory();
2276
2310
  if (ok && probe.type === 'path-is-file') ok = lstatSync(fp).isFile();
2277
- return ok ? { ok: true, detail: check.detailOk ?? `${fp} 存在` }
2311
+ return ok ? { ok: true, checked: 1, detail: check.detailOk ?? `${fp} 存在` }
2278
2312
  : { ok: false, detail: check.detailFail ?? `${fp} 不存在/类型不符` };
2279
2313
  }
2280
2314
  case 'json-valid': {
2281
2315
  const fp = p(probe.path);
2282
2316
  if (!existsSync(fp)) return probe.required === false
2283
- ? { ok: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` }
2317
+ ? { ok: true, skipped: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` }
2284
2318
  : { ok: false, detail: check.detailFail ?? `${fp} 缺失` };
2285
2319
  let utf8ok = true, jsonok = false;
2286
2320
  try { new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(fp)); } catch { utf8ok = false; }
@@ -2292,7 +2326,7 @@ export function runCatalogCheck(check, ctx) {
2292
2326
  case 'text-not-contains': {
2293
2327
  const fp = p(probe.path);
2294
2328
  if (!existsSync(fp)) return probe.required === false
2295
- ? { ok: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` }
2329
+ ? { ok: true, skipped: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` }
2296
2330
  : { ok: false, detail: check.detailFail ?? `${fp} 缺失` };
2297
2331
  let re;
2298
2332
  try { re = new RegExp(probe.pattern, probe.flags ?? ''); } catch (e) { return { ok: false, detail: `目录规则正则非法: ${e.message.slice(0, 60)}` }; }
@@ -2323,7 +2357,7 @@ export function runCatalogCheck(check, ctx) {
2323
2357
  case 'file-writable': {
2324
2358
  const fp = p(probe.path);
2325
2359
  if (!existsSync(fp)) return probe.required === false
2326
- ? { ok: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` }
2360
+ ? { ok: true, skipped: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` }
2327
2361
  : { ok: false, detail: check.detailFail ?? `${fp} 缺失` };
2328
2362
  let writable = false;
2329
2363
  try { const fd = openSync(fp, 'a'); closeSync(fd); writable = true; } catch { /* 只读/属主问题 */ }
@@ -2350,7 +2384,14 @@ function checkCatalog(ctx, catalog) {
2350
2384
  if (check.section === 'profile' && !ctx.profileDir) continue; // profile 无效时跳过 profile 段
2351
2385
  let r;
2352
2386
  try { r = runCatalogCheck(check, ctx); } catch (e) { r = { ok: false, detail: `catalog 检查异常: ${e.message.slice(0, 80)}` }; }
2353
- if (r.skipped) { report(check.section, check.id, true, r.detail, undefined, 'catalog'); continue; }
2387
+ // 红队 R1(2026-09-16):`runCatalogCheck` 自报 `skipped`(原语不支持 / 无 DSH 环境 / 目标文件不存在)
2388
+ // 曾被当成 `ok:true`(pass)—— 于是出现"✓ 未发现 DSH 环境…跳过 dsh 的 PATH 检查"这种自相矛盾的行,
2389
+ // 而且它继承目录段的占位覆盖量 1,零对象永不降级。自报 skipped 就是 skip,覆盖量为 0。
2390
+ // 归一化(红队 R1 的第二半):探测原语里有多处文案写着"…不存在(跳过)"却返回 `ok: true`,
2391
+ // 于是"没有目标可查"被记成**通过**(实测:无 profile 目录时 P6 仍 pass)。判据用探测自己的话:
2392
+ // **凡自述跳过的一律是 skip** —— 这类"文案与状态不一致"只能从结构上消灭,不能靠逐个改分支。
2393
+ if (!r.skipped && r.ok === true && /跳过|不适用/.test(String(r.detail))) r.skipped = true;
2394
+ if (r.skipped) { reportSkip(check.section, check.id, r.detail, 'catalog'); continue; }
2354
2395
  const severity = check.severity ?? 'error';
2355
2396
  catalogSeverity.set(check.id, severity);
2356
2397
  report(check.section, check.id, r.ok, r.detail, r.ok ? undefined : check.fix, 'catalog');
@@ -3039,7 +3080,7 @@ async function run() {
3039
3080
  if (jsonOut) console.log(JSON.stringify(r, null, 2));
3040
3081
  else if (r.ok && r.stage === 'verified') printVerdict(makeVerdict('pass', { checked: r.checked, detail: `已安装并预检通过(${r.checked} 条 entry 均可导入)——重启 dsh 即可` }));
3041
3082
  else if (r.ok && r.stage === 'quarantined') {
3042
- console.log(`⚠ 已安装,但该插件的 entry 导入失败,已自动隔离以避免 dsh 起不来:`);
3083
+ printVerdict(makeVerdict('warn', { detail: '已安装,但该插件的 entry 导入失败,已自动隔离以避免 dsh 起不来:' }));
3043
3084
  for (const f of r.failures || []) console.log(` ${f}`);
3044
3085
  console.log(` 隔离项:${r.quarantined.join(', ')}(用 --unquarantine <包名> 放回,修好版本后再重试)`);
3045
3086
  console.log(` dsh 现在可以正常启动。`);
@@ -3155,6 +3196,13 @@ async function run() {
3155
3196
  try { await checkPort3080(); } catch (e) { report('env', 'E10-port-3080', false, `端口检查异常: ${e.message.slice(0, 60)}`); }
3156
3197
  try { checkProfile(profileArg); } catch (e) { report('profile', 'P0', false, `profile 检查异常: ${e.message.slice(0, 100)}`); }
3157
3198
  try { checkSession(sessionArg); } catch (e) { report('session', 'S0', false, `session 检查异常: ${e.message.slice(0, 100)}`); }
3199
+ // 段级覆盖(2026-09 语料发现):整段没有产出任何检查时,报告里会出现"整段消失"——
3200
+ // 用户看不出"会话检查根本没跑"。R3 的精神在**段**这一级同样成立:没看就必须说出来。
3201
+ for (const [sec, why] of [['session', '未选中任何会话(活跃会话在保护窗口内,或被 --session 过滤)']]) {
3202
+ if (wants(sec) && !results.some((r) => r.section === sec)) {
3203
+ reportSkip(sec, 'S0', `${why}——本段检查未运行,**这不代表通过**`);
3204
+ }
3205
+ }
3158
3206
  try { scanAllSessions(); } catch (e) { report('session', 'S11', false, `全会话扫描异常: ${e.message.slice(0, 100)}`); }
3159
3207
  try { scanMigrationRefusals(); } catch (e) { report('session', 'S12', false, `迁移拒载预检异常: ${e.message.slice(0, 100)}`); }
3160
3208
  }
@@ -3286,6 +3334,9 @@ async function run() {
3286
3334
  if (s === 'fail') baseFail++;
3287
3335
  else if (s === 'warn') baseWarn++;
3288
3336
  }
3337
+ // 契约(docs/doctor-contract.md:32):0 = 全通过 · **1 = 任何 WARN** · **2 = 任何 FAIL**。
3338
+ // 红队 R3 指出 envelope 与 json/文本路径退出码不一致——**方向是 json/文本错**,不是这里错。
3339
+ // 我一度把它改成 1,等于把一致性统一到违反契约的一边;已回滚。见 §"待决策"。
3289
3340
  const baseExit = baseFail > 0 ? 2 : baseWarn > 0 ? 1 : 0;
3290
3341
  const exitCode = Math.max(baseExit, secExit);
3291
3342
  // v1.1 remediation(#1719 ADOPTED:ciceroyang 提名、两位 reviewer +1):opt-in --remediation,
@@ -3324,6 +3375,8 @@ async function run() {
3324
3375
  verified: aggJson.totals.verified,
3325
3376
  skipped: aggJson.totals.skipped,
3326
3377
  failed: aggJson.totals.failed,
3378
+ // 红队 F1:未申报覆盖量的通过**单独暴露**(既不算已验证,也不隐形)
3379
+ unreported: aggJson.totals.unreported,
3327
3380
  checks: checksWithStatus, catalog: catalogMeta, update: updateInfo, ...(securityMeta.enabled ? { security: securityMeta } : {}),
3328
3381
  }, null, 2));
3329
3382
  } else {
@@ -3342,7 +3395,7 @@ async function run() {
3342
3395
  if (!r.ok && r.fix) console.log(` ↳ 修复: ${r.fix}`);
3343
3396
  }
3344
3397
  if (updateInfo.available && !updateInfo.applied) {
3345
- console.log(`\n⚠ 新版本 ${updateInfo.latest} 可用(当前 ${updateInfo.current})→ 运行 \`dsh-doctor --update\` 或 \`dsh plugin update\``);
3398
+ console.log(''); printVerdict(makeVerdict('warn', { detail: `新版本 ${updateInfo.latest} 可用(当前 ${updateInfo.current})→ 运行 \`dsh-doctor --update\` 或 \`dsh plugin update\`` }));
3346
3399
  } else if (updateInfo.applied) {
3347
3400
  console.log(''); printVerdict(makeVerdict('action', { detail: updateInfo.applied }));
3348
3401
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moonquake2004/dsh-doctor",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
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": [