@shidesheng0218/dsh-plugin-lint 0.1.0 → 0.4.0
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/NOTICE +1 -1
- package/README.md +515 -81
- package/bin/dsh-plugin-analyze.mjs +378 -0
- package/bin/dsh-plugin-lint.mjs +315 -27
- package/bin/dsh-plugin-matrix.mjs +78 -0
- package/bin/dsh-plugin-upgrade-plan.mjs +180 -0
- package/package.json +12 -4
- package/schema/dsh-manifest.schema.json +134 -0
- package/scripts/lib/probe.mjs +28 -385
- package/scripts/probe-boot.mjs +103 -37
- package/src/composition.js +1 -0
- package/src/core/baseline.js +98 -0
- package/src/core/changed.js +41 -0
- package/src/core/load.js +145 -0
- package/src/core/mask.js +207 -0
- package/src/core/pack.js +95 -0
- package/src/core/packlist.js +186 -0
- package/src/core/peer.js +39 -0
- package/src/core/profile-scan.js +121 -0
- package/src/core/report.js +174 -0
- package/src/core/rules.js +581 -0
- package/src/core/semver.js +192 -0
- package/src/core/tarball.js +90 -0
- package/src/data/event-types.json +111 -9
- package/src/experimental/conflicts.js +315 -0
- package/src/experimental/index.js +4 -0
- package/src/experimental/performance.js +515 -0
- package/src/experimental/security.js +462 -0
- package/src/index.js +15 -17
- package/src/load.js +1 -116
- package/src/mask.js +1 -156
- package/src/matrix/cache.js +19 -0
- package/src/matrix/cell.js +79 -0
- package/src/matrix/cells.js +58 -0
- package/src/matrix/conflicts.js +210 -0
- package/src/matrix/export.js +155 -0
- package/src/matrix/index.js +71 -0
- package/src/matrix/manifest.js +17 -0
- package/src/matrix/migrate.js +41 -0
- package/src/matrix/run.js +241 -0
- package/src/matrix/sanitize.js +42 -0
- package/src/matrix/schema.js +178 -0
- package/src/matrix/site.js +61 -0
- package/src/matrix/sync.js +72 -0
- package/src/matrix/verify.js +152 -0
- package/src/performance.js +1 -0
- package/src/probe/artifact-resolver.js +161 -0
- package/src/probe/classify.js +8 -0
- package/src/probe/dsh-adapter.js +212 -0
- package/src/probe/environment/docker.js +270 -0
- package/src/probe/environment/host-unsafe.js +39 -0
- package/src/probe/environment/materialize.js +22 -0
- package/src/probe/environment.js +50 -0
- package/src/probe/installed-artifact.js +17 -0
- package/src/probe/probe.js +278 -0
- package/src/report.js +1 -79
- package/src/rules.js +1 -379
- package/src/security.js +1 -0
- package/src/shared/errors.js +7 -0
- package/src/shared/hash.js +31 -0
- package/src/shared/status.js +12 -0
- package/src/shared/validate.js +14 -0
- package/src/shared/version.js +4 -0
- package/src/upgrade/compatibility.js +14 -0
- package/src/upgrade/planner.js +119 -0
- package/src/upgrade/profile.js +82 -0
package/src/rules.js
CHANGED
|
@@ -1,379 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { appendCallSites, blockAfter, braceDepthAt, lineOfIndex, stringAfter } from './mask.js';
|
|
4
|
-
import { filesCover } from './load.js';
|
|
5
|
-
|
|
6
|
-
const CATALOG = JSON.parse(readFileSync(new URL('./data/event-types.json', import.meta.url), 'utf8'));
|
|
7
|
-
export const EVENT_TYPES = new Set(CATALOG.types);
|
|
8
|
-
|
|
9
|
-
/** Upstream documents that ship inside a DSH installation. */
|
|
10
|
-
export const DOC = {
|
|
11
|
-
practices: 'dsh plugin-development / references/practices.md',
|
|
12
|
-
host: 'dsh plugin-development / references/host-plugin.md',
|
|
13
|
-
ui: 'dsh plugin-development / references/ui-plugin.md',
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const EVENT_TYPE_RE = /^[a-z][a-z0-9-]*\/[a-z0-9-]+$/;
|
|
17
|
-
const HARNESS_CLIENT_PKG = /@deepseek-ai\/dsh-client[a-z0-9-]*/g;
|
|
18
|
-
|
|
19
|
-
export const RULES = [
|
|
20
|
-
{
|
|
21
|
-
id: 'E1',
|
|
22
|
-
level: 'error',
|
|
23
|
-
title: 'appends a session event type that appears nowhere in the official packages',
|
|
24
|
-
doc: DOC.practices,
|
|
25
|
-
check(ctx, options, push) {
|
|
26
|
-
for (const file of ctx.jsFiles) {
|
|
27
|
-
for (const site of appendCallSites(file.masked, file.strings)) {
|
|
28
|
-
if (!EVENT_TYPE_RE.test(site.type)) continue;
|
|
29
|
-
if (EVENT_TYPES.has(site.type)) continue;
|
|
30
|
-
push({
|
|
31
|
-
file: file.path,
|
|
32
|
-
line: site.line,
|
|
33
|
-
message: 'session.append("' + site.type + '", ...): this type does not appear anywhere in the official Harness packages (' + EVENT_TYPES.size + ' types observed).',
|
|
34
|
-
evidence: '.append("' + site.type + '", ...)',
|
|
35
|
-
hint: 'The reference asks plugins not to append session events with a new type: a live Session.append() cannot set ignorable: true, so a log holding an unknown type can refuse to reopen. The allowlist is harvested, not authoritative — if this type is legitimate, it is a gap in the rule, not in your plugin.',
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
|
|
42
|
-
{
|
|
43
|
-
id: 'E2',
|
|
44
|
-
level: 'error',
|
|
45
|
-
title: 'client bundle factory id matches nothing the loader can ask for',
|
|
46
|
-
doc: DOC.ui,
|
|
47
|
-
check(ctx, options, push) {
|
|
48
|
-
if (ctx.entries.client === undefined || !ctx.exists(ctx.entries.client)) return;
|
|
49
|
-
const file = ctx.byPath(ctx.entries.client);
|
|
50
|
-
if (!file) return;
|
|
51
|
-
|
|
52
|
-
// The loader keys the browser module table by an id and throws when a bundle
|
|
53
|
-
// registers a different one (dsh-client-modules: "bundle … loaded without
|
|
54
|
-
// registering \"id\""). What it asks for is either the package name or a row
|
|
55
|
-
// name from the plugin's own patch, so both are acceptable. Asserting only the
|
|
56
|
-
// package name produced a false accusation against @roarpeng/graphflow, whose
|
|
57
|
-
// patch mounts the row "@roarpeng/graphflow/dsh".
|
|
58
|
-
const acceptable = new Set();
|
|
59
|
-
if (ctx.manifest.name) acceptable.add(ctx.manifest.name);
|
|
60
|
-
const patchRows = [];
|
|
61
|
-
if (ctx.entries.patch !== undefined && ctx.exists(ctx.entries.patch)) {
|
|
62
|
-
const patch = ctx.text(ctx.entries.patch) || '';
|
|
63
|
-
for (const m of patch.matchAll(/^\s*name:\s*['"]?([^'"\s#]+)['"]?/gm)) {
|
|
64
|
-
acceptable.add(m[1]);
|
|
65
|
-
patchRows.push(m[1]);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const re = /__ModuleLoader__\s*\.\s*load\s*\(\s*\{\s*id\s*:\s*/g;
|
|
70
|
-
let m;
|
|
71
|
-
while ((m = re.exec(file.masked)) !== null) {
|
|
72
|
-
const s = stringAfter(file.strings, m.index + m[0].length, 8);
|
|
73
|
-
if (!s || acceptable.has(s.value)) continue;
|
|
74
|
-
push({
|
|
75
|
-
file: file.path,
|
|
76
|
-
line: s.line,
|
|
77
|
-
message: 'factory id "' + s.value + '" is neither the package name "' + ctx.manifest.name + '" nor a row name in ' + (ctx.entries.patch || 'the bundle patch') + (patchRows.length ? ' (' + [...new Set(patchRows)].join(', ') + ')' : '') + '.',
|
|
78
|
-
evidence: '__ModuleLoader__.load({ id: "' + s.value + '" })',
|
|
79
|
-
hint: 'The browser module table keys the factory by the id it asks for and the loader throws when a bundle registers another one, leaving the client half unreachable.',
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
|
|
85
|
-
{
|
|
86
|
-
id: 'E3',
|
|
87
|
-
level: 'error',
|
|
88
|
-
title: 'manifest references a file that is not in the package',
|
|
89
|
-
doc: DOC.host,
|
|
90
|
-
check(ctx, options, push) {
|
|
91
|
-
const declared = [
|
|
92
|
-
['host entry', ctx.entries.hostDeclared ? ctx.entries.host : undefined, 'manifest.main or exports["."]'],
|
|
93
|
-
['client entry', ctx.entries.client, 'exports["./client"]'],
|
|
94
|
-
['bundle patch', ctx.entries.patch, 'dsh.bundle.patch'],
|
|
95
|
-
['icon', ctx.entries.icon, 'icon'],
|
|
96
|
-
];
|
|
97
|
-
for (const [label, rel, source] of declared) {
|
|
98
|
-
if (rel === undefined) continue;
|
|
99
|
-
if (ctx.exists(rel)) continue;
|
|
100
|
-
push({
|
|
101
|
-
file: 'package.json',
|
|
102
|
-
line: manifestLine(ctx.manifestText, source),
|
|
103
|
-
message: label + ' points at "' + rel + '", which does not exist.',
|
|
104
|
-
evidence: source + ' -> ' + rel,
|
|
105
|
-
hint: 'Management pages read these paths without activating the plugin; a missing file is reported as a broken package.',
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
|
|
111
|
-
{
|
|
112
|
-
id: 'E4',
|
|
113
|
-
level: 'error',
|
|
114
|
-
title: 'declares a client half without a client entry',
|
|
115
|
-
doc: DOC.ui,
|
|
116
|
-
check(ctx, options, push) {
|
|
117
|
-
const hasClientSection = Boolean(ctx.manifest.dsh && ctx.manifest.dsh.client);
|
|
118
|
-
if (!hasClientSection) return;
|
|
119
|
-
if (ctx.entries.client !== undefined && ctx.exists(ctx.entries.client)) return;
|
|
120
|
-
push({
|
|
121
|
-
file: 'package.json',
|
|
122
|
-
line: manifestLine(ctx.manifestText, 'dsh.client'),
|
|
123
|
-
message: 'dsh.client is declared but exports["./client"] does not resolve to a file.',
|
|
124
|
-
evidence: 'dsh.client present, no ./client export',
|
|
125
|
-
hint: 'The platform resolves the browser artifact through the ./client export.',
|
|
126
|
-
});
|
|
127
|
-
},
|
|
128
|
-
},
|
|
129
|
-
|
|
130
|
-
{
|
|
131
|
-
id: 'E5',
|
|
132
|
-
level: 'error',
|
|
133
|
-
title: 'published files omit an artifact the manifest depends on',
|
|
134
|
-
doc: DOC.host,
|
|
135
|
-
check(ctx, options, push) {
|
|
136
|
-
if (ctx.files === undefined || ctx.files.length === 0) return;
|
|
137
|
-
const artifacts = [
|
|
138
|
-
['host entry', ctx.entries.host],
|
|
139
|
-
['client entry', ctx.entries.client],
|
|
140
|
-
['bundle patch', ctx.entries.patch],
|
|
141
|
-
['icon', ctx.entries.icon],
|
|
142
|
-
];
|
|
143
|
-
for (const [label, rel] of artifacts) {
|
|
144
|
-
if (rel === undefined || !ctx.exists(rel)) continue;
|
|
145
|
-
if (filesCover(ctx.files, rel)) continue;
|
|
146
|
-
push({
|
|
147
|
-
file: 'package.json',
|
|
148
|
-
line: manifestLine(ctx.manifestText, '"files"'),
|
|
149
|
-
message: label + ' "' + rel + '" is not covered by "files", so it is absent from the published tarball.',
|
|
150
|
-
evidence: 'files: ' + JSON.stringify(ctx.files),
|
|
151
|
-
hint: 'This class of bug passes local testing and only appears after publish.',
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
},
|
|
155
|
-
},
|
|
156
|
-
|
|
157
|
-
{
|
|
158
|
-
id: 'W1',
|
|
159
|
-
level: 'warn',
|
|
160
|
-
title: 'couples the plugin to a Harness Client package',
|
|
161
|
-
doc: DOC.ui,
|
|
162
|
-
check(ctx, options, push) {
|
|
163
|
-
// First-party packages ship in the same release as the browser module table,
|
|
164
|
-
// so the coupling this rule warns about does not apply to them. Measured on
|
|
165
|
-
// 2026-10-13: every official client bundle loads ui-primitives / store / slots.
|
|
166
|
-
if (/^@deepseek-ai\//.test(ctx.manifest.name || '')) return;
|
|
167
|
-
|
|
168
|
-
// One finding per surface, not per package: a plugin that lists six client
|
|
169
|
-
// packages is one decision to review, not six defects.
|
|
170
|
-
const loaded = new Map();
|
|
171
|
-
if (ctx.entries.client !== undefined && ctx.exists(ctx.entries.client)) {
|
|
172
|
-
const file = ctx.byPath(ctx.entries.client);
|
|
173
|
-
if (file) {
|
|
174
|
-
const re = /(?:from\s*|require\s*\(\s*|[A-Za-z_$][\w$]*\s*\(\s*)["'](@deepseek-ai\/dsh-client[a-z0-9-]*)["']/g;
|
|
175
|
-
let m;
|
|
176
|
-
while ((m = re.exec(file.masked)) !== null) {
|
|
177
|
-
if (loaded.has(m[1])) continue;
|
|
178
|
-
loaded.set(m[1], { line: file.strings.find((s) => s.start >= m.index && s.start - m.index < 80)?.line, evidence: m[0].trim() });
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
if (loaded.size > 0) {
|
|
183
|
-
const first = loaded.values().next().value;
|
|
184
|
-
push({
|
|
185
|
-
file: ctx.entries.client,
|
|
186
|
-
line: first.line,
|
|
187
|
-
message: 'loads ' + loaded.size + ' Harness Client package' + (loaded.size === 1 ? '' : 's') + ' from the browser module table: ' + [...loaded.keys()].join(', ') + '.',
|
|
188
|
-
evidence: first.evidence,
|
|
189
|
-
hint: 'The reference advises copying the primitive instead: these packages change without notice and a throwing component blanks the slot. Official first-party bundles do the same, so treat this as a coupling risk to watch at upgrade time, not as a defect.',
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const coupled = [];
|
|
194
|
-
for (const depField of ['dependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
195
|
-
for (const name of Object.keys(ctx.manifest[depField] || {})) {
|
|
196
|
-
if (/^@deepseek-ai\/dsh-client/.test(name)) coupled.push(depField + ':' + name);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
if (coupled.length > 0) {
|
|
200
|
-
push({
|
|
201
|
-
file: 'package.json',
|
|
202
|
-
line: 1,
|
|
203
|
-
message: 'declares ' + coupled.length + ' Harness Client package dependenc' + (coupled.length === 1 ? 'y' : 'ies') + ': ' + coupled.join(', ') + '.',
|
|
204
|
-
evidence: coupled.join(' '),
|
|
205
|
-
hint: 'dsh.client.inject entries only order activation and stay allowed; a dependency means the code loads it.',
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
|
|
211
|
-
{
|
|
212
|
-
id: 'W2',
|
|
213
|
-
level: 'warn',
|
|
214
|
-
title: 'folds session state by rescanning the log',
|
|
215
|
-
doc: DOC.practices,
|
|
216
|
-
check(ctx, options, push) {
|
|
217
|
-
for (const file of ctx.jsFiles) {
|
|
218
|
-
const registration = file.strings.find((s) => s.value === 'session/event');
|
|
219
|
-
if (registration === undefined) continue;
|
|
220
|
-
if (file.masked.includes('sessionProjections')) continue;
|
|
221
|
-
// Scope the check to the listener body: another object's `.events` in the
|
|
222
|
-
// same file is not a rescan. (Measured against official packages.)
|
|
223
|
-
const block = blockAfter(file.structural, registration.end);
|
|
224
|
-
if (block === undefined) continue;
|
|
225
|
-
const rescans = /\.events\b/.test(block.body) || /eventsBackwards\s*\(/.test(block.body);
|
|
226
|
-
if (!rescans) continue;
|
|
227
|
-
push({
|
|
228
|
-
file: file.path,
|
|
229
|
-
line: registration.line,
|
|
230
|
-
message: 'subscribes to session/event and reads the whole event log.',
|
|
231
|
-
evidence: "ctx.on('session/event', ...) with a full-log read",
|
|
232
|
-
hint: 'A ctx.sessionProjections unit folds events incrementally and returns the same reference for ignored events; per-event rescans cost the whole session on every append.',
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
},
|
|
236
|
-
},
|
|
237
|
-
|
|
238
|
-
{
|
|
239
|
-
id: 'W3',
|
|
240
|
-
level: 'info',
|
|
241
|
-
title: 'creates a timer with no visible owner (review hint)',
|
|
242
|
-
doc: DOC.practices,
|
|
243
|
-
check(ctx, options, push) {
|
|
244
|
-
for (const file of ctx.jsFiles) {
|
|
245
|
-
// Only timers at module top level are unambiguous leaks: a timer created
|
|
246
|
-
// inside a request handler may well be cleared by the surrounding code.
|
|
247
|
-
const re = /set(Interval|Timeout)\s*\(/g;
|
|
248
|
-
let m;
|
|
249
|
-
while ((m = re.exec(file.structural)) !== null) {
|
|
250
|
-
if (braceDepthAt(file.structural, m.index) !== 0) continue;
|
|
251
|
-
push({
|
|
252
|
-
file: file.path,
|
|
253
|
-
line: lineOfIndex(file.lineStarts, m.index),
|
|
254
|
-
message: 'appears to start a timer outside any function scope, with no context effect nearby.',
|
|
255
|
-
evidence: m[0],
|
|
256
|
-
hint: 'Registration on a context owns its cleanup: unload and hot reload dispose what the effect returned. Brace depth is approximate on bundled or minified output, so confirm this one by reading the code.',
|
|
257
|
-
});
|
|
258
|
-
break;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
},
|
|
262
|
-
},
|
|
263
|
-
|
|
264
|
-
{
|
|
265
|
-
id: 'I1',
|
|
266
|
-
level: 'info',
|
|
267
|
-
title: 'declares no DSH compatibility range',
|
|
268
|
-
doc: DOC.host,
|
|
269
|
-
check(ctx, options, push) {
|
|
270
|
-
const declared = ctx.manifest.dsh && ctx.manifest.dsh.compatibility && ctx.manifest.dsh.compatibility.dshReleases;
|
|
271
|
-
if (declared && Object.keys(declared).length > 0) {
|
|
272
|
-
if (options.dshVersion && !(options.dshVersion in declared)) {
|
|
273
|
-
push({
|
|
274
|
-
file: 'package.json',
|
|
275
|
-
line: manifestLine(ctx.manifestText, 'dshReleases'),
|
|
276
|
-
message: 'does not list the running DSH version "' + options.dshVersion + '" in dsh.compatibility.dshReleases.',
|
|
277
|
-
evidence: 'declared: ' + Object.keys(declared).join(', '),
|
|
278
|
-
hint: 'An unlisted version is unknown, not incompatible: verify it before publishing a compatibility claim.',
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
push({
|
|
284
|
-
file: 'package.json',
|
|
285
|
-
line: manifestLine(ctx.manifestText, '"dsh"'),
|
|
286
|
-
message: 'has no dsh.compatibility.dshReleases entry.',
|
|
287
|
-
evidence: 'dsh.compatibility absent',
|
|
288
|
-
hint: 'Most published plugins omit this today, which is why empirical verification matters more than self-declaration.',
|
|
289
|
-
});
|
|
290
|
-
},
|
|
291
|
-
},
|
|
292
|
-
|
|
293
|
-
{
|
|
294
|
-
id: 'I2',
|
|
295
|
-
level: 'info',
|
|
296
|
-
title: 'ships no display metadata',
|
|
297
|
-
doc: DOC.host,
|
|
298
|
-
check(ctx, options, push) {
|
|
299
|
-
if (ctx.entries.icon === undefined) {
|
|
300
|
-
push({
|
|
301
|
-
file: 'package.json',
|
|
302
|
-
line: 1,
|
|
303
|
-
message: 'declares no top-level "icon".',
|
|
304
|
-
evidence: 'icon absent',
|
|
305
|
-
hint: 'Management cards and the settings inventory read title, description and icon without activating the plugin.',
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
const dir = ctx.entries.localeDir;
|
|
309
|
-
if (dir === undefined) {
|
|
310
|
-
push({
|
|
311
|
-
file: 'package.json',
|
|
312
|
-
line: 1,
|
|
313
|
-
message: 'ships no locale/<lang>.json metadata.',
|
|
314
|
-
evidence: 'locale/ absent',
|
|
315
|
-
hint: 'Title and description live under the "meta" key of locale/en.json, not in package.json.',
|
|
316
|
-
});
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
const candidates = ctx.localeFiles();
|
|
320
|
-
const withMeta = candidates.filter((c) => c.meta && (c.meta.title || c.meta.description));
|
|
321
|
-
if (withMeta.length === 0) {
|
|
322
|
-
push({
|
|
323
|
-
file: dir + '/',
|
|
324
|
-
line: 1,
|
|
325
|
-
message: 'locale files declare no "meta" title or description.',
|
|
326
|
-
evidence: 'locale files: ' + candidates.length,
|
|
327
|
-
hint: 'Expected shape: { "meta": { "title": "...", "description": "..." } }.',
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
},
|
|
331
|
-
},
|
|
332
|
-
|
|
333
|
-
{
|
|
334
|
-
id: 'I3',
|
|
335
|
-
level: 'warn',
|
|
336
|
-
title: 'locale metadata is not published',
|
|
337
|
-
doc: DOC.host,
|
|
338
|
-
check(ctx, options, push) {
|
|
339
|
-
const dir = ctx.entries.localeDir;
|
|
340
|
-
if (dir === undefined) return;
|
|
341
|
-
const exported = Object.keys(ctx.manifest.exports || {}).some((k) => k.includes('locale'));
|
|
342
|
-
if (!exported) {
|
|
343
|
-
push({
|
|
344
|
-
file: 'package.json',
|
|
345
|
-
line: manifestLine(ctx.manifestText, '"exports"'),
|
|
346
|
-
message: 'a locale/ directory exists but exports declares no locale subpath.',
|
|
347
|
-
evidence: 'exports: ' + JSON.stringify(Object.keys(ctx.manifest.exports || {})),
|
|
348
|
-
hint: 'Display metadata is read through the locale subpath.',
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
if (ctx.files && ctx.files.length > 0 && !filesCover(ctx.files, dir + '/en.json')) {
|
|
352
|
-
push({
|
|
353
|
-
file: 'package.json',
|
|
354
|
-
line: manifestLine(ctx.manifestText, '"files"'),
|
|
355
|
-
message: 'locale files are not covered by "files".',
|
|
356
|
-
evidence: 'files: ' + JSON.stringify(ctx.files),
|
|
357
|
-
hint: 'Metadata missing from the tarball shows as a fallback title and default artwork.',
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
},
|
|
361
|
-
},
|
|
362
|
-
];
|
|
363
|
-
|
|
364
|
-
function manifestLine(text, needle) {
|
|
365
|
-
if (!text || !needle) return 1;
|
|
366
|
-
const idx = text.indexOf(needle.replace(/^"|"$/g, ''));
|
|
367
|
-
if (idx < 0) return 1;
|
|
368
|
-
return text.slice(0, idx).split('\n').length;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
export function localeMeta(dir, localeDir) {
|
|
372
|
-
const out = [];
|
|
373
|
-
for (const lang of ['en', 'zh', 'zh-CN', 'ja', 'ru']) {
|
|
374
|
-
const p = join(dir, localeDir, lang + '.json');
|
|
375
|
-
if (!existsSync(p)) continue;
|
|
376
|
-
try { out.push({ lang, ...JSON.parse(readFileSync(p, 'utf8')) }); } catch { /* ignored: malformed locale file */ }
|
|
377
|
-
}
|
|
378
|
-
return out;
|
|
379
|
-
}
|
|
1
|
+
export * from './core/rules.js';
|
package/src/security.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './experimental/security.js';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isPlainObject } from './validate.js';
|
|
3
|
+
|
|
4
|
+
export function stableStringify(value) {
|
|
5
|
+
return JSON.stringify(normalizeJson(value, new Set()));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function sha256(value) {
|
|
9
|
+
return createHash('sha256').update(value).digest('hex');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeJson(value, ancestors) {
|
|
13
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
14
|
+
if (typeof value === 'number') {
|
|
15
|
+
if (!Number.isFinite(value)) throw new TypeError('stableStringify only accepts JSON data');
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
if (!Array.isArray(value) && !isPlainObject(value)) {
|
|
19
|
+
throw new TypeError('stableStringify only accepts JSON data');
|
|
20
|
+
}
|
|
21
|
+
if (ancestors.has(value)) throw new TypeError('stableStringify cannot encode a JSON cycle');
|
|
22
|
+
ancestors.add(value);
|
|
23
|
+
try {
|
|
24
|
+
if (Array.isArray(value)) return value.map((entry) => normalizeJson(entry, ancestors));
|
|
25
|
+
const normalized = {};
|
|
26
|
+
for (const key of Object.keys(value).sort()) normalized[key] = normalizeJson(value[key], ancestors);
|
|
27
|
+
return normalized;
|
|
28
|
+
} finally {
|
|
29
|
+
ancestors.delete(value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const STATUS = Object.freeze({
|
|
2
|
+
ACTIVE: 'active',
|
|
3
|
+
AWAITING_SERVICE: 'awaiting-service',
|
|
4
|
+
INACTIVE_ERROR: 'inactive-error',
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
export const TRUST = Object.freeze({
|
|
8
|
+
SELF_REPORTED: 'self-reported',
|
|
9
|
+
VERIFIED: 'verified',
|
|
10
|
+
DISPUTED: 'disputed',
|
|
11
|
+
LEGACY_UNPINNED: 'legacy-unpinned',
|
|
12
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function isPlainObject(value) {
|
|
2
|
+
if (value === null || typeof value !== 'object') return false;
|
|
3
|
+
const prototype = Object.getPrototypeOf(value);
|
|
4
|
+
return prototype === Object.prototype || prototype === null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function requireFields(value, fields, path) {
|
|
8
|
+
if (!isPlainObject(value)) return [`${path} must be an object`];
|
|
9
|
+
const errors = [];
|
|
10
|
+
for (const field of fields) {
|
|
11
|
+
if (!Object.hasOwn(value, field) || value[field] === undefined) errors.push(`${path}.${field} is required`);
|
|
12
|
+
}
|
|
13
|
+
return errors;
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const INCOMPATIBLE = /incompatible|unsupported|broken|failed|rejected|inactive|boot/i;
|
|
2
|
+
|
|
3
|
+
export function classifyCompatibilityDeclaration(value) {
|
|
4
|
+
if (value === true || value === 'compatible') return 'compatible';
|
|
5
|
+
if (value === false || value === 'incompatible') return 'incompatible';
|
|
6
|
+
if (typeof value === 'string') return INCOMPATIBLE.test(value) ? 'incompatible' : 'unknown';
|
|
7
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
8
|
+
if (value.compatible === true) return 'compatible';
|
|
9
|
+
if (value.compatible === false) return 'incompatible';
|
|
10
|
+
const status = value.status ?? value.verdict;
|
|
11
|
+
if (typeof status === 'string' && INCOMPATIBLE.test(status)) return 'incompatible';
|
|
12
|
+
}
|
|
13
|
+
return 'unknown';
|
|
14
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ToolError } from '../shared/errors.js';
|
|
4
|
+
import { readCells } from '../matrix/cells.js';
|
|
5
|
+
import { recommendationEligible, selectCells } from '../matrix/cell.js';
|
|
6
|
+
import { readProfile } from './profile.js';
|
|
7
|
+
|
|
8
|
+
function readMatrix(matrixDir, targetDsh) {
|
|
9
|
+
if (!existsSync(matrixDir)) throw new ToolError('MATRIX_INPUT_INVALID', 'matrix directory not found: ' + matrixDir);
|
|
10
|
+
const cellsDir = existsSync(join(matrixDir, 'cells')) ? join(matrixDir, 'cells') : matrixDir;
|
|
11
|
+
const reads = readCells(cellsDir);
|
|
12
|
+
const damaged = reads.filter((entry) => !entry.ok);
|
|
13
|
+
if (damaged.length > 0) throw new ToolError('MATRIX_INPUT_INVALID', `cannot read ${damaged.length} matrix cell(s), first: ${damaged[0].path}`);
|
|
14
|
+
const cells = reads.filter((entry) => entry.kind === 'v2' || entry.kind === 'legacy-v1').map((entry) => entry.cell);
|
|
15
|
+
if (cells.length === 0) throw new ToolError('MATRIX_INPUT_INVALID', `no matrix cells found under ${cellsDir} (no data for ${targetDsh})`);
|
|
16
|
+
const dshVersions = [...new Set(cells.map((cell) => cell.dsh.version))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
17
|
+
if (!dshVersions.includes(targetDsh)) {
|
|
18
|
+
throw new ToolError('MATRIX_INPUT_INVALID', `the matrix has no cells for DSH ${targetDsh} (it covers: ${dshVersions.join(', ') || 'nothing'})`);
|
|
19
|
+
}
|
|
20
|
+
const byPlugin = new Map();
|
|
21
|
+
for (const cell of cells) {
|
|
22
|
+
if (!byPlugin.has(cell.plugin.name)) byPlugin.set(cell.plugin.name, []);
|
|
23
|
+
byPlugin.get(cell.plugin.name).push(cell);
|
|
24
|
+
}
|
|
25
|
+
return { byPlugin, dshVersions, count: cells.length };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function classifyPlugin(plugin, matrix, targetDsh, composition) {
|
|
29
|
+
const base = { name: plugin.name, version: plugin.version ?? null, firstParty: false };
|
|
30
|
+
if (!plugin.version) return { ...base, category: 'unknown', status: 'unknown', reason: 'installed version unknown' };
|
|
31
|
+
const own = matrix.byPlugin.get(plugin.name) || [];
|
|
32
|
+
const testedVersions = [...new Set(own.map((cell) => cell.plugin.resolvedVersion || cell.plugin.version))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
33
|
+
const current = own.filter((cell) => (cell.plugin.resolvedVersion || cell.plugin.version) === plugin.version);
|
|
34
|
+
if (current.length === 0) {
|
|
35
|
+
return {
|
|
36
|
+
...base,
|
|
37
|
+
category: 'unknown',
|
|
38
|
+
status: 'unknown',
|
|
39
|
+
testedVersions,
|
|
40
|
+
reason: own.length === 0
|
|
41
|
+
? 'plugin is not covered by the matrix'
|
|
42
|
+
: `version ${plugin.version} is not covered for DSH ${targetDsh} (tested versions: ${testedVersions.join(', ')})`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const selection = selectCells(current, { dshVersion: targetDsh, composition });
|
|
46
|
+
if (selection.kind !== 'cells') {
|
|
47
|
+
return {
|
|
48
|
+
...base,
|
|
49
|
+
category: selection.kind,
|
|
50
|
+
status: selection.kind,
|
|
51
|
+
testedVersions,
|
|
52
|
+
reason: selection.kind === 'mixed'
|
|
53
|
+
? 'multiple compositions are present but the current profile composition is unknown'
|
|
54
|
+
: selection.kind === 'unknown'
|
|
55
|
+
? 'the current profile composition or trust evidence is unknown'
|
|
56
|
+
: 'no exact cell exists for the installed version, target DSH, and composition',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const statuses = new Set(selection.cells.map((cell) => cell.result.status));
|
|
61
|
+
const allActiveAndEligible = selection.cells.every((cell) => cell.result.status === 'active' && recommendationEligible(cell));
|
|
62
|
+
if (allActiveAndEligible) return { ...base, category: 'compatible', status: 'active', composition };
|
|
63
|
+
if (statuses.size > 1) return { ...base, category: 'mixed', status: 'mixed', testedVersions, reason: 'observations disagree within the selected composition' };
|
|
64
|
+
|
|
65
|
+
const currentStatus = selection.cells[0].result.status;
|
|
66
|
+
const alternatives = testedVersions.filter((version) => version !== plugin.version).filter((version) => {
|
|
67
|
+
const candidate = selectCells(own.filter((cell) => (cell.plugin.resolvedVersion || cell.plugin.version) === version), { dshVersion: targetDsh, composition });
|
|
68
|
+
return candidate.kind === 'cells' && candidate.cells.every((cell) => cell.result.status === 'active' && recommendationEligible(cell));
|
|
69
|
+
}).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
|
|
70
|
+
const negative = !['active', 'awaiting-service'].includes(currentStatus);
|
|
71
|
+
if (alternatives.length > 0 && negative) {
|
|
72
|
+
return { ...base, category: 'needsUpdate', status: currentStatus, composition, recommendedVersion: alternatives[0], reason: `version ${alternatives[0]} has verified active evidence on the same DSH and composition` };
|
|
73
|
+
}
|
|
74
|
+
if (negative) {
|
|
75
|
+
return { ...base, category: 'noCompatibleVersion', status: currentStatus, composition, testedVersions, reason: 'no verified active version exists for this DSH and composition' };
|
|
76
|
+
}
|
|
77
|
+
return { ...base, category: 'untested', status: currentStatus, composition, testedVersions, reason: 'active evidence is missing, self-reported, legacy, stale, or composition-incomplete' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function planUpgrade({ profileDir, matrixDir, from, to, composition }) {
|
|
81
|
+
if (composition !== undefined && !['headless', 'web'].includes(composition)) throw new ToolError('MATRIX_INPUT_INVALID', 'composition must be headless or web');
|
|
82
|
+
const profile = readProfile(profileDir);
|
|
83
|
+
const matrix = readMatrix(matrixDir, to);
|
|
84
|
+
const selectedComposition = composition || profile.composition;
|
|
85
|
+
const categories = { compatible: [], untested: [], needsUpdate: [], noCompatibleVersion: [], unknown: [], mixed: [], firstParty: [] };
|
|
86
|
+
for (const plugin of profile.plugins) {
|
|
87
|
+
if (plugin.firstParty) {
|
|
88
|
+
categories.firstParty.push({ name: plugin.name, version: plugin.version ?? null, firstParty: true, category: 'firstParty', status: 'not-covered', reason: 'first-party bundle; the community matrix does not cover it' });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const classified = classifyPlugin(plugin, matrix, to, selectedComposition);
|
|
92
|
+
categories[classified.category].push(classified);
|
|
93
|
+
}
|
|
94
|
+
const installTargets = categories.needsUpdate.filter((plugin) => plugin.recommendedVersion).map((plugin) => `${plugin.name}@${plugin.recommendedVersion}`);
|
|
95
|
+
const summary = {
|
|
96
|
+
total: profile.plugins.length,
|
|
97
|
+
community: profile.plugins.length - categories.firstParty.length,
|
|
98
|
+
compatible: categories.compatible.length,
|
|
99
|
+
untested: categories.untested.length,
|
|
100
|
+
needsUpdate: categories.needsUpdate.length,
|
|
101
|
+
noCompatibleVersion: categories.noCompatibleVersion.length,
|
|
102
|
+
unknown: categories.unknown.length,
|
|
103
|
+
mixed: categories.mixed.length,
|
|
104
|
+
firstParty: categories.firstParty.length,
|
|
105
|
+
needAction: categories.needsUpdate.length + categories.noCompatibleVersion.length,
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
schemaVersion: 2,
|
|
109
|
+
mode: 'upgrade-plan',
|
|
110
|
+
from,
|
|
111
|
+
to,
|
|
112
|
+
composition: selectedComposition || null,
|
|
113
|
+
profile: { path: profile.path, source: profile.source, bundles: profile.plugins.length, composition: profile.composition || null },
|
|
114
|
+
matrix: { dir: matrixDir, cells: matrix.count, dshVersions: matrix.dshVersions },
|
|
115
|
+
categories,
|
|
116
|
+
summary,
|
|
117
|
+
installCommand: installTargets.length ? 'npm install ' + installTargets.join(' ') : null,
|
|
118
|
+
};
|
|
119
|
+
}
|