@moonquake2004/dsh-doctor 0.4.26 → 0.4.28

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.
Files changed (2) hide show
  1. package/dsh-doctor.mjs +92 -5
  2. package/package.json +1 -1
package/dsh-doctor.mjs CHANGED
@@ -246,11 +246,31 @@ function resolveProfile(name) {
246
246
  * 即 `@deepseek-ai/dsh-root`(`private: true`,非发布包)。**没有任何已发布包继承它**,
247
247
  * 所以 registry 查不到、npm 安装时既不校验也不警告;这正是社区里"没有 engines"印象的由来。
248
248
  * 我们内置的范围与之一致(等于官方声明值),但它是**手写维护**的:上游改动时需同步。 */
249
- function nodeInSupportedRange(v) {
249
+ /**
250
+ * 支持范围。默认是**内置常量**(出处与核对日期见上一条注释);若设置了
251
+ * `DSH_DOCTOR_HARNESS_ROOT` 指向 harness 检出,则**真读**其仓库根 package.json 的
252
+ * `engines.node` —— 这是社区建议的方向(#6651 @ciceroyang):手维护的常量会随上游漂移,
253
+ * 能读元数据就别抄数字。真正的解法仍是 #2259(让**已发布**的入口包带上 engines)。
254
+ */
255
+ const NODE_RANGE_FALLBACK = '^22.19.0 || >=24.0.0'; // 核对日期 2026-09-15(master)
256
+ function nodeRangeSource() {
257
+ const root = process.env.DSH_DOCTOR_HARNESS_ROOT;
258
+ if (root) {
259
+ try {
260
+ const e = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).engines?.node;
261
+ if (typeof e === 'string' && e.trim()) return { range: e.trim(), from: `真读 ${root}/package.json` };
262
+ } catch { /* 落到内置常量 */ }
263
+ }
264
+ return { range: NODE_RANGE_FALLBACK, from: '内置常量(出处:仓库根 package.json engines.node,核对 2026-09-15)' };
265
+ }
266
+ function nodeInSupportedRange(v, range = NODE_RANGE_FALLBACK) {
250
267
  const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v));
251
268
  if (!m) return false;
252
269
  const major = Number(m[1]); const minor = Number(m[2]);
253
- return (major === 22 && minor >= 19) || major >= 24;
270
+ // 规则来自该范围本身(^22.19.0 || >=24.0.0):22.19+ 或 24+
271
+ if (range === NODE_RANGE_FALLBACK) return (major === 22 && minor >= 19) || major >= 24;
272
+ const cur = { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: [] };
273
+ return peerRangeState(String(v).replace(/^v/, ''), range).state === 'satisfied';
254
274
  }
255
275
  function checkEnv() {
256
276
  if (!wants('env')) return;
@@ -265,12 +285,29 @@ function checkEnv() {
265
285
  report('env', 'E2-env', !isDir, isDir ? `${envFile} 是目录,dsh 启动会报 failed to load .env: EISDIR(#71)` : `${envFile} 正常`, isDir ? '删除或改名该目录' : undefined);
266
286
  }
267
287
  const nv = spawnSync('node', ['-e', 'console.log(process.version)']);
288
+ // 支持范围:优先**从仓库根 manifest 真读**(若给了 DSH_DOCTOR_HARNESS_ROOT 或存在检出),
289
+ // 否则用内置常量。社区(#6651 @ciceroyang)指出:两个实现都把这个范围写成手维护常量,
290
+ // 上游一改就各自漂移;真正的解法在 #2259(让已发布的入口包带上 engines,从而可读安装树元数据)。
291
+ // 在那之前,任何写死它的地方都必须标注**出处与核对日期**。
292
+ const RANGE_FALLBACK = '^22.19.0 || >=24.0.0'; // 出处:deepseek-harness 仓库根 package.json engines.node(@deepseek-ai/dsh-root,private);核对日期 2026-09-15(master)
293
+ const rangeInfo = (() => {
294
+ const root = process.env.DSH_DOCTOR_HARNESS_ROOT;
295
+ if (root) {
296
+ try {
297
+ const e = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).engines?.node;
298
+ if (typeof e === 'string' && e.trim()) return { range: e.trim(), from: `${root}/package.json(真读)` };
299
+ } catch { /* 落到内置 */ }
300
+ }
301
+ return { range: RANGE_FALLBACK, from: '内置常量(出处:仓库根 package.json engines.node;核对日期 2026-09-15)' };
302
+ })();
303
+
268
304
  if (nv.status === 0) {
269
305
  const version = String(nv.stdout).trim();
270
- const supported = nodeInSupportedRange(version);
306
+ const rangeSrc = nodeRangeSource();
307
+ const supported = nodeInSupportedRange(version, rangeSrc.range);
271
308
  report('env', 'E3-node', supported,
272
- supported ? `node ${version}(满足声明范围 ^22.19.0 || >=24.0.0——出处为仓库根私有 workspace package.json 的 engines,#2259)` : `node ${version} 不在支持范围(^22.19.0 || >=24.0.0,v1 词汇表)——会话日志读取等能力受限`,
273
- supported ? undefined : '升级 node 到 ^22.19.0 或 >=24.0.0(仓库根 engines 声明的范围,见 #2259)');
309
+ supported ? `node ${version}(满足 ${rangeSrc.range}——${rangeSrc.from};该范围声明在私有 workspace 上、无发布物继承,见 #2259/#6651)` : `node ${version} 不在支持范围(^22.19.0 || >=24.0.0,v1 词汇表)——会话日志读取等能力受限`,
310
+ supported ? undefined : `升级 node 到 ${rangeSrc.range}(${rangeSrc.from},见 #2259)`);
274
311
  }
