dsh-plugin-compat-check 0.1.0 → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chenyu Liu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,79 +1,43 @@
1
1
  # dsh-plugin-compat-check
2
2
 
3
- 告诉你**已经装好的 DSH 插件**跟**当前 DSH 版本**到底兼不兼容;不兼容的,给出**位置**
4
- (`file:line`)和**原因**。
3
+ 检查**已经安装的 DSH 插件**与当前 DSH 版本的兼容性,提供源码位置、运行时证据与覆盖限制。包含兼容性面板、插件详情角标、`compat_check` 对话工具和 skill provider。
5
4
 
6
- 这是同一份判定内核的三个入口之一(DSH 插件)。另外两个是 CLI 与 skill —— 见仓库说明。
7
-
8
- ## 为什么需要它
9
-
10
- 四种"看起来没问题"其实都有问题:
11
-
12
- | 你可能会以为 | 实际情况 |
13
- |---|---|
14
- | `peerDependencies` 写了支持 → 能用 | 声明可能是错的。DSH 自带的版本闸门只做**字符串范围比对** |
15
- | DSH 版本闸门放行了 → 能用 | 同上,它不验证 API 是否存在 |
16
- | 启动没报错 → 能用 | 插件普遍 `try/catch` 降级,**API 缺失时它"激活成功"但功能是死的** |
17
- | 启动日志干净 → 客户端也没问题 | 客户端跑在浏览器里,日志里根本没有它 |
18
-
19
- ## 安装
5
+ 本文件适用于 **0.1.2**。安装指定版本:
20
6
 
21
7
  ```bash
22
- dsh plugin --profile <你的 profile> add dsh-plugin-compat-check
8
+ dsh plugin --profile <profile> add dsh-plugin-compat-check@0.1.2
23
9
  ```
24
10
 
25
- 装好后插件会:
11
+ ## 从源码构建安装
26
12
 
27
- - 在**设置 → 内置插件**里加一个「**兼容性**」标签页,列出每个插件的结论
28
- - 在插件详情页给一个角标(可用 / 部分可用 / 不兼容)
29
- - 注册一个 `compat_check` 对话工具,可以直接问
30
- - 注册一个 skill,让 agent 知道何时该用它
13
+ 本仓库根为私有开发工作区,不声明DSH bundle。不要把根目录GitHub安装规格当成plugin子目录的安装方式。在仓库根运行 `npm ci --ignore-scripts`,然后:
31
14
 
