@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.
Files changed (66) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +515 -81
  3. package/bin/dsh-plugin-analyze.mjs +378 -0
  4. package/bin/dsh-plugin-lint.mjs +315 -27
  5. package/bin/dsh-plugin-matrix.mjs +78 -0
  6. package/bin/dsh-plugin-upgrade-plan.mjs +180 -0
  7. package/package.json +12 -4
  8. package/schema/dsh-manifest.schema.json +134 -0
  9. package/scripts/lib/probe.mjs +28 -385
  10. package/scripts/probe-boot.mjs +103 -37
  11. package/src/composition.js +1 -0
  12. package/src/core/baseline.js +98 -0
  13. package/src/core/changed.js +41 -0
  14. package/src/core/load.js +145 -0
  15. package/src/core/mask.js +207 -0
  16. package/src/core/pack.js +95 -0
  17. package/src/core/packlist.js +186 -0
  18. package/src/core/peer.js +39 -0
  19. package/src/core/profile-scan.js +121 -0
  20. package/src/core/report.js +174 -0
  21. package/src/core/rules.js +581 -0
  22. package/src/core/semver.js +192 -0
  23. package/src/core/tarball.js +90 -0
  24. package/src/data/event-types.json +111 -9
  25. package/src/experimental/conflicts.js +315 -0
  26. package/src/experimental/index.js +4 -0
  27. package/src/experimental/performance.js +515 -0
  28. package/src/experimental/security.js +462 -0
  29. package/src/index.js +15 -17
  30. package/src/load.js +1 -116
  31. package/src/mask.js +1 -156
  32. package/src/matrix/cache.js +19 -0
  33. package/src/matrix/cell.js +79 -0
  34. package/src/matrix/cells.js +58 -0
  35. package/src/matrix/conflicts.js +210 -0
  36. package/src/matrix/export.js +155 -0
  37. package/src/matrix/index.js +71 -0
  38. package/src/matrix/manifest.js +17 -0
  39. package/src/matrix/migrate.js +41 -0
  40. package/src/matrix/run.js +241 -0
  41. package/src/matrix/sanitize.js +42 -0
  42. package/src/matrix/schema.js +178 -0
  43. package/src/matrix/site.js +61 -0
  44. package/src/matrix/sync.js +72 -0
  45. package/src/matrix/verify.js +152 -0
  46. package/src/performance.js +1 -0
  47. package/src/probe/artifact-resolver.js +161 -0
  48. package/src/probe/classify.js +8 -0
  49. package/src/probe/dsh-adapter.js +212 -0
  50. package/src/probe/environment/docker.js +270 -0
  51. package/src/probe/environment/host-unsafe.js +39 -0
  52. package/src/probe/environment/materialize.js +22 -0
  53. package/src/probe/environment.js +50 -0
  54. package/src/probe/installed-artifact.js +17 -0
  55. package/src/probe/probe.js +278 -0
  56. package/src/report.js +1 -79
  57. package/src/rules.js +1 -379
  58. package/src/security.js +1 -0
  59. package/src/shared/errors.js +7 -0
  60. package/src/shared/hash.js +31 -0
  61. package/src/shared/status.js +12 -0
  62. package/src/shared/validate.js +14 -0
  63. package/src/shared/version.js +4 -0
  64. package/src/upgrade/compatibility.js +14 -0
  65. package/src/upgrade/planner.js +119 -0
  66. package/src/upgrade/profile.js +82 -0
