@moonquake2004/dsh-doctor 0.7.2 → 0.8.1
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 +72 -0
- package/dsh-doctor.mjs +132 -26
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,78 @@
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
+
## [0.8.1] — 2026-09-16
|
|
20
|
+
|
|
21
|
+
### 0.8.0 的回归审计 —— **修一个引入一个,被查出来了四个**
|
|
22
|
+
|
|
23
|
+
对 0.8.0 做回归审计(R12/R13),确认它**引入了新问题**:
|
|
24
|
+
|
|
25
|
+
| 引入的问题 | 现象 | 修法 |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| **覆盖量上下文跨段泄漏** | 会话段的 S6/S14 号称"检查了 **16 个 bundle 条目**",而它们实际展开的是 **11352 个事件** —— 伪造的数字比没有数字更危险,因为它看起来可信 | 段边界 `resetCoverage()` + **单位属于求值阶段**(新增 `phase` 字段);回归测试断言"单位不得串台" |
|
|
28
|
+
| **目录段继承会话单位** | 目录检查 `P6-patch-name-space`(section=profile)声称"检查了会话日志" | catalog 阶段独立单位(`目录检查项`) |
|
|
29
|
+
| **R6 只校验类型、不校验个数** | JS 静默忽略多余参数 → 我给 S11 补覆盖量时多传一个 `undefined`,**没人报错**(类型守卫也抓不到) | `report()` 增加**参数个数守卫**(>7 即抛) |
|
|
30
|
+
| **两处测试自伤** | 抽取 `report` 的测试缺 `currentPhase` 桩;清单需重新生成 | 已修 |
|
|
31
|
+
|
|
32
|
+
### Added — 论坛病例语料(`plugin/test/forum-cases.test.mjs`)
|
|
33
|
+
|
|
34
|
+
把社区**实际报告的问题**做成回归用例(R2 样本律),每例都配**健康对照**,同时防漏报与误报:
|
|
35
|
+
|
|
36
|
+
| 病例 | 出处 | 结果 |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| 缺 `dsh.bundle` → `--boot-check` 必须失败 | #6788 | ✓ 通过(含对照) |
|
|
39
|
+
| manifest 带 BOM → P15 报出且其余检查仍运行 | #6758 | ✓ 通过(含对照) |
|
|
40
|
+
| 迁移后缺 `data.message` → S14 报出 | #6686 | ✓ 通过(含对照) |
|
|
41
|
+
| 裸引用 `harness`/`styles` → P21 失败 | #6693 规则2/4 | ✓ 通过(含注释/字符串对照) |
|
|
42
|
+
| client 产物写成裸 ESM → P20 报出 | #6693 规则3 | ✓ 通过(含对照) |
|
|
43
|
+
| `apply()` 期抛错 | #6693 规则1 | **已知不能检出**:我们只到 import 层,判定仍是 loadable —— 作为**边界用例**钉在测试里,防止把"P20/P21 通过"误读成"一定能起来" |
|
|
44
|
+
| 损坏的 `.zstd` → 判为损坏而非"读取不稳" | #6739 | ✓ 通过。**仅损坏一侧可验证**:报告者的"失败帧偏移每次不同"(读取路径不稳)无法在测试里忠实复现,已在用例注释里写明 |
|
|
45
|
+
|
|
46
|
+
**写这个语料时我又犯了一次 R9**:病例 G 的 fixture 我写成两层目录,而真实布局是三层
|
|
47
|
+
(`sessions/<项目>/<会话>/<日志>`)→ 定位器找不到 → S11 报"未发现会话日志"。**那是 fixture 失真,不是漏报** ——
|
|
48
|
+
修成真实布局后,S11 正确报出"1 个损坏会话…3 次尝试均失败 → 倾向于文件本身损坏"。
|
|
49
|
+
|
|
50
|
+
测试 129 → 141(+12 论坛病例);审计 **8/8**。
|
|
51
|
+
|
|
52
|
+
## [0.8.0] — 2026-09-16
|
|
53
|
+
|
|
54
|
+
### 结构性修复:假绿灯的**类**被消灭,而不是又一个实例
|
|
55
|
+
|
|
56
|
+
用户指出:**刚复查过又犯错,说明规则本身有缺陷**。复查确认了缺陷的性质——**R3 的强制点选错了**:
|
|
57
|
+
它被写在 `report()` 里,而判定并不都在那里产生。机械枚举(而非凭记忆)发现 **21 处代码位置手写 ✓/⊖/✗**,
|
|
58
|
+
其中我只加固过 3 处;另外两处从未查过:
|
|
59
|
+
|
|
60
|
+
| 位置 | 修前行为 |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `--boot-check` | 零对象仍打印"✓ 所有可探测 entry 均可导入" |
|
|
63
|
+
| `--safe-add` | 零对象 `ok:true, stage:'verified'` **并写"已知良好"快照**(把"未验证"固化成"已验证") |
|
|
64
|
+
| `--post-upgrade` | 同样打印"✓ 装载模拟通过"并写快照 |
|
|
65
|
+
| **顶层聚合** | 极简 PATH 下 18 skip / 2 pass,仍打印 **"✓ 全部通过"**(从未查过) |
|
|
66
|
+
| **JSON 形态** | `ok: true` 只要求"无失败","全部跳过"也算通过 |
|
|
67
|
+
|
|
68
|
+
**改为收口点结构**(R10 边界律 + R11 闭集律):
|
|
69
|
+
|
|
70
|
+
- **`makeVerdict(state, opts)`** —— 唯一能构造 `pass` 的地方;`checked === 0` 时**构造期即抛错**(不是渲染期补救);
|
|
71
|
+
- **`printVerdict()` / `printItem()` / `VERDICT_MARKS`** —— **唯一**打印 `✓/⊖/✗` 的三个位置,
|
|
72
|
+
调用点传**状态名**而不是符号(于是符号泄漏在结构上不可能);
|
|
73
|
+
- **`aggregateVerdict()`** —— 聚合判定如实给出 `verified / skipped / failed`:
|
|
74
|
+
全部跳过时输出 `⊖ 没有任何检查实际验证过(N 项全部跳过)——这不等于通过`;
|
|
75
|
+
有跳过时输出 `✓ N 项通过、M 项未检查(skip)——未检查的部分不代表通过`;
|
|
76
|
+
- **JSON 契约收紧**:`ok` 只有在**确实验证过(verified > 0)且无失败**时才为 true,并新增
|
|
77
|
+
`verified / skipped / failed` 三个字段。
|
|
78
|
+
|
|
79
|
+
### Added — 审计与规则的机制化
|
|
80
|
+
|
|
81
|
+
- **`scripts/audit.sh`**(R12 可重审律 / R13 环境泛化律):一条命令、五个环境、结果可比——
|
|
82
|
+
空环境 / 极简 PATH(无 dsh、pnpm、zstd)/ 畸形输入(BOM、截断 JSON)/ 清单一致性 / 闭集与全套测试。
|
|
83
|
+
当前 **7/7 通过**。
|
|
84
|
+
- **闭集测试**(R11):断言源码中任何 `✓/⊖/✗` 都必须落在三个收口点内——**今后新加一处手写绿灯会直接红**。
|
|
85
|
+
- **规则补 4 条**(`docs/check-authoring-rules.md`):**R10 边界律**(每条规则必须指明强制点,且是所有同类输出的必经之路)、
|
|
86
|
+
**R11 闭集律**、**R12 可重审律**、**R13 环境泛化律**;§4 记录本次复查,包括**我在这轮审计里又犯的一次 R8**
|
|
87
|
+
(审计脚本第一版断言"空环境 verified 必须为 0",而空 profile 里 P15 确实验证了 3 个文件——断言强度超过了证据)。
|
|
88
|
+
|
|
89
|
+
测试 124 → 127。
|
|
90
|
+
|
|
19
91
|
## [0.7.2] — 2026-09-16
|
|
20
92
|
|
|
21
93
|
### 用新规则复查旧错误 —— **确认重复犯了同一个错**
|
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
|
}
|
|
@@ -255,7 +267,7 @@ function report(section, id, ok, detail, fix, src, examined) {
|
|
|
255
267
|
results.push({ section, id, ok: true, skip: true, coverage: 'none', detail: `${detail}(无可检查对象,未做任何比较)`, fix, src: src ?? 'builtin' });
|
|
256
268
|
return;
|
|
257
269
|
}
|
|
258
|
-
const rec = { section, id, ok, detail, fix, src: src ?? 'builtin' };
|
|
270
|
+
const rec = { section, id, ok, detail, fix, src: src ?? 'builtin', phase: currentPhase };
|
|
259
271
|
if (ok === true) {
|
|
260
272
|
const ctx = coverageNow();
|
|
261
273
|
const n = typeof examined === 'number' ? examined : ctx?.n;
|
|
@@ -270,6 +282,71 @@ function reportSkip(section, id, detail, src) {
|
|
|
270
282
|
results.push({ section, id, ok: true, skip: true, detail, src: src ?? 'builtin' });
|
|
271
283
|
}
|
|
272
284
|
|
|
285
|
+
|
|
286
|
+
/* ================= 判定收口点(R10 边界律 + R11 闭集律) =================
|
|
287
|
+
* 2026-09 复查的结论:**"绿"此前可以在任意位置被手写出来**,所以 R3 的不变量只要放在
|
|
288
|
+
* report() 或某个输出路径里,别的路径就能绕过——修了三处、漏了顶层聚合与更新提示。
|
|
289
|
+
*
|
|
290
|
+
* 现在:**所有 ✓ / ⊖ / ✗ 只能由 printVerdict() 打印**,而"通过"这个状态只能由 makeVerdict() 构造,
|
|
291
|
+
* 且 `pass` 在 `checked === 0` 时**直接抛错**(构造期就挡住,而不是渲染期才补救)。
|
|
292
|
+
* 测试里有一条闭集断言:源码中任何出现 ✓/⊖/✗ 的代码行,都必须落在 printVerdict 的函数体内。
|
|
293
|
+
*/
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 构造判定。**唯一能产生 'pass' 的地方**。
|
|
297
|
+
* @param {'pass'|'fail'|'skip'|'action'} state
|
|
298
|
+
* @param {{checked?: number, detail?: string, reason?: string, totals?: {verified:number, skipped:number, failed:number}}} opts
|
|
299
|
+
*/
|
|
300
|
+
function makeVerdict(state, opts = {}) {
|
|
301
|
+
if (state === 'pass') {
|
|
302
|
+
const n = opts.checked;
|
|
303
|
+
if (typeof n !== 'number' || n <= 0) {
|
|
304
|
+
throw new Error(`makeVerdict('pass') 需要 checked > 0:零检查对象不得构造为"通过"(R3 覆盖律;configured=${JSON.stringify(opts.checked)})`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return { state, ...opts };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 条目行打印器:**判定之下的明细**(某条 entry 失败、某个会话损坏……)。
|
|
312
|
+
* 与 printVerdict 的分工:判定回答"整件事成不成立",条目回答"具体哪几条"。
|
|
313
|
+
* 两者构成本工具的**闭集**——闭集测试断言:源码里任何 ✓/⊖/✗ 都必须落在这两个函数体内。
|
|
314
|
+
*/
|
|
315
|
+
const VERDICT_MARKS = { pass: '✓', fail: '✗', skip: '⊖', warn: '⚠', action: '✓' };
|
|
316
|
+
|
|
317
|
+
function printItem(state, text, indent = 2) {
|
|
318
|
+
console.log(`${' '.repeat(indent)}${VERDICT_MARKS[state] ?? '?'} ${text}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** **唯一**打印 ✓ / ⊖ / ✗ 的**判定**的地方。任何新输出路径都必须走这里,否则闭集测试会红。 */
|
|
322
|
+
function printVerdict(v, opts = {}) {
|
|
323
|
+
const out = opts.stream === 'stderr' ? console.error : console.log;
|
|
324
|
+
const line = (state, text) => out(opts.indent ? ` ${VERDICT_MARKS[state]} ${text}` : `${VERDICT_MARKS[state]} ${text}`);
|
|
325
|
+
if (v.state === 'pass') return line('pass', v.detail ?? `通过(检查 ${v.checked} 个对象)`);
|
|
326
|
+
if (v.state === 'fail') return line('fail', v.detail ?? '失败');
|
|
327
|
+
if (v.state === 'skip') return line('skip', `${v.reason ?? '未检查'}——**这不等于通过**`);
|
|
328
|
+
return line('action', v.detail ?? ''); // action:动作确认(不是验证断言)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 报告级聚合判定:把"验证过多少 / 跳过多少"如实呈现。
|
|
333
|
+
* 修前它对"18 项 skip + 2 项 pass"照样打印 "✓ 全部通过"——断言的强度超过了证据(R8)。
|
|
334
|
+
*/
|
|
335
|
+
function aggregateVerdict(records) {
|
|
336
|
+
const failed = records.filter((r) => !r.skip && !r.ok);
|
|
337
|
+
const verified = records.filter((r) => !r.skip && r.ok && (r.examined ?? 0) > 0).length;
|
|
338
|
+
const skipped = records.filter((r) => r.skip).length;
|
|
339
|
+
const totals = { verified, skipped, failed: failed.length };
|
|
340
|
+
if (failed.length) return makeVerdict('fail', { totals, detail: `${failed.length} 个问题` });
|
|
341
|
+
if (verified === 0) {
|
|
342
|
+
return makeVerdict('skip', { totals, reason: `没有任何检查实际验证过(${skipped} 项全部跳过)` });
|
|
343
|
+
}
|
|
344
|
+
if (skipped > 0) {
|
|
345
|
+
return makeVerdict('pass', { checked: verified, totals, detail: `${verified} 项通过、${skipped} 项未检查(skip)——**未检查的部分不代表通过**` });
|
|
346
|
+
}
|
|
347
|
+
return makeVerdict('pass', { checked: verified, totals, detail: `全部通过(${verified} 项,均已验证)` });
|
|
348
|
+
}
|
|
349
|
+
|
|
273
350
|
/** 解析 --profile 参数:名字(如 web)→ $DSH_HOME/profiles/<name>;含路径分隔符/~/开头 → 直接当 profile 目录(契约 harness 传绝对路径)。 */
|
|
274
351
|
function resolveProfile(name) {
|
|
275
352
|
if (!name) throw new Error('无效 profile 名');
|
|
@@ -313,6 +390,8 @@ function nodeInSupportedRange(v, range = NODE_RANGE_FALLBACK) {
|
|
|
313
390
|
return peerRangeState(String(v).replace(/^v/, ''), range).state === 'satisfied';
|
|
314
391
|
}
|
|
315
392
|
function checkEnv() {
|
|
393
|
+
setPhase('env');
|
|
394
|
+
resetCoverage();
|
|
316
395
|
if (!wants('env')) return;
|
|
317
396
|
// R3 覆盖律:环境段的每个探针各检查**一个**对象(某个二进制/版本/端口)。
|
|
318
397
|
// 单位不同的检查(如 E1 系列各自查一个可执行文件)如需别的数量应显式传入。
|
|
@@ -602,6 +681,8 @@ function checkProfile(name) {
|
|
|
602
681
|
}
|
|
603
682
|
const bundles = manifest.dsh?.profile?.bundles ?? [];
|
|
604
683
|
const deps = manifest.dependencies ?? {};
|
|
684
|
+
setPhase('profile');
|
|
685
|
+
resetCoverage(); // 段边界:不得继承上一段的覆盖量
|
|
605
686
|
setCoverage(bundles.length, 'bundle 条目');
|
|
606
687
|
|
|
607
688
|
const installAnchor = (() => {
|
|
@@ -1500,6 +1581,9 @@ function latestSessionLog(root = join(HOME, 'sessions')) {
|
|
|
1500
1581
|
|
|
1501
1582
|
/* ================= session ================= */
|
|
1502
1583
|
function checkSession(targetPath) {
|
|
1584
|
+
setPhase('session');
|
|
1585
|
+
resetCoverage();
|
|
1586
|
+
setCoverage(1, '会话日志'); // 单位是这一份会话日志;事件数由各检查在 detail 里给出
|
|
1503
1587
|
if (!wants('session')) return;
|
|
1504
1588
|
const target = targetPath || latestSessionLog();
|
|
1505
1589
|
if (!target || !existsSync(target)) { report('session', 'S0', true, '无会话日志,跳过单会话检查(可用 --session <path> 指定)', undefined); return; }
|
|
@@ -2008,7 +2092,7 @@ function scanAllSessions() {
|
|
|
2008
2092
|
const heapLimit = Number(process.env.DSH_DOCTOR_HEAP_MB || 1024);
|
|
2009
2093
|
const totalRisk = estHeapMB > heapLimit;
|
|
2010
2094
|
if (quars.length) {
|
|
2011
|
-
report('session', 'S11', false, `全会话扫描:${corrupt.length} 个损坏会话(#1550:冷打开会拖垮服务器): ${quars.join(' | ')}`, `隔离:把这些会话目录移出 ${join(HOME, 'sessions')}(如 mv
|
|
2095
|
+
report('session', 'S11', false, `全会话扫描:${corrupt.length} 个损坏会话(#1550:冷打开会拖垮服务器): ${quars.join(' | ')}`, `隔离:把这些会话目录移出 ${join(HOME, 'sessions')}(如 mv 到备份目录)`, undefined, files.length);
|
|
2012
2096
|
} else if (oversized.length || totalRisk || unstableReads.length) {
|
|
2013
2097
|
const parts = [];
|
|
2014
2098
|
// 间歇性读取失败:**不是文件损坏**(重试即成功、失败帧位置每次不同,见 #6739),
|
|
@@ -2020,9 +2104,9 @@ function scanAllSessions() {
|
|
|
2020
2104
|
report('session', 'S11', true, `⚠ 全会话扫描:${parts.join(';')}(未损坏,可接受或归档)`,
|
|
2021
2105
|
unstableReads.length
|
|
2022
2106
|
? '间歇性读取失败不是日志问题:先重试读取;持续出现则排查存储健康(SMART)、内存与磁盘驱动(#6739 的证据是失败帧位置每次不同)'
|
|
2023
|
-
: '冷启动会明显变慢;必要时压缩/归档历史会话');
|
|
2107
|
+
: '冷启动会明显变慢;必要时压缩/归档历史会话', undefined, files.length);
|
|
2024
2108
|
} else {
|
|
2025
|
-
report('session', 'S11', true, `全会话扫描:${clean.length} 个会话均健康(损坏 0 / 超大 0 / 估算物化堆 ${estHeapMB}MB)`, undefined);
|
|
2109
|
+
report('session', 'S11', true, `全会话扫描:${clean.length} 个会话均健康(损坏 0 / 超大 0 / 估算物化堆 ${estHeapMB}MB)`, undefined, undefined, files.length);
|
|
2026
2110
|
}
|
|
2027
2111
|
}
|
|
2028
2112
|
|
|
@@ -2253,6 +2337,11 @@ export function runCatalogCheck(check, ctx) {
|
|
|
2253
2337
|
|
|
2254
2338
|
/** 逐条执行目录检查,汇入统一 results 管线(src='catalog')。尊重 --profile/--env/--session 收窄。 */
|
|
2255
2339
|
function checkCatalog(ctx, catalog) {
|
|
2340
|
+
setPhase('catalog');
|
|
2341
|
+
// 段边界:目录检查在会话段之后求值,若不清空上下文会**继承会话的单位**——
|
|
2342
|
+
// 回归审计实测到 `P6-patch-name-space`(profile) 声称"检查了会话日志"。目录检查各查一个目标,单位独立。
|
|
2343
|
+
resetCoverage();
|
|
2344
|
+
setCoverage(1, '目录检查项');
|
|
2256
2345
|
const platform = process.platform;
|
|
2257
2346
|
for (const check of catalog.checks ?? []) {
|
|
2258
2347
|
if (!wants(check.section)) continue; // 与内置检查一致的 section 收窄
|
|
@@ -2887,7 +2976,7 @@ async function run() {
|
|
|
2887
2976
|
const base = writePreUpgrade(profDir);
|
|
2888
2977
|
if (jsonOut) console.log(JSON.stringify({ ok: true, ...base }, null, 2));
|
|
2889
2978
|
else {
|
|
2890
|
-
|
|
2979
|
+
printVerdict(makeVerdict('action', { detail: `已记录升级前基线(${profDir})` }));
|
|
2891
2980
|
console.log(` 核心版本: ${base.coreVersion ?? '(未能确定)'} | bundle ${base.bundleCount} 个 | entry ${Object.keys(base.entries).length} 条`);
|
|
2892
2981
|
console.log(' 下一步:升级 dsh,然后运行');
|
|
2893
2982
|
console.log(` npx @moonquake2004/dsh-doctor --post-upgrade --profile ${profileArg || 'web'}`);
|
|
@@ -2897,7 +2986,7 @@ async function run() {
|
|
|
2897
2986
|
}
|
|
2898
2987
|
const base = readPreUpgrade(profDir);
|
|
2899
2988
|
if (!base) {
|
|
2900
|
-
|
|
2989
|
+
printVerdict(makeVerdict('fail', { detail: `没找到升级前基线(${join(profDir, PRE_UPGRADE_FILE)})——请先在升级前运行 --pre-upgrade` }), { stream: 'stderr' }); // 错误路径:非判定
|
|
2901
2990
|
process.exit(1);
|
|
2902
2991
|
}
|
|
2903
2992
|
const nowCore = coreVersion();
|
|
@@ -2920,12 +3009,12 @@ async function run() {
|
|
|
2920
3009
|
for (const l of lines) console.log(` · ${l}`);
|
|
2921
3010
|
const verdictPost = bootVerdict(results);
|
|
2922
3011
|
if (verdictPost.state === 'skip') {
|
|
2923
|
-
|
|
3012
|
+
printVerdict(makeVerdict('skip', { reason: `${verdictPost.reason}——不改写已知良好基线` }), { indent: true });
|
|
2924
3013
|
} else if (verdictPost.state === 'pass') {
|
|
2925
|
-
|
|
3014
|
+
printVerdict(makeVerdict('pass', { checked: verdictPost.checked, detail: '装载模拟通过——升级未破坏任何可探测 entry' }), { indent: true });
|
|
2926
3015
|
writeSnapshot(profDir, nowSnap);
|
|
2927
3016
|
} else {
|
|
2928
|
-
|
|
3017
|
+
printVerdict(makeVerdict('fail', { detail: `装载模拟失败 ${failed.length} 条(这就是"升级后起不来"的直接原因):` }), { indent: true });
|
|
2929
3018
|
for (const f of failed) {
|
|
2930
3019
|
console.log(` [${f.bundle}] ${f.id} → ${f.spec}`);
|
|
2931
3020
|
console.log(` ${f.kind}: ${f.error}`);
|
|
@@ -2948,15 +3037,15 @@ async function run() {
|
|
|
2948
3037
|
const profDir = resolveProfile(profileArg || 'web');
|
|
2949
3038
|
const r = safeAdd(profileArg || 'web', safeAddArg);
|
|
2950
3039
|
if (jsonOut) console.log(JSON.stringify(r, null, 2));
|
|
2951
|
-
else if (r.ok && r.stage === 'verified')
|
|
3040
|
+
else if (r.ok && r.stage === 'verified') printVerdict(makeVerdict('pass', { checked: r.checked, detail: `已安装并预检通过(${r.checked} 条 entry 均可导入)——重启 dsh 即可` }));
|
|
2952
3041
|
else if (r.ok && r.stage === 'quarantined') {
|
|
2953
3042
|
console.log(`⚠ 已安装,但该插件的 entry 导入失败,已自动隔离以避免 dsh 起不来:`);
|
|
2954
3043
|
for (const f of r.failures || []) console.log(` ${f}`);
|
|
2955
3044
|
console.log(` 隔离项:${r.quarantined.join(', ')}(用 --unquarantine <包名> 放回,修好版本后再重试)`);
|
|
2956
3045
|
console.log(` dsh 现在可以正常启动。`);
|
|
2957
|
-
} else if (r.stage === 'install')
|
|
3046
|
+
} else if (r.stage === 'install') printVerdict(makeVerdict('fail', { detail: `安装命令本身失败(exit ${r.exit}),已还原 manifest` }));
|
|
2958
3047
|
else {
|
|
2959
|
-
|
|
3048
|
+
printVerdict(makeVerdict('fail', { detail: '安装后无法启动,且隔离也救不回来 → 已整体回滚到安装前状态' }));
|
|
2960
3049
|
for (const f of r.failures || []) console.log(` ${f}`);
|
|
2961
3050
|
}
|
|
2962
3051
|
process.exit(r.ok ? 0 : 2);
|
|
@@ -2966,13 +3055,13 @@ async function run() {
|
|
|
2966
3055
|
try {
|
|
2967
3056
|
if (quarantineArg || unquarantineArg) {
|
|
2968
3057
|
const r = quarantineBundle(profDir, quarantineArg || unquarantineArg, !!unquarantineArg);
|
|
2969
|
-
if (!r.ok) {
|
|
3058
|
+
if (!r.ok) { printVerdict(makeVerdict('fail', { detail: r.error })); process.exit(1); }
|
|
2970
3059
|
const payload = { ok: true, action: unquarantineArg ? 'unquarantine' : 'quarantine', ...r, next: '重启 dsh;随后用 --boot-check 复查,或用 --unquarantine 撤销' };
|
|
2971
3060
|
if (jsonOut) console.log(JSON.stringify(payload, null, 2));
|
|
2972
3061
|
else if (unquarantineArg) {
|
|
2973
|
-
|
|
3062
|
+
printVerdict(makeVerdict('action', { detail: `已放回 ${unquarantineArg}(当前启动列表 ${r.bundles} 项)——重启 dsh 生效` }));
|
|
2974
3063
|
} else {
|
|
2975
|
-
|
|
3064
|
+
printVerdict(makeVerdict('action', { detail: `已隔离 ${quarantineArg}(启动列表现为 ${r.bundles} 项;原始 package.json 已备份)` }));
|
|
2976
3065
|
console.log(` 重启 dsh,然后用 --boot-check 复查;要放回:--unquarantine ${quarantineArg}`);
|
|
2977
3066
|
}
|
|
2978
3067
|
process.exit(0);
|
|
@@ -3005,7 +3094,7 @@ async function run() {
|
|
|
3005
3094
|
if (jsonOut) {
|
|
3006
3095
|
console.log(JSON.stringify({ ok: true, skip: true, reason: '启动列表为空:没有任何 entry 可探测,未做装载模拟', profile: profDir, checked: 0, failed: 0, drift, results }, null, 2));
|
|
3007
3096
|
} else {
|
|
3008
|
-
console.log(`装载模拟(${profDir}
|
|
3097
|
+
console.log(`装载模拟(${profDir}):`); printVerdict(makeVerdict('skip', { reason: '无可检查对象——启动列表为空,未做任何装载模拟' }));
|
|
3009
3098
|
console.log(' 若你预期这里有插件:检查 profile 的 dsh.profile.bundles 是否为空,或插件是否装到了别的 profile。');
|
|
3010
3099
|
}
|
|
3011
3100
|
process.exit(0);
|
|
@@ -3016,10 +3105,10 @@ async function run() {
|
|
|
3016
3105
|
} else {
|
|
3017
3106
|
console.log(`装载模拟(${profDir}):检查 ${results.length} 条 entry,失败 ${failed.length} 条`);
|
|
3018
3107
|
for (const r of results) {
|
|
3019
|
-
if (r.status === 'failed')
|
|
3020
|
-
else if (r.status === 'skipped')
|
|
3108
|
+
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}`);
|
|
3109
|
+
else if (r.status === 'skipped') printItem('skip', `${r.id}(${r.reason})`);
|
|
3021
3110
|
}
|
|
3022
|
-
if (!failed.length)
|
|
3111
|
+
if (!failed.length) printVerdict(makeVerdict('pass', { checked: results.length, detail: '所有可探测 entry 均可导入——启动失败若仍发生,问题多在配置合并或原生环境,请贴 --json 输出' }), { indent: true });
|
|
3023
3112
|
}
|
|
3024
3113
|
process.exit(failed.length ? 2 : 0);
|
|
3025
3114
|
} catch (e) {
|
|
@@ -3228,7 +3317,15 @@ async function run() {
|
|
|
3228
3317
|
// 于是把 warn 当成误报。状态本就不该由消费者自行推导。
|
|
3229
3318
|
const statusOf = (r) => (r.skip ? 'skip' : (!r.ok ? (((r.section === 'security') ? r.severity !== 'critical' : catalogSeverity.get(r.id) === 'warn') ? 'warn' : 'fail') : 'pass'));
|
|
3230
3319
|
const checksWithStatus = results.map((r) => ({ ...r, status: statusOf(r) }));
|
|
3231
|
-
|
|
3320
|
+
const aggJson = aggregateVerdict(results);
|
|
3321
|
+
console.log(JSON.stringify({
|
|
3322
|
+
// ok 只有在"确实验证过且无失败"时才为 true —— 修前它只看 bad.length,于是"全部跳过"也会 ok:true
|
|
3323
|
+
ok: aggJson.state === 'pass',
|
|
3324
|
+
verified: aggJson.totals.verified,
|
|
3325
|
+
skipped: aggJson.totals.skipped,
|
|
3326
|
+
failed: aggJson.totals.failed,
|
|
3327
|
+
checks: checksWithStatus, catalog: catalogMeta, update: updateInfo, ...(securityMeta.enabled ? { security: securityMeta } : {}),
|
|
3328
|
+
}, null, 2));
|
|
3232
3329
|
} else {
|
|
3233
3330
|
const sectionOrder = { env: 0, profile: 1, session: 2, catalog: 3 };
|
|
3234
3331
|
const ordered = [...results].sort((a, b) => (sectionOrder[a.section] ?? 9) - (sectionOrder[b.section] ?? 9));
|
|
@@ -3237,18 +3334,27 @@ async function run() {
|
|
|
3237
3334
|
if (r.section !== lastSection) { console.log(`\n== ${r.section === 'security' ? '🔒 安全' : r.section.toUpperCase()} ==`); lastSection = r.section; }
|
|
3238
3335
|
const sev = catalogSeverity.get(r.id);
|
|
3239
3336
|
// 安全检查:skip 显示 ⊖;critical/high 失败 ✗;medium 及以下失败 ⚠(不影响退出码)
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
: '
|
|
3243
|
-
|
|
3337
|
+
// 状态名(不是符号):符号只由 printItem/printVerdict 决定,闭集测试据此断言
|
|
3338
|
+
const mark = r.skip ? 'skip'
|
|
3339
|
+
: (!r.ok ? (((r.section === 'security' && r.severity !== 'critical' && r.severity !== 'high') || sev === 'warn') ? 'warn' : 'fail')
|
|
3340
|
+
: 'pass');
|
|
3341
|
+
printItem(mark, `[${r.id}]${r.severity ? `(${r.severity}${r.skip ? '/skip' : ''})` : ''} ${r.detail}${r.src === 'catalog' ? ' [目录]' : ''}`, 1);
|
|
3244
3342
|
if (!r.ok && r.fix) console.log(` ↳ 修复: ${r.fix}`);
|
|
3245
3343
|
}
|
|
3246
3344
|
if (updateInfo.available && !updateInfo.applied) {
|
|
3247
3345
|
console.log(`\n⚠ 新版本 ${updateInfo.latest} 可用(当前 ${updateInfo.current})→ 运行 \`dsh-doctor --update\` 或 \`dsh plugin update\``);
|
|
3248
3346
|
} else if (updateInfo.applied) {
|
|
3249
|
-
console.log(
|
|
3347
|
+
console.log(''); printVerdict(makeVerdict('action', { detail: updateInfo.applied }));
|
|
3348
|
+
}
|
|
3349
|
+
const agg = aggregateVerdict(results.map((r) => ({ ...r, examined: r.examined })));
|
|
3350
|
+
if (agg.state === 'fail') {
|
|
3351
|
+
console.log('');
|
|
3352
|
+
printVerdict(makeVerdict('fail', { detail: `${bad.length} 个内置问题${secExit > 0 ? ` + 安全 ${secExit === 2 ? 'CRITICAL' : 'HIGH'} 级失败` : ''}(profile=${profileArg},目录=${catalogMeta.source},${catalogMeta.checks} 条)` }));
|
|
3353
|
+
} else {
|
|
3354
|
+
console.log('');
|
|
3355
|
+
printVerdict(agg);
|
|
3356
|
+
console.log(`(profile=${profileArg},目录=${catalogMeta.source},${catalogMeta.checks} 条)`);
|
|
3250
3357
|
}
|
|
3251
|
-
console.log(`\n${(bad.length === 0 && secExit === 0) ? '✓ 全部通过' : `✗ ${bad.length} 个内置问题${secExit > 0 ? ` + 安全 ${secExit === 2 ? 'CRITICAL' : 'HIGH'} 级失败` : ''}`}(profile=${profileArg},目录=${catalogMeta.source},${catalogMeta.checks} 条)`);
|
|
3252
3358
|
}
|
|
3253
3359
|
// 最终退出码:内置失败 → 1;安全 HIGH → 1、CRITICAL → 2(取 max)
|
|
3254
3360
|
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.
|
|
3
|
+
"version": "0.8.1",
|
|
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": [
|