@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
@@ -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 CHANGED
@@ -1,79 +1 @@
1
- const COLOR = {
2
- error: '\u001b[31m',
3
- warn: '\u001b[33m',
4
- info: '\u001b[36m',
5
- dim: '\u001b[2m',
6
- bold: '\u001b[1m',
7
- reset: '\u001b[0m',
8
- };
9
-
10
- const LABEL = { error: 'error', warn: 'warn ', info: 'info ' };
11
-
12
- export function formatText(report, options = {}) {
13
- const paint = (level, text) => (options.color ? COLOR[level] + text + COLOR.reset : text);
14
- const dim = (text) => (options.color ? COLOR.dim + text + COLOR.reset : text);
15
- const lines = [];
16
- const t = report.target;
17
- lines.push((options.color ? COLOR.bold : '') + t.name + '@' + t.version
18
- + (options.color ? COLOR.reset : '') + dim(' ' + report.checks + ' checks'));
19
- lines.push('');
20
-
21
- if (report.findings.length === 0) {
22
- lines.push(' ' + paint('info', 'clean') + ' no findings');
23
- }
24
- for (const f of report.findings) {
25
- const where = f.file ? f.file + (f.line ? ':' + f.line : '') : '';
26
- lines.push(' ' + paint(f.level, f.rule + ' ' + LABEL[f.level]) + ' ' + f.title);
27
- if (where) lines.push(' ' + dim(where));
28
- if (f.message) lines.push(' ' + f.message);
29
- if (f.evidence) lines.push(' ' + dim('evidence: ' + f.evidence));
30
- if (f.hint) lines.push(' ' + dim('why: ' + f.hint));
31
- lines.push(' ' + dim('doc: ' + f.doc));
32
- lines.push('');
33
- }
34
-
35
- const s = report.summary;
36
- lines.push(' ' + s.error + ' error · ' + s.warn + ' warn · ' + s.info + ' info');
37
- return lines.join('\n');
38
- }
39
-
40
- export function toSarif(reports) {
41
- const list = Array.isArray(reports) ? reports : [reports];
42
- const ruleIndex = new Map();
43
- const rules = [];
44
- const results = [];
45
- for (const report of list) {
46
- for (const f of report.findings) {
47
- if (!ruleIndex.has(f.rule)) {
48
- ruleIndex.set(f.rule, rules.length);
49
- rules.push({
50
- id: f.rule,
51
- name: f.rule,
52
- shortDescription: { text: f.title },
53
- helpUri: 'https://github.com/deepseek-ai/deepseek-harness',
54
- properties: { doc: f.doc, level: f.level },
55
- });
56
- }
57
- results.push({
58
- ruleId: f.rule,
59
- ruleIndex: ruleIndex.get(f.rule),
60
- level: f.level === 'error' ? 'error' : f.level === 'warn' ? 'warning' : 'note',
61
- message: { text: f.message + (f.hint ? ' — ' + f.hint : '') },
62
- locations: [{
63
- physicalLocation: {
64
- artifactLocation: { uri: report.target.path + '/' + (f.file || 'package.json') },
65
- region: { startLine: f.line || 1 },
66
- },
67
- }],
68
- });
69
- }
70
- }
71
- return {
72
- version: '2.1.0',
73
- $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
74
- runs: [{
75
- tool: { driver: { name: 'dsh-plugin-lint', informationUri: 'https://github.com/<owner>/dsh-plugin-lint', rules } },
76
- results,
77
- }],
78
- };
79
- }
1
+ export * from './core/report.js';