@moonquake2004/dsh-doctor 0.4.22 → 0.4.24

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 +177 -5
  2. package/package.json +1 -1
package/dsh-doctor.mjs CHANGED
@@ -241,8 +241,11 @@ function resolveProfile(name) {
241
241
 
242
242
  /* ================= env ================= */
243
243
  /** v1 词汇表 r5(#1719/#2259)node 语义:pass = 满足 ^22.19.0 || >=24.0.0,其余 warn——无民间 fail 阈值。
244
- * 注(2026-09 更正):该范围来自 v1 词汇表对齐,**不是**从某个 package.json 读来的;
245
- * 已发布的 `@deepseek-ai/dsh@0.1.5-rc.1` **不含 engines 字段**,此前文案写"root package.json engines"属出处误引。 */
244
+ * 出处(2026-09 两次更正后的准确版本,由社区 @ciceroyang 核实、我们复核):
245
+ * 该范围**确实声明在 manifest 里** —— 仓库根 `package.json` 的 `engines.node`,
246
+ * 即 `@deepseek-ai/dsh-root`(`private: true`,非发布包)。**没有任何已发布包继承它**,
247
+ * 所以 registry 查不到、npm 安装时既不校验也不警告;这正是社区里"没有 engines"印象的由来。
248
+ * 我们内置的范围与之一致(等于官方声明值),但它是**手写维护**的:上游改动时需同步。 */
246
249
  function nodeInSupportedRange(v) {
247
250
  const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v));
248
251
  if (!m) return false;
@@ -266,8 +269,8 @@ function checkEnv() {
266
269
  const version = String(nv.stdout).trim();
267
270
  const supported = nodeInSupportedRange(version);
268
271
  report('env', 'E3-node', supported,
269
- supported ? `node ${version}(满足 v1 词汇表支持范围 ^22.19.0 || >=24.0.0;该版本未发布 engines 字段,#2259)` : `node ${version} 不在支持范围(^22.19.0 || >=24.0.0,v1 词汇表)——会话日志读取等能力受限`,
270
- supported ? undefined : '升级 node 到 ^22.19.0 或 >=24.0.0(v1 词汇表范围,见 #2259)');
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)');
271
274
  }
272
275
 
273
276
  // E12:运行时 zstd 稳定性(#6651 的运行时线索)
@@ -595,6 +598,62 @@ function checkProfile(name) {
595
598
  }
596
599
  }
597
600
 
