@moonquake2004/dsh-doctor 0.8.4 → 0.8.5
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 +28 -0
- package/dsh-doctor.mjs +96 -55
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,34 @@
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
+
## [0.8.5] — 2026-09-16
|
|
20
|
+
|
|
21
|
+
### Fixed — P23 判据重构(红队第四轮:6 条反例,2 条高severity)
|
|
22
|
+
|
|
23
|
+
巡查 #6789 时新增的 P23(插件自带宿主机包副本),判据被红队**打穿三版**后定为 v5。完整档案 `docs/redteam-0.8.5.md`。
|
|
24
|
+
|
|
25
|
+
| # | 反例 | 处置 |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| **A(高)** | `dsh.bundle`/`dsh.client` **不能**用来区分"插件 vs 宿主库" —— 宿主树上 `dsh-base`/`dsh-web-app`/`dsh-client-ui-*` 全带这两个字段,于是"**宿主核心 bundle 的第二份实例**"(更致命)恰好被豁免 | 不再比类型标签,改判 **"同一包名既在 bundle 解析路径上、也能沿祖先链解析到"** |
|
|
28
|
+
| **B(高)** | 空目录 / 截断 manifest / 同名普通文件被误报成"宿主包副本" | 只有 manifest 可读且 name 匹配才算副本;读不出的单列为**残留目录** |
|
|
29
|
+
| **C(中高)** | 别名(`nm/scope-copy`,真身在 manifest 的 `name`)、深层嵌套、`.pnpm` 实体全漏 | 扫描时**读每个目录自己的 manifest** + 有界递归 6 层;旧语料对"别名"是**假保证**,已重写 |
|
|
30
|
+
| **D(中)** | "排除 `_quarantined`"读的是顶层字段,而写入方在 `dsh.profile._quarantined` → 从不生效 | 改读真实位置 |
|
|
31
|
+
| **F(低)** | 头注释仍宣称已删除/从未实现的判据(**注释说谎**,正是 C 类漏报来源) | 注释按实际判据重写,并把 v1→v5 弯路写进注释 |
|
|
32
|
+
| E | 用户 patch `insert` 加载的插件不被扫 | **未修 → D8** |
|
|
33
|
+
|
|
34
|
+
### 修的过程中**作者自己引入 4 处新问题**(全部被机制抓出)
|
|
35
|
+
|
|
36
|
+
`ancestorResolves` 从 bundle 目录开始 → 嵌套副本**自我确认**(F5 对照被误报);别名目录从未被读到;
|
|
37
|
+
`topLines` 作用域错误让**整个 profile 段 P0 崩溃**;语料用 `null` 表示空目录导致驱动抛错。
|
|
38
|
+
|
|
39
|
+
### 验证矩阵
|
|
40
|
+
|
|
41
|
+
12 例(**真实布局**:宿主层在 `profiles/node_modules`)全部通过 —— 含 A/A2/F1/C/C2/C3 六种阳性形态,
|
|
42
|
+
与 B1/B2/B3/F5/误报对照/D 六种阴性或豁免形态。**fixture 真实性(R9)再次成为关键**:
|
|
43
|
+
只放嵌套副本、不放祖先层宿主包,就测不到真实条件。
|
|
44
|
+
|
|
45
|
+
测试 221 → **224**;语料 21 → **24**;变异 **8 杀死 / 0 存活 / 1 等价**。
|
|
46
|
+
|
|
19
47
|
## [0.8.4] — 2026-09-16
|
|
20
48
|
|
|
21
49
|
**巡查产出的新检查**:社区 #6789 报告的形状,用新机制(语料优先 + 变体分离)验证后发现是**真实漏检**。
|
package/dsh-doctor.mjs
CHANGED
|
@@ -286,6 +286,11 @@ function report(section, id, ok, detail, fix, src, examined) {
|
|
|
286
286
|
// 源码里没有 ✓ 也能让 stdout 出现 ✓。故在收口处剥掉数据里的判定符号(含 ⚠)。
|
|
287
287
|
const safeDetail = String(detail).replace(VERDICT_GLYPH_RE, '·');
|
|
288
288
|
const rec = { section, id, ok, detail: safeDetail, fix, src: src ?? 'builtin', phase: currentPhase };
|
|
289
|
+
// 覆盖量对**失败**记录同样重要("我看了多少才说它有问题")——此前只在 ok===true 时记录(实测缺口)
|
|
290
|
+
if (ok !== true && typeof examined === 'number') {
|
|
291
|
+
rec.examined = examined;
|
|
292
|
+
if (typeof arguments[7] === 'string') rec.examinedWhat = arguments[7];
|
|
293
|
+
}
|
|
289
294
|
if (ok === true) {
|
|
290
295
|
// 只有显式申报才算数;`examinedWhat` 也必须与**同一处**申报配套(由调用点传 what)
|
|
291
296
|
if (typeof examined === 'number') {
|
|
@@ -857,79 +862,109 @@ function checkProfile(name) {
|
|
|
857
862
|
}
|
|
858
863
|
}
|
|
859
864
|
|
|
860
|
-
// P23:bundle
|
|
865
|
+
// P23:bundle 的解析路径上出现了**宿主包的第二个实例**(社区 #6789)
|
|
861
866
|
//
|
|
862
|
-
//
|
|
863
|
-
//
|
|
867
|
+
// 现象:插件把 `@deepseek-ai/dsh-scope` 当普通依赖 → 自带一份副本 → 两份实例的 Symbol/协议不匹配 →
|
|
868
|
+
// "第一个会话能用、之后每个会话都失败"。
|
|
864
869
|
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
//
|
|
870
|
+
// **判据(第四轮红队打穿三版后才站住):判"条件",且不靠任何类型标签。**
|
|
871
|
+
// 条件 = 同一个包名**既在 bundle 自己的解析路径里、也能沿祖先链解析到** ⇒ 确实存在两个实例。
|
|
872
|
+
// 走过的弯路(都在 docs/redteam-0.8.4.md / -0.8.5.md 留档):
|
|
873
|
+
// v1 只看 manifest 键名 → 副本躺在盘上也失明(F1);
|
|
874
|
+
// v2 用 installAnchor 判"宿主是否提供" → 该锚点依赖 PATH,**本机为 null** → 静默失效;
|
|
875
|
+
// v3 用 `dsh.bundle`/`dsh.client` 区分"插件 vs 宿主库" → **宿主自己的 dsh-base/dsh-web-app/dsh-client-ui-*
|
|
876
|
+
// 全都带这两个字段**,于是"宿主核心 bundle 的第二份实例"(更致命)恰好被豁免(红队 A);
|
|
877
|
+
// v4(现行)**不比标签、只比名字与解析路径** —— 无豁免面,也不需要锚点。
|
|
878
|
+
// 另:manifest 读不出来时**不得据此报副本**(红队 B:空目录/截断/同名文件都会被误报),
|
|
879
|
+
// 只作为"残留目录"单独提示。
|
|
875
880
|
{
|
|
881
|
+
// 在一个目录的 node_modules 里做有界扫描,找出所有包名属于 @deepseek-ai/* 的包
|
|
882
|
+
const scanHostCopies = (fromDir) => {
|
|
883
|
+
const hits = []; const residual = [];
|
|
884
|
+
let budget = 3000;
|
|
885
|
+
const walk = (dir, depth) => {
|
|
886
|
+
if (depth > 6 || budget <= 0) return;
|
|
887
|
+
let entries = [];
|
|
888
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
889
|
+
// 目录自身的 manifest 也可能是别名(红队 C:`nm/scope-copy` 的 name 才是 @deepseek-ai/dsh-scope)
|
|
890
|
+
if (!dir.endsWith('node_modules')) {
|
|
891
|
+
const selfMf = (() => { try { return readJsonReportingBom(join(dir, 'package.json')).data; } catch { return null; } })();
|
|
892
|
+
if (selfMf && typeof selfMf.name === 'string' && selfMf.name.startsWith('@deepseek-ai/')) {
|
|
893
|
+
hits.push({ name: selfMf.name, dir, version: selfMf.version });
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
for (const e of entries) {
|
|
897
|
+
if (budget-- <= 0) return;
|
|
898
|
+
if (!e.isDirectory() && !e.isSymbolicLink()) continue;
|
|
899
|
+
const full = join(dir, e.name);
|
|
900
|
+
if (e.name === '@deepseek-ai') {
|
|
901
|
+
let subs = [];
|
|
902
|
+
try { subs = readdirSync(full, { withFileTypes: true }); } catch { continue; }
|
|
903
|
+
for (const sub of subs) {
|
|
904
|
+
if (!sub.isDirectory() && !sub.isSymbolicLink()) continue;
|
|
905
|
+
const pdir = join(full, sub.name);
|
|
906
|
+
const mf = (() => { try { return readJsonReportingBom(join(pdir, 'package.json')).data; } catch { return null; } })();
|
|
907
|
+
const name = `@deepseek-ai/${sub.name}`;
|
|
908
|
+
if (mf && typeof mf.name === 'string' && mf.name === name) hits.push({ name, dir: pdir, version: mf.version });
|
|
909
|
+
else if (!mf) residual.push(pdir);
|
|
910
|
+
}
|
|
911
|
+
continue; // 不再往 @deepseek-ai 内部递归
|
|
912
|
+
}
|
|
913
|
+
if (e.name === 'node_modules' || e.name === '.pnpm' || e.name.startsWith('@') || !e.name.startsWith('.')) {
|
|
914
|
+
walk(full, depth + 1);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
walk(join(fromDir, 'node_modules'), 1);
|
|
919
|
+
return { hits, residual };
|
|
920
|
+
};
|
|
921
|
+
// 同一包名能否沿**祖先链**解析到(= 宿主层也提供它)
|
|
922
|
+
// 解析链:bundle 所在的 node_modules(profile 顶层)→ 上一层 node_modules(宿主共享根)→ 再往上。
|
|
923
|
+
// **必须从 bundle 之外开始**:若从 bundle 目录自己开始,嵌套的那份副本会把条件自我确认为真
|
|
924
|
+
// (实测:F5 对照"宿主不提供的同 scope 插件"因此被误报)。
|
|
925
|
+
const ancestorResolves = (bundleDir, name) => {
|
|
926
|
+
let nm = dirname(bundleDir); // <profile>/node_modules
|
|
927
|
+
for (let i = 0; i < 10 && nm; i++) {
|
|
928
|
+
if (existsSync(join(nm, name, 'package.json'))) return true;
|
|
929
|
+
const up = dirname(dirname(nm));
|
|
930
|
+
if (up === dirname(nm)) break;
|
|
931
|
+
nm = join(up, 'node_modules');
|
|
932
|
+
}
|
|
933
|
+
return false;
|
|
934
|
+
};
|
|
935
|
+
|
|
876
936
|
const roots23 = [join(dir, 'node_modules'), join(dirname(dir), 'node_modules')];
|
|
877
|
-
const
|
|
878
|
-
|
|
879
|
-
const resolveBundleDir = (b) => roots23.map((r) => join(r, String(b))).find((d) => existsSync(join(d, 'package.json'))) ?? null;
|
|
880
|
-
// 判定"这是宿主库还是插件"用**内容**,不用锚点也不用裸前缀——
|
|
881
|
-
// 教训链:① 只看 manifest 键名 → 副本躺在盘上也失明(红队 F1);
|
|
882
|
-
// ② 改用 CLI 安装树(installAnchor)→ 它依赖 PATH 里的 node_modules/.bin,
|
|
883
|
-
// 实测**本机也是 null**,于是永远判不出宿主包(等于静默失效);
|
|
884
|
-
// ③ 现在:读**副本自己的 manifest** —— 带 `dsh.bundle`/`dsh.client` 的是**插件**(依赖它是正确写法,红队 F5),
|
|
885
|
-
// 不带的才是**宿主库**(出现第二份副本即 #6789 的条件)。
|
|
886
|
-
const isPluginPkg = (mf) => Boolean(mf && mf.dsh && (mf.dsh.bundle || mf.dsh.client));
|
|
887
|
-
const readPkg = (d) => { try { return readJsonReportingBom(join(d, 'package.json')).data; } catch { return null; } };
|
|
937
|
+
const quarantined23 = new Set(((manifest.dsh?.profile?._quarantined) ?? manifest._quarantined ?? [])
|
|
938
|
+
.map((q) => String(q.name ?? q))); // 红队 D:写入方在 dsh.profile._quarantined(此前只读顶层,功能不生效)
|
|
888
939
|
let scanned23 = 0;
|
|
889
|
-
const issues23 = [];
|
|
940
|
+
const issues23 = []; const residual23 = [];
|
|
890
941
|
for (const b of bundles) {
|
|
891
942
|
if (quarantined23.has(String(b))) continue;
|
|
892
|
-
const bdir =
|
|
943
|
+
const bdir = roots23.map((r) => join(r, String(b))).find((d) => existsSync(join(d, 'package.json')));
|
|
893
944
|
if (!bdir) continue; // 解析不到 → 由 P1 负责
|
|
894
945
|
scanned23++;
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
const nmf = readPkg(nestedDir);
|
|
902
|
-
if (!isPluginPkg(nmf)) {
|
|
903
|
-
issues23.push(`${b} 自带**宿主库**副本 ${full}${nmf?.version ? `@${nmf.version}` : ''}(嵌套在它自己的 node_modules 里,P5 只看顶层所以看不到)`);
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
// ② 别名 / optional 指向宿主包
|
|
907
|
-
let mf23 = null;
|
|
908
|
-
try { mf23 = readJsonReportingBom(join(bdir, 'package.json')).data; } catch { /* 忽略 */ }
|
|
909
|
-
for (const field of ['dependencies', 'optionalDependencies']) {
|
|
910
|
-
for (const [k, v] of Object.entries(mf23?.[field] ?? {})) {
|
|
911
|
-
const target = /^npm:(@deepseek-ai\/[^@]+)@/.exec(String(v));
|
|
912
|
-
const name = target ? target[1] : (k.startsWith('@deepseek-ai/') ? k : null);
|
|
913
|
-
if (!name || bundleSet.has(name)) continue; // 同 scope 的插件依赖插件 = 正确写法(F5)
|
|
914
|
-
// 目标落在 profile 两根里且**不是插件** → 是宿主库被当普通依赖引入(会带来第二份副本)
|
|
915
|
-
const dirs = roots23.map((r) => join(r, name)).concat([join(bdir, 'node_modules', name)]);
|
|
916
|
-
const found = dirs.map(readPkg).find((m) => m && !isPluginPkg(m));
|
|
917
|
-
if (found) issues23.push(`${b} 用 ${field} 引入宿主库 ${name}${target ? `(别名 ${k} → ${v})` : ''}`);
|
|
946
|
+
const { hits, residual } = scanHostCopies(bdir);
|
|
947
|
+
for (const r of residual) residual23.push(r.replace(dir + '/', '').replace(bdir + '/', `${b}/`));
|
|
948
|
+
for (const h of hits) {
|
|
949
|
+
// 只有当同一包名**沿祖先链也能解析到**时,才是"两个实例"(宿主也提供它)
|
|
950
|
+
if (ancestorResolves(bdir, h.name)) {
|
|
951
|
+
issues23.push(`${b} 的解析路径里另有一份 ${h.name}${h.version ? `@${h.version}` : ''}(宿主层也提供同名包 → 两个实例)`);
|
|
918
952
|
}
|
|
919
953
|
}
|
|
920
954
|
}
|
|
921
955
|
if (issues23.length) {
|
|
922
956
|
report('profile', 'P23', false,
|
|
923
|
-
|
|
957
|
+
`bundle 的解析路径上出现**宿主包的第二份实例**(${issues23.length} 处 / 扫过 ${scanned23} 个 bundle)——`
|
|
924
958
|
+ `两份实例的 Symbol/协议不匹配时,典型症状是"第一个会话能用、之后每个会话都失败"(社区 #6789:\`dsh-scope\`):\n `
|
|
925
959
|
+ issues23.slice(0, 5).join('\n ')
|
|
926
|
-
+ (issues23.length > 5 ? `\n …另有 ${issues23.length - 5} 处` : '')
|
|
927
|
-
|
|
928
|
-
|
|
960
|
+
+ (issues23.length > 5 ? `\n …另有 ${issues23.length - 5} 处` : '')
|
|
961
|
+
+ (residual23.length ? `\n (另有 ${residual23.length} 个读取失败的残留目录,未计入副本判定)` : ''),
|
|
962
|
+
'把该插件的这类依赖改为 peerDependencies(宿主包由宿主共享根提供);若副本来自构建产物或残留安装,清理其 node_modules 后重装',
|
|
929
963
|
undefined, scanned23, '已装 bundle');
|
|
930
964
|
} else {
|
|
931
965
|
report('profile', 'P23', true,
|
|
932
|
-
|
|
966
|
+
`bundle 的解析路径上未出现宿主包的第二个实例(扫过 ${scanned23} 个 bundle)`
|
|
967
|
+
+ (residual23.length ? `;另有 ${residual23.length} 个读取失败的残留目录,未计入判定` : ''),
|
|
933
968
|
undefined, undefined, scanned23, '已装 bundle');
|
|
934
969
|
}
|
|
935
970
|
}
|
|
@@ -1134,6 +1169,7 @@ function checkProfile(name) {
|
|
|
1134
1169
|
// 2) 顶层映射(key: value)与顶层序列(- xxx)混排 → js-yaml "document separator expected"
|
|
1135
1170
|
// 3) tab 缩进(YAML 硬错误);4) insert 缺冒号
|
|
1136
1171
|
const yamlProblems = [];
|
|
1172
|
+
let patchEntries = 0; // 块外声明:else 分支要用它申报覆盖量(块内的 topLines 在那里不可见)
|
|
1137
1173
|
const patchText = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '';
|
|
1138
1174
|
if (patchText) {
|
|
1139
1175
|
const topLines = patchText.split('\n');
|
|
@@ -1141,6 +1177,7 @@ function checkProfile(name) {
|
|
|
1141
1177
|
topLines.forEach((line, i) => {
|
|
1142
1178
|
if (!line.trim() || line.trim().startsWith('#')) return;
|
|
1143
1179
|
if (line.includes('\t')) yamlProblems.push(`第 ${i + 1} 行含制表符缩进(YAML 禁止 tab)`);
|
|
1180
|
+
if (/^\s*-\s/.test(line)) patchEntries++; // 覆盖量单位:patch 条目数
|
|
1144
1181
|
if (/^\s*(~|null|Null|NULL)\s*insert\s*:/.test(line)) yamlProblems.push(`第 ${i + 1} 行 "${line.trim()}" —— ~ 是 YAML null 字面量,应为 "- insert:"(#1724)`);
|
|
1145
1182
|
else if (/^\s*-\s*insert(\s|$)/.test(line) && !/^\s*-\s*insert\s*:/.test(line)) yamlProblems.push(`第 ${i + 1} 行 "${line.trim()}" —— "- insert" 缺冒号`);
|
|
1146
1183
|
// 顶层混排检测:col 0 的映射键 vs col 0 的序列项
|
|
@@ -1152,7 +1189,11 @@ function checkProfile(name) {
|
|
|
1152
1189
|
if (hasTopMapping && hasTopSeq) yamlProblems.push('顶层同时存在 key: value 映射与 - xxx 序列(js-yaml 报 "stream or a document separator is expected",#1724 实测)');
|
|
1153
1190
|
}
|
|
1154
1191
|
if (yamlProblems.length) report('profile', 'P7', false, `cordis.patch.yml 结构错误(boot 会崩,UI 打不开 #1724): ${yamlProblems.join('; ')}`, 'patch 必须是顶层纯列表(只有 - insert: / - id: 条目):删掉顶层 key: value 行;~ 是 YAML null;缩进用空格不用 tab');
|
|
1155
|
-
else
|
|
1192
|
+
else {
|
|
1193
|
+
// 显式申报覆盖量:单位是**patch 条目数**。`~`(YAML null)表示 0 个条目 → 由覆盖不变量降级为 skip,
|
|
1194
|
+
// 而不是宣称「结构正常」(那正是「没比较却说没问题」)。
|
|
1195
|
+
report('profile', 'P7', true, 'cordis.patch.yml 结构正常(无 tab / 无 ~ insert / 无映射-序列混排)', undefined, undefined, patchEntries, 'patch 条目');
|
|
1196
|
+
}
|
|
1156
1197
|
|
|
1157
1198
|
// P8/P9 需要扫描 bundle 构建产物:收集目录下有限深度的 .js 文件(lib/dist/根 + main 入口,跳过 node_modules)
|
|
1158
1199
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moonquake2004/dsh-doctor",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
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": [
|