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.
@@ -1,620 +1,525 @@
1
- /**
2
- * 判定引擎(A8):把「静态提取结果 + 宿主真实 API 面」变成逐插件的五元组结论。
3
- *
4
- * 五元组 = 类别 / 位置 / 依据 / 严重度 / 影响面,外加**确定性**标注。
5
- *
6
- * 四条不可动的原则(Plan.md §2.1、§2.2、§4):
7
- * 1. **LLM 不参与判定**。严重度由分层默认档 + `rules/severity.json` 决定。
8
- * 2. **宁可标疑似**。判不准时降级标注,而不是升格成确定。
9
- * 3. **目录 ≠ 运行时 API 面**。官方目录里抽象服务(`host.jobs`)**一个方法都不声明**,
10
- * 而运行时实现有 29 个方法。拿目录当运行时面会把所有 `jobs.list` 误报成"方法缺失"。
11
- * 所以方法级判定**优先用运行时面**;只有目录声明了方法时才退回目录。
12
- * 4. **客户端服务不能用宿主面判**。`locale` / `slots` 活在浏览器里,宿主探针必然
13
- * 看不到(`present=false`)。拿它去判会得出"服务缺失 → 致命",把 `dsh-keep-awake`
14
- * 这种"部分可用"误判成"不兼容"。官方有对称的**客户端服务目录**,用它区分。
15
- */
16
-
17
- /**
18
- * 一条发现的**影响面**是测出来的,还是我们推断的?
19
- *
20
- * ## 这个字段存在的理由(一次实测的假阳性)
21
- *
22
- * 工具曾对 `dshmarket` 报过「功能失效 / **设置页会消失**」(`certainty: certain`),
23
- * 而作者**早就兜住了** —— 只是能力检测写在一个辅助函数里(`if (!canRegister(…)) return;`),
24
- * 正则看不见。**事实对了**(`settings.register` 确实不在运行时里),**推断错了**。
25
- *
26
- * 所以一条发现里有两种东西,可信度**不一样**:
27
- *
28
- * | | 是什么 | 能不能被独立信号确认 |
29
- * |---|---|---|
30
- * | `basis` | **事实**:我们量到了什么(方法不在运行时里、调用点没有能力检测) | ✅ 能(运行时面 + 源码行) |
31
- * | `impact` | **推断**:这意味着用户会失去什么 | ⚠️ **只有第 ① 层能** |
32
- *
33
- * - **`measured`** —— 有独立机器信号能确认这一条影响。
34
- * 目前**只有**「条目会停在 pending」这一类:宿主自己报 `fiber.state`,
35
- * 而 `tools/labels.mjs` 用合成正负例量过它(真阳性 1 / 假阳性 0 / 漏报 0)。
36
- * - **`inferred`** —— 剩下全部:我们读代码得出的结论。
37
- * 它**不等于错**,但它是**我们的视野**,而视野有边界(辅助函数里的兜底就是一条已知边界)。
38
- *
39
- * ⚠️ **不要因为 `inferred` 就把影响面删掉** —— 用户要的正是"这会让我失去什么"。
40
- * 要做的是**标明它是推断**,而不是把有用的信息藏起来。
41
- */
42
- export const IMPACT_BASIS = {
43
- MEASURED: 'measured', // 有独立机器信号确认
44
- INFERRED: 'inferred', // 我们的推断(默认)
45
- };
46
-
1
+ /** Pure evidence-driven compatibility verdicts. Unknown is not absence. */
2
+ import { normalizeObservation } from "./observation.mjs";
3
+ import { declaredSupport } from "./declarations.mjs";
4
+ export const IMPACT_BASIS = { MEASURED: "measured", INFERRED: "inferred" };
5
+ export const CERTAINTY = { CERTAIN: "certain", SUSPECTED: "suspected" };
47
6
  export const CATEGORY = {
48
- HOST_SERVICE_MISSING: 'host-service-missing',
49
- HOST_METHOD_MISSING: 'host-method-missing',
50
- UNPROVIDED_DEPENDENCY: 'unprovided-dependency',
51
- DECLARATION_MISMATCH: 'declaration-mismatch',
52
- // 客户端半边端不出来 —— 见下面 why:它不是"某个功能没了",而是**整个界面没了**
53
- CLIENT_ENTRY_MISSING: 'client-entry-missing',
54
- }
55
-
56
- export const CERTAINTY = { CERTAIN: 'certain', SUSPECTED: 'suspected' }
57
-
58
- /**
59
- * 每个服务在判定里的**去处**。这是「每一件事都有交代」的落地。
60
- *
61
- * 为什么需要它(实测,不是设想):desktop profile 上有 **63 个服务**落进两条静默
62
- * `continue`(宿主有该服务时直接 continue、宿主没有且无人提供时又 continue),
63
- * 它们既不出现在 findings 里,也不出现在任何「已检查」名单里。后果是
64
- * **「确认没问题」与「根本没看」在输出里长得一模一样** —— 而对一个
65
- * 「告诉你插件跟 DSH 兼不兼容」的工具来说,这两件事的差别就是全部。
66
- *
67
- * 有了它,`services` 与 `checked` 的服务集合相等成为一条可断言的不变式。
68
- */
7
+ HOST_SERVICE_MISSING: "host-service-missing",
8
+ HOST_METHOD_MISSING: "host-method-missing",
9
+ UNPROVIDED_DEPENDENCY: "unprovided-dependency",
10
+ DECLARATION_MISMATCH: "declaration-mismatch",
11
+ DECLARED_INCOMPATIBLE: "declared-incompatible",
12
+ CLIENT_ENTRY_MISSING: "client-entry-missing",
13
+ HOST_ENTRY_INACTIVE: "host-entry-inactive",
14
+ };
69
15
  export const DISPOSITION = {
70
- FINDING: 'finding', // 有结论(见 findings)
71
- CLIENT_SIDE: 'client-side', // 浏览器侧服务,本形态测不到
72
- SELF_PROVIDED: 'self-provided', // 插件自己提供的服务,不是宿主服务
73
- RUNTIME_FACILITY: 'runtime-facility', // Cordis 上下文内置成员
74
- FILTERED: 'filtered', // 判为噪音/假阳性,不进结论
75
- ABSTAINED: 'abstained', // 数据不足以判定
76
- GUARDED: 'guarded', // 作者自己做了能力检测,缺失有兜底
77
- NO_CALLS: 'no-calls', // 只出现名字,没有任何调用点
78
- UNKNOWN: 'unknown', // 兜底:不该出现,出现即是判定引擎的 bug
16
+ FINDING: "finding",
17
+ CLIENT_SIDE: "client-side",
18
+ SELF_PROVIDED: "self-provided",
19
+ RUNTIME_FACILITY: "runtime-facility",
20
+ FILTERED: "filtered",
21
+ ABSTAINED: "abstained",
22
+ GUARDED: "guarded",
23
+ NO_CALLS: "no-calls",
24
+ UNKNOWN: "unknown",
25
+ };
26
+ const builtin = new Set([
27
+ "loader",
28
+ "logger",
29
+ "fiber",
30
+ "effect",
31
+ "inject",
32
+ "provide",
33
+ "reflect",
34
+ "scope",
35
+ "root",
36
+ "get",
37
+ "set",
38
+ "on",
39
+ "off",
40
+ "once",
41
+ "emit",
42
+ "parallel",
43
+ "waterfall",
44
+ "bail",
45
+ "start",
46
+ "stop",
47
+ "dispose",
48
+ "isActive",
49
+ "extend",
50
+ "isolate",
51
+ "intercept",
52
+ "plugin",
53
+ ]);
54
+ const rank = {
55
+ finding: 60,
56
+ guarded: 50,
57
+ filtered: 40,
58
+ "client-side": 30,
59
+ "self-provided": 25,
60
+ "runtime-facility": 24,
61
+ abstained: 20,
62
+ "no-calls": 10,
63
+ unknown: 100,
64
+ };
65
+ const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
66
+ const location = (s) =>
67
+ s
68
+ ? {
69
+ file: s.file,
70
+ line: s.line,
71
+ column: s.column ?? null,
72
+ start: s.start ?? null,
73
+ end: s.end ?? null,
74
+ }
75
+ : null;
76
+ function severity(rules, svc, method, fallback) {
77
+ const rec = methodRule(rules, svc, method, "host");
78
+ if (rec?.severity)
79
+ return {
80
+ severity: rec.severity,
81
+ impact: rec.impact,
82
+ ruleSource: "rule",
83
+ ruleRef: `services.${svc}.methods.${method}`,
84
+ };
85
+ return {
86
+ ...fallback,
87
+ ruleSource: "default",
88
+ ruleRef: own(rules?.services || {}, svc) ? `services.${svc}` : null,
89
+ };
79
90
  }