package/src/mask.js CHANGED
@@ -1,156 +1 @@
1
- /**
2
- * Comment- and string-aware scanner.
3
- *
4
- * Rules must never match text inside a comment (real plugins document the very
5
- * patterns we look for, e.g. "this listener returns next()"). Two views:
6
- *
7
- * masked comments blanked, string literals INTACT — for matching quoted
8
- * event names and reading literal values with exact line numbers.
9
- * structural masked AND string interiors blanked — for brace-depth analysis.
10
- */
11
-
12
- const BACKTICK = '\u0060';
13
-
14
- /** @returns {{ masked: string, structural: string, strings: Array<{value:string,start:number,end:number,line:number}>, lineStarts: number[] }} */
15
- export function maskSource(text) {
16
- const maskedChars = text.split('');
17
- const structuralChars = text.slice().split('');
18
- const strings = [];
19
- const lineStarts = [0];
20
- for (let k = 0; k < text.length; k++) if (text[k] === '\n') lineStarts.push(k + 1);
21
-
22
- const NL = '\n';
23
- const SP = ' ';
24
- const blank = (from, to, chars) => {
25
- for (let k = from; k < to; k++) if (chars[k] !== NL) chars[k] = SP;
26
- };
27
- // A '/' starts a regex only where a value may begin; otherwise it is division.
28
- const regexAllowed = (prev) => prev === '' || '(,=:[!&|?{};+-*%<>~^'.indexOf(prev) >= 0;
29
- const lastMeaningful = (idx) => {
30
- for (let k = idx - 1; k >= 0; k--) {
31
- const c = maskedChars[k];
32
- if (c === SP || c === NL || c === '\t') continue;
33
- return c;
34
- }
35
- return '';
36
- };
37
-
38
- let i = 0;
39
- const n = text.length;
40
- while (i < n) {
41
- const c = text[i];
42
- const next = text[i + 1];
43
-
44
- if (c === '/' && next === '/') {
45
- const from = i;
46
- while (i < n && text[i] !== NL) i++;
47
- blank(from, i, maskedChars);
48
- blank(from, i, structuralChars);
49
- continue;
50
- }
51
- if (c === '/' && next === '*') {
52
- const from = i;
53
- i += 2;
54
- while (i < n && !(text[i] === '*' && text[i + 1] === '/')) i++;
55
- i = Math.min(n, i + 2);
56
- blank(from, i, maskedChars);
57
- blank(from, i, structuralChars);
58
- continue;
59
- }
60
-
61
- // The literal delimiter itself, not a boolean: the closing delimiter must
62
- // compare equal to it.
63
- const quote = c === '"' ? '"' : c === "'" ? "'" : c === BACKTICK ? BACKTICK : undefined;
64
- if (quote !== undefined) {
65
- const from = i;
66
- i += 1;
67
- const contentFrom = i;
68
- while (i < n) {
69
- if (text[i] === '\\') { i += 2; continue; }
70
- if (text[i] === quote) break;
71
- if (text[i] === NL && quote !== BACKTICK) break; // unterminated literal
72
- i += 1;
73
- }
74
- const contentTo = Math.min(i, n);
75
- const end = Math.min(i + 1, n);
76
- strings.push({ value: text.slice(contentFrom, contentTo), start: from, end, line: lineOfIndex(lineStarts, from) });
77
- blank(contentFrom, contentTo, structuralChars);
78
- i = end;
79
- continue;
80
- }
81
-
82
- if (c === '/' && regexAllowed(lastMeaningful(i))) {
83
- i += 1;
84
- let inClass = false;
85
- while (i < n) {
86
- if (text[i] === '\\') { i += 2; continue; }
87
- if (text[i] === '[') inClass = true;
88
- else if (text[i] === ']') inClass = false;
89
- else if (text[i] === '/' && !inClass) { i += 1; break; }
90
- else if (text[i] === NL) break;
91
- i += 1;
92
- }
93
- continue;
94
- }
95
- i += 1;
96
- }
97
- return { masked: maskedChars.join(''), structural: structuralChars.join(''), strings, lineStarts };
98
- }
99
-
100
- export function lineOfIndex(lineStarts, index) {
101
- let lo = 0;
102
- let hi = lineStarts.length - 1;
103
- while (lo < hi) {
104
- const mid = (lo + hi + 1) >> 1;
105
- if (lineStarts[mid] <= index) lo = mid;
106
- else hi = mid - 1;
107
- }
108
- return lo + 1;
109
- }
110
-
111
- /** The first string literal starting at or after \`from\` within \`window\` characters. */
112
- export function stringAfter(strings, from, window) {
113
- for (const s of strings) {
114
- if (s.start >= from) return s.start - from <= window ? s : undefined;
115
- }
116
- return undefined;
117
- }
118
-
119
- /** Literal types passed to session.append("type", data) call sites. */
120
- export function appendCallSites(masked, strings) {
121
- const out = [];
122
- const re = /\.append\(\s*/g;
123
- let m;
124
- while ((m = re.exec(masked)) !== null) {
125
- const s = stringAfter(strings, m.index + m[0].length, 32);
126
- if (s) out.push({ type: s.value, index: s.start, line: s.line });
127
- }
128
- return out;
129
- }
130
-
131
- /** The balanced brace block that starts at or after `index`, on the structural view. */
132
- export function blockAfter(structural, index) {
133
- const open = structural.indexOf('{', index);
134
- if (open < 0) return undefined;
135
- let depth = 0;
136
- for (let i = open; i < structural.length; i++) {
137
- const c = structural[i];
138
- if (c === '{') depth += 1;
139
- else if (c === '}') {
140
- depth -= 1;
141
- if (depth === 0) return { start: open, end: i + 1, body: structural.slice(open + 1, i) };
142
- }
143
- }
144
- return undefined;
145
- }
146
-
147
- /** Brace depth at a character index, computed on the structural view. */
148
- export function braceDepthAt(structural, index) {
149
- let depth = 0;
150
- for (let k = 0; k < index && k < structural.length; k++) {
151
- const c = structural[k];
152
- if (c === '{' || c === '(' || c === '[') depth += 1;
153
- else if (c === '}' || c === ')' || c === ']') depth -= 1;
154
- }
155
- return depth;
156
- }
1
+ export * from './core/mask.js';
@@ -0,0 +1,19 @@
1
+ import { stableStringify, sha256 } from '../shared/hash.js';
2
+ import { ToolError } from '../shared/errors.js';
3
+ import { CELL_SCHEMA_VERSION } from '../shared/version.js';
4
+
5
+ export function cacheKeyFor({ artifact, dshVersion, composition, probeVersion, schemaVersion = CELL_SCHEMA_VERSION }) {
6
+ const integrityOrSourceDigest = artifact?.integrity ?? artifact?.sourceDigest;
7
+ if (!artifact?.name || !artifact?.resolvedVersion || !integrityOrSourceDigest || !dshVersion || !composition || !probeVersion) {
8
+ throw new ToolError('CACHE_INPUT_INVALID', 'A reproducible cache key needs artifact, DSH, composition, probe, and integrity inputs.');
9
+ }
10
+ return sha256(stableStringify({
11
+ name: artifact.name,
12
+ resolvedVersion: artifact.resolvedVersion,
13
+ integrityOrSourceDigest,
14
+ dshVersion,
15
+ composition,
16
+ probeVersion,
17
+ cellSchemaVersion: schemaVersion,
18
+ }));
19
+ }
@@ -0,0 +1,79 @@
1
+ import { CELL_SCHEMA_VERSION, PROBE_VERSION } from '../shared/version.js';
2
+ import { ToolError } from '../shared/errors.js';
3
+ import { cacheKeyFor } from './cache.js';
4
+ import { validateCell } from './schema.js';
5
+
6
+ export function createCell(input) {
7
+ const { artifact, dsh, observation } = input;
8
+ const composition = input.composition || dsh.composition || observation.composition;
9
+ const probeVersion = input.probeVersion || PROBE_VERSION;
10
+ const cacheKey = cacheKeyFor({ artifact, dshVersion: dsh.version, composition, probeVersion });
11
+ const cell = {
12
+ schemaVersion: CELL_SCHEMA_VERSION,
13
+ cacheKey,
14
+ plugin: {
15
+ name: artifact.name,
16
+ requestedVersion: artifact.requestedVersion,
17
+ resolvedVersion: artifact.resolvedVersion,
18
+ loadedVersion: observation.loadedVersion,
19
+ integrity: artifact.integrity,
20
+ local: artifact.local === true,
21
+ sourceDigest: artifact.sourceDigest,
22
+ path: artifact.path,
23
+ tarballUrl: artifact.tarballUrl,
24
+ },
25
+ dsh: { version: dsh.version, source: dsh.source || dsh.kind || 'npm', composition },
26
+ probe: {
27
+ version: probeVersion,
28
+ isolation: input.isolation || observation.isolation || 'unknown',
29
+ host: input.host || observation.host || { os: process.platform + '-' + process.arch, node: process.version },
30
+ },
31
+ run: {
32
+ outcome: observation.outcome,
33
+ at: input.at || new Date().toISOString(),
34
+ durationMs: observation.durationMs,
35
+ trust: input.trust || 'self-reported',
36
+ },
37
+ reproduce: observation.reproduce,
38
+ };
39
+ if (input.verification) cell.run.verification = input.verification;
40
+ if (input.dispute) cell.run.dispute = input.dispute;
41
+ if (observation.outcome === 'error') {
42
+ // Infrastructure errors can arrive with a numeric exec code; the cell
43
+ // schema requires non-empty strings.
44
+ cell.run.error = {
45
+ code: String(observation.error?.code || 'PROBE_FAILED'),
46
+ message: String(observation.error?.message || 'The probe could not complete.'),
47
+ };
48
+ } else {
49
+ cell.result = observation.result;
50
+ cell.evidence = { note: observation.note || '', outputSha256: observation.outputSha256 };
51
+ }
52
+ const validation = validateCell(cell);
53
+ if (!validation.ok) throw new ToolError('CELL_INVALID', validation.errors.join('; '));
54
+ return cell;
55
+ }
56
+
57
+ export function selectCells(cells, { dshVersion, composition } = {}) {
58
+ const selected = cells.filter((cell) => dshVersion === undefined || cell.dsh.version === dshVersion);
59
+ if (composition !== undefined) {
60
+ const exact = selected.filter((cell) => cell.dsh.composition === composition);
61
+ return { kind: exact.length > 0 ? 'cells' : 'untested', cells: exact };
62
+ }
63
+ if (selected.length === 0) return { kind: 'untested', cells: [] };
64
+ const compositions = new Set(selected.map((cell) => cell.dsh.composition));
65
+ return { kind: compositions.size > 1 ? 'mixed' : 'unknown', cells: selected };
66
+ }
67
+
68
+ export function recommendationEligible(cell, { now = Date.now(), maxAgeDays = 30 } = {}) {
69
+ if (cell?.recommendationEligible === false || cell?.migration) return false;
70
+ if (!validateCell(cell).ok) return false;
71
+ if (cell.plugin.local !== false || typeof cell.plugin.integrity !== 'string') return false;
72
+ if (cell.plugin.resolvedVersion !== cell.plugin.loadedVersion) return false;
73
+ if (cell.run.outcome !== 'completed' || cell.run.trust !== 'verified') return false;
74
+ if (!cell.run.verification || cell.run.verification.probeVersion !== cell.probe.version) return false;
75
+ if (cell.result.status !== 'active') return false;
76
+ const at = Date.parse(cell.run.at);
77
+ const age = now - at;
78
+ return Number.isFinite(at) && age >= 0 && age <= maxAgeDays * 24 * 60 * 60 * 1000;
79
+ }
@@ -0,0 +1,58 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { ToolError } from '../shared/errors.js';
4
+ import { readCellAny } from './migrate.js';
5
+ import { validateCell } from './schema.js';
6
+
7
+ function segment(value) {
8
+ return String(value).replace(/^@/, '').replace(/[^A-Za-z0-9._-]/g, '_');
9
+ }
10
+
11
+ export function cellFilePath(outDir, cell) {
12
+ return join(
13
+ outDir,
14
+ segment(cell.plugin.name),
15
+ segment(cell.plugin.resolvedVersion),
16
+ `${segment(cell.dsh.version)}+${segment(cell.dsh.composition)}.${cell.cacheKey.slice(0, 16)}.json`,
17
+ );
18
+ }
19
+
20
+ export function writeCell(outDir, cell) {
21
+ if (cell.schemaVersion !== 2) throw new ToolError('CELL_SCHEMA_UNSUPPORTED', 'Only v2 cells can be written by the matrix runner.');
22
+ if (cell.run.outcome === 'completed') {
23
+ const validation = validateCell(cell);
24
+ if (!validation.ok) throw new ToolError('CELL_INVALID', validation.errors.join('; '));
25
+ }
26
+ const path = cellFilePath(outDir, cell);
27
+ mkdirSync(dirname(path), { recursive: true });
28
+ writeFileSync(path, JSON.stringify(cell, null, 2) + '\n');
29
+ return path;
30
+ }
31
+
32
+ export function readCells(dir) {
33
+ if (!existsSync(dir)) throw new ToolError('CELL_READ_FAILED', `The cells directory does not exist: ${dir}`);
34
+ const results = [];
35
+ function walk(current) {
36
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
37
+ const path = join(current, entry.name);
38
+ if (entry.isDirectory()) {
39
+ walk(path);
40
+ continue;
41
+ }
42
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
43
+ try {
44
+ const value = JSON.parse(readFileSync(path, 'utf8'));
45
+ if (entry.name === 'run-manifest.json') {
46
+ results.push({ ok: true, path, kind: 'run-manifest', manifest: value });
47
+ continue;
48
+ }
49
+ const { kind, cell } = readCellAny(value);
50
+ results.push({ ok: true, path, kind, cell });
51
+ } catch (error) {
52
+ results.push({ ok: false, path, error: { code: error.code || 'CELL_INVALID', message: error.message } });
53
+ }
54
+ }
55
+ }
56
+ walk(dir);
57
+ return results.sort((a, b) => a.path.localeCompare(b.path));
58
+ }
@@ -0,0 +1,210 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { detectConflicts } from '../experimental/conflicts.js';
6
+ import { loadPackage } from '../core/load.js';
7
+ import { ToolError } from '../shared/errors.js';
8
+ import { downloadArtifact, resolveArtifact } from '../probe/artifact-resolver.js';
9
+ import { createProbeEnvironment } from '../probe/environment.js';
10
+ import { resolveDsh, safeEnv } from '../probe/dsh-adapter.js';
11
+ import { readInstalledArtifact } from '../probe/installed-artifact.js';
12
+ import { classify } from '../probe/classify.js';
13
+ import { parseInactive, payloadOf } from '../probe/probe.js';
14
+
15
+ const execFileAsync = promisify(execFile);
16
+
17
+ function safeName(value) {
18
+ return String(value).replace(/^@/, '').replace(/[^A-Za-z0-9._-]/g, '_');
19
+ }
20
+
21
+ function artifactFromCell(cell) {
22
+ return {
23
+ name: cell.plugin.name,
24
+ requestedVersion: cell.plugin.resolvedVersion,
25
+ resolvedVersion: cell.plugin.resolvedVersion,
26
+ integrity: cell.plugin.integrity,
27
+ sourceDigest: cell.plugin.sourceDigest,
28
+ local: cell.plugin.local,
29
+ path: cell.plugin.path,
30
+ tarballUrl: cell.plugin.tarballUrl,
31
+ installTarget: `${cell.plugin.name}@${cell.plugin.resolvedVersion}`,
32
+ };
33
+ }
34
+
35
+ function uniqueArtifacts(cells) {
36
+ const artifacts = new Map();
37
+ for (const cell of cells) {
38
+ const artifact = artifactFromCell(cell);
39
+ const key = `${artifact.name}@${artifact.resolvedVersion}@${artifact.integrity || artifact.sourceDigest}`;
40
+ if (!artifacts.has(key)) artifacts.set(key, { artifact, cells: [] });
41
+ artifacts.get(key).cells.push(cell);
42
+ }
43
+ return [...artifacts.values()];
44
+ }
45
+
46
+ function assertSafeTarEntry(path) {
47
+ if (path.startsWith('/') || path === '..' || path.startsWith('../') || path.includes('/../') || path.includes('\0')) {
48
+ throw new ToolError('ARTIFACT_SPEC_UNSUPPORTED', 'The artifact contains an unsafe archive path.');
49
+ }
50
+ }
51
+
52
+ function assertNoSymlinks(dir) {
53
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
54
+ if (entry.isSymbolicLink()) throw new ToolError('ARTIFACT_SPEC_UNSUPPORTED', 'The artifact contains a symbolic link.');
55
+ if (entry.isDirectory()) assertNoSymlinks(join(dir, entry.name));
56
+ }
57
+ }
58
+
59
+ export function createPinnedMaterializer({ cacheRoot }) {
60
+ return async (artifact) => {
61
+ if (artifact.local && artifact.path) return artifact.path;
62
+ const pinned = await resolveArtifact({ name: artifact.name, version: artifact.resolvedVersion, integrity: artifact.integrity });
63
+ const downloaded = await downloadArtifact(pinned);
64
+ const destination = join(cacheRoot, 'pkgs', safeName(artifact.name), safeName(artifact.resolvedVersion));
65
+ if (existsSync(join(destination, 'package.json'))) return destination;
66
+ mkdirSync(cacheRoot, { recursive: true });
67
+ const listing = await execFileAsync('tar', ['-tzf', downloaded.tarballPath], { env: safeEnv(), maxBuffer: 16 * 1024 * 1024 });
68
+ for (const path of listing.stdout.split('\n').filter(Boolean)) assertSafeTarEntry(path);
69
+ const temporary = mkdtempSync(join(cacheRoot, 'extract-'));
70
+ try {
71
+ await execFileAsync('tar', ['-xzf', downloaded.tarballPath, '-C', temporary, '--strip-components=1', '--no-same-owner', '--no-same-permissions'], { env: safeEnv(), maxBuffer: 16 * 1024 * 1024 });
72
+ assertNoSymlinks(temporary);
73
+ if (!existsSync(join(temporary, 'package.json'))) throw new ToolError('ARTIFACT_MISSING', 'The extracted artifact has no package.json.');
74
+ rmSync(destination, { recursive: true, force: true });
75
+ mkdirSync(join(destination, '..'), { recursive: true });
76
+ renameSync(temporary, destination);
77
+ } catch (error) {
78
+ rmSync(temporary, { recursive: true, force: true });
79
+ throw error;
80
+ }
81
+ return destination;
82
+ };
83
+ }
84
+
85
+ async function verifyPinnedCombo({ artifacts, directories, dshVersion }) {
86
+ const environment = await createProbeEnvironment({ backend: 'auto' });
87
+ let stub;
88
+ try {
89
+ const dsh = await resolveDsh(dshVersion, environment.dshDir, { environment });
90
+ stub = await environment.startStub();
91
+ const patchPath = environment.isolation === 'docker' ? '/profile/probe-patch.yml' : join(environment.profileDir, 'probe-patch.yml');
92
+ writeFileSync(join(environment.profileDir, 'probe-patch.yml'), [
93
+ '- id: llm-deepseek',
94
+ ' name: "@deepseek-ai/dsh-llm-deepseek-api-key"',
95
+ ' config:',
96
+ ` baseURL: ${stub.baseURL}`,
97
+ ' apiKeyEnv: PROBE_API_KEY',
98
+ '',
99
+ ].join('\n'));
100
+ const bootInput = {
101
+ dsh,
102
+ args: ['--profile', 'headless', '--patch', patchPath, 'probe'],
103
+ env: { PROBE_API_KEY: 'probe' },
104
+ network: stub.network,
105
+ };
106
+ await environment.boot(bootInput);
107
+ await stub.sync();
108
+ const before = payloadOf(stub.seen.splice(0));
109
+ for (const artifact of artifacts) {
110
+ const directory = directories.get(artifact.name);
111
+ const materialized = await environment.materialize({ ...artifact, local: true, path: directory });
112
+ await environment.install({ dsh, target: materialized, profile: 'headless', env: { PROBE_API_KEY: 'probe' } });
113
+ const installed = readInstalledArtifact(join(environment.profileDir, 'profiles', 'headless'), artifact.name);
114
+ if (installed.version !== artifact.resolvedVersion) throw new ToolError('VERSION_MISMATCH', 'A combination installed a different artifact version.');
115
+ }
116
+ const booted = await environment.boot(bootInput);
117
+ await stub.sync();
118
+ const after = payloadOf(stub.seen.splice(0));
119
+ const entries = parseInactive(booted.out);
120
+ const beforeNames = new Set(before?.tools?.map((tool) => tool.name) || []);
121
+ const afterNames = new Set(after?.tools?.map((tool) => tool.name) || []);
122
+ return {
123
+ status: classify(booted.out, entries, Boolean(after)),
124
+ trust: 'self-reported',
125
+ entries,
126
+ dropped: [...beforeNames].filter((name) => !afterNames.has(name)),
127
+ beforeCount: beforeNames.size,
128
+ afterCount: afterNames.size,
129
+ };
130
+ } finally {
131
+ if (stub?.server) stub.server.close();
132
+ await environment.destroy();
133
+ }
134
+ }
135
+
136
+ function candidateTools(cells) {
137
+ const owners = new Map();
138
+ for (const cell of cells) {
139
+ for (const tool of cell.result.tools?.added || []) {
140
+ if (!owners.has(tool)) owners.set(tool, new Map());
141
+ owners.get(tool).set(`${cell.plugin.name}@${cell.plugin.resolvedVersion}`, cell);
142
+ }
143
+ }
144
+ return [...owners.entries()].filter(([, byPlugin]) => byPlugin.size > 1).map(([resource, byPlugin]) => {
145
+ const claims = [...byPlugin.values()];
146
+ return {
147
+ resource,
148
+ plugins: claims.map((cell) => cell.plugin.name),
149
+ cells: claims,
150
+ status: 'candidate',
151
+ };
152
+ });
153
+ }
154
+
155
+ export async function scanConflicts({ cells, materialize, verify = false, verifyCombo = verifyPinnedCombo, limit = Number.POSITIVE_INFINITY }) {
156
+ const errors = [];
157
+ const unique = uniqueArtifacts(cells);
158
+ const packages = [];
159
+ const directories = new Map();
160
+ const pluginsScanned = [];
161
+ for (const entry of unique) {
162
+ try {
163
+ const directory = await materialize(entry.artifact);
164
+ directories.set(entry.artifact.name, directory);
165
+ packages.push(loadPackage(directory));
166
+ pluginsScanned.push(entry.artifact.name);
167
+ } catch (error) {
168
+ errors.push({ code: error.code || 'ARTIFACT_MATERIALIZE_FAILED', plugin: entry.artifact.name, message: error.message });
169
+ }
170
+ }
171
+ const staticReport = detectConflicts(packages);
172
+ const tools = candidateTools(cells);
173
+ const slots = staticReport.slots.map((entry) => ({ ...entry, status: 'candidate' }));
174
+ const routes = staticReport.routes.map((entry) => ({ ...entry, status: 'candidate' }));
175
+ const verified = { tools: [], slots: [], routes: [] };
176
+ if (verify) {
177
+ for (const [kind, candidates] of [['tools', tools], ['slots', slots], ['routes', routes]]) {
178
+ for (const candidate of candidates.slice(0, limit)) {
179
+ try {
180
+ const names = new Set(candidate.plugins);
181
+ const artifacts = unique.filter((entry) => names.has(entry.artifact.name)).map((entry) => entry.artifact);
182
+ const relatedCells = candidate.cells || unique.filter((entry) => names.has(entry.artifact.name)).flatMap((entry) => entry.cells);
183
+ const result = await verifyCombo({
184
+ kind,
185
+ candidate,
186
+ artifacts,
187
+ directories,
188
+ dshVersion: relatedCells[0]?.dsh.version,
189
+ composition: relatedCells[0]?.dsh.composition,
190
+ });
191
+ verified[kind].push({ resource: candidate.resource, plugins: candidate.plugins, ...result });
192
+ } catch (error) {
193
+ errors.push({ code: error.code || 'CONFLICT_VERIFICATION_FAILED', resource: candidate.resource, message: error.message });
194
+ }
195
+ }
196
+ }
197
+ }
198
+ return {
199
+ schemaVersion: 2,
200
+ generatedAt: new Date().toISOString(),
201
+ cells: cells.length,
202
+ pluginsScanned,
203
+ tools,
204
+ slots,
205
+ routes,
206
+ unknownRoutes: staticReport.unknownRoutes,
207
+ verified,
208
+ errors,
209
+ };
210
+ }
@@ -0,0 +1,155 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, relative } from 'node:path';
3
+ import { ToolError } from '../shared/errors.js';
4
+ import { PROBE_VERSION } from '../shared/version.js';
5
+ import { stableStringify, sha256 } from '../shared/hash.js';
6
+ import { readCells, writeCell } from './cells.js';
7
+ import { buildIndex } from './index.js';
8
+ import { buildManifest } from './manifest.js';
9
+ import { validateCell, validateIndex, validateManifest } from './schema.js';
10
+ import { sanitizeCellForPublish, sanitizeForPublish } from './sanitize.js';
11
+
12
+ function readJson(path) {
13
+ return JSON.parse(readFileSync(path, 'utf8'));
14
+ }
15
+
16
+ function writeJson(path, value) {
17
+ mkdirSync(dirname(path), { recursive: true });
18
+ writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
19
+ }
20
+
21
+ function filesUnder(dir) {
22
+ const files = [];
23
+ function walk(current) {
24
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
25
+ const path = join(current, entry.name);
26
+ if (entry.isDirectory()) walk(path);
27
+ else if (entry.isFile()) files.push(relative(dir, path).replace(/\\/g, '/'));
28
+ }
29
+ }
30
+ walk(dir);
31
+ return files.sort();
32
+ }
33
+
34
+ function buildAggregate(index, sourceRunId) {
35
+ const aggregate = {
36
+ schemaVersion: 2,
37
+ generatedAt: index.generatedAt,
38
+ sourceRunId,
39
+ dshVersions: index.dshVersions,
40
+ plugins: index.plugins.length,
41
+ versions: {},
42
+ };
43
+ for (const plugin of index.plugins) {
44
+ for (const version of plugin.versions) {
45
+ for (const cell of version.cells) {
46
+ if (!aggregate.versions[cell.dsh]) aggregate.versions[cell.dsh] = {};
47
+ aggregate.versions[cell.dsh][cell.status] = (aggregate.versions[cell.dsh][cell.status] || 0) + 1;
48
+ }
49
+ }
50
+ }
51
+ return aggregate;
52
+ }
53
+
54
+ export async function exportPublicArtifact({ cellsDir, conflictsPath, outDir, runId, generatedAt = new Date().toISOString(), expectedCells }) {
55
+ const reads = readCells(cellsDir);
56
+ const invalid = reads.filter((entry) => !entry.ok);
57
+ if (invalid.length > 0) throw new ToolError('EXPORT_INPUT_INVALID', `Cannot read ${invalid.length} cell file(s).`);
58
+ if (reads.some((entry) => entry.kind === 'legacy-v1')) throw new ToolError('EXPORT_INPUT_INVALID', 'Legacy cells cannot be republished as v2 evidence.');
59
+ const cells = reads.filter((entry) => entry.kind === 'v2').map((entry) => sanitizeCellForPublish(entry.cell));
60
+ if (expectedCells !== undefined && cells.length !== expectedCells) {
61
+ throw new ToolError('EXPORT_CELL_COUNT_MISMATCH', `Expected ${expectedCells} cells, found ${cells.length}.`);
62
+ }
63
+ if (!runId) throw new ToolError('EXPORT_INPUT_INVALID', 'runId is required.');
64
+ if (!existsSync(conflictsPath)) throw new ToolError('EXPORT_INPUT_INVALID', 'The computed conflicts artifact is required.');
65
+ const conflicts = sanitizeForPublish(readJson(conflictsPath));
66
+ if (conflicts.schemaVersion !== 2) throw new ToolError('EXPORT_INPUT_INVALID', 'Conflicts must use schema v2.');
67
+ const index = buildIndex(cells, { generatedAt, source: { runId, conflicts } });
68
+ const aggregate = buildAggregate(index, runId);
69
+ const sourceManifest = reads.find((entry) => entry.kind === 'run-manifest')?.manifest;
70
+ const parent = dirname(outDir);
71
+ mkdirSync(parent, { recursive: true });
72
+ const staging = mkdtempSync(join(parent, '.export-'));
73
+ try {
74
+ for (const cell of cells) writeCell(join(staging, 'cells'), cell);
75
+ writeJson(join(staging, 'index', 'latest.json'), index);
76
+ writeJson(join(staging, 'index', 'conflicts.json'), conflicts);
77
+ writeJson(join(staging, 'aggregate.json'), aggregate);
78
+ const artifacts = filesUnder(staging).map((path) => {
79
+ const bytes = readFileSync(join(staging, path));
80
+ return { path, bytes: bytes.length, sha256: sha256(bytes) };
81
+ });
82
+ const manifest = buildManifest({
83
+ runId,
84
+ generatedAt,
85
+ probeVersion: PROBE_VERSION,
86
+ inputs: { targetMode: sourceManifest?.targetMode || 'pinned', expectedCells: expectedCells ?? cells.length, inputs: sourceManifest?.inputs || index.plugins.map((plugin) => plugin.name) },
87
+ artifacts,
88
+ });
89
+ manifest.targetMode = sourceManifest?.targetMode || 'pinned';
90
+ manifest.inputs = sourceManifest?.inputs || index.plugins.map((plugin) => plugin.name);
91
+ manifest.cellCount = cells.length;
92
+ if (expectedCells !== undefined) manifest.expectedCells = expectedCells;
93
+ writeJson(join(staging, 'manifest.json'), manifest);
94
+ const verification = await verifyPublicArtifact({ dir: staging });
95
+ if (!verification.ok) throw new ToolError('EXPORT_INPUT_INVALID', verification.errors.join('; '));
96
+ const backup = `${outDir}.backup-${Date.now()}`;
97
+ if (existsSync(outDir)) renameSync(outDir, backup);
98
+ try {
99
+ renameSync(staging, outDir);
100
+ rmSync(backup, { recursive: true, force: true });
101
+ } catch (error) {
102
+ if (existsSync(backup)) renameSync(backup, outDir);
103
+ throw error;
104
+ }
105
+ return { manifest, filesWritten: artifacts.length + 1, cells: cells.length };
106
+ } catch (error) {
107
+ rmSync(staging, { recursive: true, force: true });
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ export async function verifyPublicArtifact({ dir }) {
113
+ const errors = [];
114
+ try {
115
+ const manifest = readJson(join(dir, 'manifest.json'));
116
+ const manifestValidation = validateManifest(manifest);
117
+ if (!manifestValidation.ok) errors.push(...manifestValidation.errors);
118
+ const declared = new Set(manifest.artifacts.map((artifact) => artifact.path));
119
+ const actual = new Set(filesUnder(dir).filter((path) => path !== 'manifest.json'));
120
+ for (const path of declared) {
121
+ if (!actual.has(path)) {
122
+ errors.push(`artifact is missing: ${path}`);
123
+ continue;
124
+ }
125
+ const expected = manifest.artifacts.find((artifact) => artifact.path === path);
126
+ const bytes = readFileSync(join(dir, path));
127
+ if (bytes.length !== expected.bytes || sha256(bytes) !== expected.sha256) errors.push(`artifact hash mismatch: ${path}`);
128
+ }
129
+ for (const path of actual) if (!declared.has(path)) errors.push(`undeclared artifact: ${path}`);
130
+
131
+ const index = readJson(join(dir, 'index', 'latest.json'));
132
+ const indexValidation = validateIndex(index);
133
+ if (!indexValidation.ok) errors.push(...indexValidation.errors);
134
+ const conflicts = readJson(join(dir, 'index', 'conflicts.json'));
135
+ if (conflicts.schemaVersion !== 2) errors.push('conflicts.schemaVersion must be 2');
136
+ const reads = readCells(join(dir, 'cells'));
137
+ const invalid = reads.filter((entry) => !entry.ok);
138
+ if (invalid.length > 0) errors.push(`${invalid.length} damaged public cell(s)`);
139
+ const cells = reads.filter((entry) => entry.kind === 'v2').map((entry) => entry.cell);
140
+ for (const cell of cells) {
141
+ const validation = validateCell(cell);
142
+ if (!validation.ok) errors.push(...validation.errors);
143
+ }
144
+ if (manifest.cellCount !== cells.length) errors.push(`cell count mismatch: manifest ${manifest.cellCount}, actual ${cells.length}`);
145
+ if (manifest.expectedCells !== undefined && manifest.expectedCells !== cells.length) errors.push(`expected cell count mismatch: ${manifest.expectedCells} vs ${cells.length}`);
146
+ const expectedIndex = buildIndex(cells, { generatedAt: index.generatedAt, source: index.source });
147
+ if (stableStringify(expectedIndex) !== stableStringify(index)) errors.push('index does not match the public cells');
148
+ const expectedAggregate = buildAggregate(index, manifest.runId);
149
+ const aggregate = readJson(join(dir, 'aggregate.json'));
150
+ if (stableStringify(expectedAggregate) !== stableStringify(aggregate)) errors.push('aggregate does not match the index');
151
+ } catch (error) {
152
+ errors.push(error.message);
153
+ }
154
+ return { ok: errors.length === 0, errors };
155
+ }