@shidesheng0218/dsh-plugin-lint 0.0.0-stage → 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/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +734 -2
- package/bin/dsh-plugin-analyze.mjs +378 -0
- package/bin/dsh-plugin-lint.mjs +381 -0
- package/bin/dsh-plugin-matrix.mjs +78 -0
- package/bin/dsh-plugin-upgrade-plan.mjs +180 -0
- package/package.json +62 -4
- package/schema/dsh-manifest.schema.json +134 -0
- package/scripts/lib/probe.mjs +34 -0
- package/scripts/probe-boot.mjs +123 -0
- 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 +393 -0
- 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 +74 -0
- package/src/load.js +1 -0
- package/src/mask.js +1 -0
- 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 -0
- package/src/rules.js +1 -0
- 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
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { loadPackage } from '../core/load.js';
|
|
4
|
+
import { lineOfIndex } from '../core/mask.js';
|
|
5
|
+
import { sha256 } from '../shared/hash.js';
|
|
6
|
+
import { ToolError } from '../shared/errors.js';
|
|
7
|
+
import { downloadArtifact } from './artifact-resolver.js';
|
|
8
|
+
import { classify } from './classify.js';
|
|
9
|
+
import { readInstalledArtifact } from './installed-artifact.js';
|
|
10
|
+
import { WEB_READY, resolveDsh, startStub } from './dsh-adapter.js';
|
|
11
|
+
|
|
12
|
+
export async function capture(fn) {
|
|
13
|
+
try {
|
|
14
|
+
const result = await fn();
|
|
15
|
+
const out = typeof result === 'string'
|
|
16
|
+
? result
|
|
17
|
+
: result?.out !== undefined
|
|
18
|
+
? String(result.out)
|
|
19
|
+
: String(result?.stdout || '') + String(result?.stderr || '');
|
|
20
|
+
return { out, failed: false };
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return { out: String(error.stdout || '') + String(error.stderr || ''), failed: true, error };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const redact = (value) => String(value).replace(/token=[^\s&"']+/gi, 'token=<redacted>');
|
|
27
|
+
|
|
28
|
+
export function parseInactive(text) {
|
|
29
|
+
const out = [];
|
|
30
|
+
const lines = text.split('\n');
|
|
31
|
+
const start = lines.findIndex((line) => /did not activate/.test(line));
|
|
32
|
+
if (start < 0) return out;
|
|
33
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
34
|
+
const match = /^(\S.*?)\s+\((\S+)\):\s*(.*)$/.exec(lines[index]);
|
|
35
|
+
if (match) {
|
|
36
|
+
out.push({ name: match[1], id: match[2], reason: match[3].trim() });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (out.length > 0 && /^\s+at /.test(lines[index])) {
|
|
40
|
+
const last = out[out.length - 1];
|
|
41
|
+
last.detail = (last.detail ? last.detail + ' ' : '') + lines[index].trim();
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (out.length > 0) break;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function awaitedServices(entries) {
|
|
50
|
+
const services = [];
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
// The host prints "waiting for service: x" for one and "waiting for
|
|
53
|
+
// services: x, y" for several; both must reach the cell.
|
|
54
|
+
const match = /waiting for services?:\s*([^)]+)/.exec(entry.reason || '');
|
|
55
|
+
if (!match) continue;
|
|
56
|
+
for (const name of match[1].split(',')) {
|
|
57
|
+
const service = name.trim();
|
|
58
|
+
if (service) services.push(service);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...new Set(services)];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function rejectionOf(installText) {
|
|
65
|
+
const match = /installation rejected:\s*([^\n]+)/.exec(installText);
|
|
66
|
+
return match ? match[1].trim().slice(0, 400) : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function noteOf(text) {
|
|
70
|
+
return redact(text).split('\n').map((line) => line.trim())
|
|
71
|
+
.filter((line) => line && !/MISSING_CREDENTIAL/.test(line) && !/^at /.test(line))
|
|
72
|
+
.slice(0, 3).join(' | ').slice(0, 300);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const payloadOf = (seen) => seen.filter((body) => Array.isArray(body.tools)).pop();
|
|
76
|
+
|
|
77
|
+
async function prepareDsh(dsh, environment) {
|
|
78
|
+
if (dsh.kind === 'desktop') {
|
|
79
|
+
if (environment.isolation === 'docker') {
|
|
80
|
+
throw new ToolError('ISOLATION_UNSUPPORTED', 'Desktop DSH is not supported by the Docker backend.');
|
|
81
|
+
}
|
|
82
|
+
return dsh;
|
|
83
|
+
}
|
|
84
|
+
if (!dsh.cli || (environment.isolation === 'docker' && !dsh.containerCli)) {
|
|
85
|
+
return resolveDsh(dsh.version, environment.dshDir, { environment });
|
|
86
|
+
}
|
|
87
|
+
return dsh;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function scanCapabilities(dir) {
|
|
91
|
+
const pkg = loadPackage(dir);
|
|
92
|
+
const observed = new Map();
|
|
93
|
+
const patterns = [
|
|
94
|
+
[/\bctx\.slots\.(register|inject)\s*\(/, 'ui-injection'],
|
|
95
|
+
[/\bctx\.llm\.(stream|complete|chat)\s*\(/, 'llm'],
|
|
96
|
+
[/\bctx\.session\.append\s*\(/, 'session-write'],
|
|
97
|
+
[/\bctx\.connection\b/, 'connection'],
|
|
98
|
+
[/\bfetch\s*\(/, 'network'],
|
|
99
|
+
[/\bhttps?\.request\s*\(/, 'network'],
|
|
100
|
+
[/\bnet\.connect\s*\(/, 'network'],
|
|
101
|
+
[/\bchild_process\b/, 'shell'],
|
|
102
|
+
[/\bexec(?:File)?\s*\(/, 'shell'],
|
|
103
|
+
[/\bspawn\s*\(/, 'shell'],
|
|
104
|
+
[/\bfs\.(write|append|createWrite)/, 'fs-write'],
|
|
105
|
+
[/\bfs\.(read|createRead)/, 'fs-read'],
|
|
106
|
+
[/\bprocess\.env\b/, 'env'],
|
|
107
|
+
[/\bnavigator\.sendBeacon\s*\(/, 'telemetry'],
|
|
108
|
+
];
|
|
109
|
+
for (const file of pkg.jsFiles) {
|
|
110
|
+
for (const [pattern, capability] of patterns) {
|
|
111
|
+
const match = pattern.exec(file.structural);
|
|
112
|
+
if (!match) continue;
|
|
113
|
+
if (!observed.has(capability)) observed.set(capability, []);
|
|
114
|
+
observed.get(capability).push({ file: file.path, line: lineOfIndex(file.lineStarts, match.index) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { observed: [...observed.entries()].map(([capability, sites]) => ({ capability, sites: sites.length, first: sites[0] })) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function toolDiff(before, after) {
|
|
121
|
+
const names = (payload) => payload.tools.map((tool) => tool.name);
|
|
122
|
+
const baseline = new Set(names(before));
|
|
123
|
+
const current = new Set(names(after));
|
|
124
|
+
return {
|
|
125
|
+
tools: { baseline: baseline.size, total: current.size, added: [...current].filter((name) => !baseline.has(name)), removed: [...baseline].filter((name) => !current.has(name)) },
|
|
126
|
+
systemPrompt: { baselineChars: JSON.stringify(before.system ?? '').length, chars: JSON.stringify(after.system ?? '').length },
|
|
127
|
+
packagesAdded: [...new Set((after.dsh_plugin_packages?.packages ?? []).map((pkg) => pkg.name + '@' + pkg.version))]
|
|
128
|
+
.filter((name) => !new Set((before.dsh_plugin_packages?.packages ?? []).map((pkg) => pkg.name + '@' + pkg.version)).has(name)),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function publicError(error) {
|
|
133
|
+
const code = typeof error?.code === 'string' && error.code !== '' ? error.code : 'PROBE_FAILED';
|
|
134
|
+
// Keep a short, redacted tail of the child output: a generic message makes
|
|
135
|
+
// an infrastructure failure impossible to diagnose from the cell alone.
|
|
136
|
+
const out = String(error?.stderr || error?.stdout || '').trim();
|
|
137
|
+
const message = error instanceof ToolError
|
|
138
|
+
? error.message
|
|
139
|
+
: out
|
|
140
|
+
? 'The probe could not complete: ' + redact(out).slice(-400)
|
|
141
|
+
: 'The probe could not complete because of an internal or host failure.';
|
|
142
|
+
return { code, message };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function probePackage({
|
|
146
|
+
artifact: inputArtifact,
|
|
147
|
+
dsh: inputDsh,
|
|
148
|
+
environment,
|
|
149
|
+
stub: inputStub,
|
|
150
|
+
tools = false,
|
|
151
|
+
keep = false,
|
|
152
|
+
composition = 'headless',
|
|
153
|
+
audit = false,
|
|
154
|
+
}) {
|
|
155
|
+
const started = Date.now();
|
|
156
|
+
const observation = { outcome: 'completed', install: 'ok', entries: [], durationMs: 0 };
|
|
157
|
+
let stub = inputStub;
|
|
158
|
+
let ownsStub = false;
|
|
159
|
+
try {
|
|
160
|
+
const dsh = await prepareDsh(inputDsh, environment);
|
|
161
|
+
const artifact = inputArtifact.local || inputArtifact.tarballPath ? inputArtifact : await downloadArtifact(inputArtifact);
|
|
162
|
+
const materialized = await environment.materialize(artifact);
|
|
163
|
+
if (tools && composition !== 'web' && !stub) {
|
|
164
|
+
stub = environment.startStub ? await environment.startStub() : await startStub();
|
|
165
|
+
ownsStub = true;
|
|
166
|
+
}
|
|
167
|
+
const profile = composition === 'web' ? 'web' : 'headless';
|
|
168
|
+
const bootEnv = tools && stub ? { PROBE_API_KEY: 'probe' } : {};
|
|
169
|
+
const patchPath = environment.isolation === 'docker' ? '/profile/probe-patch.yml' : join(environment.profileDir, 'probe-patch.yml');
|
|
170
|
+
if (tools && stub) {
|
|
171
|
+
const baseURL = stub.baseURL || `http://127.0.0.1:${stub.port}`;
|
|
172
|
+
writeFileSync(join(environment.profileDir, 'probe-patch.yml'), [
|
|
173
|
+
'- id: llm-deepseek',
|
|
174
|
+
' name: "@deepseek-ai/dsh-llm-deepseek-api-key"',
|
|
175
|
+
' config:',
|
|
176
|
+
` baseURL: ${baseURL}`,
|
|
177
|
+
' apiKeyEnv: PROBE_API_KEY',
|
|
178
|
+
'',
|
|
179
|
+
].join('\n'));
|
|
180
|
+
}
|
|
181
|
+
const bootArgs = composition === 'web'
|
|
182
|
+
? ['--profile', 'web', '--port', '0', '--no-open']
|
|
183
|
+
: ['--profile', 'headless', ...(tools ? ['--patch', patchPath] : []), 'probe'];
|
|
184
|
+
const bootInput = {
|
|
185
|
+
dsh,
|
|
186
|
+
args: bootArgs,
|
|
187
|
+
env: bootEnv,
|
|
188
|
+
composition,
|
|
189
|
+
readyRe: composition === 'web' ? WEB_READY : undefined,
|
|
190
|
+
network: composition !== 'web' && stub?.network ? stub.network : undefined,
|
|
191
|
+
};
|
|
192
|
+
const baseline = await capture(() => environment.boot(bootInput));
|
|
193
|
+
let booted;
|
|
194
|
+
if (!readdirSync(environment.profileDir).includes('profiles')) {
|
|
195
|
+
throw new ToolError('TEMPLATE_FAILED', 'DSH did not create a temporary profile.');
|
|
196
|
+
}
|
|
197
|
+
if (stub?.sync) await stub.sync();
|
|
198
|
+
const before = tools && composition !== 'web' ? payloadOf(stub.seen.splice(0)) : undefined;
|
|
199
|
+
|
|
200
|
+
const installed = await capture(() => environment.install({ dsh, target: materialized, profile, env: bootEnv }));
|
|
201
|
+
const rejection = rejectionOf(installed.out);
|
|
202
|
+
const profileDir = join(environment.profileDir, 'profiles', profile);
|
|
203
|
+
let installedArtifact;
|
|
204
|
+
try {
|
|
205
|
+
installedArtifact = readInstalledArtifact(profileDir, artifact.name);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (!rejection) throw error;
|
|
208
|
+
}
|
|
209
|
+
if (installedArtifact && installedArtifact.version !== artifact.resolvedVersion) {
|
|
210
|
+
throw new ToolError('VERSION_MISMATCH', 'The installed artifact version differs from the pinned artifact.');
|
|
211
|
+
}
|
|
212
|
+
observation.loadedVersion = installedArtifact?.version;
|
|
213
|
+
if (installed.failed) observation.install = 'ok-with-warnings';
|
|
214
|
+
if (rejection) {
|
|
215
|
+
observation.result = {
|
|
216
|
+
status: 'rejected-by-host',
|
|
217
|
+
composition,
|
|
218
|
+
rejection,
|
|
219
|
+
entries: [],
|
|
220
|
+
awaitedServices: [],
|
|
221
|
+
clientHalf: 'not-covered',
|
|
222
|
+
};
|
|
223
|
+
} else {
|
|
224
|
+
booted = await capture(() => environment.boot(bootInput));
|
|
225
|
+
if (stub?.sync) await stub.sync();
|
|
226
|
+
const entries = parseInactive(booted.out);
|
|
227
|
+
const after = tools && composition !== 'web' ? payloadOf(stub.seen.splice(0)) : undefined;
|
|
228
|
+
const ready = composition === 'web' ? WEB_READY.test(booted.out) : undefined;
|
|
229
|
+
const status = classify(booted.out, entries, composition === 'web' ? ready : (tools ? Boolean(after) : undefined));
|
|
230
|
+
const diff = before && after ? toolDiff(before, after) : undefined;
|
|
231
|
+
const auditResult = audit ? scanCapabilities(artifact.local ? materialized.hostPath : (installedArtifact?.path || materialized.hostPath)) : undefined;
|
|
232
|
+
observation.result = {
|
|
233
|
+
status,
|
|
234
|
+
composition,
|
|
235
|
+
ready,
|
|
236
|
+
awaitedServices: awaitedServices(entries),
|
|
237
|
+
entries,
|
|
238
|
+
tools: diff?.tools || (tools && composition !== 'web' ? { error: 'the harness sent no tool-bearing request' } : undefined),
|
|
239
|
+
systemPrompt: diff?.systemPrompt,
|
|
240
|
+
packagesAdded: diff?.packagesAdded,
|
|
241
|
+
audit: auditResult,
|
|
242
|
+
clientHalf: 'not-covered',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
observation.entries = observation.result.entries;
|
|
246
|
+
observation.audit = observation.result.audit;
|
|
247
|
+
observation.tools = observation.result.tools;
|
|
248
|
+
observation.systemPrompt = observation.result.systemPrompt;
|
|
249
|
+
observation.packagesAdded = observation.result.packagesAdded;
|
|
250
|
+
observation.status = observation.result.status;
|
|
251
|
+
observation.composition = composition;
|
|
252
|
+
observation.note = noteOf(booted?.out || baseline.out);
|
|
253
|
+
observation.outputSha256 = sha256(installed.out + '\n' + (booted?.out || baseline.out));
|
|
254
|
+
observation.reproduce = {
|
|
255
|
+
command: `node scripts/probe-boot.mjs ${artifact.installTarget}${tools ? ' --tools' : ''}${composition === 'web' ? ' --mode web' : ''} --dsh ${dsh.version}${environment.isolation === 'host-unsafe' ? ' --unsafe-host' : ''}`,
|
|
256
|
+
};
|
|
257
|
+
if (installed.failed) observation.installOutput = redact(installed.out).slice(-700);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
observation.outcome = 'error';
|
|
260
|
+
observation.error = publicError(error);
|
|
261
|
+
observation.result = undefined;
|
|
262
|
+
} finally {
|
|
263
|
+
observation.durationMs = Date.now() - started;
|
|
264
|
+
try {
|
|
265
|
+
await environment.destroy();
|
|
266
|
+
} catch (error) {
|
|
267
|
+
// Cleanup failure is an infrastructure note, not a plugin verdict. A
|
|
268
|
+
// completed observation keeps its result; an earlier error keeps its code.
|
|
269
|
+
observation.cleanup = {
|
|
270
|
+
code: 'ISOLATION_CLEANUP_FAILED',
|
|
271
|
+
message: 'The probe environment could not be cleaned up.',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (ownsStub && stub?.server) stub.server.close();
|
|
275
|
+
void keep;
|
|
276
|
+
}
|
|
277
|
+
return observation;
|
|
278
|
+
}
|
package/src/report.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './core/report.js';
|
package/src/rules.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
function readJson(path) {
|
|
5
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function isDirectory(path) {
|
|
9
|
+
try { return statSync(path).isDirectory(); } catch { return false; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function exactVersion(value) {
|
|
13
|
+
return typeof value === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(value) ? value : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function installedVersion(profileDir, name, entry, profileManifest) {
|
|
17
|
+
if (entry && typeof entry === 'object' && exactVersion(entry.version)) return entry.version;
|
|
18
|
+
const manifestPath = join(profileDir, 'node_modules', ...name.split('/'), 'package.json');
|
|
19
|
+
if (existsSync(manifestPath)) {
|
|
20
|
+
try {
|
|
21
|
+
const manifest = readJson(manifestPath);
|
|
22
|
+
if (exactVersion(manifest.version)) return manifest.version;
|
|
23
|
+
} catch { /* use the profile declaration below */ }
|
|
24
|
+
}
|
|
25
|
+
return exactVersion({ ...(profileManifest?.dependencies || {}), ...(profileManifest?.devDependencies || {}) }[name]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readProfile(profileDir) {
|
|
29
|
+
if (!existsSync(profileDir) || !isDirectory(profileDir)) throw new Error('profile directory not found: ' + profileDir);
|
|
30
|
+
const manifestPath = join(profileDir, 'package.json');
|
|
31
|
+
const manifest = existsSync(manifestPath) ? readJson(manifestPath) : undefined;
|
|
32
|
+
const declared = manifest?.dsh?.profile?.bundles;
|
|
33
|
+
const composition = ['headless', 'web'].includes(manifest?.dsh?.profile?.composition) ? manifest.dsh.profile.composition : undefined;
|
|
34
|
+
const plugins = [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
if (Array.isArray(declared) && declared.length > 0) {
|
|
37
|
+
for (const entry of declared) {
|
|
38
|
+
const name = typeof entry === 'string'
|
|
39
|
+
? entry.trim()
|
|
40
|
+
: entry && typeof entry === 'object'
|
|
41
|
+
? entry.name || entry.id || entry.package || entry.bundle || entry.plugin
|
|
42
|
+
: undefined;
|
|
43
|
+
if (typeof name !== 'string' || !name || seen.has(name)) continue;
|
|
44
|
+
seen.add(name);
|
|
45
|
+
plugins.push({ name, version: installedVersion(profileDir, name, entry, manifest), firstParty: name.startsWith('@deepseek-ai/'), source: 'dsh.profile.bundles' });
|
|
46
|
+
}
|
|
47
|
+
return { path: profileDir, manifest, plugins, source: 'dsh.profile.bundles', composition };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const nodeModulesDir = join(profileDir, 'node_modules');
|
|
51
|
+
if (manifest === undefined && (!existsSync(nodeModulesDir) || !isDirectory(nodeModulesDir))) {
|
|
52
|
+
throw new Error('not a profile directory (no package.json and no node_modules): ' + profileDir);
|
|
53
|
+
}
|
|
54
|
+
if (!existsSync(nodeModulesDir) || !isDirectory(nodeModulesDir)) {
|
|
55
|
+
return { path: profileDir, manifest, plugins: [], source: 'none', composition };
|
|
56
|
+
}
|
|
57
|
+
const consider = (dir, fallbackName) => {
|
|
58
|
+
const manifestFile = join(dir, 'package.json');
|
|
59
|
+
if (!existsSync(manifestFile)) return;
|
|
60
|
+
let pkg;
|
|
61
|
+
try { pkg = readJson(manifestFile); } catch { return; }
|
|
62
|
+
if (!pkg.dsh && !(Array.isArray(pkg.keywords) && pkg.keywords.includes('dsh-plugin'))) return;
|
|
63
|
+
const name = typeof pkg.name === 'string' && pkg.name ? pkg.name : fallbackName;
|
|
64
|
+
if (seen.has(name)) return;
|
|
65
|
+
seen.add(name);
|
|
66
|
+
plugins.push({ name, version: exactVersion(pkg.version), firstParty: name.startsWith('@deepseek-ai/'), source: 'node_modules' });
|
|
67
|
+
};
|
|
68
|
+
for (const entry of readdirSync(nodeModulesDir, { withFileTypes: true })) {
|
|
69
|
+
if (entry.name.startsWith('.')) continue;
|
|
70
|
+
const entryPath = join(nodeModulesDir, entry.name);
|
|
71
|
+
if (!(entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(entryPath)))) continue;
|
|
72
|
+
if (entry.name.startsWith('@')) {
|
|
73
|
+
for (const child of readdirSync(entryPath, { withFileTypes: true })) {
|
|
74
|
+
const childPath = join(entryPath, child.name);
|
|
75
|
+
if (child.isDirectory() || (child.isSymbolicLink() && isDirectory(childPath))) consider(childPath, entry.name + '/' + child.name);
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
consider(entryPath, entry.name);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { path: profileDir, manifest, plugins, source: 'node_modules', composition };
|
|
82
|
+
}
|