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 +21 -0
- package/README.md +21 -57
- package/index.js +93 -155
- package/kernel/lib/baseline.mjs +110 -34
- package/kernel/lib/contracts.mjs +493 -0
- package/kernel/lib/declarations.mjs +50 -0
- package/kernel/lib/extract-worker.mjs +10 -0
- package/kernel/lib/extract.mjs +235 -1515
- package/kernel/lib/flow-values.mjs +53 -0
- package/kernel/lib/observation.mjs +182 -0
- package/kernel/lib/report-service.mjs +55 -0
- package/kernel/lib/report.mjs +237 -0
- package/kernel/lib/source-analysis.mjs +1913 -0
- package/kernel/lib/source-graph.mjs +297 -0
- package/kernel/lib/state-store.mjs +126 -0
- package/kernel/lib/syntax.mjs +73 -0
- package/kernel/lib/verdict.mjs +492 -587
- package/kernel/lib/version.mjs +2 -2
- package/kernel/lib/worker-analysis.mjs +82 -0
- package/kernel/rules/README.md +46 -58
- package/kernel/rules/severity.json +161 -35
- package/kernel/rules/usage-inventory.json +369 -61
- package/lib/client.js +523 -10
- package/lib/host-resources.js +92 -0
- package/lib/report-contract.js +494 -0
- package/package.json +14 -5
- package/scripts/prepublish-check.mjs +14 -9
- package/scripts/stage-contract.mjs +43 -0
- package/scripts/stage-kernel.mjs +9 -21
- package/skill/SKILL.md +40 -39
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
|
-
|
|
4
|
-
(`file:line`)和**原因**。
|
|
3
|
+
检查**已经安装的 DSH 插件**与当前 DSH 版本的兼容性,提供源码位置、运行时证据与覆盖限制。包含兼容性面板、插件详情角标、`compat_check` 对话工具和 skill provider。
|
|
5
4
|
|
|
6
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
`settings` 目录 6 个、运行时 10 个;`loader` 完全不收录。
|
|
59
|
-
所以**没有运行时面时,方法级结论一律降为「疑似」**。
|
|
27
|
+
## 判定与边界
|
|
60
28
|
|
|
61
|
-
|
|
29
|
+
当前profile在宿主进程内采样服务描述符,不执行getter;未知访问器保留未知,并纳入能可靠映射的loader FAILED/PENDING。查询其它profile只用静态证据,不借用当前宿主服务。Babel AST扫描不执行目标源码,分析运行在有时间和内存上限的worker中;动态调用和跨函数数据流仍有限。
|
|
62
30
|
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 §
|
|
2
|
+
// (docs/Plan.md §2.4: read ctx.loader.entries(), recover failure reasons,
|
|
3
3
|
// never throw).
|
|
4
4
|
//
|
|
5
|
-
// HARD RULE (Plan.md §
|
|
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 §
|
|
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
|
|
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 ||
|
|
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 §
|
|
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
|
-
*
|
|
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`
|
|
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 §
|
|
658
|
+
* - **`dsh --version` 起子进程**:不行,而且**不是**能力问题 —— docs/Plan.md §1 锁定了
|
|
670
659
|
* 「插件严格只读:不装包、不起子进程、不联网」。为拿一个版本号破这条边界不值。
|
|
671
660
|
* - **`Info.plist`**:macOS 专有(`CFBundleShortVersionString` = 0.2.0-rc.2,能读到)。
|
|
672
|
-
* 本产品要跨平台(§
|
|
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
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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:
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
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
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
1011
|
-
out.
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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(`
|
|
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) 「可用」不等于"功能验证过"
|
|
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',
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1471
|
+
serveReport(state, ctx, false)
|
|
1534
1472
|
.then((out) => {
|
|
1535
1473
|
state.lastReport = out;
|
|
1536
1474
|
state.lastReportAt = Date.now();
|