275
312
 
276
313
  // E12:运行时 zstd 稳定性(#6651 的运行时线索)
@@ -1279,6 +1316,51 @@ function checkSession(targetPath) {
1279
1316
  report('session', 'S9', true, '非 zstd 输入,跳过容器检查', undefined);
1280
1317
  }
1281
1318
 
1319
+ // S14:投影安全性预检(#6686)——迁移**成功**、但打开(project)时抛错的会话
1320
+ // 报告者实测:v0→v3 迁移后部分事件**没有消息体**,而 stock projection 直接穿透读取,
1321
+ // 于是 `failed to project session: Cannot read properties of undefined (reading 'content')`,
1322
+ // 整个历史会话打不开。我们离线预判同一批形态,让用户在"点开之前"就知道哪些会话会炸:
1323
+ // ① 事件类型暗示有消息体,但 data.message 缺失/非对象(如 assistant/message、tool/result、
1324
+ // system/message —— 三个 stock projection 分别读 data.message.content、
1325
+ // data.message.source.callId、data.message.content[0].isError);
1326
+ // ② surfaceOp 为 replace 形态({op:'replace', start/end} 或 startSeq/endSeq),
1327
+ // 但引用的位置在本日志里解析不到。
1328
+ {
1329
+ const rows = [];
1330
+ for (const ln of String(text).split('\n')) {
1331
+ if (!ln.trim()) continue;
1332
+ try { rows.push(JSON.parse(ln)); } catch { /* 解析失败由 S11/SS4 报 */ }
1333
+ }
1334
+ const seqs = new Set(rows.map((r) => r.seq).filter((x) => typeof x === 'number'));
1335
+ const probs = [];
1336
+ for (let i = 0; i < rows.length; i++) {
1337
+ const r = rows[i];
1338
+ const bodyTypes = /^(assistant\/message|tool\/result|system\/message)$/;
1339
+ if (bodyTypes.test(String(r.type)) && !(r.data && typeof r.data.message === 'object' && r.data.message !== null)) {
1340
+ probs.push(`行 ${i + 1}(${r.type})缺 data.message——stock projection 会穿透读取并抛错`);
1341
+ }
1342
+ const so = r.surfaceOp;
1343
+ if (so && typeof so === 'object' && so.op === 'replace') {
1344
+ const hasStartEnd = Object.hasOwn(so, 'start') && Object.hasOwn(so, 'end');
1345
+ const hasSeqRange = Object.hasOwn(so, 'startSeq') && Object.hasOwn(so, 'endSeq');
1346
+ const okRef = hasSeqRange
1347
+ ? (seqs.has(so.startSeq) || seqs.has(so.endSeq))
1348
+ : (hasStartEnd && (seqs.has(so.start) || seqs.has(so.end)));
1349
+ if (!okRef) probs.push(`行 ${i + 1}(${r.type})surfaceOp replace 的位置在本日志里解析不到`);
1350
+ }
1351
+ }
1352
+ if (probs.length) {
1353
+ report('session', 'S14', false,
1354
+ `检测到 ${probs.length} 处会让会话**打不开**的事件体/引用不完整(#6686:迁移成功但 project 抛错):\n `
1355
+ + probs.slice(0, 6).join('\n ')
1356
+ + (probs.length > 6 ? `\n …另有 ${probs.length - 6} 处` : ''),
1357
+ '这类会话在 GUI 里点开会报 failed to project session;上游修法(可选链 + 中性兜底)见 #6686;'
1358
+ + '在那之前可先把该会话目录移出 sessions/ 隔离,避免它影响会话列表');
1359
+ } else {
1360
+ report('session', 'S14', true, `投影安全性预检通过(${rows.length} 个事件:消息体完整、replace 引用可解析)`);
1361
+ }
1362
+ }
1363
+
1282
1364
  // S13:会话头完整性(#6651)——首行必须是 {"type":"session",...}
1283
1365
  // 实测:首行被写成事件时 dsh-doctor 的其余 S 检查**全部照常通过**(它们看的是事件流),
1284
1366
  // S11 甚至会报"均健康";而 harness 侧会 `corrupt Zstandard session log` 直接拒绝启动 `dsh web`。
@@ -1645,6 +1727,11 @@ function scanAllSessions() {
1645
1727
  for (let li = 0; li < lines.length; li++) {
1646
1728
  const ln = lines[li]; if (!ln.trim()) continue;
1647
1729
  let d; try { d = JSON.parse(ln); } catch { problems.push(`行 ${li + 1} 无法解析`); continue; }
1730
+ // #6686:事件类型暗示有消息体却没有 → 打开(project)时会抛错,整个会话打不开
1731
+ if (/^(assistant\/message|tool\/result|system\/message)$/.test(String(d.type))
1732
+ && !(d.data && typeof d.data.message === 'object' && d.data.message !== null)) {
1733
+ problems.push(`行 ${li + 1}(${d.type})缺 data.message(#6686:project 会抛错,会话打不开)`);
1734
+ }
1648
1735
  // #6651:首行必须是会话头——否则 harness 拒绝启动 dsh web,而其余检查看不出问题
1649
1736
  if (!firstRowSeen) {
1650
1737
  firstRowSeen = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moonquake2004/dsh-doctor",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
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": [