@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 +62 -0
- package/dsh-doctor.mjs +70 -15
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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": [
|