@moonquake2004/dsh-doctor 0.7.2 → 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,45 @@
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
+
19
58
  ## [0.7.2] — 2026-09-16
20
59
 
21
60
  ### 用新规则复查旧错误 —— **确认重复犯了同一个错**
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 名');
@@ -2887,7 +2952,7 @@ async function run() {
2887
2952
  const base = writePreUpgrade(profDir);
2888
2953
  if (jsonOut) console.log(JSON.stringify({ ok: true, ...base }, null, 2));
2889
2954
  else {
2890
- console.log(`✓ 已记录升级前基线(${profDir})`);
2955
+ printVerdict(makeVerdict('action', { detail: `已记录升级前基线(${profDir})` }));
2891
2956
  console.log(` 核心版本: ${base.coreVersion ?? '(未能确定)'} | bundle ${base.bundleCount} 个 | entry ${Object.keys(base.entries).length} 条`);
2892
2957
  console.log(' 下一步:升级 dsh,然后运行');
2893
2958
  console.log(` npx @moonquake2004/dsh-doctor --post-upgrade --profile ${profileArg || 'web'}`);
@@ -2897,7 +2962,7 @@ async function run() {
2897
2962
  }
2898
2963
  const base = readPreUpgrade(profDir);
2899
2964
  if (!base) {
2900
- console.error(`✗ 没找到升级前基线(${join(profDir, PRE_UPGRADE_FILE)})——请先在升级前运行 --pre-upgrade`);
2965
+ printVerdict(makeVerdict('fail', { detail: `没找到升级前基线(${join(profDir, PRE_UPGRADE_FILE)})——请先在升级前运行 --pre-upgrade` }), { stream: 'stderr' }); // 错误路径:非判定
2901
2966
  process.exit(1);
2902
2967
  }
2903
2968
  const nowCore = coreVersion();
@@ -2920,12 +2985,12 @@ async function run() {
2920
2985
  for (const l of lines) console.log(` · ${l}`);
2921
2986
  const verdictPost = bootVerdict(results);
2922
2987
  if (verdictPost.state === 'skip') {
2923
- console.log(` ⊖ ${verdictPost.reason}——**不改写已知良好基线**`);
2988
+ printVerdict(makeVerdict('skip', { reason: `${verdictPost.reason}——不改写已知良好基线` }), { indent: true });
2924
2989
  } else if (verdictPost.state === 'pass') {
2925
- console.log(' ✓ 装载模拟通过——升级未破坏任何可探测 entry');
2990
+ printVerdict(makeVerdict('pass', { checked: verdictPost.checked, detail: '装载模拟通过——升级未破坏任何可探测 entry' }), { indent: true });
2926
2991
  writeSnapshot(profDir, nowSnap);
2927
2992
  } else {
2928
- console.log(` ✗ 装载模拟失败 ${failed.length} 条(这就是"升级后起不来"的直接原因):`);
2993
+ printVerdict(makeVerdict('fail', { detail: `装载模拟失败 ${failed.length} 条(这就是"升级后起不来"的直接原因):` }), { indent: true });
2929
2994
  for (const f of failed) {
2930
2995
  console.log(` [${f.bundle}] ${f.id} → ${f.spec}`);
2931
2996
  console.log(` ${f.kind}: ${f.error}`);
@@ -2948,15 +3013,15 @@ async function run() {
2948
3013
  const profDir = resolveProfile(profileArg || 'web');
2949
3014
  const r = safeAdd(profileArg || 'web', safeAddArg);
2950
3015
  if (jsonOut) console.log(JSON.stringify(r, null, 2));
2951
- 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 即可` }));
2952
3017
  else if (r.ok && r.stage === 'quarantined') {
2953
3018
  console.log(`⚠ 已安装,但该插件的 entry 导入失败,已自动隔离以避免 dsh 起不来:`);
2954
3019
  for (const f of r.failures || []) console.log(` ${f}`);
2955
3020
  console.log(` 隔离项:${r.quarantined.join(', ')}(用 --unquarantine <包名> 放回,修好版本后再重试)`);
2956
3021
  console.log(` dsh 现在可以正常启动。`);
2957
- } 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` }));
2958
3023
  else {
2959
- console.log(`✗ 安装后无法启动,且隔离也救不回来 → 已整体回滚到安装前状态`);
3024
+ printVerdict(makeVerdict('fail', { detail: '安装后无法启动,且隔离也救不回来 → 已整体回滚到安装前状态' }));
2960
3025
  for (const f of r.failures || []) console.log(` ${f}`);
2961
3026
  }
2962
3027
  process.exit(r.ok ? 0 : 2);
@@ -2966,13 +3031,13 @@ async function run() {
2966
3031
  try {
2967
3032
  if (quarantineArg || unquarantineArg) {
2968
3033
  const r = quarantineBundle(profDir, quarantineArg || unquarantineArg, !!unquarantineArg);
2969
- if (!r.ok) { console.error(`✗ ${r.error}`); process.exit(1); }
3034
+ if (!r.ok) { printVerdict(makeVerdict('fail', { detail: r.error })); process.exit(1); }
2970
3035
  const payload = { ok: true, action: unquarantineArg ? 'unquarantine' : 'quarantine', ...r, next: '重启 dsh;随后用 --boot-check 复查,或用 --unquarantine 撤销' };
2971
3036
  if (jsonOut) console.log(JSON.stringify(payload, null, 2));
2972
3037
  else if (unquarantineArg) {
2973
- console.log(`✓ 已放回 ${unquarantineArg}(当前启动列表 ${r.bundles} 项)——重启 dsh 生效`);
3038
+ printVerdict(makeVerdict('action', { detail: `已放回 ${unquarantineArg}(当前启动列表 ${r.bundles} 项)——重启 dsh 生效` }));
2974
3039
  } else {
2975
- console.log(`✓ 已隔离 ${quarantineArg}(启动列表现为 ${r.bundles} 项;原始 package.json 已备份)`);
3040
+ printVerdict(makeVerdict('action', { detail: `已隔离 ${quarantineArg}(启动列表现为 ${r.bundles} 项;原始 package.json 已备份)` }));
2976
3041
  console.log(` 重启 dsh,然后用 --boot-check 复查;要放回:--unquarantine ${quarantineArg}`);
2977
3042
  }
2978
3043
  process.exit(0);
@@ -3005,7 +3070,7 @@ async function run() {
3005
3070
  if (jsonOut) {
3006
3071
  console.log(JSON.stringify({ ok: true, skip: true, reason: '启动列表为空:没有任何 entry 可探测,未做装载模拟', profile: profDir, checked: 0, failed: 0, drift, results }, null, 2));
3007
3072
  } else {
3008
- console.log(`装载模拟(${profDir}):⊖ 无可检查对象——启动列表为空,**未做任何装载模拟**(这不等于"通过")`);
3073
+ console.log(`装载模拟(${profDir}):`); printVerdict(makeVerdict('skip', { reason: '无可检查对象——启动列表为空,未做任何装载模拟' }));
3009
3074
  console.log(' 若你预期这里有插件:检查 profile 的 dsh.profile.bundles 是否为空,或插件是否装到了别的 profile。');
3010
3075
  }
3011
3076
  process.exit(0);
@@ -3016,10 +3081,10 @@ async function run() {
3016
3081
  } else {
3017
3082
  console.log(`装载模拟(${profDir}):检查 ${results.length} 条 entry,失败 ${failed.length} 条`);
3018
3083
  for (const r of results) {
3019
- 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}`);
3020
- 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})`);
3021
3086
  }
3022
- if (!failed.length) console.log(' ✓ 所有可探测 entry 均可导入——启动失败若仍发生,问题多在配置合并或原生环境,请贴 --json 输出');
3087
+ if (!failed.length) printVerdict(makeVerdict('pass', { checked: results.length, detail: '所有可探测 entry 均可导入——启动失败若仍发生,问题多在配置合并或原生环境,请贴 --json 输出' }), { indent: true });
3023
3088
  }
3024
3089
  process.exit(failed.length ? 2 : 0);
3025
3090
  } catch (e) {
@@ -3228,7 +3293,15 @@ async function run() {
3228
3293
  // 于是把 warn 当成误报。状态本就不该由消费者自行推导。
3229
3294
  const statusOf = (r) => (r.skip ? 'skip' : (!r.ok ? (((r.section === 'security') ? r.severity !== 'critical' : catalogSeverity.get(r.id) === 'warn') ? 'warn' : 'fail') : 'pass'));
3230
3295
  const checksWithStatus = results.map((r) => ({ ...r, status: statusOf(r) }));
3231
- 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));
3232
3305
  } else {
3233
3306
  const sectionOrder = { env: 0, profile: 1, session: 2, catalog: 3 };
3234
3307
  const ordered = [...results].sort((a, b) => (sectionOrder[a.section] ?? 9) - (sectionOrder[b.section] ?? 9));
@@ -3237,18 +3310,27 @@ async function run() {
3237
3310
  if (r.section !== lastSection) { console.log(`\n== ${r.section === 'security' ? '🔒 安全' : r.section.toUpperCase()} ==`); lastSection = r.section; }
3238
3311
  const sev = catalogSeverity.get(r.id);
3239
3312
  // 安全检查:skip 显示 ⊖;critical/high 失败 ✗;medium 及以下失败 ⚠(不影响退出码)
3240
- const mark = r.skip ? '⊖'
3241
- : (!r.ok ? (((r.section === 'security' && r.severity !== 'critical' && r.severity !== 'high') || sev === 'warn') ? '⚠' : '✗')
3242
- : '✓');
3243
- 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);
3244
3318
  if (!r.ok && r.fix) console.log(` ↳ 修复: ${r.fix}`);
3245
3319
  }
3246
3320
  if (updateInfo.available && !updateInfo.applied) {
3247
3321
  console.log(`\n⚠ 新版本 ${updateInfo.latest} 可用(当前 ${updateInfo.current})→ 运行 \`dsh-doctor --update\` 或 \`dsh plugin update\``);
3248
3322
  } else if (updateInfo.applied) {
3249
- 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} 条)`);
3250
3333
  }
3251
- console.log(`\n${(bad.length === 0 && secExit === 0) ? '✓ 全部通过' : `✗ ${bad.length} 个内置问题${secExit > 0 ? ` + 安全 ${secExit === 2 ? 'CRITICAL' : 'HIGH'} 级失败` : ''}`}(profile=${profileArg},目录=${catalogMeta.source},${catalogMeta.checks} 条)`);
3252
3334
  }
3253
3335
  // 最终退出码:内置失败 → 1;安全 HIGH → 1、CRITICAL → 2(取 max)
3254
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.2",
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": [