dsh-plugin-compat-check 0.1.6 → 0.1.7
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/README.md +17 -30
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/index.js +1295 -550
- package/kernel/lib/api-semantics.mjs +223 -0
- package/kernel/lib/author-ci-budget.mjs +25 -0
- package/kernel/lib/author-ci-output.mjs +22 -0
- package/kernel/lib/author-ci-policy.mjs +6 -1
- package/kernel/lib/baseline.mjs +109 -38
- package/kernel/lib/catalog.mjs +11 -3
- package/kernel/lib/contracts.mjs +16 -1
- package/kernel/lib/execution-environment.mjs +52 -0
- package/kernel/lib/extract.mjs +57 -13
- package/kernel/lib/finding-guidance.mjs +6 -0
- package/kernel/lib/finding-review-store.mjs +11 -23
- package/kernel/lib/issue-report.mjs +2 -1
- package/kernel/lib/patch-mounts.mjs +232 -0
- package/kernel/lib/report-identity-worker.mjs +47 -26
- package/kernel/lib/report-identity.mjs +4 -3
- package/kernel/lib/report-service.mjs +118 -38
- package/kernel/lib/report.mjs +13 -17
- package/kernel/lib/source-analysis.mjs +258 -42
- package/kernel/lib/source-graph.mjs +353 -75
- package/kernel/lib/state-lock.mjs +206 -0
- package/kernel/lib/state-store.mjs +41 -30
- package/kernel/lib/static-input.mjs +111 -0
- package/kernel/lib/syntax.mjs +242 -20
- package/kernel/lib/upgrade-contract.mjs +1 -0
- package/kernel/lib/upgrade-input.mjs +9 -4
- package/kernel/lib/upgrade-preview.mjs +11 -15
- package/kernel/lib/upgrade-worker.mjs +10 -1
- package/kernel/lib/verdict.mjs +28 -5
- package/kernel/lib/version.mjs +2 -34
- package/kernel/lib/worker-analysis.mjs +74 -3
- package/kernel/rules/README.md +30 -208
- package/kernel/rules/dsh-0.2.1-rc.3-review.json +63 -0
- package/kernel/rules/dsh-api-catalog-0.2.1-rc.3.json +743 -0
- package/kernel/rules/severity.json +77 -0
- package/lib/client.js +4668 -3423
- package/lib/finding-guidance.js +6 -0
- package/lib/local-request.js +44 -0
- package/lib/report-contract.js +16 -1
- package/lib/upgrade-contract.js +1 -0
- package/package.json +3 -2
- package/scripts/prepublish-check.mjs +1 -22
- package/scripts/stage-contract.mjs +7 -0
- package/skill/SKILL.md +13 -12
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/** Small, version-bound semantic checks. No plugin execution or argument contents. */
|
|
2
|
+
import { CATALOG_REFERENCES } from "./contracts.mjs";
|
|
3
|
+
const policyValues = new Set(["keep", "replace", "append"]);
|
|
4
|
+
const optionNames = [
|
|
5
|
+
"prompt",
|
|
6
|
+
"agentPreset",
|
|
7
|
+
"clearPreviousDraft",
|
|
8
|
+
"whenDraftExists",
|
|
9
|
+
];
|
|
10
|
+
export const semanticTarget = (service, method) =>
|
|
11
|
+
(service === "uiWorkspace" && method === "startSession") ||
|
|
12
|
+
(service === "timer" && ["setTimeout", "setInterval"].includes(method)) ||
|
|
13
|
+
(service === "sessions" && method === "retain");
|
|
14
|
+
const unknown = () => ({ kind: "unknown" });
|
|
15
|
+
function scalar(value, name, generation) {
|
|
16
|
+
if (value?.kind !== "constant" || value.semanticGeneration !== generation)
|
|
17
|
+
return unknown();
|
|
18
|
+
const kind = typeof value.value;
|
|
19
|
+
if (!["string", "number", "boolean"].includes(kind)) return unknown();
|
|
20
|
+
return {
|
|
21
|
+
kind,
|
|
22
|
+
...(name === "whenDraftExists" && kind === "string"
|
|
23
|
+
? { policy: policyValues.has(value.value) ? value.value : "invalid" }
|
|
24
|
+
: name === "clearPreviousDraft" && kind === "boolean"
|
|
25
|
+
? { enabled: value.value }
|
|
26
|
+
: {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Called after all argument expressions; copies facts, never mutable references. */
|
|
30
|
+
export function captureSemanticArguments(
|
|
31
|
+
service,
|
|
32
|
+
method,
|
|
33
|
+
values,
|
|
34
|
+
nodes,
|
|
35
|
+
unboundUndefined,
|
|
36
|
+
generation,
|
|
37
|
+
) {
|
|
38
|
+
if (service !== "uiWorkspace" || method !== "startSession") return null;
|
|
39
|
+
if (nodes.some((node) => node.type === "SpreadElement")) return unknown();
|
|
40
|
+
const value = values[1],
|
|
41
|
+
node = nodes[1];
|
|
42
|
+
if (!node || unboundUndefined(node)) return { kind: "omitted" };
|
|
43
|
+
if (
|
|
44
|
+
value?.kind !== "object" ||
|
|
45
|
+
value.opaque ||
|
|
46
|
+
value.semanticGeneration !== generation
|
|
47
|
+
)
|
|
48
|
+
return unknown();
|
|
49
|
+
const fields = Object.create(null);
|
|
50
|
+
for (const name of optionNames)
|
|
51
|
+
if (value.fields.has(name))
|
|
52
|
+
fields[name] = scalar(value.fields.get(name), name, generation);
|
|
53
|
+
return { kind: "options", fields };
|
|
54
|
+
}
|
|
55
|
+
export function mergeSemanticArguments(a, b) {
|
|
56
|
+
return JSON.stringify(a) === JSON.stringify(b) ? a : unknown();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const record = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
60
|
+
const digest = (v) => typeof v === "string" && /^[a-f0-9]{64}$/.test(v);
|
|
61
|
+
/** Metadata governance, not a substitute for source review or runtime acceptance. */
|
|
62
|
+
export function validateSemanticBaselines(rules) {
|
|
63
|
+
const group = rules.apiSemantics;
|
|
64
|
+
if (!record(group) || group.schemaVersion !== 1 || !record(group.baselines))
|
|
65
|
+
return ["Invalid semantic baseline schema"];
|
|
66
|
+
const errors = [],
|
|
67
|
+
versions = CATALOG_REFERENCES.map((v) => v.version);
|
|
68
|
+
const entries = Object.entries(group.baselines);
|
|
69
|
+
if (!entries.length || entries.length > versions.length)
|
|
70
|
+
return ["Invalid semantic version count"];
|
|
71
|
+
for (const [version, b] of entries) {
|
|
72
|
+
if (
|
|
73
|
+
!versions.includes(version) ||
|
|
74
|
+
!record(b) ||
|
|
75
|
+
b.sourceTag !== "dsh-v" + version ||
|
|
76
|
+
!digest(b.sourceArchiveSha256) ||
|
|
77
|
+
!record(b.sources) ||
|
|
78
|
+
Object.keys(b.sources).length > 16 ||
|
|
79
|
+
!Object.keys(b.sources).length ||
|
|
80
|
+
Object.entries(b.sources).some(
|
|
81
|
+
([p, h]) =>
|
|
82
|
+
!/^packages\/(?:[a-z0-9-]+\/)+[a-z0-9-]+\.ts$/.test(p) || !digest(h),
|
|
83
|
+
) ||
|
|
84
|
+
!record(b.startSession) ||
|
|
85
|
+
!Object.hasOwn(b.sources, b.startSession.source) ||
|
|
86
|
+
!record(b.startSession.options) ||
|
|
87
|
+
!Object.keys(b.startSession.options).length ||
|
|
88
|
+
Object.entries(b.startSession.options).some(
|
|
89
|
+
([k, v]) =>
|
|
90
|
+
!optionNames.includes(k) ||
|
|
91
|
+
v !== (k === "clearPreviousDraft" ? "boolean" : "string"),
|
|
92
|
+
) ||
|
|
93
|
+
!record(b.notices) ||
|
|
94
|
+
Object.keys(b.notices).length > 3 ||
|
|
95
|
+
Object.entries(b.notices).some(
|
|
96
|
+
([api, n]) =>
|
|
97
|
+
![
|
|
98
|
+
"timer.setTimeout",
|
|
99
|
+
"timer.setInterval",
|
|
100
|
+
"sessions.retain",
|
|
101
|
+
].includes(api) ||
|
|
102
|
+
!record(n) ||
|
|
103
|
+
!["弃用", "生命周期"].includes(n.kind) ||
|
|
104
|
+
typeof n.message !== "string" ||
|
|
105
|
+
!n.message ||
|
|
106
|
+
n.message.length > 1024 ||
|
|
107
|
+
!Object.hasOwn(b.sources, n.source),
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
errors.push("Invalid semantic baseline: " + version);
|
|
111
|
+
}
|
|
112
|
+
return errors;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Findings are nonfatal contract risks; maintenance/lifetime notices stay limitations. */
|
|
116
|
+
export function checkApiSemantics(plugin, rules, version) {
|
|
117
|
+
const findings = [],
|
|
118
|
+
limitations = [];
|
|
119
|
+
const baseline =
|
|
120
|
+
validateSemanticBaselines(rules).length === 0 &&
|
|
121
|
+
Object.hasOwn(rules.apiSemantics.baselines, version)
|
|
122
|
+
? rules.apiSemantics.baselines[version]
|
|
123
|
+
: null;
|
|
124
|
+
for (const site of plugin.callSites || []) {
|
|
125
|
+
if (
|
|
126
|
+
!site.sides?.includes("client") ||
|
|
127
|
+
!semanticTarget(site.service, site.method)
|
|
128
|
+
)
|
|
129
|
+
continue;
|
|
130
|
+
const api = `${site.service}.${site.method}`;
|
|
131
|
+
const at = `${site.file}:${site.line}`;
|
|
132
|
+
const context = `${api} @ ${at} [DSH ${version ?? "未知"}]`;
|
|
133
|
+
if (!baseline) {
|
|
134
|
+
limitations.push(
|
|
135
|
+
`语义未审查(参数/弃用/生命周期):${context};不借用邻近版本或回退目录认证`,
|
|
136
|
+
);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const notice = baseline.notices[api];
|
|
140
|
+
if (notice)
|
|
141
|
+
limitations.push(
|
|
142
|
+
`语义提示·${notice.kind}:${context};${notice.message}`,
|
|
143
|
+
);
|
|
144
|
+
if (api !== "uiWorkspace.startSession") continue;
|
|
145
|
+
const args = site.semanticArguments;
|
|
146
|
+
if (!args || args.kind === "unknown") {
|
|
147
|
+
limitations.push(
|
|
148
|
+
`参数未能静态确定:${context};动态、展开、访问器或合并值不证明参数正确`,
|
|
149
|
+
);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const add = (option, category, message) =>
|
|
153
|
+
findings.push({
|
|
154
|
+
category,
|
|
155
|
+
service: site.service,
|
|
156
|
+
method: site.method,
|
|
157
|
+
severity: "degraded",
|
|
158
|
+
certainty: site.inferredReceiver ? "suspected" : "certain",
|
|
159
|
+
impactBasis: "inferred",
|
|
160
|
+
location: {
|
|
161
|
+
file: site.file,
|
|
162
|
+
line: site.line,
|
|
163
|
+
column: site.column,
|
|
164
|
+
start: site.start,
|
|
165
|
+
end: site.end,
|
|
166
|
+
},
|
|
167
|
+
impact: `${option} ${message};可能改变或忽略草稿处理,未实测业务失败`,
|
|
168
|
+
basis: `固定官方源码的客户端参数契约,精确版本 ${version};方法存在或能力守卫不验证参数`,
|
|
169
|
+
ruleSource: "rule",
|
|
170
|
+
ruleRef: `apiSemantics.baselines.${version}.startSession.${option}`,
|
|
171
|
+
evidence: {
|
|
172
|
+
dshVersion: version,
|
|
173
|
+
side: "client",
|
|
174
|
+
option,
|
|
175
|
+
sourceArchiveSha256: baseline.sourceArchiveSha256,
|
|
176
|
+
source: baseline.startSession.source,
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
if (args.kind === "options") {
|
|
180
|
+
for (const [name, value] of Object.entries(args.fields)) {
|
|
181
|
+
if (value.kind === "unknown") {
|
|
182
|
+
limitations.push(
|
|
183
|
+
`参数未能静态确定:${context} 的 ${name};不猜测运行时值`,
|
|
184
|
+
);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const expected = baseline.startSession.options[name];
|
|
188
|
+
if (!expected) {
|
|
189
|
+
add(
|
|
190
|
+
name,
|
|
191
|
+
"api-option-unsupported",
|
|
192
|
+
"不在此版本已审查的选项中,官方实现未使用该字段",
|
|
193
|
+
);
|
|
194
|
+
} else if (
|
|
195
|
+
value.kind !== expected ||
|
|
196
|
+
(name === "whenDraftExists" && value.policy === "invalid")
|
|
197
|
+
) {
|
|
198
|
+
add(
|
|
199
|
+
name,
|
|
200
|
+
"api-argument-invalid",
|
|
201
|
+
"参数不符:类型或枚举值不符合公开契约",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (baseline.startSession.options.whenDraftExists) {
|
|
206
|
+
const explicit = args.fields.whenDraftExists;
|
|
207
|
+
if (
|
|
208
|
+
explicit?.kind === "string" &&
|
|
209
|
+
explicit.policy !== "invalid" &&
|
|
210
|
+
args.fields.clearPreviousDraft
|
|
211
|
+
)
|
|
212
|
+
limitations.push(
|
|
213
|
+
`语义提示·选项:${context};whenDraftExists 优先于兼容选项 clearPreviousDraft,后者仍受支持`,
|
|
214
|
+
);
|
|
215
|
+
else if (!explicit && args.fields.prompt?.kind === "string")
|
|
216
|
+
limitations.push(
|
|
217
|
+
`语义提示·选项:${context};未显式提供 whenDraftExists 时默认保留已有草稿,clearPreviousDraft 为 true 才采用 replace;不证明已提交或创建了新会话`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { findings, limitations };
|
|
223
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** One budget for emitted evidence, saved reports and previous matrices. */
|
|
2
|
+
const reportBytes = 8 * 1024 * 1024;
|
|
3
|
+
export const AUTHOR_CI_LIMITS = Object.freeze({
|
|
4
|
+
versions: 4,
|
|
5
|
+
timeoutMs: 30000,
|
|
6
|
+
previousBytes: reportBytes,
|
|
7
|
+
reportBytes,
|
|
8
|
+
cellBytes: 2 * 1024 * 1024,
|
|
9
|
+
policyBytes: 65536,
|
|
10
|
+
});
|
|
11
|
+
export function checkCIOutput(text) {
|
|
12
|
+
if (Buffer.byteLength(text + "\n") > AUTHOR_CI_LIMITS.reportBytes)
|
|
13
|
+
throw Error("Author CI output exceeds the shared output budget");
|
|
14
|
+
return text;
|
|
15
|
+
}
|
|
16
|
+
export function ciJSON(value, { cell = false } = {}) {
|
|
17
|
+
const text = JSON.stringify(value, null, cell ? 0 : 2);
|
|
18
|
+
if (typeof text !== "string") throw Error("Invalid author CI evidence");
|
|
19
|
+
if (cell) {
|
|
20
|
+
if (Buffer.byteLength(text) > AUTHOR_CI_LIMITS.cellBytes)
|
|
21
|
+
throw Error("Author CI cell exceeds output budget");
|
|
22
|
+
return text;
|
|
23
|
+
}
|
|
24
|
+
return checkCIOutput(text);
|
|
25
|
+
}
|
|
@@ -186,3 +186,25 @@ export function renderJUnit(matrix) {
|
|
|
186
186
|
"",
|
|
187
187
|
)}</properties>${cases.map((c) => c.xml).join("")}</testsuite></testsuites>\n`;
|
|
188
188
|
}
|
|
189
|
+
|
|
190
|
+
/** A self-contained, inert view. All report-controlled values become text. */
|
|
191
|
+
export function renderHTML(matrix) {
|
|
192
|
+
const pretty = (v) => xml(JSON.stringify(v, null, 2));
|
|
193
|
+
const cells = (matrix.cells || [])
|
|
194
|
+
.map((c) => {
|
|
195
|
+
const plugins = Object.entries(c.report?.plugins || {})
|
|
196
|
+
.map(
|
|
197
|
+
([name, p]) =>
|
|
198
|
+
`<h3>${xml(name)}</h3><p>Verdict: ${xml(p.verdict)}; Coverage: ${xml(p.coverage?.status)}; Vacuous: ${xml(p.vacuous)}</p><pre>${pretty({ coverage: p.coverage, findings: p.findings })}</pre>`,
|
|
199
|
+
)
|
|
200
|
+
.join("");
|
|
201
|
+
return `<section><h2>DSH ${xml(c.dshVersion)} — ${xml(c.status)}</h2><p>Kernel exit code: ${xml(c.kernelExitCode)}</p>${c.error ? `<p>${xml(c.error)}</p>` : ""}<h3>Identity</h3><pre>${pretty(c.identity)}</pre>${plugins}</section>`;
|
|
202
|
+
})
|
|
203
|
+
.join("");
|
|
204
|
+
return `<!doctype html>
|
|
205
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"><title>Plugin author CI evidence</title><style>body{font:16px system-ui,sans-serif;max-width:1000px;margin:2rem auto;padding:0 1rem;color:#202124;background:#fff}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f3f4f5;padding:1rem}section{border-top:1px solid #aaa;margin-top:2rem}h1,h2,h3{overflow-wrap:anywhere}</style></head><body>
|
|
206
|
+
<h1>Plugin author CI evidence</h1><p>Policy gate: ${xml(matrix.gate.status)}; exit code: ${xml(matrix.gate.exitCode)}.</p><p>Static evidence only. Policy pass is not business acceptance. Saved reports are not signed; validate their provenance before using them as CI inputs.</p>
|
|
207
|
+
<h2>Selected package identity</h2><pre>${pretty(matrix.identity)}</pre><h2>Policy and gate reasons</h2><pre>${pretty({ policy: matrix.policy, gate: matrix.gate })}</pre>
|
|
208
|
+
<h2>Limitations</h2><ul>${(matrix.limitations || []).map((s) => `<li>${xml(s)}</li>`).join("")}</ul>${cells}
|
|
209
|
+
<h2>Complete canonical evidence</h2><p>This is the same JSON snapshot, including extraction, coverage, vacuous status, findings, identities and kernel results for every version.</p><pre id="evidence">${pretty(matrix)}</pre></body></html>`;
|
|
210
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Pure author CI gate. Kernel reports remain unchanged and independently useful. */
|
|
2
2
|
import { assertReportBody, CATALOG_REFERENCES } from "./contracts.mjs";
|
|
3
|
+
import { AUTHOR_CI_LIMITS, ciJSON } from "./author-ci-budget.mjs";
|
|
3
4
|
const severities = ["fatal", "feature-dead", "degraded"];
|
|
4
5
|
const certainties = ["certain", "suspected"];
|
|
5
6
|
export const DEFAULT_POLICY = Object.freeze({
|
|
@@ -60,7 +61,9 @@ function validIdentity(i) {
|
|
|
60
61
|
}
|
|
61
62
|
function validateCells(cells, { baseline = false } = {}) {
|
|
62
63
|
need(
|
|
63
|
-
Array.isArray(cells) &&
|
|
64
|
+
Array.isArray(cells) &&
|
|
65
|
+
cells.length > 0 &&
|
|
66
|
+
cells.length <= AUTHOR_CI_LIMITS.versions,
|
|
64
67
|
"Matrix requires at least one version cell",
|
|
65
68
|
);
|
|
66
69
|
const seen = new Set();
|
|
@@ -84,6 +87,7 @@ function validateCells(cells, { baseline = false } = {}) {
|
|
|
84
87
|
c.error === null && validIdentity(c.identity),
|
|
85
88
|
"Invalid analyzed cell identity",
|
|
86
89
|
);
|
|
90
|
+
ciJSON(c.report, { cell: true });
|
|
87
91
|
assertReportBody(c.report);
|
|
88
92
|
need(
|
|
89
93
|
c.kernelExitCode === c.report.exitCode,
|
|
@@ -121,6 +125,7 @@ function validateCells(cells, { baseline = false } = {}) {
|
|
|
121
125
|
}
|
|
122
126
|
}
|
|
123
127
|
function validatePrevious(previous, cells) {
|
|
128
|
+
ciJSON(previous);
|
|
124
129
|
need(
|
|
125
130
|
record(previous) &&
|
|
126
131
|
previous.schemaVersion === 1 &&
|
package/kernel/lib/baseline.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validateBaseline } from "./contracts.mjs";
|
|
1
|
+
import { validateBaseline, terminalText } from "./contracts.mjs";
|
|
2
2
|
/**
|
|
3
3
|
* 基线:回答「**变了没有**」,以及变了多少、往哪个方向变。
|
|
4
4
|
*
|
|
@@ -41,7 +41,7 @@ export function snapshotForBaseline(verdict, dshVersion, identity = {}) {
|
|
|
41
41
|
const names = Object.keys(verdict.plugins || {}).sort();
|
|
42
42
|
for (const name of names) {
|
|
43
43
|
const p = verdict.plugins[name];
|
|
44
|
-
|
|
44
|
+
const plugin = {
|
|
45
45
|
version: p.version ?? null,
|
|
46
46
|
verdict: p.verdict,
|
|
47
47
|
vacuous: p.vacuous === true,
|
|
@@ -53,25 +53,97 @@ export function snapshotForBaseline(verdict, dshVersion, identity = {}) {
|
|
|
53
53
|
? `${f.service}.${f.method}`
|
|
54
54
|
: f.service
|
|
55
55
|
: "(declaration)",
|
|
56
|
+
...(f.category === "host-entry-inactive"
|
|
57
|
+
? {
|
|
58
|
+
entryId:
|
|
59
|
+
typeof f.evidence?.entryId === "string"
|
|
60
|
+
? f.evidence.entryId
|
|
61
|
+
: null,
|
|
62
|
+
}
|
|
63
|
+
: {}),
|
|
64
|
+
...(Number.isSafeInteger(f.location?.column)
|
|
65
|
+
? { column: f.location.column }
|
|
66
|
+
: {}),
|
|
56
67
|
category: f.category,
|
|
57
68
|
severity: f.severity,
|
|
58
69
|
certainty: f.certainty,
|
|
59
70
|
location: f.location ? `${f.location.file}:${f.location.line}` : null,
|
|
60
71
|
}))
|
|
61
|
-
.sort(
|
|
62
|
-
(a, b) =>
|
|
63
|
-
a.key.localeCompare(b.key) || a.category.localeCompare(b.category),
|
|
64
|
-
),
|
|
72
|
+
.sort(compareFinding),
|
|
65
73
|
};
|
|
74
|
+
Object.defineProperty(plugins, name, {
|
|
75
|
+
value: plugin,
|
|
76
|
+
enumerable: true,
|
|
77
|
+
writable: true,
|
|
78
|
+
configurable: true,
|
|
79
|
+
});
|
|
66
80
|
}
|
|
67
81
|
return {
|
|
68
82
|
schemaVersion: BASELINE_SCHEMA,
|
|
69
83
|
dshVersion: dshVersion ?? null,
|
|
70
|
-
identity: {
|
|
84
|
+
identity: {
|
|
85
|
+
mode: verdict.observation?.mode ?? "unspecified",
|
|
86
|
+
...identity,
|
|
87
|
+
findingKeyVersion: 1,
|
|
88
|
+
},
|
|
71
89
|
plugins,
|
|
72
90
|
};
|
|
73
91
|
}
|
|
74
92
|
|
|
93
|
+
// Identity is structured, so delimiters in malformed metadata cannot collide.
|
|
94
|
+
const groupKey = (f) => JSON.stringify([f.key, f.category, f.entryId ?? null]);
|
|
95
|
+
const compareFinding = (a, b) => {
|
|
96
|
+
const key = (f) =>
|
|
97
|
+
JSON.stringify([
|
|
98
|
+
groupKey(f),
|
|
99
|
+
f.location,
|
|
100
|
+
f.column ?? null,
|
|
101
|
+
f.severity,
|
|
102
|
+
f.certainty,
|
|
103
|
+
]);
|
|
104
|
+
const x = key(a),
|
|
105
|
+
y = key(b);
|
|
106
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
107
|
+
};
|
|
108
|
+
/** Match exact occurrences first, then same location, then equal evidence,
|
|
109
|
+
* then deterministic remaining occurrences. Every row is consumed once. */
|
|
110
|
+
function matchFindings(before, after) {
|
|
111
|
+
const a = [...before].sort(compareFinding),
|
|
112
|
+
b = [...after].sort(compareFinding);
|
|
113
|
+
const usedA = new Set(),
|
|
114
|
+
usedB = new Set(),
|
|
115
|
+
pairs = [];
|
|
116
|
+
for (const detail of [
|
|
117
|
+
(f) => [f.location, f.column ?? null, f.severity, f.certainty],
|
|
118
|
+
(f) => [f.location, f.column ?? null],
|
|
119
|
+
(f) => [f.severity, f.certainty],
|
|
120
|
+
() => [],
|
|
121
|
+
]) {
|
|
122
|
+
const queues = new Map();
|
|
123
|
+
const key = (f) => JSON.stringify([groupKey(f), ...detail(f)]);
|
|
124
|
+
for (let i = 0; i < a.length; i++) {
|
|
125
|
+
if (usedA.has(i)) continue;
|
|
126
|
+
const k = key(a[i]);
|
|
127
|
+
if (!queues.has(k)) queues.set(k, { indices: [], offset: 0 });
|
|
128
|
+
queues.get(k).indices.push(i);
|
|
129
|
+
}
|
|
130
|
+
for (let j = 0; j < b.length; j++) {
|
|
131
|
+
if (usedB.has(j)) continue;
|
|
132
|
+
const queue = queues.get(key(b[j]));
|
|
133
|
+
if (!queue || queue.offset === queue.indices.length) continue;
|
|
134
|
+
const i = queue.indices[queue.offset++];
|
|
135
|
+
usedA.add(i);
|
|
136
|
+
usedB.add(j);
|
|
137
|
+
pairs.push({ from: a[i], to: b[j] });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
pairs,
|
|
142
|
+
added: b.filter((_, i) => !usedB.has(i)),
|
|
143
|
+
removed: a.filter((_, i) => !usedA.has(i)),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
75
147
|
/** 结论档位的"好坏"序:用于判断某个变化是变好还是变差 */
|
|
76
148
|
const RANK = { broken: 0, partial: 1, unknown: 2, ok: 3 };
|
|
77
149
|
|
|
@@ -144,30 +216,20 @@ export function diffAgainstBaseline(prev, next) {
|
|
|
144
216
|
const worseVerdict = RANK[y.verdict] < RANK[x.verdict];
|
|
145
217
|
const betterVerdict = RANK[y.verdict] > RANK[x.verdict];
|
|
146
218
|
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
);
|
|
152
|
-
const goneFindings = x.findings.filter(
|
|
153
|
-
(f) => !yk.has(`${f.key}|${f.category}`),
|
|
154
|
-
);
|
|
219
|
+
const {
|
|
220
|
+
added: newFindings,
|
|
221
|
+
removed: goneFindings,
|
|
222
|
+
pairs,
|
|
223
|
+
} = matchFindings(x.findings, y.findings);
|
|
155
224
|
const severityRank = { degraded: 0, "feature-dead": 1, fatal: 2 };
|
|
156
225
|
const certaintyRank = { suspected: 0, certain: 1 };
|
|
157
|
-
const changes =
|
|
158
|
-
|
|
159
|
-
from
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
to
|
|
163
|
-
|
|
164
|
-
.filter(
|
|
165
|
-
(c) =>
|
|
166
|
-
c.from &&
|
|
167
|
-
(c.from.severity !== c.to.severity ||
|
|
168
|
-
c.from.certainty !== c.to.certainty ||
|
|
169
|
-
c.from.location !== c.to.location),
|
|
170
|
-
);
|
|
226
|
+
const changes = pairs.filter(
|
|
227
|
+
({ from, to }) =>
|
|
228
|
+
from.severity !== to.severity ||
|
|
229
|
+
from.certainty !== to.certainty ||
|
|
230
|
+
from.location !== to.location ||
|
|
231
|
+
from.column !== to.column,
|
|
232
|
+
);
|
|
171
233
|
const evidenceWorse = changes.some(
|
|
172
234
|
(c) =>
|
|
173
235
|
severityRank[c.to.severity] > severityRank[c.from.severity] ||
|
|
@@ -289,21 +351,30 @@ export function badgeFor(diff) {
|
|
|
289
351
|
* 而坏消息被"另外还发现两个插件变好了"稀释掉,就等于没说。
|
|
290
352
|
*/
|
|
291
353
|
export function describeDiff(diff) {
|
|
292
|
-
if (!diff || !diff.comparable)
|
|
354
|
+
if (!diff || !diff.comparable)
|
|
355
|
+
return terminalText(diff ? diff.reason : "没有基线");
|
|
293
356
|
const badge = badgeFor(diff);
|
|
294
|
-
if (!badge.show) return badge.detail;
|
|
357
|
+
if (!badge.show) return terminalText(badge.detail);
|
|
295
358
|
const L = [badge.text];
|
|
296
359
|
for (const e of diff.worsened) {
|
|
297
|
-
L.push(
|
|
360
|
+
L.push(
|
|
361
|
+
` ↓ ${terminalText(e.name)}@${terminalText(e.versionFrom)}→${terminalText(e.versionTo)}:${terminalText(e.reason)}`,
|
|
362
|
+
);
|
|
298
363
|
for (const f of e.newFindings)
|
|
299
|
-
L.push(
|
|
364
|
+
L.push(
|
|
365
|
+
` 新增:${terminalText(f.key)} @ ${terminalText(f.location || "—")}`,
|
|
366
|
+
);
|
|
300
367
|
}
|
|
301
368
|
for (const e of diff.improved) {
|
|
302
|
-
L.push(` ↑ ${e.name}:${e.reason}`);
|
|
303
|
-
for (const f of e.goneFindings)
|
|
369
|
+
L.push(` ↑ ${terminalText(e.name)}:${terminalText(e.reason)}`);
|
|
370
|
+
for (const f of e.goneFindings)
|
|
371
|
+
L.push(` 不再报:${terminalText(f.key)}`);
|
|
304
372
|
}
|
|
305
|
-
for (const e of diff.added)
|
|
306
|
-
|
|
307
|
-
for (const e of diff.
|
|
373
|
+
for (const e of diff.added)
|
|
374
|
+
L.push(` + ${terminalText(e.name)}@${terminalText(e.version)}`);
|
|
375
|
+
for (const e of diff.removed)
|
|
376
|
+
L.push(` − ${terminalText(e.name)}@${terminalText(e.version)}`);
|
|
377
|
+
for (const e of diff.updated || [])
|
|
378
|
+
L.push(` • ${terminalText(e.name)}:${terminalText(e.reason)}`);
|
|
308
379
|
return L.join("\n");
|
|
309
380
|
}
|
package/kernel/lib/catalog.mjs
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/** Keep the frozen legacy catalogue available for older targets and overrides. */
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import semver from "semver";
|
|
4
|
-
import fs from "node:fs";
|
|
5
4
|
import { createHash } from "node:crypto";
|
|
6
5
|
import { CATALOG_REFERENCES, describeCatalog } from "./contracts.mjs";
|
|
6
|
+
import { readStaticFile } from "./static-input.mjs";
|
|
7
7
|
|
|
8
8
|
export function catalogPathFor(root, version, override) {
|
|
9
9
|
if (override) return override;
|
|
10
|
+
const newest =
|
|
11
|
+
typeof version === "string" &&
|
|
12
|
+
semver.valid(version) &&
|
|
13
|
+
semver.gte(version, "0.2.1-rc.3");
|
|
10
14
|
const modern =
|
|
11
15
|
typeof version === "string" &&
|
|
12
16
|
semver.valid(version) &&
|
|
@@ -14,14 +18,18 @@ export function catalogPathFor(root, version, override) {
|
|
|
14
18
|
return path.join(
|
|
15
19
|
root,
|
|
16
20
|
"rules",
|
|
17
|
-
|
|
21
|
+
newest
|
|
22
|
+
? "dsh-api-catalog-0.2.1-rc.3.json"
|
|
23
|
+
: modern
|
|
24
|
+
? "dsh-api-catalog-0.2.1-rc.2.json"
|
|
25
|
+
: "dsh-api-catalog.json",
|
|
18
26
|
);
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
/** Read once: the reported digest always describes the bytes used to judge. */
|
|
22
30
|
export function loadCatalog(root, version, override) {
|
|
23
31
|
const file = catalogPathFor(root, version, override),
|
|
24
|
-
bytes =
|
|
32
|
+
bytes = readStaticFile(file, 8 * 1024 * 1024);
|
|
25
33
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
26
34
|
const known = CATALOG_REFERENCES.find(
|
|
27
35
|
(c) => c.file === path.basename(file) && c.sha256 === sha256,
|
package/kernel/lib/contracts.mjs
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
export const REPORT_SCHEMA = 1;
|
|
9
9
|
export const REPORT_KIND = "plugin-compat-check/result";
|
|
10
10
|
export const ENVELOPE_KIND = "plugin-compat-check/report";
|
|
11
|
+
/** Escape untrusted single-line display text; callers supply layout newlines.
|
|
12
|
+
* JSON identities remain untouched. Visible escapes are safe to render again. */
|
|
13
|
+
export function terminalText(value) {
|
|
14
|
+
return String(value).replace(
|
|
15
|
+
/[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g,
|
|
16
|
+
(c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
11
19
|
/** Reviewed catalogue bytes, not a promise of plugin business compatibility.
|
|
12
20
|
* Adding a version requires source verification, rule review and integration
|
|
13
21
|
* evidence; extraction alone must never extend this list. */
|
|
@@ -22,6 +30,11 @@ export const CATALOG_REFERENCES = Object.freeze([
|
|
|
22
30
|
file: "dsh-api-catalog-0.2.1-rc.2.json",
|
|
23
31
|
sha256: "90b4ae484ebc6660c53e5c9ad14ba657581534b30e8222c636c759e19587f743",
|
|
24
32
|
}),
|
|
33
|
+
Object.freeze({
|
|
34
|
+
version: "0.2.1-rc.3",
|
|
35
|
+
file: "dsh-api-catalog-0.2.1-rc.3.json",
|
|
36
|
+
sha256: "dd253bc418a787d881e402f3650857c2d8e7631b818927743a5f2b81104f25a5",
|
|
37
|
+
}),
|
|
25
38
|
]);
|
|
26
39
|
export function describeCatalog({
|
|
27
40
|
targetVersion = null,
|
|
@@ -102,7 +115,9 @@ function snapshotFinding(f) {
|
|
|
102
115
|
text(f.category) &&
|
|
103
116
|
severities.includes(f.severity) &&
|
|
104
117
|
certainties.includes(f.certainty) &&
|
|
105
|
-
nullableText(f.location)
|
|
118
|
+
nullableText(f.location) &&
|
|
119
|
+
(f.entryId === undefined || nullableText(f.entryId)) &&
|
|
120
|
+
(f.column === undefined || integer(f.column))
|
|
106
121
|
);
|
|
107
122
|
}
|
|
108
123
|
function checkedEntry(v) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Private runner state and a minimal inherited environment; not an OS sandbox.
|
|
5
|
+
* PATH still selects trusted executables. Copied profile configuration and
|
|
6
|
+
* explicitly selected local dependencies remain readable by the host/plugin.
|
|
7
|
+
*/
|
|
8
|
+
export function isolatedEnvironment(root) {
|
|
9
|
+
const env = {};
|
|
10
|
+
for (const key of [
|
|
11
|
+
"PATH",
|
|
12
|
+
"SystemRoot",
|
|
13
|
+
"COMSPEC",
|
|
14
|
+
"PATHEXT",
|
|
15
|
+
"LANG",
|
|
16
|
+
"LC_ALL",
|
|
17
|
+
"TMPDIR",
|
|
18
|
+
"TEMP",
|
|
19
|
+
"TMP",
|
|
20
|
+
]) {
|
|
21
|
+
if (process.env[key]) env[key] = process.env[key];
|
|
22
|
+
}
|
|
23
|
+
Object.assign(env, {
|
|
24
|
+
HOME: path.join(root, "user"),
|
|
25
|
+
USERPROFILE: path.join(root, "user"),
|
|
26
|
+
XDG_CONFIG_HOME: path.join(root, "config"),
|
|
27
|
+
XDG_CACHE_HOME: path.join(root, "cache"),
|
|
28
|
+
DSH_HOME: path.join(root, "home"),
|
|
29
|
+
DSH_AGENTS_HOME: path.join(root, "agents"),
|
|
30
|
+
npm_config_cache: path.join(root, "npm-cache"),
|
|
31
|
+
npm_config_store_dir: path.join(root, "pnpm-store"),
|
|
32
|
+
npm_config_ignore_scripts: "true",
|
|
33
|
+
});
|
|
34
|
+
for (const directory of new Set([
|
|
35
|
+
env.HOME,
|
|
36
|
+
env.XDG_CONFIG_HOME,
|
|
37
|
+
env.XDG_CACHE_HOME,
|
|
38
|
+
env.DSH_HOME,
|
|
39
|
+
env.DSH_AGENTS_HOME,
|
|
40
|
+
env.npm_config_cache,
|
|
41
|
+
env.npm_config_store_dir,
|
|
42
|
+
]))
|
|
43
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
44
|
+
for (const [key, name] of [
|
|
45
|
+
["npm_config_userconfig", "npm-user.rc"],
|
|
46
|
+
["npm_config_globalconfig", "npm-global.rc"],
|
|
47
|
+
]) {
|
|
48
|
+
env[key] = path.join(root, name);
|
|
49
|
+
fs.writeFileSync(env[key], "", { mode: 0o600, flag: "wx" });
|
|
50
|
+
}
|
|
51
|
+
return env;
|
|
52
|
+
}
|