80
-
81
- /**
82
- * 去处之间的**强度序**:一个服务可能多条路径都命中(某些方法有结论、另一些方法
83
- * 被作者自己兜了底)。此时取最强的那条作为这个服务的去处 —— 因为「有结论」
84
- * 是用户最需要看到的信息,而较弱的那条仍然留在 `checked` 里可查。
85
- *
86
- * UNKNOWN 排最高不是为了让它赢,正相反:它是 bug 信号,必须浮到面上。
87
- */
88
- const DISPOSITION_RANK = {
89
- [DISPOSITION.UNKNOWN]: 100,
90
- [DISPOSITION.FINDING]: 60,
91
- [DISPOSITION.GUARDED]: 50,
92
- [DISPOSITION.FILTERED]: 40,
93
- [DISPOSITION.CLIENT_SIDE]: 30,
94
- [DISPOSITION.SELF_PROVIDED]: 25,
95
- [DISPOSITION.RUNTIME_FACILITY]: 24,
96
- [DISPOSITION.ABSTAINED]: 20,
97
- [DISPOSITION.NO_CALLS]: 10,
91
+ /** Rule semantics belong to a side, even when the service name is shared. */
92
+ export function methodRule(rules, service, method, side = "host") {
93
+ const groups = rules?.services || {};
94
+ const group = own(groups, service) ? groups[service] : null;
95
+ const methods = group?.methods || {};
96
+ const rule = own(methods, method) ? methods[method] : null;
97
+ const layer = rule?.layer || group?.layer;
98
+ if (!rule?.severity || (layer && layer !== "both" && layer !== side))
99
+ return null;
100
+ return rule;
98
101
  }
99
-
100
- const has = (obj, k) => Object.prototype.hasOwnProperty.call(obj, k)
101
-
102
- /**
103
- * Cordis 上下文(ctx)的**内置成员** —— 它们不是 DSH 服务,而是插件运行时框架本身提供的。
104
- *
105
- * 实测教训:`loader` / `effect` / `fiber` 这些名字会出现在插件源码里,但**不在官方
106
- * DSH 服务目录里**。若不排除,判定引擎会得出「宿主服务缺失 → 致命 → 插件不兼容」,
107
- * 而实际上这些设施在任何 Cordis 上下文里都存在。实测 desktop profile 上,
108
- * `loader` 一个名字就把 `dshmarket` 误判成了"不兼容"。
109
- */
110
- const CORDIS_CONTEXT_BUILTINS = new Set([
111
- // ⚠️ `logger` 也在这里,而它**是实测出来的**,不是推断:
112
- // 用 `--probe-boot` 起了一次真实宿主,把 `logger` 放进探针的 wanted 名单,
113
- // 结果是 `present: false`(同一批里 `tools` 39 个方法、`agents` 24 个都在)。
114
- // 也就是说 `ctx.logger` 与 `ctx.loader` / `ctx.effect` 同类 —— 是 Cordis 的上下文
115
- // 便利成员,**不是可注入的服务**。所以插件写 `inject: ['logger']` 并不会让它停在
116
- // pending(实测 dsh-keep-awake / dsh-pet 都这么写,而它们在运行时是好的)。
117
- 'loader', 'logger', 'fiber', 'effect', 'inject', 'provide', 'reflect', 'scope', 'root',
118
- 'get', 'set', 'on', 'off', 'once', 'emit', 'parallel', 'waterfall', 'bail',
119
- 'start', 'stop', 'dispose', 'isActive', 'extend', 'isolate', 'intercept', 'plugin',
120
- ])
121
-
122
- /**
123
- * JavaScript 语言内置的集合/字符串方法名。
124
- *
125
- * 实测教训:插件里 `const sessions = new Map()` 之后的 `sessions.set(...)`、
126
- * `sessions.map(...)` 会被当成"调用了宿主 not-exist 的方法"。只靠名字黑名单**不安全**
127
- * (`sessions.get` 是真实存在的宿主方法),所以配合"该方法不在宿主方法表里"一起用。
128
- */
129
- const JS_BUILTIN_METHODS = new Set([
130
- 'map', 'set', 'sort', 'values', 'has', 'reduce', 'forEach', 'filter', 'find', 'findIndex',
131
- 'push', 'pop', 'shift', 'unshift', 'slice', 'splice', 'indexOf', 'lastIndexOf', 'includes',
132
- 'replace', 'replaceAll', 'split', 'join', 'concat', 'keys', 'delete', 'clear', 'add',
133
- 'toString', 'valueOf', 'then', 'catch', 'finally', 'bind', 'call', 'apply',
134
- 'test', 'exec', 'trim', 'toLowerCase', 'toUpperCase', 'startsWith', 'endsWith',
135
- 'padStart', 'padEnd', 'repeat', 'match', 'search', 'charAt', 'substring', 'substr',
136
- 'flat', 'flatMap', 'some', 'every', 'fill', 'copyWithin', 'reverse', 'at',
137
- 'assign', 'freeze', 'isArray', 'from', 'entries',
138
- ])
139
-
140
- /** 先查 服务.方法,再查 服务,最后落分层默认档 */
141
- function lookupSeverity(rules, service, method, fallback) {
142
- const svc = (rules.services || {})[service]
143
- if (svc) {
144
- const m = method ? (svc.methods || {})[method] : null
145
- if (m && m.severity) {
146
- // ruleRef 必须**结构化地**带出去,不能只在 basis 里写成一句散文。
147
- // 理由:规则表是 34 KB 手工维护的数据文件,改错一个键名会让严重度静默
148
- // 降级到默认档 —— 那正是最该被发现的一类错误,而散文无法被断言。
149
- return {
150
- severity: m.severity,
151
- impact: m.impact,
152
- source: 'rule',
153
- ruleRef: `services.${service}${method ? `.methods.${method}` : ''}`,
154
- }
155
- }
156
- if (svc.methods && method) {
157
- return {
158
- severity: fallback.severity,
159
- impact: fallback.impact,
160
- source: 'default',
161
- ruleRef: `services.${service}`,
162
- ruleNote: '规则表覆盖了该服务但未覆盖这个方法',
163
- }
164
- }
165
- }
166
- return { severity: fallback.severity, impact: fallback.impact, source: 'default', ruleRef: null }
102
+ export function declarationOf(p, version) {
103
+ return declaredSupport(p, version);
167
104
  }
168
-
169
- /**
170
- * @param {object} o.report lib/extract.mjs 的 static.json
171
- * @param {object?} o.hostSurface 运行时宿主 API 面(探针产物);缺失则退回目录
172
- * @param {object} o.rules rules/severity.json
173
- * @param {object} o.catalog rules/dsh-api-catalog.json({host, client})
174
- * @param {string?} o.dshVersion
175
- */
176
- export function buildVerdicts({ report, hostSurface = null, rules, catalog, dshVersion = null }) {
177
- const hostCat = (catalog && catalog.host) || {}
178
- const clientCat = (catalog && catalog.client) || {}
179
- const providedAnywhere = new Set(report.providedServices || [])
180
- // 「被某个插件声明过」的服务(inject / ctx.inject / ctx.get / ctx.<svc>.<m>())。
181
- // 用途见下面兜底分支:**声明了但没有任何调用点** ≠ 「只是名字出现过」。
182
- // 前者是真实依赖(只是插件没用它),后者只是噪音 —— 两者不能同样处理。
183
- const declaredAnywhere = new Set()
184
- for (const p of Object.values(report.perPlugin || {})) {
185
- for (const s of p.declaredServices || []) declaredAnywhere.add(s)
105
+ export function buildVerdicts({
106
+ report,
107
+ hostSurface = null,
108
+ rules = {},
109
+ catalog = {},
110
+ dshVersion = null,
111
+ entryStates = [],
112
+ observationTarget = {},
113
+ }) {
114
+ const observation = normalizeObservation(hostSurface, {
115
+ dshVersion,
116
+ ...observationTarget,
117
+ });
118
+ const hostCat = catalog.host || {},
119
+ clientCat = catalog.client || {};
120
+ const providers = new Set();
121
+ // In-process loader evidence is independent. File-probe entries share the
122
+ // probe's identity/age validation and must be rejected together with it.
123
+ const probeEntries =
124
+ observation.usable && Array.isArray(hostSurface?.entries?.plugins)
125
+ ? hostSurface.entries.plugins.filter((r) => r && typeof r === "object")
126
+ : [];
127
+ const rows = [...(entryStates || []), ...probeEntries];
128
+ for (const [pkg, p] of Object.entries(report.perPlugin || {})) {
129
+ const states = rows.filter((r) => r.packageName === pkg && !r.group);
130
+ if (
131
+ states.length &&
132
+ !states.some((r) => !r.disabled && r.state === "ACTIVE")
133
+ )
134
+ continue;
135
+ for (const svc of p.providedServices || []) providers.add(svc);
186
136
  }
187
- const runtimeRoot = (hostSurface && hostSurface.root) || null
188
-
189
- const plugins = {}
190
- const summary = { ok: 0, partial: 0, broken: 0, unknown: 0 }
191
-
137
+ const plugins = {},
138
+ summary = { ok: 0, partial: 0, broken: 0, unknown: 0 };
192
139
  for (const [name, p] of Object.entries(report.perPlugin || {})) {
193
- const declared = new Set(p.declaredServices || [])
194
- const softDeclared = new Set(p.softDeclaredServices || []) // ctx.get 这类软依赖
195
- const selfProvided = new Set(p.providedServices || [])
196
- // 「插件作者自己做了能力检测」的 service.method 对(`lib/extract.mjs` 的 guardedPairs)。
197
- // 对这种对报「方法缺失」是**假阳性**:作者明知它可能不在并自己兜了底,
198
- // 用户并没有失去任何功能。实测 `dsh-pet` 的 `llm.listProviders` 就是这样
199
- // (`typeof llm?.listProviders === "function" ? ... : []`),
200
- // 不排除掉就会把「可用」错报成「部分可用」—— 而假阳性正是这个工具唯一卖点的反面。
201
- const guarded = new Set(p.guardedPairs || [])
202
- const findings = []
203
- const clientSide = []
204
- const filtered = []
205
- const abstained = []
206
- const runtimeFacilities = []
207
- // **每个服务的去处**。append-only:一个服务可以有多条记录(例如某些方法有结论、
208
- // 另一些被作者自己兜了底),最终去处取强度最高的那条 —— 见 DISPOSITION_RANK。
209
- // 全留着的理由:空谈「放弃了什么」没有意义,要能逐条查。
210
- const checked = []
211
- const account = (svc, disposition, extra = {}) => checked.push({ service: svc, disposition, ...extra })
212
-
213
- // ── H1' 的宿主侧那一半:客户端半边真的端得出来吗 ──
214
- //
215
- // 为什么放在服务循环**之前**、且单列一类:实测过这个分界(Plan.md §6)——
216
- // 客户端条目加载失败时**整个界面被一张错误屏替代**(`#root` 从 35397 字符剩 450)。
217
- // 也就是说它不属于"某个服务的某个方法没了",而是"用户什么都用不了"。
218
- // 把它混进服务级发现会**低估**它。
219
- //
220
- // ⚠️ 它**不是**完整的 H1':只回答"声明了、文件在不在、能不能解析到"。
221
- // 文件在但浏览器里 import 失败(语法错/依赖缺失/运行时报错)**测不到** ——
222
- // 那需要浏览器。不要把这条检查说成"客户端对账已完成"。
223
- if (p.clientHalf && p.clientHalf.declared && p.clientHalf.ok === false) {
224
- findings.push({
140
+ const findings = [],
141
+ checked = [],
142
+ clientSide = [],
143
+ filtered = [],
144
+ abstained = [],
145
+ runtimeFacilities = [];
146
+ const resolved = Object.create(null);
147
+ const limitations = [
148
+ ...observation.notes,
149
+ ...(p.diagnostics || []).map((d) => `${d.kind}: ${d.file ?? ""}`),
150
+ ];
151
+ let verified = 0;
152
+ const account = (service, disposition, extra = {}) => {
153
+ const value = { service, disposition, ...extra };
154
+ checked.push(value);
155
+ if (
156
+ !resolved[service] ||
157
+ rank[disposition] > rank[resolved[service].disposition]
158
+ )
159
+ Object.defineProperty(resolved, service, {
160
+ value,
161
+ writable: true,
162
+ enumerable: true,
163
+ configurable: true,
164
+ });
165
+ };
166
+ const add = (f) => findings.push(f);
167
+ const packageEntries = rows.filter(
168
+ (r) => r.packageName === name && !r.group,
169
+ );
170
+ for (const row of packageEntries) {
171
+ if (row.disabled) continue;
172
+ if (row.state === "FAILED" || row.state === "PENDING")
173
+ add({
174
+ category: CATEGORY.HOST_ENTRY_INACTIVE,
175
+ certainty: "certain",
176
+ impactBasis: "measured",
177
+ service: null,
178
+ method: null,
179
+ location: null,
180
+ severity: "fatal",
181
+ impact: `宿主条目 ${row.id ?? name} 当前为 ${row.state}`,
182
+ basis: row.failureReason || `loader 实际状态:${row.state}`,
183
+ evidence: { entryId: row.id ?? null, state: row.state },
184
+ ruleSource: "runtime",
185
+ ruleRef: "loader.entry.state",
186
+ });
187
+ else if (row.state !== "ACTIVE")
188
+ limitations.push(`条目 ${row.id ?? name} 尚未稳定:${row.state}`);
189
+ }
190
+ if (p.clientHalf?.declared && p.clientHalf.ok === false)
191
+ add({
225
192
  category: CATEGORY.CLIENT_ENTRY_MISSING,
226
- // 条目取不到是**静态事实**(文件不在);「整个界面被错误屏替代」是我们实测过的行为。
227
- impactBasis: IMPACT_BASIS.MEASURED,
228
- certainty: CERTAINTY.CERTAIN,
193
+ certainty: "certain",
194
+ impactBasis: "inferred",
229
195
  service: null,
230
196
  method: null,
231
197
  location: null,
232
- severity: 'fatal',
233
- // ⚠️ 实测出**两种不同**的失败,而它们的严重度不一样(`tools/client-arrival.mjs`):
234
- // ① 文件**不存在** → 宿主**拒绝启动**:`startup failed: 1 required plugin did not activate`
235
- // (`@deepseek-ai/dsh-client-modules` 在 compose 阶段就抛),用户连 URL 都拿不到;
236
- // ② 文件在但**导入/执行失败** → 宿主起来了,浏览器里整个界面被一张错误屏替代。
237
- // 静态检查只能判 ① 的那一半(文件在不在),所以影响面按更严重的那种写。
238
- impact: '客户端半边取不到 —— 宿主会在启动时拒绝加载(`startup failed: … did not activate`),'
239
- + '用户连界面都进不去;若文件在但导入失败,界面会被一张错误屏替代',
198
+ severity: "fatal",
199
+ impact: "声明的客户端入口不可交付,宿主可能拒绝加载客户端",
240
200
  basis: p.clientHalf.why,
241
- ruleSource: 'runtime',
242
- ruleRef: 'plugins.client.half',
243
- })
244
- }
245
-
246
- for (const svc of p.services) {
247
- if (selfProvided.has(svc)) {
248
- account(svc, DISPOSITION.SELF_PROVIDED, { reason: '插件自己用 ctx.provide 提供的服务,不是宿主服务' })
249
- continue
201
+ ruleSource: "static",
202
+ ruleRef: "plugins.client.half",
203
+ });
204
+ const self = new Set(p.providedServices || []);
205
+ for (const svc of p.services || []) {
206
+ const allSites = (p.callSites || []).filter((s) => s.service === svc);
207
+ const deps = (p.dependencies || []).filter((d) => d.service === svc);
208
+ const sidesFor = (s) =>
209
+ s.sides ||
210
+ p.fileSides?.[s.file] ||
211
+ ((p.files?.["client-entry"] || []).includes(s.file) ||
212
+ (p.files?.["client-extra"] || []).includes(s.file)
213
+ ? ["client"]
214
+ : ["host"]);
215
+ const hostSites = allSites.filter((s) => sidesFor(s).includes("host"));
216
+ const clientSites = allSites.filter((s) =>
217
+ sidesFor(s).includes("client"),
218
+ );
219
+ const hostDeps = deps.filter((d) => sidesFor(d).includes("host"));
220
+ if (
221
+ hostSites.some((s) => s.inferredReceiver) ||
222
+ hostDeps.some((d) => d.inferred)
223
+ )
224
+ limitations.push(`${svc} Context 绑定仅由语法推断`);
225
+ const legacyHard =
226
+ !p.dependencies &&
227
+ (p.declaredServices || []).includes(svc) &&
228
+ !(p.softDeclaredServices || []).includes(svc);
229
+ const hard =
230
+ legacyHard || hostDeps.some((d) => d.kind === "required-inject");
231
+ const hostUse = hostSites.length > 0 || hostDeps.length > 0 || legacyHard;
232
+ const clientUse =
233
+ clientSites.length > 0 ||
234
+ deps.some((d) => sidesFor(d).includes("client"));
235
+ if (clientUse || (!hostUse && own(clientCat, svc)))
236
+ clientSide.push({ service: svc, note: "客户端证据未作浏览器功能验证" });
237
+ if (!hostUse && clientUse) {
238
+ account(svc, "client-side", {
239
+ reason: "只有客户端需求,宿主面不用于判定",
240
+ });
241
+ limitations.push(`${svc} 客户端尚未验证`);
242
+ continue;
243
+ }
244
+ if (builtin.has(svc)) {
245
+ runtimeFacilities.push({
246
+ name: svc,
247
+ reason: "Cordis 上下文设施,不按 DSH 服务目录判定",
248
+ });
249
+ account(svc, "runtime-facility");
250
+ continue;
250
251
  }
251
- if (CORDIS_CONTEXT_BUILTINS.has(svc)) {
252
- runtimeFacilities.push({ name: svc, reason: 'Cordis 上下文内置成员,不是 DSH 服务' })
253
- account(svc, DISPOSITION.RUNTIME_FACILITY, { reason: 'Cordis 上下文内置成员' })
254
- continue
252
+ const observed = observation.root[svc] || {
253
+ status: "unobserved",
254
+ methods: [],
255
+ complete: false,
256
+ };
257
+ if (!hostUse && self.has(svc)) {
258
+ account(svc, "self-provided", { reason: "插件提供声明,无消费调用" });
259
+ continue;
255
260
  }
256
-
257
- const inHostCat = has(hostCat, svc)
258
- const inClientCat = has(clientCat, svc)
259
- const runtimeInfo = runtimeRoot ? runtimeRoot[svc] : null
260
- const runtimePresent = runtimeInfo ? Boolean(runtimeInfo.present) : null
261
- const runtimeMethods = runtimeInfo && runtimeInfo.methods ? Object.keys(runtimeInfo.methods) : null
262
-
263
- // 判定「宿主有没有」:运行时说有就是有;运行时没探到才退回目录
264
- const hostHas = runtimePresent === true || (runtimePresent === null && inHostCat)
265
-
266
- // ── 三档确定性(Plan.md §2.2)──
267
- const certainty = declared.has(svc)
268
- ? CERTAINTY.CERTAIN
269
- : ((inHostCat || inClientCat) ? CERTAINTY.SUSPECTED : null)
270
- if (certainty === null) {
271
- filtered.push({ service: svc, reason: '无可信声明,且不在宿主/客户端官方目录里(多半是 vendored 代码里的同名变量)' })
272
- account(svc, DISPOSITION.FILTERED, { reason: '无可信声明,且不在官方双端目录里' })
273
- continue
261
+ if (
262
+ (self.has(svc) || providers.has(svc)) &&
263
+ !(observed.status === "present" && observed.complete)
264
+ ) {
265
+ limitations.push(`${svc} 仅有提供声明,没有完整的运行时方法证据`);
266
+ account(svc, "abstained", { reason: "提供声明不是运行时可用证明" });
267
+ continue;
274
268
  }
275
-
276
- // ── 客户端服务:本形态测不到,不得判为不兼容 ──
277
- //
278
- // 两个判据合起来用:
279
- // a) 在官方客户端目录里(权威,但那份目录只有 8 个条目,远不完整);
280
- // b) 它出现的文件**全部**落在客户端桶里(`client-entry` / `client-extra`)。
281
- // 判据 b 是从我们自己的来源分桶推出来的,能覆盖目录没收录的客户端服务
282
- // (实测 `sidebarRight` / `configForms` / `settingsScope` / `pluginNavigation`
283
- // 都是这一类 —— 只靠目录会把他们误判成"宿主服务缺失 → 致命",把
284
- // `dsh-agent-teams` 这种插件错报成"不兼容")。
285
- const clientFileSet = new Set([...(p.files?.['client-entry'] || []), ...(p.files?.['client-extra'] || [])])
286
- const filesOfSvc = (p.serviceFiles || {})[svc] || []
287
- const onlyInClientFiles = filesOfSvc.length > 0 && filesOfSvc.every((f) => clientFileSet.has(f))
288
- // 判据 c:插件**自己的 `dsh.client.inject`** 里点名了它。
289
- //
290
- // 为什么需要第三条:判据 b 要求"出现的文件**全部**在客户端桶里",而共用代码
291
- // (同一个 `lib/index.js` 既跑在宿主也被浏览器 import)会给服务留下一个宿主文件,
292
- // 于是 b 不成立。实测 `dsh-better-sidebar` 的 `modules` 就是这样:
293
- // 它出现在 5 个 `client-*.js` 加一个 `lib/index.js`,而插件自己的
294
- // `clientInject` 里赫然写着 `@deepseek-ai/dsh-client-modules` —— 也就是说
295
- // **作者已经声明了它属于客户端**,只是共用代码把文件判据搅浑了。
296
- //
297
- // 匹配方式:包名 `@scope/dsh-client-<x>` 的服务名就是 `<x>`(也可能整体就是服务名)。
298
- // 只做**后缀匹配**,不做模糊猜测 —— 名字对不上就不算,宁可多报也不误吞。
299
- const clientInjectNames = new Set()
300
- for (const spec of (p.clientInject || [])) {
301
- if (typeof spec !== 'string' || !spec) continue
302
- clientInjectNames.add(spec)
303
- const tail = spec.split('/').pop() || spec
304
- clientInjectNames.add(tail)
305
- const m = /(?:^|-)client-(.+)$/.exec(tail)
306
- if (m && m[1]) clientInjectNames.add(m[1])
269
+ const unknown =
270
+ observed.status === "unobserved" || observed.status === "unreadable";
271
+ if (unknown) {
272
+ limitations.push(
273
+ `${svc} ${observed.status === "unreadable" ? "采样失败" : "未采样"}`,
274
+ );
307
275
  }
308
- const declaredClientInjected = clientInjectNames.has(svc)
309
- // 判据 d:它在插件的**客户端文件**里出现过,而且宿主没有它。
310
- //
311
- // 判据 b 要求"全部文件都在客户端桶里",这在**共用代码**面前必然失效:
312
- // 同一个 `lib/index.js` 既跑在宿主也被浏览器 import,于是服务总会留下一个
313
- // 宿主文件。实测 `settingsScope` 就是 `lib/client.js` + `lib/index.js` 各一份。
314
- //
315
- // d 比 b 宽一档:**只要在客户端出现过**就算(但仍要求宿主没有它)。
316
- // 方向上是安全的 —— 客户端和宿主都出现的服务,如果宿主真的有,`hostHas` 已经
317
- // 把它拦下了;走到这里说明宿主确实没有,而它又在浏览器侧被用到,
318
- // 那它就是"宿主形态看不见",不该报成插件坏了。
319
- //
320
- // 实测这一档把最后两大类假阳性清掉:`settingsScope`(共用代码)与
321
- // `modules`(clientInject 点名但名字对不上)。
322
- const inAnyClientFile = filesOfSvc.some((f) => clientFileSet.has(f))
323
- if (!hostHas && (inClientCat || onlyInClientFiles || declaredClientInjected || inAnyClientFile)) {
324
- clientSide.push({
276
+ if (observed.status === "absent") {
277
+ const sev = hard
278
+ ? {
279
+ severity: "fatal",
280
+ impact: "插件顶层硬依赖在本次运行时面中明确不存在",
281
+ }
282
+ : {
283
+ severity: "degraded",
284
+ impact: "读取或子作用域依赖的服务不存在;相关行为需进一步验证",
285
+ };
286
+ add({
287
+ category: hard
288
+ ? CATEGORY.HOST_SERVICE_MISSING
289
+ : CATEGORY.UNPROVIDED_DEPENDENCY,
290
+ certainty:
291
+ hard ||
292
+ [...hostSites, ...hostDeps].some(
293
+ (s) => !s.inferredReceiver && !s.inferred,
294
+ )
295
+ ? "certain"
296
+ : "suspected",
297
+ impactBasis: "inferred",
325
298
  service: svc,
326
- note: '客户端服务(浏览器侧)。宿主探针看不到它,命令行形态也测不到 —— 需界面验证',
327
- basis: inClientCat
328
- ? '在官方客户端服务目录里'
329
- : (onlyInClientFiles
330
- ? '它出现的文件全部属于客户端半边(按来源分桶判定)'
331
- : (onlyInClientFiles
332
- ? '它出现的文件全部属于客户端半边(按来源分桶判定)'
333
- : '它在插件自己的客户端文件里被用到,而宿主没有它')),
334
- })
335
- account(svc, DISPOSITION.CLIENT_SIDE, {
336
- reason: inClientCat
337
- ? '在官方客户端服务目录里'
338
- : (onlyInClientFiles ? '它出现的文件全部属于客户端半边'
339
- : (declaredClientInjected ? '插件自己的 client.inject 点了名'
340
- : '它在插件自己的客户端文件里被用到,而宿主没有它')),
341
- })
342
- continue
299
+ method: null,
300
+ location: location(hostDeps[0] || hostSites[0]),
301
+ ...severity(rules, svc, null, sev),
302
+ evidence: {
303
+ status: "absent",
304
+ hardInject: hard,
305
+ inHostCatalog: own(hostCat, svc),
306
+ providedByAnyInstalledPlugin: false,
307
+ },
308
+ basis: "本次探针明确记录该服务不存在;依赖种类与作用域独立保留",
309
+ });
310
+ account(svc, "finding", { reason: "运行时明确缺失" });
311
+ continue;
343
312
  }
344
-
345
- if (hostHas) {
346
- let methods = runtimeMethods
347
- let methodSource = '运行时'
348
- if (!methods) {
349
- const cm = (hostCat[svc] || {}).methods || []
350
- if (cm.length === 0) {
351
- abstained.push({
352
- service: svc,
353
- basis: `宿主目录对 ${svc} 未声明任何方法(抽象服务或客户端服务),本次也没有运行时面可用 —— 无法判定方法级兼容性`,
354
- })
355
- account(svc, DISPOSITION.ABSTAINED, { reason: '宿主目录未声明任何方法,且无运行时面 —— 无法判定' })
356
- continue
357
- }
358
- methods = cm
359
- methodSource = '目录'
360
- }
361
- // 走到这里说明这个服务**确实被看过**了。先记一条兜底去处:即使下面
362
- // 一个方法都没报,用户也能看到「这个服务检查过了」,而不是一片空白。
363
- // 若下面真的报了,更强的 FINDING 会覆盖它(见 DISPOSITION_RANK)。
364
- //
365
- // ⚠️ 但**一个调用点都没有**的服务不能记成 ABSTAINED。那是本会话里真实
366
- // 出现过的静默回归的形状:某次改动让 `calls` 变成空对象而 `services` 一字未变,
367
- // 于是「每条调用都兼容」与「什么调用都没看到」在输出里长得一模一样,
368
- // 结论从「部分可用」翻成「可用」。所以零调用点必须留下 no-calls 痕迹 ——
369
- // 它明确说「没有任何证据」,而不是含糊的「暂时判不了」。
370
- const usedMethods = p.calls[svc] || []
371
-
372
- // ── 只判**宿主侧**的调用点;只在客户端调用的方法不进结论 ──
373
- //
374
- // 为什么必须分:`sessions` 这类服务**两端都有**(宿主 14 个方法,浏览器侧另有一套)。
375
- // 而命令行只能拿到宿主面,于是 `ctx.sessions.scope?.()` 这种**写在 lib/client.js 里**
376
- // 的调用,会被拿宿主方法表去对,对不上就报「宿主方法缺失」—— 实测
377
- // `dsh-better-sidebar` / `dsh-mnemon` / `dsh-recall-plugin` 的 `sessions.*`
378
- // 全是这一类,**全是假阳性**。
379
- //
380
- // 做法:把"这个方法在哪些文件被调用过"列出来,只有当**至少有一个宿主侧调用点**时
381
- // 才拿宿主面去判。全部调用点都在客户端文件里的方法,归入「客户端半边,需界面验证」。
382
- const sitesOfService = (p.callSites || []).filter((x) => x.service === svc)
383
- const hostSideMethods = new Set()
384
- const clientOnlyMethods = new Set()
385
- for (const site of sitesOfService) {
386
- if (clientFileSet.has(site.file)) clientOnlyMethods.add(site.method)
387
- else hostSideMethods.add(site.method)
388
- }
389
- // `p.calls` 是"这个插件调用过的方法名"的并集,没有文件维度 ——
390
- // 所以用调用点把只在客户端出现的那些摘出去。
391
- const hostSideUsed = usedMethods.filter((m) => hostSideMethods.has(m) || !clientOnlyMethods.has(m))
392
- for (const m of usedMethods) {
393
- if (clientOnlyMethods.has(m) && !hostSideMethods.has(m)) {
394
- account(svc, DISPOSITION.CLIENT_SIDE, {
395
- method: m,
396
- reason: '该方法只在客户端文件里被调用 —— 浏览器侧另有一套 API,宿主面看不见它',
397
- })
398
- }
399
- }
400
- account(svc, usedMethods.length === 0 ? DISPOSITION.NO_CALLS : DISPOSITION.ABSTAINED, {
401
- reason: usedMethods.length === 0
402
- ? `宿主有该服务,但插件对它没有任何调用点 —— 无证据可判(方法清单来自${methodSource})`
403
- : `宿主有该服务,方法级判定来自${methodSource}(共 ${methods.length} 个方法)`,
404
- })
405
- const methodSet = new Set(methods)
406
- const catalogMethods = new Set(((hostCat[svc] || {}).methods) || [])
407
- for (const used of hostSideUsed) {
408
- if (methodSet.has(used)) continue
409
- // 作者自己检测过的调用 → 不进结论,但要记进 filtered(可审计,不是静默丢)
410
- if (guarded.has(`${svc}.${used}`)) {
411
- filtered.push({
412
- service: svc,
413
- method: used,
414
- reason: '插件在调用前自己做了能力检测(可选链或 typeof === "function"),缺失时它已有兜底行为 —— 报出来是假阳性',
415
- })
416
- account(svc, DISPOSITION.GUARDED, { method: used, reason: '作者自己做了能力检测,缺失有兜底' })
417
- continue
418
- }
419
- // 集合方法噪音:既不在宿主方法表里、又是 JS 内置方法名 → 判噪音而非缺失。
420
- // 必须两个条件同时成立:`sessions.get` 虽在 JS 内置名单里,但它在宿主方法表里,属真方法。
421
- if (JS_BUILTIN_METHODS.has(used) && !catalogMethods.has(used)) {
422
- filtered.push({ service: svc, method: used, reason: 'JS 内置方法名,且不在宿主方法表里(大概率是对局部集合/字符串的调用)' })
423
- account(svc, DISPOSITION.FILTERED, { method: used, reason: 'JS 内置方法名且不在宿主方法表里' })
424
- continue
425
- }
426
- const sev = lookupSeverity(rules, svc, used, { severity: 'feature-dead', impact: '影响面未知' })
427
- findings.push({
428
- category: CATEGORY.HOST_METHOD_MISSING,
429
- // 方法不在运行时里是**事实**;「这个功能会死」是**推断** —— 作者可能兜在别处。
430
- impactBasis: IMPACT_BASIS.INFERRED,
431
- // 只有官方目录、没有运行时面时,方法级结论降级为「疑似」:
432
- // 目录对抽象服务(jobs)一个方法都不声明,对另一些服务(settings)也少于运行时实有。
433
- // 拿一份已知不完整的清单去断言"方法不存在",不够格叫确定。
434
- certainty: methodSource === '运行时' ? certainty : CERTAINTY.SUSPECTED,
313
+ let methods = null,
314
+ methodSource = "目录";
315
+ if (observed.status === "present" && observed.complete) {
316
+ methods = observed.methods;
317
+ methodSource = "运行时";
318
+ } else if (
319
+ unknown &&
320
+ Array.isArray(hostCat[svc]?.methods) &&
321
+ hostCat[svc].methods.length
322
+ )
323
+ methods = hostCat[svc].methods;
324
+ if (!methods) {
325
+ if (unknown && !own(hostCat, svc) && hostUse) {
326
+ add({
327
+ category: CATEGORY.UNPROVIDED_DEPENDENCY,
328
+ certainty: "suspected",
329
+ impactBasis: "inferred",
435
330
  service: svc,
436
- method: used,
437
- location: firstSite(p, svc, used),
438
- evidence: { methodSource, hostMethodCount: methods.length },
439
- severity: sev.severity,
440
- impact: sev.impact,
441
- // 结构化的规则来源:basis 里那句散文无法被断言,而规则表是手工维护的数据文件
442
- ruleSource: sev.source,
443
- ruleRef: sev.ruleRef ?? null,
444
- ...(sev.ruleNote ? { ruleNote: sev.ruleNote } : {}),
445
- basis: sev.source === 'rule'
446
- ? `${methodSource}里没有这个方法;规则表给出影响面`
447
- : `${methodSource}里没有这个方法;规则表未覆盖该 API,按默认档判为功能失效、影响面未知`,
448
- })
449
- account(svc, DISPOSITION.FINDING, { method: used, severity: sev.severity, ruleSource: sev.source })
331
+ method: null,
332
+ location: location(hostDeps[0] || hostSites[0]),
333
+ severity: "degraded",
334
+ impact: "该依赖没有足够运行时或目录证据,不能确认可用或缺失",
335
+ basis: "未采样不等于不存在;仅列出待核实依赖",
336
+ ruleSource: "default",
337
+ ruleRef: null,
338
+ });
339
+ account(svc, "finding");
340
+ } else {
341
+ const reason =
342
+ observed.status === "present"
343
+ ? "方法采样缺失或被截断,不能判断缺方法"
344
+ : "没有完整方法证据";
345
+ abstained.push({ service: svc, basis: reason });
346
+ account(svc, hostSites.length ? "abstained" : "no-calls", { reason });
347
+ limitations.push(`${svc} ${reason}`);
450
348
  }
451
- continue
349
+ continue;
452
350
  }
453
-
454
- // ── 宿主没有:看有没有别的插件提供 ──
455
- if (providedAnywhere.has(svc)) {
456
- account(svc, DISPOSITION.SELF_PROVIDED, { reason: '由已安装的某个插件提供(见 report.providedServices)' })
457
- continue
351
+ const unknownMethods = new Set(observed.unknownMethods || []),
352
+ nonCallable = new Set(observed.nonCallableMethods || []);
353
+ const protectedCall = (s) =>
354
+ s.guarded &&
355
+ !(
356
+ s.guardKind === "optional-method" &&
357
+ nonCallable.has(s.method) &&
358
+ !s.capabilityGuarded
359
+ );
360
+ for (const site of hostSites)
361
+ if (unknownMethods.has(site.method))
362
+ limitations.push(
363
+ `${svc}.${site.method} 是未读取的访问器,可调用性未知`,
364
+ );
365
+ const missing = hostSites.filter(
366
+ (s) => !methods.includes(s.method) && !unknownMethods.has(s.method),
367
+ );
368
+ if (hostSites.length === 0) {
369
+ account(svc, "no-calls", {
370
+ reason: "没有宿主调用点,不能证明功能可用",
371
+ });
372
+ continue;
458
373
  }
459
-
460
- // **只有目录、没有运行时面时,不能断言"宿主没有这个服务"。**
461
- // 官方目录是**契约**目录:它对 `loader` 完全不收录、对 `jobs` 一个方法都不声明、
462
- // 对 `settings` 声明的方法数也少于运行时实有。拿它当"宿主服务清单"会得出
463
- // 「服务缺失 → 致命 → 插件不兼容」的错误结论(实测把 `dsh-mnemon`、`dsh-catppuccin`
464
- // 错报成"不兼容")。所以此时降级为「疑似 + 降级」,而不是「确定 + 致命」。
465
- const hasRuntime = runtimeRoot !== null
466
- const isSoft = softDeclared.has(svc) || !hasRuntime
467
- const sev = lookupSeverity(rules, svc, null, isSoft
468
- ? { severity: 'degraded', impact: '依赖的服务由其它插件提供,当前 profile 里没有提供方 —— 相关功能降级或不可用' }
469
- : { severity: 'fatal', impact: '插件条目会停在 pending(等待该服务),等同于没起来' })
470
- findings.push({
471
- category: isSoft ? CATEGORY.UNPROVIDED_DEPENDENCY : CATEGORY.HOST_SERVICE_MISSING,
472
- // 硬依赖缺失会让**条目停在 pending** —— 这一条有独立机器信号(宿主自己报 fiber.state),
473
- // 而 `tools/labels.mjs` 用合成正负例量过它(真阳性 1 / 假阳性 0 / 漏报 0)。
474
- // 软取只是降级,「会不会真的缺」是推断。
475
- impactBasis: isSoft ? IMPACT_BASIS.INFERRED : IMPACT_BASIS.MEASURED,
476
- certainty: hasRuntime ? certainty : CERTAINTY.SUSPECTED,
477
- service: svc,
374
+ account(svc, "abstained", {
375
+ reason: `已比对 ${hostSites.length} 个宿主调用点,依据${methodSource}`,
376
+ });
377
+ for (const method of [...new Set(missing.map((s) => s.method))]) {
378
+ const relevant = missing.filter((s) => s.method === method);
379
+ const unsafe = relevant.filter((s) => !protectedCall(s));
380
+ if (unsafe.length === 0) {
381
+ filtered.push({
382
+ service: svc,
383
+ method,
384
+ reason: "调用自身或控制它的分支明确检查方法能力",
385
+ });
386
+ account(svc, "guarded", { method });
387
+ continue;
388
+ }
389
+ add({
390
+ category: CATEGORY.HOST_METHOD_MISSING,
391
+ certainty:
392
+ methodSource === "运行时" &&
393
+ unsafe.every(
394
+ (s) => s.evidenceSource === "ast" && !s.inferredReceiver,
395
+ )
396
+ ? "certain"
397
+ : "suspected",
398
+ impactBasis: "inferred",
399
+ service: svc,
400
+ method,
401
+ location: location(unsafe[0]),
402
+ ...severity(rules, svc, method, {
403
+ severity: "feature-dead",
404
+ impact: "影响面未知",
405
+ }),
406
+ evidence: {
407
+ methodSource,
408
+ hostMethodCount: methods.length,
409
+ callSource: unsafe[0].evidenceSource || "legacy",
410
+ spans: unsafe.map((s) => ({
411
+ file: s.file,
412
+ start: s.start,
413
+ end: s.end,
414
+ })),
415
+ },
416
+ basis: `${methodSource}方法集没有此方法;源码存在未被方法能力检查保护的宿主调用`,
417
+ });
418
+ account(svc, "finding", { method });
419
+ }
420
+ if (methodSource === "运行时")
421
+ verified += hostSites.filter(
422
+ (s) => methods.includes(s.method) || protectedCall(s),
423
+ ).length;
424
+ }
425
+ const declaration = declarationOf(p, dshVersion);
426
+ const rejectedPeers =
427
+ declaration?.raw?.peers?.filter(
428
+ (r) => r.source === "peerDependencies" && !r.compatible,
429
+ ) || [];
430
+ if (rejectedPeers.length) {
431
+ limitations.push(
432
+ "官方 peer 范围不匹配;未读取 exact-version 豁免,不能断言实际加载失败",
433
+ );
434
+ add({
435
+ category: CATEGORY.DECLARED_INCOMPATIBLE,
436
+ certainty: "certain",
437
+ impactBasis: "inferred",
438
+ service: null,
478
439
  method: null,
479
440
  location: null,
480
- evidence: { inHostCatalog: inHostCat, providedByAnyInstalledPlugin: false },
481
- severity: sev.severity,
482
- impact: sev.impact,
483
- ruleSource: sev.source,
484
- ruleRef: sev.ruleRef ?? null,
485
- basis: (isSoft
486
- ? '插件用 ctx.get() 软取该服务(写法上允许缺失)'
487
- : '插件在 inject 里把它声明为硬依赖') + ',而宿主与所有已装插件都没有提供它',
488
- })
489
- account(svc, DISPOSITION.FINDING, { severity: sev.severity, ruleSource: sev.source })
441
+ severity: "degraded",
442
+ impact:
443
+ "官方 peer 声明不支持当前 DSH;请确认版本范围与精确版本豁免,API 存在不等于完整兼容",
444
+ basis: rejectedPeers
445
+ .map((r) => `${r.name}: ${r.range},当前 ${dshVersion}`)
446
+ .join("; "),
447
+ evidence: {
448
+ runtimeVersion: dshVersion,
449
+ peers: rejectedPeers,
450
+ exemption: "unobserved",
451
+ },
452
+ ruleSource: "declaration",
453
+ ruleRef: "package.peerDependencies",
454
+ });
490
455
  }
491
-
492
- // ── 声明与实测不符 ──
493
- const decl = declarationOf(p, dshVersion)
494
- if (decl && decl.claimsCompatible === true && findings.length) {
495
- findings.push({
456
+ if (declaration?.claimsCompatible && findings.length) {
457
+ const certain = findings.every((f) => f.certainty === "certain");
458
+ add({
496
459
  category: CATEGORY.DECLARATION_MISMATCH,
497
- impactBasis: IMPACT_BASIS.INFERRED,
498
- certainty: CERTAINTY.CERTAIN,
460
+ certainty: certain ? "certain" : "suspected",
461
+ impactBasis: "inferred",
499
462
  service: null,
500
463
  method: null,
501
464
  location: null,
502
- evidence: { declared: decl.raw, measuredFindings: findings.length },
503
- severity: 'feature-dead',
504
- impact: `声明自己与 DSH ${dshVersion} 兼容,但实测有 ${findings.length} 处失效`,
505
- basis: '「声明不等于现实」的直接证据 —— 这是本工具相对既有方案唯一不可替代的结论',
506
- })
507
- }
508
-
509
- // ── 兜底:还有服务没有去处吗?──
510
- // 这条**必须存在**。只要 `services` 与 `checked` 的服务集合不相等,就说明
511
- // 又冒出了一条静默路径。与其让它静默,不如记成 UNKNOWN 并让不变式去炸 ——
512
- // 「有调用点却没落到任何判定分支」是判定引擎的 bug,不是插件的问题,
513
- // 它必须浮到面上而不是变成一个看起来干净的结论。
514
- const accounted = new Set(checked.map((c) => c.service))
515
- for (const svc of p.services) {
516
- if (accounted.has(svc)) continue
517
- const hasCalls = has(p.calls || {}, svc)
518
- if (hasCalls) {
519
- checked.push({
520
- service: svc,
521
- disposition: DISPOSITION.UNKNOWN,
522
- reason: '有调用点却没有落到任何判定分支 —— 这是判定引擎的缺陷,不是插件的问题',
523
- })
524
- continue
525
- }
526
- // **声明了但没有任何调用点** —— 这是一个真实缺口,不能只记一笔「无证据可判」。
527
- // 实证:`dsh-mnemon-strategy-*` 一族都写 `const inject = ["mnemonMemory"]`
528
- // 然后把它交给 SDK,自己从不直接调用;而当前 profile 里**无人提供** mnemonMemory。
529
- // 只认 `inject:` 字面量时这批插件被判成「0 个服务 → 可用」—— 那是漏报:
530
- // 工具对真实缺口只字不提,用户却以为自己拿到了结论。
531
- if (declaredAnywhere.has(svc) && !selfProvided.has(svc)) {
532
- const other = providedAnywhere.has(svc)
533
- if (!other) {
534
- const isSoft = softDeclared.has(svc) || !runtimeRoot
535
- const sev = lookupSeverity(rules, svc, null, isSoft
536
- ? { severity: 'degraded', impact: '依赖的服务由其它插件提供,当前 profile 里没有提供方 —— 相关功能降级或不可用' }
537
- : { severity: 'fatal', impact: '插件条目会停在 pending(等待该服务),等同于没起来' })
538
- findings.push({
539
- category: isSoft ? CATEGORY.UNPROVIDED_DEPENDENCY : CATEGORY.HOST_SERVICE_MISSING,
540
- // 硬依赖缺失会让**条目停在 pending** —— 这一条有独立机器信号(宿主自己报 fiber.state),
541
- // 而 `tools/labels.mjs` 用合成正负例量过它(真阳性 1 / 假阳性 0 / 漏报 0)。
542
- // 软取只是降级,「会不会真的缺」是推断。
543
- impactBasis: isSoft ? IMPACT_BASIS.INFERRED : IMPACT_BASIS.MEASURED,
544
- certainty: runtimeRoot ? CERTAINTY.CERTAIN : CERTAINTY.SUSPECTED,
545
- service: svc,
546
- method: null,
547
- location: null,
548
- evidence: { declaredButUncalled: true, inHostCatalog: has(hostCat, svc), providedByAnyInstalledPlugin: false },
549
- severity: sev.severity,
550
- impact: sev.impact,
551
- ruleSource: sev.source,
552
- ruleRef: sev.ruleRef ?? null,
553
- basis: '插件把这个服务声明为依赖(inject / ctx.inject),但当前 profile 里'
554
- + '宿主与所有已装插件都没有提供它。声明本身就是依赖,与它有没有被直接调用无关。',
555
- })
556
- checked.push({ service: svc, disposition: DISPOSITION.FINDING, severity: sev.severity, ruleSource: sev.source })
557
- continue
558
- }
559
- }
560
- checked.push({
561
- service: svc,
562
- disposition: DISPOSITION.NO_CALLS,
563
- reason: '服务名出现过,但没有任何可归属的调用点 —— 无法据此构成结论',
564
- })
565
- }
566
-
567
- // 每个服务的**最终去处** = 强度最高的那条(一个服务可以有多条记录)。
568
- const resolved = {}
569
- for (const c of checked) {
570
- const prev = resolved[c.service]
571
- if (!prev || DISPOSITION_RANK[c.disposition] > DISPOSITION_RANK[prev.disposition]) resolved[c.service] = c
572
- }
573
-
574
- const fatal = findings.some((f) => f.severity === 'fatal')
575
- const verdict = fatal ? 'broken' : (findings.length ? 'partial' : 'ok')
576
- // 「可用」必须建立在**确实看过东西**之上。
577
- // 实测 desktop profile 上有 4 个插件是「空洞的 ok」:它一个服务都没有,
578
- // 所以没有任何东西可以被检查,结论却是「可用」。那不是结论,那是没做事。
579
- const examined = Object.values(resolved).filter((c) => c.disposition !== DISPOSITION.NO_CALLS
580
- && c.disposition !== DISPOSITION.UNKNOWN)
581
- const vacuous = verdict === 'ok' && examined.length === 0
582
- summary[verdict] += 1
583
- if (vacuous) summary.vacuousOk = (summary.vacuousOk || 0) + 1
584
-
585
- plugins[name] = {
586
- name,
587
- version: p.version,
588
- verdict,
589
- // 「可用」但没有任何东西可检查 —— 调用方必须能看到这个区别
590
- vacuous,
591
- findings,
592
- // append-only 的完整台账,与「最终去处」并存:
593
- // 前者回答「发生了什么」,后者回答「这个服务算哪种情况」
594
- checked,
595
- resolved,
596
- clientSide,
597
- filtered,
598
- abstained,
599
- runtimeFacilities,
465
+ severity: "feature-dead",
466
+ impact: `声明兼容 DSH ${dshVersion},但发现 ${findings.length} 项${certain ? "有证据的冲突" : "待核实风险"}`,
467
+ basis: certain
468
+ ? "源码/运行时证据与兼容声明冲突"
469
+ : "原始发现含疑似证据,不能称为实测不符",
470
+ evidence: {
471
+ declared: declaration.raw,
472
+ measuredFindings: findings.length,
473
+ },
474
+ ruleSource: "declaration",
475
+ ruleRef: null,
476
+ });
600
477
  }
478
+ for (const svc of p.services || [])
479
+ if (!own(resolved, svc))
480
+ account(svc, "no-calls", { reason: "没有可判定调用证据" });
481
+ const verdict = findings.some((f) => f.severity === "fatal")
482
+ ? "broken"
483
+ : findings.length
484
+ ? "partial"
485
+ : "ok";
486
+ const vacuous = verdict === "ok" && verified === 0;
487
+ summary[verdict]++;
488
+ if (vacuous) summary.vacuousOk = (summary.vacuousOk || 0) + 1;
489
+ const limited = limitations.length > 0 || clientSide.length > 0 || vacuous;
490
+ if (limited) summary.limited = (summary.limited || 0) + 1;
491
+ Object.defineProperty(plugins, name, {
492
+ value: {
493
+ name,
494
+ version: p.version,
495
+ verdict,
496
+ vacuous,
497
+ findings,
498
+ checked,
499
+ resolved,
500
+ clientSide,
501
+ filtered,
502
+ abstained,
503
+ runtimeFacilities,
504
+ coverage: {
505
+ status: limited ? "limited" : "complete",
506
+ verifiedCalls: verified,
507
+ limitations: [...new Set(limitations)],
508
+ },
509
+ },
510
+ enumerable: true,
511
+ });
601
512
  }
602
-
603
- return { dshVersion, plugins, summary, categories: CATEGORY, certaintyLevels: CERTAINTY }
604
- }
605
-
606
- function firstSite(p, svc, method) {
607
- const site = (p.callSites || []).find((s) => s.service === svc && s.method === method)
608
- return site ? { file: site.file, line: site.line } : null
609
- }
610
-
611
- /** 只做最保守的一档:读不到声明就返回 null(不猜) */
612
- function declarationOf(p, dshVersion) {
613
- const compat = p.compatibility || null
614
- if (!compat) return null
615
- const v = dshVersion ? compat[dshVersion] : null
616
513
  return {
617
- raw: { compatibility: compat, engines: p.engines ?? null },
618
- claimsCompatible: v === 'compatible' ? true : (v === 'incompatible' ? false : null),
619
- }
514
+ dshVersion,
515
+ plugins,
516
+ summary,
517
+ categories: CATEGORY,
518
+ certaintyLevels: CERTAINTY,
519
+ observation: {
520
+ ...observation,
521
+ root: undefined,
522
+ mode: observation.usable ? "runtime" : "catalogue",
523
+ },
524
+ };
620
525
  }