32
- ## 结论怎么读
33
-
34
- ```
35
- 部分可用 dsh-keep-awake@0.2.0
36
- 功能失效 / 宿主方法缺失 settings.register @ lib/index.js:466
37
- 影响: 插件的设置页/设置项会消失
38
- 依据: 运行时里没有这个方法;规则表给出影响面
39
- 客户端半边(命令行测不到,需界面验证): locale, slots
40
- 已过滤(判为噪音/有兜底): jobs.onJobsChanged
15
+ ```bash
16
+ cd plugin
17
+ npm pack --pack-destination <已存在的绝对输出目录>
18
+ dsh plugin --profile <临时profile> add file:<生成的tgz绝对路径>
41
19
  ```
42
20
 
43
- - **每条发现都带位置**:只说"不兼容"而不给 `file:line`,用户就无法验证我们凭什么判
44
- - **`[疑似]`**:这一条没有运行时面支撑。转述时**必须**带上「疑似」
45
- - **`已过滤(判为噪音/有兜底)`**:报了但判定为不构成问题(例如作者自己写了
46
- `typeof x.y === 'function'` 能力检测)。**过滤也是结论的一部分**,不要只报红叉
47
- - **`客户端半边`**:列出来的服务命令行测不到,只能人工在界面上确认 —— 工具不下结论
21
+ prepack复制权威skill、同步单源报告契约的宿主/客户端产物,并暂存共享kernel和MIT LICENSE,postpack清理kernel。固定运行时依赖为 `@babel/parser@7.29.9` 与 `semver@7.7.3`,支持 Node `^22.19.0 || >=24`。源码裸复制或file安装不保证prepack运行,应使用完整仓库开发布局或预打包tarball。
48
22
 
49
- ## 确定性是三档的
23
+ npm产物**不带CLI bin、安装检查shell或probe入口**。CLI和安装检查从仓库使用;本插件不能做安装前检查。仓库使用说明:[README](https://github.com/lcy0121/dsh-plugin-compat-check#readme)。版本说明与发布状态请查看[开发计划与验收记录](https://github.com/lcy0121/dsh-plugin-compat-check/blob/main/docs/Plan.md),本地路径为docs/Plan.md。
50
24
 
51
- | 档 | 判据 |
52
- |---|---|
53
- | **确定** | 服务名出现在可信声明里(`inject` / `ctx.get` / `ctx.inject`),或有运行时面实证 |
54
- | **疑似** | 只靠官方 API 目录推断 —— **那份目录是契约目录,不是运行时面** |
55
- | **已过滤** | 判为噪音(vendored 代码里的同名变量)或作者有兜底 |
25
+ 0.1.2修正发布状态文案并统一版本标记;判定算法与0.1.1一致。规则/API目录仍基于0.2.0-rc.2,不把新版接入测试当作全面适配。
56
26
 
57
- 官方目录与运行时面**差别很大**:实测 `jobs` 在目录里 0 个方法、运行时 29 个;
58
- `settings` 目录 6 个、运行时 10 个;`loader` 完全不收录。
59
- 所以**没有运行时面时,方法级结论一律降为「疑似」**。
27
+ ## 判定与边界
60
28
 
61
- ## 安全边界
29
+ 当前profile在宿主进程内采样服务描述符,不执行getter;未知访问器保留未知,并纳入能可靠映射的loader FAILED/PENDING。查询其它profile只用静态证据,不借用当前宿主服务。Babel AST扫描不执行目标源码,分析运行在有时间和内存上限的worker中;动态调用和跨函数数据流仍有限。
62
30
 
63
- 这个插件**严格只读**:不装包、不起子进程、不联网(默认)、只碰当前 profile。
31
+ `certainty`为certain或suspected;过滤、守卫、弃权是独立去处。省略的probe键表示未采样,不表示服务缺失。现代观测匹配profile/DSH版本且默认24小时过期;旧报告标记未绑定。声明不能代替API实测,官方peer冲突以推断影响呈现,当前实现仍按0.2.0-rc.2把engines.dsh作建议处理。0.2.1-rc.1宿主已优先按该字段判断,声明有误报/漏报风险,应核对实际宿主结果;本补丁未改判定算法。
64
32
 
65
- **唯一的副作用**是它会把自己的体检记录写成本机基线
66
- (`$DSH_HOME/dsh-plugin-compat-check/baseline.<profile>.json`),
67
- 好让下次对比出「相比上次变了什么」。它不写别的地方。
33
+ 转述时保留 `impactBasis=inferred`(影响为推断,非实测)、coverage限制和vacuous结果。API发现通常有file:line,loader/元数据发现可能没有源码位置。客户端服务列为待界面核验项;独立浏览器开发工具不属于本面板,且不验证业务交互。
68
34
 
69
- 如果连这个也不想让它写,用 CLI 形态加 `--no-baseline`。
35
+ 插件体检不安装包、不调用外部命令或联网;会启动分析worker,并默认写自己的schema2基线:`$DSH_HOME/dsh-plugin-compat-check/baseline.<profile>.<path-hash>.<mode>.json`。模式分离、锁内比较、原子替换;源码扫描不完整不推进基线。默认不输出共享loader诊断;诊断需显式私有DSC_OUT_DIR。若需完全禁止基线读写,请使用仓库CLI的 `--no-baseline`。
70
36
 
71
- ## 版本
37
+ 0.1.1的分阶段证据包括默认回归、独立tarball执行及2026-10-15的真实DSH0.2.0-rc.2/macOS arm64验收:工具、skill、面板、详情角标、基线及Chrome正负向对照。2026-10-16另在Ubuntu24.04.5 arm64 VM通过Node22.19.0/24.21.0、真实DSH/tgz/HTTP及资源边界验收。同日提交38fa4a0的[GitHub Actions](https://github.com/lcy0121/dsh-plugin-compat-check/actions/runs/38892527900)在Ubuntu x64通过Node22/24资源检查及联合18项检查(4项真实DSH、1项冻结pnpm安装、13项离线)。后续main提交49403d7的[CI](https://github.com/lcy0121/dsh-plugin-compat-check/actions/runs/38923778251)再次通过Node22/24发布前与资源检查、Linux真实集成。Windows按约定待验收;skill正文修订后的仓库验证不冒充旧现场正文验收。具体测试数量与证据见仓库docs/current-state.json、docs/Plan.md;不把历史测试数字作为当前版本的完整功能认证。
72
38
 
73
- `--json` 输出的 `toolVersion`(CLI 形态下 `--version`)是**这份结论是哪一版工具给的**。
74
- 注意它和 `dshVersion` 是两件事:后者是**被体检对象**的版本。
75
- 同一个插件在不同版本的规则表/提取器下结论会变,所以报结论时请带上 `toolVersion`。
39
+ 报告结构由仓库lib/contracts.mjs校验:HTTP envelope.report与compat_check.report均保留完整发现、覆盖、观测和基线;工具原summary/text字段保留。新面板拒绝损坏或不支持版本的负载,提示刷新和对齐版本。旧probe保持未绑定警告,旧schema1基线中性重建;内核现支持有界常量属性、函数参数/返回值、包内模块别名及透明Proxy;未知动态行为与超预算继续显示覆盖限制。具体字段与错误码见 [Plan契约记录](https://github.com/lcy0121/dsh-plugin-compat-check/blob/main/docs/Plan.md#23-报告结构与兼容策略)。
76
40
 
77
- ## 许可证
41
+ ## License
78
42
 
79
- MIT。源码与完整说明:<https://github.com/lcy0121/dsh-plugin-compat-check>
43
+ MIT。源码、问题反馈与仓库文档:[GitHub](https://github.com/lcy0121/dsh-plugin-compat-check)。
package/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // Host half of the dsh-plugin-compat-check plugin — loader self-introspection
2
- // (Plan.md §5 B1/B2/B3: read ctx.loader.entries(), recover failure reasons,
2
+ // (docs/Plan.md §2.4: read ctx.loader.entries(), recover failure reasons,
3
3
  // never throw).
4
4
  //
5
- // HARD RULE (Plan.md §4 第 6 条:绝不抛、绝不拖累宿主): this plugin must never
5
+ // HARD RULE (docs/Plan.md §3 第 3 条:绝不抛、绝不拖累宿主): this plugin must never
6
6
  // throw and must never keep the host from booting. A health-check plugin that
7
7
  // breaks host startup becomes an instance of the very class of problem it is
8
8
  // meant to detect. Every host object in here is touched behind try/catch, and
@@ -11,14 +11,14 @@
11
11
  // Why `inject` stays empty: `ctx.loader` is a Cordis service, and the context
12
12
  // proxy resolves it by walking the fiber chain — declaring it in `inject` is
13
13
  // not required to read it. Declaring a service we do not strictly need is the
14
- // exact way to park this plugin's own fiber in PENDING (Plan.md §6 地雷清单).
14
+ // exact way to park this plugin's own fiber in PENDING (docs/Plan.md §2.4 接入要点).
15
15
  // `resolveLoader()` below therefore tries the inject-free lookup first, and the
16
16
  // plugin additionally runs a one-shot child-fiber probe (`ctx.inject`) to
17
17
  // record, empirically, whether `inject: ["loader"]` *would* have been safe —
18
18
  // without risking its own activation.
19
19
  //
20
20
  // Output (the verifiable form required before any UI exists):
21
- // ${DSC_OUT_DIR:-$TMPDIR/dsh-plugin-compat-check}/loader-introspect.json
21
+ // ${DSC_OUT_DIR}/loader-introspect.json (explicit owned private directory only)
22
22
  // plus structured console lines prefixed with [dsh-plugin-compat-check].
23
23
 
24
24
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -27,6 +27,9 @@ import path, { join } from 'node:path';
27
27
  import { fileURLToPath, pathToFileURL } from 'node:url';
28
28
  import { createRequire as __createRequire } from 'node:module';
29
29
 
30
+ import {loaderObservations,privateDiagnostic} from './lib/host-resources.js';
31
+ import {assertEnvelope,failureEnvelope} from './lib/report-contract.js';
32
+
30
33
  export const name = 'dsh-plugin-compat-check';
31
34
 
32
35
  /**
@@ -53,8 +56,8 @@ const FIBER_STATES = {
53
56
  5: 'UNLOADING',
54
57
  };
55
58
 
56
- const OUT_DIR = process.env.DSC_OUT_DIR || join(tmpdir(), 'dsh-plugin-compat-check');
57
- const OUT_JSON = join(OUT_DIR, 'loader-introspect.json');
59
+ const OUT_DIR = process.env.DSC_OUT_DIR || null;
60
+ const OUT_JSON = OUT_DIR ? join(OUT_DIR, 'loader-introspect.json') : null;
58
61
 
59
62
  /** Defensive cap: a pathological iterable must not exhaust the host's heap. */
60
63
  const MAX_ENTRIES = 20000;
@@ -125,21 +128,7 @@ function describe(value) {
125
128
  }
126
129
  }
127
130
 
128
- function writeReport(report) {
129
- try {
130
- mkdirSync(OUT_DIR, { recursive: true });
131
- } catch {
132
- /* 目录已存在或不可写 */
133
- }
134
- let written = false;
135
- try {
136
- writeFileSync(OUT_JSON, `${JSON.stringify(report, null, 2)}\n`);
137
- written = true;
138
- } catch (error) {
139
- log(`WARN could not write ${OUT_JSON}: ${errText(error)}`);
140
- }
141
- return written;
142
- }
131
+ function writeReport(report) { return privateDiagnostic(OUT_JSON, report); }
143
132
 
144
133
  // ---------------------------------------------------------------------------
145
134
  // B1: loader access
@@ -317,7 +306,7 @@ function declaredInjectKeys(fiber) {
317
306
  }
318
307
  }
319
308
 
320
- /** 缺服务 = fiber.ctx.get(key) === undefined (Plan.md §5 B1). */
309
+ /** 缺服务 = fiber.ctx.get(key) === undefined (docs/Plan.md §2.4). */
321
310
  function missingServices(fiber, keys) {
322
311
  const missing = [];
323
312
  const present = [];
@@ -638,7 +627,7 @@ function resolveKernelDir() {
638
627
  /**
639
628
  * 当前 profile 的 node_modules。
640
629
  *
641
- * 优先级:显式覆盖 → DSH 正在跑的 profile → `desktop`。
630
+ * 优先级:显式请求/覆盖 → DSH 正在跑的 profile;无法确认时返回错误,不回退 desktop。
642
631
  * 「正在跑的是哪个 profile」没有正式服务暴露,只能靠环境变量与目录推断 ——
643
632
  * 所以这里把推断的**依据**一起放进报告,让结论可追溯,而不是猜一个就不吭声。
644
633
  */
@@ -647,7 +636,7 @@ function resolveKernelDir() {
647
636
  *
648
637
  * ## 为什么必须拿到它
649
638
  *
650
- * `dshVersion` 是基线的三个字段之一(Plan.md §5 F1),也是「**声明与实测不符**」
639
+ * `dshVersion` 是基线差分的输入(docs/Plan.md §2.2),也是「**声明与实测不符**」
651
640
  * 这类结论的**前提** —— 没有它就没法把插件的 `dsh.compatibility` 声明与实际版本对上,
652
641
  * 于是那一类发现(本工具相对既有方案唯一不可替代的结论)在插件形态下根本不成立。
653
642
  *
@@ -666,10 +655,10 @@ function resolveKernelDir() {
666
655
  * ### 被否掉的方案,以及为什么
667
656
  *
668
657
  * - **`process.env.DSH_VERSION`**:不存在(就是这次要解决的缺口本身)。
669
- * - **`dsh --version` 起子进程**:不行,而且**不是**能力问题 —— Plan.md §2 第 1 题锁定了
658
+ * - **`dsh --version` 起子进程**:不行,而且**不是**能力问题 —— docs/Plan.md §1 锁定了
670
659
  * 「插件严格只读:不装包、不起子进程、不联网」。为拿一个版本号破这条边界不值。
671
660
  * - **`Info.plist`**:macOS 专有(`CFBundleShortVersionString` = 0.2.0-rc.2,能读到)。
672
- * 本产品要跨平台(§2 第 6 题),不能把它当主路。
661
+ * 本产品要跨平台(§5.1 的验证边界),不能把它当主路。
673
662
  *
674
663
  * ## 拿不到时返回 null,而不是猜
675
664
  *
@@ -769,29 +758,17 @@ function resolveActiveProfile() {
769
758
  return { name: null, why: '无法判断' };
770
759
  }
771
760
 
772
- function resolveNodeModules() {
773
- const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh');
774
- const candidates = [];
775
-
776
- const inferred = resolveActiveProfile();
777
- if (inferred.name) {
778
- candidates.push({ dir: join(dshHome, 'profiles', inferred.name, 'node_modules'), why: inferred.why });
779
- }
780
-
781
- // 兜底:从 `DSH_PROFILE_DIR` 直接拿目录(它指向 profile 根,不是 node_modules)
782
- if (process.env.DSH_PROFILE_DIR) {
783
- candidates.push({ dir: join(process.env.DSH_PROFILE_DIR, 'node_modules'), why: `DSH_PROFILE_DIR=${process.env.DSH_PROFILE_DIR}` });
784
- }
785
- // 最后回退。到这一步说明前面都没中(例如插件是从仓库直接跑的、或发布形态装在别处),
786
- // 那就只能挑一个 —— 但 why 里会**写明这是回退**,否则用户会以为它检查的是当前 profile。
787
- candidates.push({ dir: join(dshHome, 'profiles', 'desktop', 'node_modules'), why: '回退到 desktop profile(没能判断出正在跑哪一个)' });
788
-
789
- for (const c of candidates) {
790
- try {
791
- if (existsSync(c.dir)) return { dir: c.dir, why: c.why };
792
- } catch { /* 下一个 */ }
761
+ function resolveNodeModules(requestedProfile = null) {
762
+ const dshHome=process.env.DSH_HOME || join(homedir(),'.dsh');
763
+ const inferred=resolveActiveProfile();
764
+ const selected=requestedProfile || inferred.name;
765
+ if(selected){
766
+ if(!/^[\w.-]+$/.test(selected) || selected==='.' || selected==='..')return {dir:null,why:'profile 名称非法'};
767
+ const dir=join(dshHome,'profiles',selected,'node_modules');
768
+ return existsSync(dir)?{dir,why:requestedProfile?'请求明确指定的 profile '+selected:inferred.why}:{dir:null,why:'指定 profile 不存在: '+selected};
793
769
  }
794
- return { dir: null, why: '没有任何候选目录存在' };
770
+ if(process.env.DSH_PROFILE_DIR){const dir=join(process.env.DSH_PROFILE_DIR,'node_modules');return existsSync(dir)?{dir,why:'显式 DSH_PROFILE_DIR'}:{dir:null,why:'DSH_PROFILE_DIR 不存在'};}
771
+ return {dir:null,why:'无法确认当前 profile;请显式指定'};
795
772
  }
796
773
 
797
774
  /**
@@ -812,38 +789,15 @@ function resolveNodeModules() {
812
789
  * 并发安全:`state.computing` 保存"正在跑的那一个 promise",所以十个面板请求
813
790
  * 同时到达也只会算一次 —— 否则它们会各自算一遍,白烧 1.5 秒 × 10。
814
791
  */
815
- async function serveReport(state, ctx, force) {
816
- const now = Date.now();
817
- const age = now - state.lastReportAt;
818
-
819
- if (force || !state.lastReport) {
820
- // 没有缓存时不并发去重:第一次访问就该拿到真结果。
821
- const out = await computeReport(state, ctx);
822
- state.lastReport = out;
823
- state.lastReportAt = Date.now();
824
- return out;
825
- }
826
- if (age <= state.cacheTtlMs) {
827
- return { ...state.lastReport, cache: { hit: true, ageMs: age, refreshing: false } };
792
+ async function serveReport(state, ctx, force, options = {}) {
793
+ if(!state.reportService){
794
+ const kernel=resolveKernelDir();
795
+ if(!kernel)return failureEnvelope('找不到判定内核');
796
+ const {ReportService}=await import(pathToFileURL(join(kernel,'lib','report-service.mjs')).href);
797
+ if(!state.reportService)state.reportService=new ReportService(async options=>{const out=await computeReport(state,ctx,options);return out.ok?assertEnvelope(out):failureEnvelope(out.error,out);},{ttlMs:state.cacheTtlMs});
828
798
  }
829
- // 过期:**不等它**。给旧的,后台刷。
830
- if (!state.computing) {
831
- state.computing = computeReport(state, ctx)
832
- .then((out) => {
833
- state.lastReport = out;
834
- state.lastReportAt = Date.now();
835
- return out;
836
- })
837
- .catch((error) => {
838
- // 后台刷新失败**不能影响已经发出去的那份**,但必须留下痕迹:
839
- // 静默失败的刷新会让面板一直显示过期数据,而用户以为那是当前的。
840
- try { log(`WARN F2 background refresh failed: ${errText(error)}`); } catch { /* 忽略 */ }
841
- state.lastRefreshError = errText(error);
842
- return null;
843
- })
844
- .finally(() => { state.computing = null; });
845
- }
846
- return { ...state.lastReport, cache: { hit: true, ageMs: age, refreshing: true } };
799
+ const key=options.profile?`profile:${options.profile}`:'current';
800
+ const out=await state.reportService.get(key,options,force);return out?.ok?assertEnvelope(out):failureEnvelope(out?.error,out||{});
847
801
  }
848
802
 
849
803
  /**
@@ -869,7 +823,7 @@ function baselineFileFor(nmDir) {
869
823
  * - 客户端跑在浏览器里,读不了文件系统;
870
824
  * - 结论(五元组 + 确定性)是内核的产物,算出唯一一份才不会两边漂移。
871
825
  */
872
- async function computeReport(state, ctx) {
826
+ async function computeReport(state, ctx, options = {}) {
873
827
  const started = Date.now();
874
828
  const out = {
875
829
  schemaVersion: 1,
@@ -880,7 +834,7 @@ async function computeReport(state, ctx) {
880
834
  kernelDir: null,
881
835
  // 「这份结论是哪一版工具给的」。面板是本插件的主要入口,
882
836
  // 少了它,用户来问"凭什么"时第一句就卡住 —— 而同一插件在不同版本的
883
- // 规则表/提取器下结论会变(见 lib/version.mjs 与 Plan.md §5.2.8)。
837
+ // 规则表/提取器下结论会变(见 lib/version.mjs 和 docs/Plan.md §1、§3 第 2 条)。
884
838
  toolVersion: null,
885
839
  nodeModules: null,
886
840
  nodeModulesWhy: null,
@@ -907,7 +861,7 @@ async function computeReport(state, ctx) {
907
861
  + '插件形态下它们是同仓库的相邻目录;发布形态请设 DSC_KERNEL。';
908
862
  return out;
909
863
  }
910
- const nm = resolveNodeModules();
864
+ const nm = resolveNodeModules(options.profile || null);
911
865
  out.nodeModules = nm.dir;
912
866
  out.nodeModulesWhy = nm.why;
913
867
  if (!nm.dir) {
@@ -915,7 +869,7 @@ async function computeReport(state, ctx) {
915
869
  return out;
916
870
  }
917
871
 
918
- const { extractWithProvenance } = await import(pathToFileURL(join(kernel, 'lib', 'extract.mjs')).href);
872
+ const { extractInWorker } = await import(pathToFileURL(join(kernel, 'lib', 'worker-analysis.mjs')).href);
919
873
  const { buildVerdicts } = await import(pathToFileURL(join(kernel, 'lib', 'verdict.mjs')).href);
920
874
 
921
875
  const catalogPath = join(kernel, 'rules', 'dsh-api-catalog.json');
@@ -938,13 +892,27 @@ async function computeReport(state, ctx) {
938
892
  }
939
893
  out.surfaceWhy = surfaceWhy;
940
894
 
941
- const extracted = extractWithProvenance(nm.dir, { catalog });
895
+ const extracted = await extractInWorker(nm.dir, { catalog });
896
+ const { sampleServices } = await import(pathToFileURL(join(kernel,'lib','observation.mjs')).href);
897
+ const currentTarget=resolveNodeModules();
898
+ const sameHost=currentTarget.dir&&path.resolve(currentTarget.dir)===path.resolve(nm.dir);
899
+ if(!hostSurface && sameHost){
900
+ hostSurface=sampleServices(ctx,extracted.report.services,{profileId:nm.dir,dshVersion:resolveDshVersion()});
901
+ surfaceWhy='当前宿主进程内采集的服务结构;未采集或读取失败不等于缺失';
902
+ }
903
+ const itemListing=await (async()=>{try{const listing=await ctx.get?.('pluginManager',false)?.list?.();return (Array.isArray(listing)?listing:listing?.items||listing?.plugins||[]).map(i=>({id:i.id??i.entryId??i.slotId,packageName:i.packageName??i.package??i.name}));}catch{return [];}})();
904
+ const loaderState=sameHost?loaderObservations(ctx,extracted.report.manifests,itemListing):{rows:[],unmapped:[],available:false};
905
+ out.loaderCoverage=loaderState;
906
+
907
+ out.surfaceWhy=surfaceWhy;
942
908
  const verdict = buildVerdicts({
943
909
  report: extracted.report,
944
910
  hostSurface,
945
911
  rules,
946
912
  catalog,
947
913
  dshVersion: resolveDshVersion(),
914
+ entryStates: loaderState.rows,
915
+ observationTarget: {profileId:nm.dir,maxAgeMs:24*60*60*1000},
948
916
  });
949
917
 
950
918
  // ── C4:基线差分 ──
@@ -954,29 +922,11 @@ async function computeReport(state, ctx) {
954
922
  try {
955
923
  const { snapshotForBaseline, diffAgainstBaseline, badgeFor } =
956
924
  await import(pathToFileURL(join(kernel, 'lib', 'baseline.mjs')).href);
957
- const snap = snapshotForBaseline(verdict, resolveDshVersion());
958
- const bfile = baselineFileFor(nm.dir);
959
- let prev = null;
960
- let readNote = null;
961
- try {
962
- if (existsSync(bfile)) prev = JSON.parse(readFileSync(bfile, 'utf8'));
963
- else readNote = '还没有基线(这是第一次体检)';
964
- } catch (error) {
965
- // 读不出来**不是**"没有变化"。必须区分"你没跑过"与"你的基线坏了" ——
966
- // 后者会让用户以为一切照旧,继续用着已经变坏的环境。
967
- readNote = `基线读不出来(${errText(error)})—— 这次不做比较`;
968
- }
969
- const diff = diffAgainstBaseline(prev, snap);
970
- baseline = { file: bfile, note: readNote, diff, badge: badgeFor(diff), wrote: false };
971
- // 只有这一次真的算出了结论才写基线:一次失败的体检写下的基线,
972
- // 会把"读不到"记成"这些插件都没了",下次比较全是假变化。
973
- try {
974
- mkdirSync(path.dirname(bfile), { recursive: true });
975
- writeFileSync(bfile, `${JSON.stringify(snap, null, 2)}\n`);
976
- baseline.wrote = true;
977
- } catch (error) {
978
- baseline.writeError = `基线写不进去(${errText(error)})`;
979
- }
925
+ const store=await import(pathToFileURL(join(kernel,'lib','state-store.mjs')).href);
926
+ const identity=store.baselineIdentity(nm.dir,verdict.observation.mode,rules,catalog);
927
+ const snap = snapshotForBaseline(verdict, resolveDshVersion(),identity);
928
+ const bfile = store.baselinePath(nm.dir,verdict.observation.mode);
929
+ baseline=store.baselineTransaction(bfile,snap,{advance:extracted.report.scanComplete});
980
930
  } catch (error) {
981
931
  baseline = { error: errText(error), note: '基线差分没算成(不影响本次结论)' };
982
932
  }
@@ -1006,22 +956,12 @@ async function computeReport(state, ctx) {
1006
956
  out.itemsError = errText(error);
1007
957
  }
1008
958
 
1009
- out.baseline = baseline;
1010
- out.dshVersion = resolveDshVersion();
1011
- out.report = {
1012
- summary: verdict.summary,
1013
- dshVersion: verdict.dshVersion,
1014
- hasRuntimeSurface: Boolean(hostSurface),
1015
- surfaceWhy,
1016
- plugins: verdict.plugins,
1017
- // 逐条发现的证据形态:面板要能回答「凭什么这么判」
1018
- // 条目台账(id → 包名),客户端解析角标归属时用
1019
- items: out.items || [],
1020
- itemsError: out.itemsError || null,
1021
- manifests: extracted.report.manifests,
1022
- serviceCount: extracted.report.services.length,
1023
- siteTotal: (extracted.report.callSites || []).length,
1024
- };
959
+ const {toJSON}=await import(pathToFileURL(join(kernel,'lib','report.mjs')).href);
960
+ const canonical=toJSON({error:null,extract:extracted,verdict,baseline,inputs:{nodeModules:nm.dir,hasRuntimeSurface:verdict.observation.usable,catalog:catalogPath,rules:rulesPath}});
961
+ out.baseline = canonical.baseline;
962
+ out.dshVersion = canonical.dshVersion;
963
+ out.exitCode = canonical.exitCode;
964
+ out.report = {...canonical,hasRuntimeSurface:verdict.observation.usable,loaderCoverage:out.loaderCoverage,surfaceWhy,items:out.items||[],itemsError:out.itemsError||null,manifests:extracted.report.manifests,serviceCount:extracted.report.services.length,siteTotal:(extracted.report.callSites||[]).length};
1025
965
  out.ok = true;
1026
966
  out.elapsedMs = Date.now() - started;
1027
967
  } catch (error) {
@@ -1067,9 +1007,12 @@ function renderForModel(compat) {
1067
1007
  const s = r.summary || {};
1068
1008
  L.push(`结论:可用 ${s.ok || 0} 部分可用 ${s.partial || 0} 不兼容 ${s.broken || 0}`
1069
1009
  + (s.vacuousOk ? `(其中 ${s.vacuousOk} 个「可用」是空洞的:该插件没有用过任何 DSH 服务,因此没有可检查的东西)` : ''));
1010
+ L.push(`工具版本:${compat.toolVersion || '未知'}`);
1011
+ for(const note of r.observation?.notes||[])L.push(`采样说明:${note}`);
1012
+ if(s.limited)L.push(`证据覆盖有限:${s.limited} 个插件尚有未验证部分`);
1070
1013
  L.push(`体检对象:${compat.nodeModules}(${compat.nodeModulesWhy})`);
1071
1014
  L.push(r.hasRuntimeSurface
1072
- ? '方法级判定依据:宿主运行时面 —— 这类结论是确定的。'
1015
+ ? '方法级判定依据:宿主运行时面;具体确定性仍以每条发现和覆盖限制为准。'
1073
1016
  : '方法级判定依据:官方 API 目录(**契约目录,不是运行时面**)—— 因此方法级结论一律标「疑似」。'
1074
1017
  + '「疑似」不是缺陷,是诚实:判不准时降级标注,而不是升格成确定。');
1075
1018
  L.push('');
@@ -1088,10 +1031,12 @@ function renderForModel(compat) {
1088
1031
  const cat = {
1089
1032
  'host-service-missing': '服务缺失', 'host-method-missing': '方法缺失',
1090
1033
  'unprovided-dependency': '无人提供的依赖', 'declaration-mismatch': '声明与实测不符',
1034
+ 'declared-incompatible':'版本声明不支持',
1091
1035
  }[f.category] || f.category;
1092
1036
  const sev = { fatal: '致命', 'feature-dead': '功能失效', degraded: '降级' }[f.severity] || f.severity;
1093
1037
  L.push(` ${sev} / ${cat}${f.certainty === 'certain' ? '' : ' [疑似]'} ${api} @ ${loc}`);
1094
- if (f.impact) L.push(` 影响:${f.impact}`);
1038
+ if (f.impact) L.push(` 影响${f.impactBasis==='inferred'?'(推断,非实测)':''}:${f.impact}`);
1039
+ if(f.basis)L.push(` 依据:${f.basis}`);
1095
1040
  }
1096
1041
  const cs = p.clientSide || [];
1097
1042
  if (cs.length) L.push(` 客户端半边(命令行测不到,需界面验证):${cs.map((c) => c.service).join('、')}`);
@@ -1102,9 +1047,9 @@ function renderForModel(compat) {
1102
1047
 
1103
1048
  L.push('');
1104
1049
  L.push('读这个结果时注意两点:');
1105
- L.push(' 1) 「可用」不等于"功能验证过"。它只说明**用到的 DSH 服务与 API 都在**。'
1050
+ L.push(' 1) 「可用」不等于"功能验证过"。它只说明本轮未发现冲突;请同时看证据覆盖与未验证项。'
1106
1051
  + '插件仍然可能因为自身逻辑、配置或第三方依赖而出问题。');
1107
- L.push(' 2) 「客户端半边」的那些服务命令行永远测不到 —— 它们活在浏览器里,'
1052
+ L.push(' 2) 「客户端半边」的那些服务默认报告不验证 —— 它们活在浏览器里,'
1108
1053
  + '只能靠"界面上有没有出现该插件的 UI"来验证。别把"没报错"当成"没问题"。');
1109
1054
  return L.join('\n');
1110
1055
  } catch (error) {
@@ -1115,6 +1060,12 @@ function renderForModel(compat) {
1115
1060
  /** 工具的服务集合:`tools`;`agents` 用来把相对路径解析到会话工作目录。 */
1116
1061
  const TOOL_SERVICES = ['tools', 'agents'];
1117
1062
 
1063
+ function toolResult(compat,text){
1064
+ const s=compat.report?.summary||{};
1065
+ return {schemaVersion:1,kind:'plugin-compat-check/tool-result',ok:compat.ok,error:compat.error,toolVersion:compat.toolVersion,exitCode:compat.exitCode,report:compat.report,baseline:compat.baseline,cache:compat.cache||null,
1066
+ summary:compat.ok?`可用 ${s.ok||0} / 部分可用 ${s.partial||0} / 不兼容 ${s.broken||0}`:'无法判定',nodeModules:compat.nodeModules||'',hasRuntimeSurface:!!compat.report?.hasRuntimeSurface,text};
1067
+ }
1068
+
1118
1069
  function registerTool(ctx, state) {
1119
1070
  // 与 C2 路由同一个理由:走作用域 ctx.inject,headless/CLI 形态下 `tools` 不在场时
1120
1071
  // 这段根本不注册,本插件的 fiber 也就不会去等一个不存在的服务(`inject` 保持 [])。
@@ -1158,12 +1109,21 @@ function registerTool(ctx, state) {
1158
1109
  type: 'object',
1159
1110
  additionalProperties: false,
1160
1111
  properties: {
1112
+ schemaVersion: {type:'integer',enum:[1]},
1113
+ kind: {type:'string'},
1114
+ ok: {type:'boolean'},
1115
+ error: {oneOf:[{type:'string'},{type:'null'}]},
1116
+ exitCode: {type:'integer',enum:[0,1,2,3]},
1117
+ toolVersion: {oneOf:[{type:'string'},{type:'null'}]},
1118
+ report: {type:'object',additionalProperties:true},
1119
+ baseline: {oneOf:[{type:'object',additionalProperties:true},{type:'null'}]},
1120
+ cache: {oneOf:[{type:'object',additionalProperties:true},{type:'null'}]},
1161
1121
  summary: { type: 'string' },
1162
1122
  nodeModules: { type: 'string' },
1163
1123
  hasRuntimeSurface: { type: 'boolean' },
1164
1124
  text: { type: 'string' },
1165
1125
  },
1166
- required: ['summary', 'nodeModules', 'hasRuntimeSurface', 'text'],
1126
+ required: ['schemaVersion','kind','ok','error','exitCode','toolVersion','report','baseline','summary','nodeModules','hasRuntimeSurface','text'],
1167
1127
  },
1168
1128
  render: (_args, value) => [{ type: 'text', text: value && typeof value.text === 'string' ? value.text : '' }],
1169
1129
  },
@@ -1179,35 +1139,13 @@ function registerTool(ctx, state) {
1179
1139
  const wanted = args && typeof args.profile === 'string' && args.profile.trim() !== ''
1180
1140
  ? args.profile.trim()
1181
1141
  : null;
1182
- const prev = process.env.DSC_PROFILE;
1183
- if (wanted) process.env.DSC_PROFILE = wanted;
1184
- let compat;
1185
- try {
1186
- compat = await computeReport(state, ctx);
1187
- } finally {
1188
- if (wanted) {
1189
- if (prev === undefined) delete process.env.DSC_PROFILE;
1190
- else process.env.DSC_PROFILE = prev;
1191
- }
1192
- }
1142
+ const compat = await serveReport(state,ctx,false,wanted?{profile:wanted}:{});
1193
1143
  const text = renderForModel(compat);
1194
- const s = (compat.report && compat.report.summary) || {};
1195
- return {
1196
- summary: `可用 ${s.ok || 0} / 部分可用 ${s.partial || 0} / 不兼容 ${s.broken || 0}`,
1197
- nodeModules: compat.nodeModules || '',
1198
- hasRuntimeSurface: Boolean(compat.report && compat.report.hasRuntimeSurface),
1199
- text,
1200
- };
1144
+ return toolResult(compat,text);
1201
1145
  } catch (error) {
1202
1146
  // 工具的 execute 抛错会把整轮对话变成错误 —— 而"体检失败"不是用户的错,
1203
1147
  // 也不该让他重来一遍。所以这里**不抛**,把失败也变成一段可读的文本。
1204
- return {
1205
- summary: '无法判定',
1206
- nodeModules: '',
1207
- hasRuntimeSurface: false,
1208
- text: `体检没跑成(这是工具的问题,不是插件的结论):${errText(error)}\n`
1209
- + '请不要据此判断兼容性。',
1210
- };
1148
+ return toolResult(failureEnvelope(errText(error)),`体检没跑成(这是工具的问题,不是插件的结论):${errText(error)}`);
1211
1149
  }
1212
1150
  },
1213
1151
  });
@@ -1428,7 +1366,7 @@ export function apply(ctx) {
1428
1366
  } catch (error) {
1429
1367
  // 连处理器里都不抛:把错误变成一条可读的 JSON,而不是 500 空响应。
1430
1368
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
1431
- res.end(JSON.stringify({ ok: false, error: errText(error) }));
1369
+ res.end(JSON.stringify(failureEnvelope(errText(error))));
1432
1370
  }
1433
1371
  },
1434
1372
  });
@@ -1530,7 +1468,7 @@ export function apply(ctx) {
1530
1468
  if (state.autoCheckStarted) return; // 幂等:只跑一次
1531
1469
  state.autoCheckStarted = true;
1532
1470
  try {
1533
- computeReport(state, ctx)
1471
+ serveReport(state, ctx, false)
1534
1472
  .then((out) => {
1535
1473
  state.lastReport = out;
1536
1474
  state.lastReportAt = Date.now();