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/kernel/lib/verdict.mjs
CHANGED
|
@@ -1,620 +1,525 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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:
|
|
49
|
-
HOST_METHOD_MISSING:
|
|
50
|
-
UNPROVIDED_DEPENDENCY:
|
|
51
|
-
DECLARATION_MISMATCH:
|
|
52
|
-
|
|
53
|
-
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:
|
|
71
|
-
CLIENT_SIDE:
|
|
72
|
-
SELF_PROVIDED:
|
|
73
|
-
RUNTIME_FACILITY:
|
|
74
|
-
FILTERED:
|
|
75
|
-
ABSTAINED:
|
|
76
|
-
GUARDED:
|
|
77
|
-
NO_CALLS:
|
|
78
|
-
UNKNOWN:
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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:
|
|
228
|
-
certainty: CERTAINTY.CERTAIN,
|
|
193
|
+
certainty: "certain",
|
|
194
|
+
impactBasis: "inferred",
|
|
229
195
|
service: null,
|
|
230
196
|
method: null,
|
|
231
197
|
location: null,
|
|
232
|
-
severity:
|
|
233
|
-
|
|
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:
|
|
242
|
-
ruleRef:
|
|
243
|
-
})
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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:
|
|
437
|
-
location:
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
:
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
-
|
|
498
|
-
|
|
460
|
+
certainty: certain ? "certain" : "suspected",
|
|
461
|
+
impactBasis: "inferred",
|
|
499
462
|
service: null,
|
|
500
463
|
method: null,
|
|
501
464
|
location: null,
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
618
|
-
|
|
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
|
}
|