@moonquake2004/dsh-doctor 0.4.22 → 0.4.23

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