601
+ // P19:插件声明的 host peer 范围 vs 实际提供的 host 版本(社区 #6678 @ciceroyang 提案)
602
+ // 这是"升级后起不来"的常见原因之一:插件声明只支持某段 core 版本,而实际装的核心已在区间外,
603
+ // 安装时却没有任何警告(#6680 的机制:profile 的 pnpm 配置 autoInstallPeers:false 且 host 由
604
+ // CLI 共享根层提供,pnpm 没有可解析的 peer 目标)。完全离线可判。
605
+ {
606
+ const findings19 = [];
607
+ let unknown19 = 0;
608
+ let checked19 = 0;
609
+ // 内联枚举(含 scoped;isDirectory() || isSymbolicLink() —— pnpm 与 file:/dev 安装都是软链接,
610
+ // 只看 isDirectory 会漏掉它们,这正是社区提醒的坑)
611
+ const pkgDirs19 = [];
612
+ try {
613
+ for (const e of readdirSync(join(dir, 'node_modules'), { withFileTypes: true })) {
614
+ if (!e.name || e.name.startsWith('.') || e.name === '.bin') continue;
615
+ if (e.name.startsWith('@')) {
616
+ try {
617
+ for (const sub of readdirSync(join(dir, 'node_modules', e.name), { withFileTypes: true })) {
618
+ if (!sub.name || sub.name.startsWith('.')) continue;
619
+ if (sub.isDirectory() || sub.isSymbolicLink()) pkgDirs19.push({ name: `${e.name}/${sub.name}`, dir: join(dir, 'node_modules', e.name, sub.name) });
620
+ }
621
+ } catch { /* 跳过不可读 scope */ }
622
+ } else if (e.isDirectory() || e.isSymbolicLink()) {
623
+ pkgDirs19.push({ name: e.name, dir: join(dir, 'node_modules', e.name) });
624
+ }
625
+ }
626
+ } catch { /* 无 node_modules */ }
627
+ for (const { name: pkgName, dir: pkgDir } of pkgDirs19) {
628
+ let mf;
629
+ try { mf = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')); } catch { continue; }
630
+ const peers = mf.peerDependencies ?? {};
631
+ const hostPeers = Object.entries(peers).filter(([k]) => k.startsWith('@deepseek-ai/'));
632
+ for (const [hostName, range] of hostPeers) {
633
+ checked19++;
634
+ const host = resolveHostVersion(dir, hostName);
635
+ if (!host) { unknown19++; continue; } // 解析不到 → unknown(不猜、不算兼容)
636
+ const r = peerRangeState(host.version, range);
637
+ if (r.state === 'satisfied') continue;
638
+ if (r.state === 'unknown') { unknown19++; continue; }
639
+ findings19.push(`${pkgName} 声明 ${hostName} ${range},但实际提供 ${host.version}`);
640
+ }
641
+ }
642
+ if (findings19.length) {
643
+ report('profile', 'P19', false,
644
+ `插件声明的 host peer 范围不接受实际安装的核心版本(${findings19.length} 处,共核对 ${checked19} 条 peer 声明):\n `
645
+ + findings19.slice(0, 8).join('\n ')
646
+ + (unknown19 ? `\n (另有 ${unknown19} 条无法判定:区间为 * / 未声明 / host 解析不到,或纯 release 区间面对预发布版本——按"未知"处理,不计为不兼容)` : ''),
647
+ '按提示升级到该插件声明支持的版本(或降级核心)。安装时不会有警告,所以升级 dsh 前先用本检查看一眼最省事');
648
+ } else if (checked19 === 0) {
649
+ reportSkip('profile', 'P19', '未见任何 @deepseek-ai/* 的 peer 声明,跳过 host 范围核对');
650
+ } else {
651
+ report('profile', 'P19', true,
652
+ `核对 ${checked19} 条 host peer 声明,均可接受当前核心版本`
653
+ + (unknown19 ? `(${unknown19} 条无法判定,按未知处理未计入)` : ''));
654
+ }
655
+ }
656
+
598
657
  // P4 file: 依赖悬空(file: 目标可能是相对(file:./plugins/x)或绝对(file:/abs/path))
599
658
  const resolveFileSpec = (spec) => {
600
659
  const target = spec.slice(5);
@@ -1638,7 +1697,8 @@ catalogSeverity.set('P14', 'warn');
1638
1697
  catalogSeverity.set('P15', 'error');
1639
1698
  catalogSeverity.set('P16', 'warn');
1640
1699
  catalogSeverity.set('P17', 'warn');
1641
- catalogSeverity.set('P18', 'warn'); // #6667:条件性风险(需游离本地模块才触发),提示但不翻退出码
1700
+ catalogSeverity.set('P18', 'warn');
1701
+ catalogSeverity.set('P19', 'warn'); // #6678:声明不匹配是风险信号而非确定失败,提示但不阻断 // #6667:条件性风险(需游离本地模块才触发),提示但不翻退出码
1642
1702
 
1643
1703
  function bundledCatalog() {
1644
1704
  const p = new URL('./checks.json', import.meta.url);
@@ -1951,6 +2011,118 @@ const profileArg = (() => { const i = process.argv.indexOf('--profile'); return
1951
2011
  const sessionArg = (() => { const i = process.argv.indexOf('--session'); return i >= 0 ? process.argv[i + 1] : undefined; })();
1952
2012
 
1953
2013
 
2014
+
2015
+ /* ---- P19:插件声明的 host peer 范围 vs 磁盘上实际提供的 host 版本 ----
2016
+ * 提案来自社区 @ciceroyang(#6678 评论,附其 dsh-doctor 实现与真实案例):这类不匹配是
2017
+ * "升级后起不来"的常见原因之一,且**完全可离线判定**。他给的坑我们逐条照做:
2018
+ * - 只取 peerDependencies 里的 @deepseek-ai/*;
2019
+ * - 解析 host 版本时**跟随软链接**(dev/file: 安装与 root 层复用都是软链接,用 lstat 风格会误报);
2020
+ * - 作用域名按第一段斜杠切(@scope/name 两段);
2021
+ * - **三态**:兼容 / 不兼容 / 未知(`*`、未声明、解析不到);未知**不得**当作兼容,也不得当作不兼容;
2022
+ * - **rc 语义**(最容易做错):区间里出现任何预发布比较器就按数值判定 ——
2023
+ * `>=0.1.0-rc.5 <0.2.0` 应接受 `0.1.5-rc.2`,`>=0.1.0-rc.5 <0.1.0-rc.7` 应拒绝它;
2024
+ * 只有**纯 release 区间**(如 `>=4.0.0`)面对预发布安装版本时才记未知。
2025
+ * strict semver 的字面规则会把第一种判成不满足 —— 那是误报,而误报健康 profile 比不报更糟。
2026
+ */
2027
+
2028
+ /** 解析版本为 {nums:[a,b,c], pre:[...]};无法解析返回 null。 */
2029
+ function parseVer(v) {
2030
+ const m = /^\s*v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(v ?? ''));
2031
+ if (!m) return null;
2032
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split('.') : [] };
2033
+ }
2034
+ /** semver 预发布优先级:数字段比数字,字母段按字典序,数字 < 字母;无 prerelease 者更高。 */
2035
+ function cmpVer(a, b) {
2036
+ for (let i = 0; i < 3; i++) if (a.nums[i] !== b.nums[i]) return a.nums[i] < b.nums[i] ? -1 : 1;
2037
+ if (!a.pre.length && !b.pre.length) return 0;
2038
+ if (!a.pre.length) return 1;
2039
+ if (!b.pre.length) return -1;
2040
+ for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
2041
+ const x = a.pre[i], y = b.pre[i];
2042
+ if (x === undefined) return -1;
2043
+ if (y === undefined) return 1;
2044
+ const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
2045
+ if (xn && yn) { if (Number(x) !== Number(y)) return Number(x) < Number(y) ? -1 : 1; continue; }
2046
+ if (xn !== yn) return xn ? -1 : 1;
2047
+ if (x !== y) return x < y ? -1 : 1;
2048
+ }
2049
+ return 0;
2050
+ }
2051
+ /** 单个比较器是否满足(数值判定,已含预发布比较)。 */
2052
+ function cmpSatisfied(ver, op, target) {
2053
+ const c = cmpVer(ver, target);
2054
+ switch (op) {
2055
+ case '>': return c > 0;
2056
+ case '>=': return c >= 0;
2057
+ case '<': return c < 0;
2058
+ case '<=': return c <= 0;
2059
+ case '=': return c === 0;
2060
+ default: return false;
2061
+ }
2062
+ }
2063
+ /** 展开 ^ / ~ 为区间对(只覆盖生态里实际出现的形态)。 */
2064
+ function expandCaret(target) {
2065
+ const [maj, min, pat] = target.nums;
2066
+ if (maj > 0) return { lower: ['>=', target], upper: ['<', parseVer(`${maj + 1}.0.0`)] };
2067
+ if (min > 0) return { lower: ['>=', target], upper: ['<', parseVer(`0.${min + 1}.0`)] };
2068
+ return { lower: ['>=', target], upper: ['<', parseVer(`0.${min}.${pat + 1}`)] };
2069
+ }
2070
+ function expandTilde(target) {
2071
+ const [maj, min] = target.nums;
2072
+ return { lower: ['>=', target], upper: ['<', parseVer(`${maj}.${min + 1}.0`)] };
2073
+ }
2074
+ /**
2075
+ * 判定 range 是否接受 version。返回 { state: 'satisfied' | 'unsatisfied' | 'unknown', hasPreComparator }
2076
+ * 未知的三种来源:`*`/空、区间不可解析、**纯 release 区间面对预发布安装版本**(不猜)。
2077
+ */
2078
+ function peerRangeState(version, range) {
2079
+ const ver = parseVer(version);
2080
+ const raw = String(range ?? '').trim();
2081
+ if (!ver || !raw) return { state: 'unknown', hasPreComparator: null };
2082
+ if (raw === '*' || raw === '' || raw === 'x' || raw === 'latest') return { state: 'unknown', hasPreComparator: false };
2083
+ let hasPre = false;
2084
+ const groups = raw.split('||').map((g) => g.trim()).filter(Boolean);
2085
+ if (!groups.length) return { state: 'unknown', hasPreComparator: null };
2086
+ for (const g of groups) {
2087
+ const parts = g.split(/\s+/).filter(Boolean);
2088
+ let ok = true; let preHere = false;
2089
+ for (const part of parts) {
2090
+ const m = /^([\^~]|>=|<=|>|<|=)?\s*v?(.+)$/.exec(part);
2091
+ if (!m) { ok = false; break; }
2092
+ const op = m[1] ?? '=';
2093
+ const target = parseVer(m[2]);
2094
+ if (!target) { ok = false; break; }
2095
+ if (target.pre.length) preHere = true;
2096
+ if (op === '^' || op === '~') {
2097
+ const { lower, upper } = op === '^' ? expandCaret(target) : expandTilde(target);
2098
+ if (!(cmpSatisfied(ver, lower[0], lower[1]) && cmpSatisfied(ver, upper[0], upper[1]))) { ok = false; break; }
2099
+ } else if (!cmpSatisfied(ver, op, target)) { ok = false; break; }
2100
+ }
2101
+ if (ok) {
2102
+ hasPre = hasPre || preHere;
2103
+ return { state: 'satisfied', hasPreComparator: hasPre };
2104
+ }
2105
+ hasPre = hasPre || preHere;
2106
+ }
2107
+ // 全部组都不满足:若这次不匹配发生在"纯 release 区间 vs 预发布版本",按规则记 unknown
2108
+ if (!hasPre && ver.pre.length) return { state: 'unknown', hasPreComparator: false };
2109
+ return { state: 'unsatisfied', hasPreComparator: hasPre };
2110
+ }
2111
+
2112
+ /** 解析 host 包版本:profile node_modules → 共享镜像根;**跟随软链接**(readFileSync 会跟随)。 */
2113
+ function resolveHostVersion(profileDir, name) {
2114
+ const roots = [join(profileDir, 'node_modules'), join(dirname(profileDir), 'node_modules')];
2115
+ for (const root of roots) {
2116
+ try {
2117
+ const mf = join(root, name, 'package.json');
2118
+ if (!existsSync(mf)) continue;
2119
+ const v = JSON.parse(readFileSync(mf, 'utf8')).version;
2120
+ if (v) return { version: v, from: mf };
2121
+ } catch { /* 继续下一个根 */ }
2122
+ }
2123
+ return null;
2124
+ }
2125
+
1954
2126
  /* ================= 启动失败自救:装载模拟 + 隔离 =================
1955
2127
  * 场景(用户高频反馈):装了个不兼容的插件、或 dsh 升级后与旧插件不兼容 → **dsh 根本起不来**,
1956
2128
  * 于是没法用 dsh 自己来诊断,只能借别的工具。本工具是独立 Node CLI(`npx @moonquake2004/dsh-doctor`),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moonquake2004/dsh-doctor",
3
- "version": "0.4.22",
3
+ "version": "0.4.24",
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": [