@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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ToolError } from '../shared/errors.js';
|
|
2
|
+
import { validateCell, validateIndex } from './schema.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* JSON has no `undefined`. A cell that was probed without the tool inventory or
|
|
6
|
+
* without a system prompt would otherwise put `undefined` into the index, and
|
|
7
|
+
* the canonical hash (stableStringify) throws on it — so the artifact could be
|
|
8
|
+
* written but never verified. Drop those keys instead of inventing a value.
|
|
9
|
+
*/
|
|
10
|
+
function compact(value) {
|
|
11
|
+
if (Array.isArray(value)) return value.map(compact);
|
|
12
|
+
if (value && typeof value === 'object') {
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
15
|
+
if (entry !== undefined) out[key] = compact(entry);
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildIndex(cells, { generatedAt = new Date().toISOString(), source = {} } = {}) {
|
|
23
|
+
const grouped = new Map();
|
|
24
|
+
const dshVersions = new Set();
|
|
25
|
+
for (const cell of cells) {
|
|
26
|
+
if (cell.run?.outcome !== 'completed' || !validateCell(cell).ok) {
|
|
27
|
+
throw new ToolError('INDEX_INPUT_INVALID', 'Every input cell must be a complete, valid v2 observation.');
|
|
28
|
+
}
|
|
29
|
+
const name = cell.plugin.name;
|
|
30
|
+
const version = cell.plugin.resolvedVersion;
|
|
31
|
+
if (!grouped.has(name)) grouped.set(name, new Map());
|
|
32
|
+
const versions = grouped.get(name);
|
|
33
|
+
if (!versions.has(version)) versions.set(version, []);
|
|
34
|
+
versions.get(version).push(cell);
|
|
35
|
+
dshVersions.add(cell.dsh.version);
|
|
36
|
+
}
|
|
37
|
+
const plugins = [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, versions]) => ({
|
|
38
|
+
name,
|
|
39
|
+
versions: [...versions.entries()].sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true })).map(([version, entries]) => ({
|
|
40
|
+
version,
|
|
41
|
+
integrity: entries[0].plugin.integrity,
|
|
42
|
+
cells: entries.map((cell) => ({
|
|
43
|
+
cacheKey: cell.cacheKey,
|
|
44
|
+
dsh: cell.dsh.version,
|
|
45
|
+
composition: cell.dsh.composition,
|
|
46
|
+
status: cell.result.status,
|
|
47
|
+
trust: cell.run.trust,
|
|
48
|
+
at: cell.run.at,
|
|
49
|
+
note: cell.evidence?.note,
|
|
50
|
+
awaitedServices: cell.result.awaitedServices,
|
|
51
|
+
toolsAdded: cell.result.tools?.added || [],
|
|
52
|
+
audit: cell.result.audit,
|
|
53
|
+
systemPromptChars: cell.result.systemPrompt?.chars,
|
|
54
|
+
clientHalf: cell.result.clientHalf,
|
|
55
|
+
reproduce: cell.reproduce.command,
|
|
56
|
+
})),
|
|
57
|
+
})),
|
|
58
|
+
}));
|
|
59
|
+
const index = {
|
|
60
|
+
schemaVersion: 2,
|
|
61
|
+
generatedAt,
|
|
62
|
+
source,
|
|
63
|
+
dshVersions: [...dshVersions].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })),
|
|
64
|
+
plugins,
|
|
65
|
+
conflicts: source.conflicts || { tools: [], slots: [], routes: [] },
|
|
66
|
+
};
|
|
67
|
+
const clean = compact(index);
|
|
68
|
+
const validation = validateIndex(clean);
|
|
69
|
+
if (!validation.ok) throw new ToolError('INDEX_INPUT_INVALID', validation.errors.join('; '));
|
|
70
|
+
return clean;
|
|
71
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { stableStringify, sha256 } from '../shared/hash.js';
|
|
2
|
+
import { ToolError } from '../shared/errors.js';
|
|
3
|
+
import { validateManifest } from './schema.js';
|
|
4
|
+
|
|
5
|
+
export function buildManifest({ runId, generatedAt = new Date().toISOString(), probeVersion, inputs, artifacts }) {
|
|
6
|
+
const manifest = {
|
|
7
|
+
schemaVersion: 2,
|
|
8
|
+
runId,
|
|
9
|
+
generatedAt,
|
|
10
|
+
probeVersion,
|
|
11
|
+
inputsSha256: sha256(stableStringify(inputs)),
|
|
12
|
+
artifacts: [...artifacts].sort((a, b) => a.path.localeCompare(b.path)),
|
|
13
|
+
};
|
|
14
|
+
const validation = validateManifest(manifest);
|
|
15
|
+
if (!validation.ok) throw new ToolError('MANIFEST_INVALID', validation.errors.join('; '));
|
|
16
|
+
return manifest;
|
|
17
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ToolError } from '../shared/errors.js';
|
|
2
|
+
import { TRUST } from '../shared/status.js';
|
|
3
|
+
import { validateCell } from './schema.js';
|
|
4
|
+
|
|
5
|
+
export function migrateCellV1(value) {
|
|
6
|
+
if (!value || value.schemaVersion !== 1 || typeof value.plugin?.name !== 'string' || typeof value.plugin?.version !== 'string' || typeof value.dsh?.version !== 'string' || typeof value.result?.status !== 'string') {
|
|
7
|
+
throw new ToolError('CELL_INVALID', 'The legacy cell is missing required v1 fields.');
|
|
8
|
+
}
|
|
9
|
+
const cell = structuredClone(value);
|
|
10
|
+
const originalTrust = cell.run?.trust;
|
|
11
|
+
cell.run = { ...(cell.run || {}), outcome: cell.run?.outcome || 'completed', trust: TRUST.LEGACY_UNPINNED };
|
|
12
|
+
cell.plugin = {
|
|
13
|
+
...cell.plugin,
|
|
14
|
+
requestedVersion: cell.plugin.requestedVersion || 'legacy',
|
|
15
|
+
resolvedVersion: cell.plugin.version,
|
|
16
|
+
};
|
|
17
|
+
delete cell.plugin.loadedVersion;
|
|
18
|
+
cell.dsh = {
|
|
19
|
+
...cell.dsh,
|
|
20
|
+
composition: cell.dsh.composition || cell.result.composition || cell.dsh.profile || cell.probe?.mode || 'unknown',
|
|
21
|
+
};
|
|
22
|
+
cell.probe = {
|
|
23
|
+
...(cell.probe || {}),
|
|
24
|
+
version: cell.probe?.version || 'legacy',
|
|
25
|
+
isolation: cell.probe?.isolation || 'unknown',
|
|
26
|
+
host: cell.probe?.host || { os: cell.run.os || 'unknown', node: cell.run.node || 'unknown' },
|
|
27
|
+
};
|
|
28
|
+
cell.recommendationEligible = false;
|
|
29
|
+
cell.migration = { originalTrust };
|
|
30
|
+
return cell;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readCellAny(value) {
|
|
34
|
+
if (value?.schemaVersion === 2) {
|
|
35
|
+
const validation = validateCell(value);
|
|
36
|
+
if (!validation.ok) throw new ToolError('CELL_INVALID', validation.errors.join('; '));
|
|
37
|
+
return { kind: 'v2', cell: value };
|
|
38
|
+
}
|
|
39
|
+
if (value?.schemaVersion === 1) return { kind: 'legacy-v1', cell: migrateCellV1(value) };
|
|
40
|
+
throw new ToolError('CELL_SCHEMA_UNSUPPORTED', 'Only matrix cell schema v1 and v2 are supported.');
|
|
41
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { ToolError } from '../shared/errors.js';
|
|
4
|
+
import { PROBE_VERSION } from '../shared/version.js';
|
|
5
|
+
import { sha256 } from '../shared/hash.js';
|
|
6
|
+
import { resolveArtifact } from '../probe/artifact-resolver.js';
|
|
7
|
+
import { createProbeEnvironment } from '../probe/environment.js';
|
|
8
|
+
import { probePackage } from '../probe/probe.js';
|
|
9
|
+
import { startStub as startHostStub } from '../probe/dsh-adapter.js';
|
|
10
|
+
import { cacheKeyFor } from './cache.js';
|
|
11
|
+
import { createCell } from './cell.js';
|
|
12
|
+
import { cellFilePath, writeCell } from './cells.js';
|
|
13
|
+
import { readCellAny } from './migrate.js';
|
|
14
|
+
import { buildManifest } from './manifest.js';
|
|
15
|
+
import { validateCell } from './schema.js';
|
|
16
|
+
|
|
17
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
18
|
+
const productionServices = {
|
|
19
|
+
resolveArtifact,
|
|
20
|
+
createProbeEnvironment,
|
|
21
|
+
probePackage,
|
|
22
|
+
startStub: async ({ environment }) => environment.startStub ? environment.startStub() : startHostStub(),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function splitSpec(value) {
|
|
26
|
+
const text = String(value);
|
|
27
|
+
const split = text.startsWith('@') ? text.lastIndexOf('@') > text.indexOf('/') : text.lastIndexOf('@') > 0;
|
|
28
|
+
if (!split) return { name: text, version: 'latest' };
|
|
29
|
+
const index = text.lastIndexOf('@');
|
|
30
|
+
return { name: text.slice(0, index), version: text.slice(index + 1) || 'latest' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function inputFor(target, targetMode) {
|
|
34
|
+
if (targetMode === 'refresh-latest') {
|
|
35
|
+
if (typeof target === 'string' && existsSync(target)) return target;
|
|
36
|
+
if (target && typeof target === 'object' && (target.local || target.path)) return target;
|
|
37
|
+
return typeof target === 'string' ? splitSpec(target).name : target.name;
|
|
38
|
+
}
|
|
39
|
+
if ((typeof target === 'string' && existsSync(target)) || (target && typeof target === 'object' && (target.local || target.path))) return target;
|
|
40
|
+
if (target && typeof target === 'object') {
|
|
41
|
+
if (!EXACT_VERSION.test(target.version || '') || typeof target.integrity !== 'string') {
|
|
42
|
+
throw new ToolError('ARTIFACT_SPEC_UNSUPPORTED', 'Pinned matrix targets need an exact version and registry integrity.');
|
|
43
|
+
}
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
const spec = splitSpec(target);
|
|
47
|
+
if (!EXACT_VERSION.test(spec.version)) {
|
|
48
|
+
throw new ToolError('ARTIFACT_SPEC_UNSUPPORTED', 'Pinned matrix targets cannot re-resolve latest or a dist-tag.');
|
|
49
|
+
}
|
|
50
|
+
return target;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function publicError(error) {
|
|
54
|
+
return {
|
|
55
|
+
code: error?.code || 'MATRIX_INFRASTRUCTURE_ERROR',
|
|
56
|
+
message: error?.code ? error.message : 'The matrix cell could not complete because of an internal or host failure.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function runMatrix({
|
|
61
|
+
targets,
|
|
62
|
+
targetMode = 'pinned',
|
|
63
|
+
dshVersions,
|
|
64
|
+
compositions = ['headless', 'web'],
|
|
65
|
+
outDir,
|
|
66
|
+
cacheRoot,
|
|
67
|
+
concurrency = 4,
|
|
68
|
+
force = false,
|
|
69
|
+
keep = false,
|
|
70
|
+
useTools = true,
|
|
71
|
+
backend = 'auto',
|
|
72
|
+
services = productionServices,
|
|
73
|
+
runId = 'local-matrix',
|
|
74
|
+
}) {
|
|
75
|
+
if (!['pinned', 'refresh-latest'].includes(targetMode)) throw new ToolError('MATRIX_INPUT_INVALID', 'targetMode must be pinned or refresh-latest.');
|
|
76
|
+
if (!Array.isArray(targets) || targets.length === 0) throw new ToolError('MATRIX_INPUT_INVALID', 'At least one target is required.');
|
|
77
|
+
if (!Array.isArray(dshVersions) || dshVersions.length === 0) throw new ToolError('MATRIX_INPUT_INVALID', 'At least one DSH version is required.');
|
|
78
|
+
if (!Array.isArray(compositions) || compositions.length === 0 || compositions.some((mode) => !['headless', 'web'].includes(mode))) {
|
|
79
|
+
throw new ToolError('MATRIX_INPUT_INVALID', 'compositions must contain only headless and/or web.');
|
|
80
|
+
}
|
|
81
|
+
if (!outDir || !cacheRoot) throw new ToolError('MATRIX_INPUT_INVALID', 'outDir and cacheRoot are required.');
|
|
82
|
+
const resolved = [];
|
|
83
|
+
for (const target of targets) {
|
|
84
|
+
const artifact = await services.resolveArtifact(inputFor(target, targetMode));
|
|
85
|
+
resolved.push({
|
|
86
|
+
artifact,
|
|
87
|
+
input: {
|
|
88
|
+
name: artifact.name,
|
|
89
|
+
requestedVersion: artifact.requestedVersion,
|
|
90
|
+
resolvedVersion: artifact.resolvedVersion,
|
|
91
|
+
// A local artifact has integrity: undefined. JSON has no undefined, and
|
|
92
|
+
// stableStringify rightly rejects it; omitting the key keeps the manifest
|
|
93
|
+
// hashable instead of aborting every local-directory run.
|
|
94
|
+
...(artifact.integrity !== undefined ? { integrity: artifact.integrity } : {}),
|
|
95
|
+
...(artifact.sourceDigest !== undefined ? { sourceDigest: artifact.sourceDigest } : {}),
|
|
96
|
+
local: artifact.local,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const tasks = [];
|
|
101
|
+
for (const { artifact } of resolved) {
|
|
102
|
+
for (const version of dshVersions) {
|
|
103
|
+
for (const composition of compositions) tasks.push({ artifact, version, composition });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const summary = {
|
|
107
|
+
total: tasks.length,
|
|
108
|
+
completed: 0,
|
|
109
|
+
errors: 0,
|
|
110
|
+
cached: 0,
|
|
111
|
+
invalidCache: 0,
|
|
112
|
+
targetMode,
|
|
113
|
+
inputs: resolved.map((entry) => entry.input),
|
|
114
|
+
statuses: {},
|
|
115
|
+
outDir,
|
|
116
|
+
cacheRoot,
|
|
117
|
+
};
|
|
118
|
+
const written = [];
|
|
119
|
+
let invalidCache = 0;
|
|
120
|
+
|
|
121
|
+
function cachedCell(task, cacheKey) {
|
|
122
|
+
if (force) return undefined;
|
|
123
|
+
const path = cellFilePath(cacheRoot, {
|
|
124
|
+
plugin: task.artifact,
|
|
125
|
+
dsh: { version: task.version, composition: task.composition },
|
|
126
|
+
cacheKey,
|
|
127
|
+
});
|
|
128
|
+
if (!existsSync(path)) return undefined;
|
|
129
|
+
try {
|
|
130
|
+
const { kind, cell } = readCellAny(JSON.parse(readFileSync(path, 'utf8')));
|
|
131
|
+
if (kind !== 'v2' || cell.cacheKey !== cacheKey || cell.run.outcome !== 'completed' || !validateCell(cell).ok) {
|
|
132
|
+
invalidCache += 1;
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
writeCell(outDir, cell);
|
|
136
|
+
return { cell, path: cellFilePath(outDir, cell), cached: true };
|
|
137
|
+
} catch {
|
|
138
|
+
invalidCache += 1;
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function execute(task) {
|
|
144
|
+
const cacheKey = cacheKeyFor({ artifact: task.artifact, dshVersion: task.version, composition: task.composition, probeVersion: PROBE_VERSION });
|
|
145
|
+
const cached = cachedCell(task, cacheKey);
|
|
146
|
+
if (cached) return cached;
|
|
147
|
+
let environment;
|
|
148
|
+
let stub;
|
|
149
|
+
let observation;
|
|
150
|
+
try {
|
|
151
|
+
environment = await services.createProbeEnvironment({
|
|
152
|
+
backend,
|
|
153
|
+
workDir: cacheRoot,
|
|
154
|
+
dshDir: task.version !== 'desktop' ? join(cacheRoot, 'dsh', task.version) : undefined,
|
|
155
|
+
});
|
|
156
|
+
stub = useTools && task.composition !== 'web' ? await services.startStub({ environment, composition: task.composition }) : undefined;
|
|
157
|
+
const dsh = { kind: task.version === 'desktop' ? 'desktop' : 'npm', version: task.version, source: task.version === 'desktop' ? 'desktop' : 'npm', composition: task.composition };
|
|
158
|
+
observation = await services.probePackage({ artifact: task.artifact, dsh, environment, stub, tools: useTools, keep, composition: task.composition, audit: true });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
observation = { outcome: 'error', error: publicError(error), entries: [], durationMs: 0 };
|
|
161
|
+
if (environment) await environment.destroy();
|
|
162
|
+
} finally {
|
|
163
|
+
if (stub?.server) stub.server.close();
|
|
164
|
+
}
|
|
165
|
+
let cell;
|
|
166
|
+
try {
|
|
167
|
+
cell = createCell({
|
|
168
|
+
artifact: task.artifact,
|
|
169
|
+
dsh: { version: task.version, source: task.version === 'desktop' ? 'desktop' : 'npm', composition: task.composition },
|
|
170
|
+
observation,
|
|
171
|
+
isolation: environment?.isolation || backend,
|
|
172
|
+
probeVersion: PROBE_VERSION,
|
|
173
|
+
});
|
|
174
|
+
} catch (error) {
|
|
175
|
+
// A cell that cannot be represented is an infrastructure error for that
|
|
176
|
+
// cell, not a reason to abort the remaining queue.
|
|
177
|
+
cell = createCell({
|
|
178
|
+
artifact: task.artifact,
|
|
179
|
+
dsh: { version: task.version, source: task.version === 'desktop' ? 'desktop' : 'npm', composition: task.composition },
|
|
180
|
+
observation: {
|
|
181
|
+
outcome: 'error',
|
|
182
|
+
error: { code: 'CELL_BUILD_FAILED', message: String(error?.message || error) },
|
|
183
|
+
entries: [],
|
|
184
|
+
durationMs: observation?.durationMs || 0,
|
|
185
|
+
},
|
|
186
|
+
isolation: environment?.isolation || backend,
|
|
187
|
+
probeVersion: PROBE_VERSION,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const path = writeCell(outDir, cell);
|
|
191
|
+
if (cell.run.outcome === 'completed' && cacheRoot !== outDir) writeCell(cacheRoot, cell);
|
|
192
|
+
return { cell, path, cached: false };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const queue = [...tasks];
|
|
196
|
+
const workers = Array.from({ length: Math.min(Math.max(1, concurrency), queue.length) }, async () => {
|
|
197
|
+
while (queue.length > 0) {
|
|
198
|
+
const result = await execute(queue.shift());
|
|
199
|
+
written.push(result);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
await Promise.all(workers);
|
|
203
|
+
written.sort((a, b) => a.cell.plugin.name.localeCompare(b.cell.plugin.name)
|
|
204
|
+
|| a.cell.plugin.resolvedVersion.localeCompare(b.cell.plugin.resolvedVersion, undefined, { numeric: true })
|
|
205
|
+
|| a.cell.dsh.version.localeCompare(b.cell.dsh.version, undefined, { numeric: true })
|
|
206
|
+
|| a.cell.dsh.composition.localeCompare(b.cell.dsh.composition));
|
|
207
|
+
|
|
208
|
+
for (const { cell, cached } of written) {
|
|
209
|
+
if (cached) summary.cached += 1;
|
|
210
|
+
if (cell.run.outcome === 'completed') {
|
|
211
|
+
summary.completed += 1;
|
|
212
|
+
summary.statuses[cell.result.status] = (summary.statuses[cell.result.status] || 0) + 1;
|
|
213
|
+
} else {
|
|
214
|
+
summary.errors += 1;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
summary.invalidCache = invalidCache;
|
|
218
|
+
const manifest = buildManifest({
|
|
219
|
+
runId,
|
|
220
|
+
probeVersion: PROBE_VERSION,
|
|
221
|
+
inputs: {
|
|
222
|
+
targetMode,
|
|
223
|
+
targets: summary.inputs,
|
|
224
|
+
dshVersions,
|
|
225
|
+
compositions,
|
|
226
|
+
probeVersion: PROBE_VERSION,
|
|
227
|
+
},
|
|
228
|
+
artifacts: written.map(({ path }) => {
|
|
229
|
+
const bytes = readFileSync(path);
|
|
230
|
+
return { path: relative(outDir, path).replace(/\\/g, '/'), bytes: bytes.length, sha256: sha256(bytes) };
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
233
|
+
manifest.targetMode = targetMode;
|
|
234
|
+
manifest.inputs = summary.inputs;
|
|
235
|
+
manifest.cellCount = written.length;
|
|
236
|
+
manifest.errorCount = summary.errors;
|
|
237
|
+
const manifestPath = `${outDir}/run-manifest.json`;
|
|
238
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
239
|
+
summary.manifestPath = manifestPath;
|
|
240
|
+
return { cells: written.map((entry) => entry.cell), summary };
|
|
241
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ToolError } from '../shared/errors.js';
|
|
2
|
+
import { validateCell } from './schema.js';
|
|
3
|
+
|
|
4
|
+
export function sanitizeText(value) {
|
|
5
|
+
return String(value)
|
|
6
|
+
.replace(/token=[^\s&"']+/gi, 'token=<redacted>')
|
|
7
|
+
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, '$1<redacted>')
|
|
8
|
+
.replace(/(https?:\/\/)[^/\s:@]+:[^/\s@]+@/gi, '$1<redacted>@')
|
|
9
|
+
.replace(/((?:api[_-]?key|api[_-]?token|access[_-]?token|secret|password|npm_token|probe_api_key)\s*[:=]\s*["']?)[^\s,"'}]+/gi, '$1<redacted>')
|
|
10
|
+
.replace(/\/(Users|home)\/[^/\s]+/g, '/$1/<redacted>');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function sanitizeCellForPublish(cell) {
|
|
14
|
+
if (cell?.schemaVersion !== 2 || cell.run?.outcome !== 'completed') {
|
|
15
|
+
throw new ToolError('EXPORT_INPUT_INVALID', 'Only completed v2 cells can be published.');
|
|
16
|
+
}
|
|
17
|
+
const validation = validateCell(cell);
|
|
18
|
+
if (!validation.ok) throw new ToolError('EXPORT_INPUT_INVALID', validation.errors.join('; '));
|
|
19
|
+
const publicCell = structuredClone(cell);
|
|
20
|
+
delete publicCell.plugin.path;
|
|
21
|
+
delete publicCell.installOutput;
|
|
22
|
+
delete publicCell.stdout;
|
|
23
|
+
delete publicCell.stderr;
|
|
24
|
+
delete publicCell.env;
|
|
25
|
+
if (publicCell.evidence) publicCell.evidence.note = sanitizeText(publicCell.evidence.note || '');
|
|
26
|
+
if (publicCell.reproduce?.command) publicCell.reproduce.command = sanitizeText(publicCell.reproduce.command);
|
|
27
|
+
return publicCell;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function sanitizeForPublish(value) {
|
|
31
|
+
if (Array.isArray(value)) return value.map(sanitizeForPublish);
|
|
32
|
+
if (value && typeof value === 'object') {
|
|
33
|
+
if (value.schemaVersion === 2 && value.plugin && value.run) return sanitizeCellForPublish(value);
|
|
34
|
+
const output = {};
|
|
35
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
36
|
+
if (['stdout', 'stderr', 'installOutput', 'env', 'cause', 'rawOutput'].includes(key)) continue;
|
|
37
|
+
output[key] = sanitizeForPublish(entry);
|
|
38
|
+
}
|
|
39
|
+
return output;
|
|
40
|
+
}
|
|
41
|
+
return typeof value === 'string' ? sanitizeText(value) : value;
|
|
42
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { isPlainObject } from '../shared/validate.js';
|
|
2
|
+
import { cacheKeyFor } from './cache.js';
|
|
3
|
+
|
|
4
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
5
|
+
const TRUST = new Set(['self-reported', 'verified', 'disputed']);
|
|
6
|
+
const OUTCOME = new Set(['completed', 'error']);
|
|
7
|
+
|
|
8
|
+
function object(value, path, errors) {
|
|
9
|
+
if (!isPlainObject(value)) errors.push(`${path} must be an object`);
|
|
10
|
+
return isPlainObject(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function string(value, path, errors, { allowEmpty = false } = {}) {
|
|
14
|
+
if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) errors.push(`${path} must be a non-empty string`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function date(value, path, errors) {
|
|
18
|
+
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) errors.push(`${path} must be an ISO date`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function verificationFields(run, probeVersion, errors) {
|
|
22
|
+
if (run.trust === 'verified') {
|
|
23
|
+
if (!object(run.verification, 'run.verification', errors)) return;
|
|
24
|
+
for (const field of ['workflow', 'runId', 'url']) string(run.verification[field], `run.verification.${field}`, errors);
|
|
25
|
+
if (run.verification.probeVersion !== probeVersion) errors.push('run.verification.probeVersion must match probe.version');
|
|
26
|
+
}
|
|
27
|
+
if (run.trust === 'disputed') {
|
|
28
|
+
if (!object(run.dispute, 'run.dispute', errors)) return;
|
|
29
|
+
string(run.dispute.reported, 'run.dispute.reported', errors);
|
|
30
|
+
string(run.dispute.observed, 'run.dispute.observed', errors);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function validateCell(value) {
|
|
35
|
+
const errors = [];
|
|
36
|
+
if (!object(value, 'cell', errors)) return { ok: false, errors };
|
|
37
|
+
if (value.schemaVersion !== 2) errors.push('schemaVersion must be 2');
|
|
38
|
+
if (!HEX64.test(value.cacheKey || '')) errors.push('cacheKey must be a sha256 hex digest');
|
|
39
|
+
|
|
40
|
+
if (object(value.plugin, 'plugin', errors)) {
|
|
41
|
+
for (const field of ['name', 'requestedVersion', 'resolvedVersion']) string(value.plugin[field], `plugin.${field}`, errors);
|
|
42
|
+
if (typeof value.plugin.local !== 'boolean') errors.push('plugin.local must be boolean');
|
|
43
|
+
if (value.plugin.local === false && typeof value.plugin.integrity !== 'string') errors.push('registry integrity is required');
|
|
44
|
+
if (value.plugin.local === true && typeof value.plugin.sourceDigest !== 'string') errors.push('local sourceDigest is required');
|
|
45
|
+
// A plugin the host rejected was never loaded, so there is no loaded version
|
|
46
|
+
// to record. Requiring one here made createCell throw and aborted the whole
|
|
47
|
+
// matrix run on the first rejection.
|
|
48
|
+
if (value.run?.outcome === 'completed' && value.result?.status !== 'rejected-by-host') {
|
|
49
|
+
string(value.plugin.loadedVersion, 'plugin.loadedVersion', errors);
|
|
50
|
+
}
|
|
51
|
+
if (value.plugin.loadedVersion !== undefined && value.plugin.loadedVersion !== value.plugin.resolvedVersion) {
|
|
52
|
+
errors.push('plugin.loadedVersion must equal resolvedVersion');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (object(value.dsh, 'dsh', errors)) {
|
|
57
|
+
string(value.dsh.version, 'dsh.version', errors);
|
|
58
|
+
string(value.dsh.source, 'dsh.source', errors);
|
|
59
|
+
if (!['headless', 'web'].includes(value.dsh.composition)) errors.push('dsh.composition must be headless or web');
|
|
60
|
+
}
|
|
61
|
+
if (object(value.probe, 'probe', errors)) {
|
|
62
|
+
string(value.probe.version, 'probe.version', errors);
|
|
63
|
+
string(value.probe.isolation, 'probe.isolation', errors);
|
|
64
|
+
if (object(value.probe.host, 'probe.host', errors)) {
|
|
65
|
+
string(value.probe.host.os, 'probe.host.os', errors);
|
|
66
|
+
string(value.probe.host.node, 'probe.host.node', errors);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (object(value.run, 'run', errors)) {
|
|
71
|
+
if (!OUTCOME.has(value.run.outcome)) errors.push('run.outcome must be completed or error');
|
|
72
|
+
if (!TRUST.has(value.run.trust)) errors.push('run.trust is invalid');
|
|
73
|
+
date(value.run.at, 'run.at', errors);
|
|
74
|
+
if (!Number.isFinite(value.run.durationMs) || value.run.durationMs < 0) errors.push('run.durationMs must be non-negative');
|
|
75
|
+
verificationFields(value.run, value.probe?.version, errors);
|
|
76
|
+
if (value.run.outcome === 'error') {
|
|
77
|
+
if (value.result !== undefined) errors.push('error cells must not contain a plugin verdict');
|
|
78
|
+
if (object(value.run.error, 'run.error', errors)) {
|
|
79
|
+
string(value.run.error.code, 'run.error.code', errors);
|
|
80
|
+
string(value.run.error.message, 'run.error.message', errors);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (value.run?.outcome === 'completed') {
|
|
86
|
+
if (object(value.result, 'result', errors)) {
|
|
87
|
+
string(value.result.status, 'result.status', errors);
|
|
88
|
+
if (!Array.isArray(value.result.entries)) errors.push('result.entries must be an array');
|
|
89
|
+
if (!Array.isArray(value.result.awaitedServices)) errors.push('result.awaitedServices must be an array');
|
|
90
|
+
string(value.result.clientHalf, 'result.clientHalf', errors);
|
|
91
|
+
if (value.result.composition !== value.dsh?.composition) errors.push('result.composition must match dsh.composition');
|
|
92
|
+
}
|
|
93
|
+
if (object(value.evidence, 'evidence', errors)) {
|
|
94
|
+
string(value.evidence.note, 'evidence.note', errors, { allowEmpty: true });
|
|
95
|
+
if (!HEX64.test(value.evidence.outputSha256 || '')) errors.push('evidence.outputSha256 must be a sha256 hex digest');
|
|
96
|
+
}
|
|
97
|
+
if (object(value.reproduce, 'reproduce', errors)) string(value.reproduce.command, 'reproduce.command', errors);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (HEX64.test(value.cacheKey || '') && value.plugin?.resolvedVersion && value.dsh?.composition && value.probe?.version) {
|
|
101
|
+
try {
|
|
102
|
+
const expected = cacheKeyFor({
|
|
103
|
+
artifact: value.plugin,
|
|
104
|
+
dshVersion: value.dsh.version,
|
|
105
|
+
composition: value.dsh.composition,
|
|
106
|
+
probeVersion: value.probe.version,
|
|
107
|
+
});
|
|
108
|
+
if (expected !== value.cacheKey) errors.push('cacheKey does not match the artifact inputs');
|
|
109
|
+
} catch (error) {
|
|
110
|
+
errors.push(error.message);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { ok: errors.length === 0, errors };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function validateIndex(value) {
|
|
117
|
+
const errors = [];
|
|
118
|
+
if (!object(value, 'index', errors)) return { ok: false, errors };
|
|
119
|
+
if (value.schemaVersion !== 2) errors.push('index.schemaVersion must be 2');
|
|
120
|
+
date(value.generatedAt, 'index.generatedAt', errors);
|
|
121
|
+
if (!Array.isArray(value.dshVersions) || value.dshVersions.some((entry) => typeof entry !== 'string')) errors.push('index.dshVersions must be strings');
|
|
122
|
+
if (!Array.isArray(value.plugins)) {
|
|
123
|
+
errors.push('index.plugins must be an array');
|
|
124
|
+
return { ok: false, errors };
|
|
125
|
+
}
|
|
126
|
+
for (const [pluginIndex, plugin] of value.plugins.entries()) {
|
|
127
|
+
string(plugin.name, `plugins[${pluginIndex}].name`, errors);
|
|
128
|
+
if (!Array.isArray(plugin.versions)) {
|
|
129
|
+
errors.push(`plugins[${pluginIndex}].versions must be an array`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
for (const [versionIndex, version] of plugin.versions.entries()) {
|
|
133
|
+
string(version.version, `plugins[${pluginIndex}].versions[${versionIndex}].version`, errors);
|
|
134
|
+
if (!Array.isArray(version.cells)) {
|
|
135
|
+
errors.push(`plugins[${pluginIndex}].versions[${versionIndex}].cells must be an array`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
for (const [cellIndex, cell] of version.cells.entries()) {
|
|
139
|
+
const path = `plugins[${pluginIndex}].versions[${versionIndex}].cells[${cellIndex}]`;
|
|
140
|
+
if (!HEX64.test(cell.cacheKey || '')) errors.push(`${path}.cacheKey is invalid`);
|
|
141
|
+
string(cell.dsh, `${path}.dsh`, errors);
|
|
142
|
+
if (!['headless', 'web'].includes(cell.composition)) errors.push(`${path}.composition is invalid`);
|
|
143
|
+
string(cell.status, `${path}.status`, errors);
|
|
144
|
+
string(cell.trust, `${path}.trust`, errors);
|
|
145
|
+
date(cell.at, `${path}.at`, errors);
|
|
146
|
+
if (!Array.isArray(cell.toolsAdded)) errors.push(`${path}.toolsAdded must be an array`);
|
|
147
|
+
string(cell.reproduce, `${path}.reproduce`, errors);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { ok: errors.length === 0, errors };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function validateManifest(value) {
|
|
155
|
+
const errors = [];
|
|
156
|
+
if (!object(value, 'manifest', errors)) return { ok: false, errors };
|
|
157
|
+
if (value.schemaVersion !== 2) errors.push('manifest.schemaVersion must be 2');
|
|
158
|
+
string(value.runId, 'manifest.runId', errors);
|
|
159
|
+
date(value.generatedAt, 'manifest.generatedAt', errors);
|
|
160
|
+
string(value.probeVersion, 'manifest.probeVersion', errors);
|
|
161
|
+
if (!HEX64.test(value.inputsSha256 || '')) errors.push('manifest.inputsSha256 is invalid');
|
|
162
|
+
if (!Array.isArray(value.artifacts)) {
|
|
163
|
+
errors.push('manifest.artifacts must be an array');
|
|
164
|
+
return { ok: false, errors };
|
|
165
|
+
}
|
|
166
|
+
const paths = new Set();
|
|
167
|
+
for (const [index, artifact] of value.artifacts.entries()) {
|
|
168
|
+
const path = artifact.path;
|
|
169
|
+
if (typeof path !== 'string' || path.startsWith('/') || path.includes('\\') || path === '..' || path.startsWith('../') || path.includes('/../') || path.includes('\0')) {
|
|
170
|
+
errors.push(`artifacts[${index}].path is not a safe relative path`);
|
|
171
|
+
}
|
|
172
|
+
if (paths.has(path)) errors.push(`artifacts[${index}].path is duplicated`);
|
|
173
|
+
paths.add(path);
|
|
174
|
+
if (!Number.isInteger(artifact.bytes) || artifact.bytes < 0) errors.push(`artifacts[${index}].bytes must be non-negative`);
|
|
175
|
+
if (!HEX64.test(artifact.sha256 || '')) errors.push(`artifacts[${index}].sha256 is invalid`);
|
|
176
|
+
}
|
|
177
|
+
return { ok: errors.length === 0, errors };
|
|
178
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const COLORS = {
|
|
2
|
+
active: '#1a7f37',
|
|
3
|
+
'inactive-error': '#cf222e',
|
|
4
|
+
'rejected-by-host': '#cf222e',
|
|
5
|
+
'awaiting-service': '#bf8700',
|
|
6
|
+
'install-failed': '#8250df',
|
|
7
|
+
'boot-failed': '#cf222e',
|
|
8
|
+
'template-failed': '#57606a',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function escapeHtml(value) {
|
|
12
|
+
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function renderCell(cell) {
|
|
16
|
+
const status = escapeHtml(cell.status);
|
|
17
|
+
const note = cell.note || cell.title;
|
|
18
|
+
const title = escapeHtml(note || cell.status);
|
|
19
|
+
const color = COLORS[cell.status] || '#57606a';
|
|
20
|
+
const tools = cell.toolsAdded?.length ? `<div class="tools">+${cell.toolsAdded.length} tools</div>` : '';
|
|
21
|
+
const services = cell.awaitedServices?.length ? `<div class="meta">needs ${escapeHtml(cell.awaitedServices.join(', '))}</div>` : '';
|
|
22
|
+
const detail = note ? `<div class="note">${escapeHtml(note)}</div>` : '';
|
|
23
|
+
return `<div class="observation"><span class="pill" style="background:${color}" title="${title}">${status}</span>${tools}${services}<div class="meta">${escapeHtml(cell.composition)} · ${escapeHtml(cell.trust)}</div>${detail}</div>`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function renderSite(index) {
|
|
27
|
+
const dshVersions = index.dshVersions || [];
|
|
28
|
+
const plugins = index.plugins || [];
|
|
29
|
+
const cells = plugins.flatMap((plugin) => plugin.versions.flatMap((version) => version.cells));
|
|
30
|
+
const tally = {};
|
|
31
|
+
for (const cell of cells) tally[cell.status] = (tally[cell.status] || 0) + 1;
|
|
32
|
+
const rows = plugins.map((plugin) => plugin.versions.map((version) => {
|
|
33
|
+
const columns = dshVersions.map((dsh) => {
|
|
34
|
+
const observations = version.cells.filter((cell) => cell.dsh === dsh)
|
|
35
|
+
.sort((a, b) => a.composition.localeCompare(b.composition) || a.at.localeCompare(b.at));
|
|
36
|
+
return `<td>${observations.length ? observations.map(renderCell).join('') : '<span class="none">—</span>'}</td>`;
|
|
37
|
+
}).join('');
|
|
38
|
+
return `<tr><th>${escapeHtml(plugin.name)}<div class="ver">${escapeHtml(version.version)}</div></th>${columns}</tr>`;
|
|
39
|
+
}).join('\n')).join('\n');
|
|
40
|
+
const source = index.source?.runId || index.sourceRunId || 'unknown';
|
|
41
|
+
return [
|
|
42
|
+
'<!doctype html>',
|
|
43
|
+
'<html lang="en"><head><meta charset="utf-8">',
|
|
44
|
+
'<title>DSH plugin compatibility matrix</title>',
|
|
45
|
+
'<style>',
|
|
46
|
+
'body{font:14px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;margin:2rem;color:#1f2328}',
|
|
47
|
+
'h1{font-size:1.4rem;margin:0 0 .25rem}.sub{color:#57606a;margin-bottom:1.5rem}',
|
|
48
|
+
'table{border-collapse:collapse}th,td{border:1px solid #d0d7de;padding:.5rem .75rem;text-align:left;vertical-align:top}',
|
|
49
|
+
'th{background:#f6f8fa;font-weight:600}.ver{color:#57606a;font-weight:400;font-size:.8rem}',
|
|
50
|
+
'.observation{margin-bottom:.65rem}.pill{display:inline-block;color:#fff;border-radius:999px;padding:.1rem .5rem;font-size:.75rem}',
|
|
51
|
+
'.tools{color:#1a7f37;font-size:.75rem;margin-top:.25rem}.meta{color:#8c959f;font-size:.7rem;margin-top:.2rem}',
|
|
52
|
+
'.note{margin-top:1rem;color:#57606a;font-size:.78rem;max-width:60rem}.none{color:#d0d7de;text-align:center}',
|
|
53
|
+
'</style></head><body>',
|
|
54
|
+
'<h1>DSH plugin compatibility matrix</h1>',
|
|
55
|
+
`<div class="sub">${cells.length} cells · ${plugins.length} plugins · ${dshVersions.length} DSH versions · generated ${escapeHtml(index.generatedAt)} · source ${escapeHtml(source)}</div>`,
|
|
56
|
+
`<table><thead><tr><th>Plugin</th>${dshVersions.map((version) => `<th>${escapeHtml(version)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table>`,
|
|
57
|
+
'<div class="note"><strong>Read "awaiting service" as untested, not as broken.</strong> Each composition is shown separately; no best result is selected across headless and web. Host-half probes do not prove browser client-half behavior or security.</div>',
|
|
58
|
+
`<div class="note"><strong>Statuses:</strong> ${Object.entries(tally).sort((a, b) => b[1] - a[1]).map(([status, count]) => `${escapeHtml(status)} ${count}`).join(' · ')}</div>`,
|
|
59
|
+
'</body></html>',
|
|
60
|
+
].join('\n');
|
|
61
|
+
}
|