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.
@@ -0,0 +1,53 @@
1
+ /** Small abstract values only; no inspected code is evaluated. */
2
+ export function hasEvidence(value, seen = new Set()) {
3
+ if (!value || seen.has(value)) return false;
4
+ seen.add(value);
5
+ if (["context", "service", "method"].includes(value.kind)) return true;
6
+ if (value.kind === "proxy") return hasEvidence(value.target, seen);
7
+ return value.fields
8
+ ? [...value.fields.values()].some((v) => hasEvidence(v, seen))
9
+ : false;
10
+ }
11
+ export function hasProof(value, seen = new Set()) {
12
+ if (!value || seen.has(value)) return false;
13
+ seen.add(value);
14
+ if (value.kind === "proxy")
15
+ return (
16
+ !value.handler?.opaque &&
17
+ value.handler?.fields?.size === 0 &&
18
+ hasProof(value.target, seen)
19
+ );
20
+ if (value.kind === "context" || value.kind === "service")
21
+ return value.inferred === false && !value.state?.opaque;
22
+ if (value.kind === "method") return hasProof(value.receiver, seen);
23
+ return value.fields && !value.opaque
24
+ ? [...value.fields.values()].some((v) => hasProof(v, seen))
25
+ : false;
26
+ }
27
+ export function sameValue(a, b, depth = 0) {
28
+ if (a === b) return true;
29
+ if (!a || !b || a.kind !== b.kind || depth > 12) return false;
30
+ if (a.kind === "constant") return a.value === b.value;
31
+ // Mutable values are equal only when they share identity. Structural equality
32
+ // must never choose one object as the destination of a conditional write.
33
+ if (a.kind === "context" || a.kind === "service")
34
+ return (
35
+ a.kind === b.kind &&
36
+ a.service === b.service &&
37
+ a.inferred === b.inferred &&
38
+ !!a.state &&
39
+ a.state === b.state
40
+ );
41
+ if (a.kind === "method")
42
+ return (
43
+ a.service === b.service &&
44
+ a.method === b.method &&
45
+ sameValue(a.receiver, b.receiver, depth + 1)
46
+ );
47
+ return false;
48
+ }
49
+ export function mergedValue(values) {
50
+ return values.length && values.every((v) => sameValue(v, values[0]))
51
+ ? values[0]
52
+ : null;
53
+ }
@@ -0,0 +1,182 @@
1
+ import path from "node:path";
2
+
3
+ const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
4
+ const samePath = (a, b) => path.resolve(a) === path.resolve(b);
5
+
6
+ /** Legacy reports authorize only their explicit keys, never missing keys. */
7
+ export function normalizeObservation(surface, target = {}) {
8
+ if (surface?.error) throw new Error(`Probe failed: ${String(surface.error)}`);
9
+ const root = Object.create(null),
10
+ notes = [];
11
+ if (surface === null || surface === undefined)
12
+ return { root, notes, bound: false, usable: false };
13
+ if (!object(surface) || !object(surface.root))
14
+ throw new Error("探针报告必须包含对象类型 root");
15
+ const bound = surface.observationVersion === 1;
16
+ if (
17
+ bound &&
18
+ ((target.profileId &&
19
+ (!surface.profileId || !samePath(surface.profileId, target.profileId))) ||
20
+ (target.dshVersion && surface.dshVersion !== target.dshVersion))
21
+ ) {
22
+ notes.push("探针的 profile/DSH 版本与体检对象不匹配,忽略运行时证据");
23
+ return { root, notes, bound, usable: false };
24
+ }
25
+ if (
26
+ bound &&
27
+ target.maxAgeMs &&
28
+ (!surface.probedAt ||
29
+ !Number.isFinite(Date.parse(surface.probedAt)) ||
30
+ Date.now() - Date.parse(surface.probedAt) > target.maxAgeMs)
31
+ ) {
32
+ notes.push("探针采样时间缺失或已过期,忽略运行时证据");
33
+ return { root, notes, bound, usable: false };
34
+ }
35
+ if (!bound)
36
+ notes.push(
37
+ "旧格式探针未绑定 profile/DSH 版本;仅使用显式记录的服务,不推断缺项",
38
+ );
39
+ for (const [name, info] of Object.entries(surface.root)) {
40
+ if (!object(info)) throw new Error(`探针服务 ${name} 的记录必须为对象`);
41
+ if (info.methods !== undefined && !object(info.methods))
42
+ throw new Error(`探针服务 ${name}.methods 必须为对象`);
43
+ const status =
44
+ info.error || info.status === "unreadable"
45
+ ? "unreadable"
46
+ : info.status === "unobserved"
47
+ ? "unobserved"
48
+ : info.present === true || info.status === "present"
49
+ ? "present"
50
+ : info.present === false || info.status === "absent"
51
+ ? "absent"
52
+ : "unobserved";
53
+ root[name] = {
54
+ status,
55
+ methods: Object.keys(info.methods || {}),
56
+ complete:
57
+ info.methods !== undefined &&
58
+ info.truncated !== true &&
59
+ info.namesComplete !== false &&
60
+ (info.namesComplete === true || info.complete !== false),
61
+ unknownMethods: Array.isArray(info.unknownMethods)
62
+ ? info.unknownMethods.filter((n) => typeof n === "string")
63
+ : [],
64
+ nonCallableMethods: Array.isArray(info.nonCallableMethods)
65
+ ? info.nonCallableMethods.filter((n) => typeof n === "string")
66
+ : [],
67
+ reason: info.error || null,
68
+ };
69
+ }
70
+ if (surface.entries?.note)
71
+ notes.push(`Loader 覆盖限制:${String(surface.entries.note)}`);
72
+ if (surface.entries?.unmapped?.length)
73
+ notes.push(
74
+ `Loader 有 ${surface.entries.unmapped.length} 个条目无法映射到包名`,
75
+ );
76
+ return {
77
+ root,
78
+ notes,
79
+ bound,
80
+ usable: true,
81
+ probedAt: surface.probedAt ?? null,
82
+ profileId: surface.profileId ?? null,
83
+ dshVersion: surface.dshVersion ?? null,
84
+ };
85
+ }
86
+
87
+ /** Shape inspection reads descriptors; it never invokes service getters. */
88
+ export function serviceShape(
89
+ service,
90
+ { maxMethods = 200, maxDepth = 12 } = {},
91
+ ) {
92
+ const methods = {},
93
+ unknownMethods = [],
94
+ nonCallableMethods = [],
95
+ seen = new Set();
96
+ if (service === null || service === undefined)
97
+ return {
98
+ methods,
99
+ complete: false,
100
+ namesComplete: false,
101
+ truncated: false,
102
+ unknownMethods,
103
+ nonCallableMethods,
104
+ };
105
+ let obj = Object(service),
106
+ depth = 0,
107
+ truncated = false;
108
+ while (obj) {
109
+ if (depth++ >= maxDepth) {
110
+ truncated = true;
111
+ break;
112
+ }
113
+ for (const key of Object.getOwnPropertyNames(obj)) {
114
+ if (seen.has(key)) continue;
115
+ seen.add(key);
116
+ const descriptor = Object.getOwnPropertyDescriptor(obj, key);
117
+ if (!descriptor) continue;
118
+ if (!("value" in descriptor)) {
119
+ unknownMethods.push(key);
120
+ continue;
121
+ }
122
+ if (typeof descriptor.value === "function") {
123
+ if (Object.keys(methods).length >= maxMethods) {
124
+ truncated = true;
125
+ continue;
126
+ }
127
+ Object.defineProperty(methods, key, {
128
+ value: "function",
129
+ enumerable: true,
130
+ });
131
+ } else if (descriptor.value !== null && descriptor.value !== undefined)
132
+ nonCallableMethods.push(key);
133
+ }
134
+ obj = Object.getPrototypeOf(obj);
135
+ }
136
+ return {
137
+ methods,
138
+ complete: !truncated && unknownMethods.length === 0,
139
+ namesComplete: !truncated,
140
+ truncated,
141
+ unknownMethods,
142
+ nonCallableMethods,
143
+ };
144
+ }
145
+
146
+ export function sampleServices(
147
+ ctx,
148
+ names,
149
+ { profileId = null, dshVersion = null } = {},
150
+ ) {
151
+ const root = Object.create(null);
152
+ for (const name of [...new Set(names)].sort()) {
153
+ try {
154
+ const value =
155
+ typeof ctx.get === "function" ? ctx.get(name, false) : ctx[name];
156
+ root[name] =
157
+ value === undefined
158
+ ? { present: false, status: "absent" }
159
+ : value === null
160
+ ? {
161
+ present: true,
162
+ status: "unreadable",
163
+ error:
164
+ "Service value is null; dependency presence is not proven",
165
+ }
166
+ : { present: true, status: "present", ...serviceShape(value) };
167
+ } catch {
168
+ root[name] = {
169
+ status: "unreadable",
170
+ error: "Service structure could not be read",
171
+ };
172
+ }
173
+ }
174
+ return {
175
+ observationVersion: 1,
176
+ probedAt: new Date().toISOString(),
177
+ profileId,
178
+ dshVersion,
179
+ wanted: Object.keys(root),
180
+ root,
181
+ };
182
+ }
@@ -0,0 +1,55 @@
1
+ /** Single flight per target; retain the last valid report on failed refresh. */
2
+ export class ReportService {
3
+ constructor(compute, { ttlMs = 300000, clock = Date.now } = {}) {
4
+ this.compute = compute;
5
+ this.ttlMs = ttlMs;
6
+ this.clock = clock;
7
+ this.states = new Map();
8
+ }
9
+ state(key) {
10
+ if (!this.states.has(key))
11
+ this.states.set(key, {
12
+ report: null,
13
+ at: 0,
14
+ inflight: null,
15
+ error: null,
16
+ });
17
+ return this.states.get(key);
18
+ }
19
+ start(state, options) {
20
+ if (state.inflight) return state.inflight;
21
+ state.inflight = Promise.resolve()
22
+ .then(() => this.compute(options))
23
+ .then((out) => {
24
+ if (out?.ok === true) {
25
+ state.report = out;
26
+ state.at = this.clock();
27
+ state.error = null;
28
+ } else state.error = out?.error || "体检未产生有效报告";
29
+ return out;
30
+ })
31
+ .catch((error) => {
32
+ state.error = error.message;
33
+ return { ok: false, error: state.error };
34
+ })
35
+ .finally(() => {
36
+ state.inflight = null;
37
+ });
38
+ return state.inflight;
39
+ }
40
+ async get(key, options = {}, force = false) {
41
+ const state = this.state(key),
42
+ age = this.clock() - state.at;
43
+ if (force || !state.report) return this.start(state, options);
44
+ if (age > this.ttlMs) this.start(state, options);
45
+ return {
46
+ ...state.report,
47
+ cache: {
48
+ hit: true,
49
+ ageMs: age,
50
+ refreshing: !!state.inflight,
51
+ refreshError: state.error,
52
+ },
53
+ };
54
+ }
55
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * 结论的**渲染**:给人看的表 + 给机器看的 JSON。
3
+ *
4
+ * ## 为什么单独一个文件
5
+ *
6
+ * 到 D1 为止,「一份结论」已经有三个消费方:CLI、对话工具、面板。
7
+ * 它们各自的**呈现**不同(终端表 / 文本 / DOM),但**取值口径**必须完全一致 ——
8
+ * 标签文案、排序、哪一类发现要标「疑似」、哪些"没发现"要额外解释。
9
+ * 把这些放在一处,是为了让"面板说可用、工具说部分可用"这类分叉**在结构上不可能发生**。
10
+ *
11
+ * 它不做判定。判定在 `lib/verdict.mjs`;这里只把判定结果翻译成人话。
12
+ */
13
+
14
+ import { TOOL_VERSION } from "./version.mjs";
15
+ import path from "node:path";
16
+ import { sealReportBody, createFailure } from "./contracts.mjs";
17
+ import { DISPOSITION } from "./verdict.mjs";
18
+ import { describeDiff } from "./baseline.mjs";
19
+
20
+ const VERDICT_LABEL = {
21
+ ok: "可用",
22
+ partial: "部分可用",
23
+ broken: "不兼容",
24
+ unknown: "未知",
25
+ };
26
+ const SEVERITY_LABEL = {
27
+ fatal: "致命",
28
+ "feature-dead": "功能失效",
29
+ degraded: "降级",
30
+ };
31
+ const CATEGORY_LABEL = {
32
+ "host-service-missing": "宿主服务缺失",
33
+ "host-method-missing": "宿主方法缺失",
34
+ "unprovided-dependency": "无人提供的依赖",
35
+ "declaration-mismatch": "声明与实测不符",
36
+ "declared-incompatible": "版本声明不支持",
37
+ "host-entry-inactive": "宿主条目未激活",
38
+ "client-entry-missing": "客户端入口不可交付",
39
+ };
40
+
41
+ /** 在终端里对齐中英文混排用的显示宽度(东亚字符按 2 列算) */
42
+ const pad = (s, n) => {
43
+ const w = [...String(s)].reduce(
44
+ (a, c) => a + (c.charCodeAt(0) > 0x2e80 ? 2 : 1),
45
+ 0,
46
+ );
47
+ return String(s) + " ".repeat(Math.max(0, n - w));
48
+ };
49
+
50
+ export function render(result, opts) {
51
+ const L = [];
52
+ const { verdict, extract } = result;
53
+
54
+ if (result.error) {
55
+ L.push(`结论: 无法判定 —— ${result.error}`);
56
+ return L.join("\n");
57
+ }
58
+
59
+ const meta = extract.report.manifests;
60
+ // `--quiet` 只输出那一条总结论。真实 profile 上有 21 个插件、每个几行,
61
+ // 而这个开关的用途是「脚本里只要一个信号」—— 它必须真的安静,而不是「安静一点」。
62
+ // (本会话里已经踩过一次死参数:.sh 的 --json 被解析却从没被用过。)
63
+ if (opts.quiet) {
64
+ const q = verdict.summary;
65
+ L.push(
66
+ `结论: ${VERDICT_LABEL.ok} ${q.ok} ${VERDICT_LABEL.partial} ${q.partial} ${VERDICT_LABEL.broken} ${q.broken}`,
67
+ );
68
+ return L.join("\n");
69
+ }
70
+
71
+ L.push(
72
+ `插件数: ${meta.length} 有运行时面: ${result.inputs.hasRuntimeSurface ? "是" : "否(方法级结论降为「疑似」)"}`,
73
+ );
74
+ L.push("");
75
+ for (const note of verdict.observation?.notes || [])
76
+ L.push(`采样说明: ${note}`);
77
+ L.push(
78
+ `工具版本: ${TOOL_VERSION}${verdict.summary.limited ? ` 证据覆盖有限: ${verdict.summary.limited} 个插件` : ""}`,
79
+ );
80
+
81
+ const s = verdict.summary;
82
+ L.push(
83
+ `结论: ${VERDICT_LABEL.ok} ${s.ok} ${VERDICT_LABEL.partial} ${s.partial} ${VERDICT_LABEL.broken} ${s.broken}` +
84
+ (s.vacuousOk
85
+ ? ` (其中 ${s.vacuousOk} 个「可用」是空洞的:它没有任何可检查的依赖)`
86
+ : ""),
87
+ );
88
+ L.push("");
89
+
90
+ // ── 与上次比 ──
91
+ // 放在结论之后、逐插件之前:用户先要知道"整体变了没有",再决定看细节。
92
+ // 而且**「没有变化」也要说出来** —— 沉默会让人以为没检查,或者以为坏消息被藏起来了。
93
+ if (result.baseline) {
94
+ const b = result.baseline;
95
+ L.push("与上次相比:");
96
+ // ⚠️ `note` 优先于 `badge.detail`。当基线**读不出来**时,diff 会退化成
97
+ // 「还没有基线(这是第一次体检)」—— 两种完全不同的事得到同一句话:
98
+ // 一种是"你没跑过",另一种是"你的基线坏了"。后者必须说出来,
99
+ // 否则用户会以为没有变化,继续用着已经变坏的环境。
100
+ if (b.note) {
101
+ L.push(` ${b.note}`);
102
+ } else if (b.badge && b.badge.show) {
103
+ L.push(`${b.badge.text}`);
104
+ for (const line of describeDiff(b.diff).split("\n").slice(1))
105
+ L.push(line);
106
+ } else {
107
+ L.push(` ${(b.badge && b.badge.detail) || "没有可比基线"}`);
108
+ }
109
+ if (b.writeError) L.push(` ⚠️ ${b.writeError}`);
110
+ L.push(` 基线: ${b.file}${b.wrote ? "" : "(本次未写入)"}`);
111
+ L.push("");
112
+ }
113
+
114
+ const names = Object.keys(verdict.plugins).sort((a, b) => {
115
+ const rank = { broken: 0, partial: 1, unknown: 2, ok: 3 };
116
+ return (
117
+ rank[verdict.plugins[a].verdict] - rank[verdict.plugins[b].verdict] ||
118
+ a.localeCompare(b)
119
+ );
120
+ });
121
+
122
+ for (const name of names) {
123
+ const p = verdict.plugins[name];
124
+ L.push(
125
+ `${VERDICT_LABEL[p.verdict]} ${name}@${p.version}${p.vacuous ? " [空洞的结论:无可检查对象]" : ""}`,
126
+ );
127
+ if (opts.quiet) continue;
128
+ for (const f of p.findings) {
129
+ const api = f.service
130
+ ? f.method
131
+ ? `${f.service}.${f.method}`
132
+ : f.service
133
+ : "(声明)";
134
+ const loc = f.location ? `${f.location.file}:${f.location.line}` : "-";
135
+ const cert = f.certainty === "certain" ? "" : " [疑似]";
136
+ L.push(
137
+ ` ${SEVERITY_LABEL[f.severity] ?? f.severity} / ${CATEGORY_LABEL[f.category] ?? f.category}${cert} ${api} @ ${loc}`,
138
+ );
139
+ // ⚠️ 「事实」和「推断」必须**看得出来不一样** —— 一次实测的假阳性
140
+ // (`dshmarket` 的"设置页会消失",而作者把能力检测写在了辅助函数里)说明:
141
+ // `basis` 那条链是量出来的,而 `impact` 是我们读出来的,两者可信度不同。
142
+ // 标出来,用户才知道哪一句可以去核实。
143
+ if (f.impact) {
144
+ L.push(
145
+ f.impactBasis === "inferred"
146
+ ? ` 影响(推断,非实测): ${f.impact}`
147
+ : ` 影响: ${f.impact}`,
148
+ );
149
+ }
150
+ if (f.basis) L.push(` 依据: ${f.basis}`);
151
+ }
152
+ // 「没报」和「报了」一样要能审:过滤与弃权是结论的一部分。
153
+ const cs = p.clientSide || [];
154
+ if (cs.length)
155
+ L.push(
156
+ ` 客户端半边(命令行测不到,需界面验证): ${cs.map((c) => c.service).join(", ")}`,
157
+ );
158
+ const filtered = p.filtered || [];
159
+ if (filtered.length)
160
+ L.push(
161
+ ` 已过滤(判为噪音/有兜底): ${filtered.map((x) => `${x.service}${x.method ? "." + x.method : ""}`).join(", ")}`,
162
+ );
163
+ const noCalls = Object.values(p.resolved || {}).filter(
164
+ (c) => c.disposition === DISPOSITION.NO_CALLS,
165
+ );
166
+ if (noCalls.length)
167
+ L.push(
168
+ ` 无证据可判(只出现名字,没有调用点): ${noCalls.map((c) => c.service).join(", ")}`,
169
+ );
170
+ }
171
+
172
+ L.push("");
173
+ L.push(
174
+ "说明: 「疑似」= 没有运行时面、只能靠官方目录推断(那份目录是契约目录,不是运行时面)。",
175
+ );
176
+ L.push(
177
+ " 假阳性会让用户去做不必要的处置,所以判不准时一律降级,而不是升格成确定。",
178
+ );
179
+ return L.join("\n");
180
+ }
181
+
182
+ export { VERDICT_LABEL, SEVERITY_LABEL, CATEGORY_LABEL };
183
+
184
+ /** 机器可读形态:保留完整结论,但把 extract 换成一个摘要(原始输出可能几 MB) */
185
+ export function toJSON(result) {
186
+ // ⚠️ 报错路径也要带版本:用户拿一份 `{error: ...}` 来问的时候,
187
+ // "哪一版工具没跑成"同样是他要的第一个信息。
188
+ if (result.error)
189
+ return createFailure(result.error, {
190
+ toolVersion: TOOL_VERSION,
191
+ inputs: result.inputs,
192
+ });
193
+ const report = result.extract.report;
194
+ return sealReportBody({
195
+ schemaVersion: 1,
196
+ // 「这份结论是哪一版工具给的」。`dshVersion` 是**被体检的对象**的版本,
197
+ // 与这一条是两件事 —— 少了它就等于结论无法追溯(见 lib/version.mjs 的注释)。
198
+ toolVersion: TOOL_VERSION,
199
+ inputs: result.inputs,
200
+ // 差分与角标也带上:面板/对话工具/CLI 必须看同一件事,
201
+ // 否则「面板说没变、CLI 说变差了」这种分叉就会发生。
202
+ baseline: result.baseline
203
+ ? {
204
+ ...result.baseline,
205
+ file: result.baseline.file,
206
+ wrote: result.baseline.wrote,
207
+ note: result.baseline.note,
208
+ writeError: result.baseline.writeError ?? null,
209
+ diff: result.baseline.diff,
210
+ badge: result.baseline.badge,
211
+ }
212
+ : null,
213
+ dshVersion: result.verdict.dshVersion,
214
+ summary: result.verdict.summary,
215
+ observation: {
216
+ ...result.verdict.observation,
217
+ profileId: result.verdict.observation.profileId
218
+ ? path.resolve(result.verdict.observation.profileId)
219
+ : null,
220
+ },
221
+ plugins: result.verdict.plugins,
222
+ extraction: {
223
+ scanComplete: report.scanComplete,
224
+ diagnostics: report.diagnostics,
225
+ manifests: report.manifests,
226
+ services: report.services,
227
+ fallbackPackages: report.fallbackPackages,
228
+ siteTotal: (report.callSites || []).length,
229
+ provenanceCounts: Object.fromEntries(
230
+ Object.entries(result.extract.provenance.packages).map(([k, v]) => [
231
+ k,
232
+ v.counts,
233
+ ]),
234
+ ),
235
+ },
236
+ });
237
+ }