@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,381 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { lintPackage, formatText, toSarif, toBadge, RULES } from '../src/index.js';
|
|
5
|
+
import { toGithub, toJunit } from '../src/core/report.js';
|
|
6
|
+
import { formatProfileScan, scanProfile } from '../src/core/profile-scan.js';
|
|
7
|
+
import { changedFiles, packageRoots } from '../src/core/changed.js';
|
|
8
|
+
import { extractTarballTo, packDirectory, packFromNpm } from '../src/core/pack.js';
|
|
9
|
+
import {
|
|
10
|
+
applyBaseline,
|
|
11
|
+
createBaseline,
|
|
12
|
+
pruneBaseline,
|
|
13
|
+
readBaseline,
|
|
14
|
+
writeBaseline,
|
|
15
|
+
} from '../src/core/baseline.js';
|
|
16
|
+
|
|
17
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A directory without its own package.json is treated as a container: every
|
|
21
|
+
* immediate subdirectory that is a package gets linted. Monorepos and fixture
|
|
22
|
+
* folders are the reason — measured: CI pointed the tool at a folder of two
|
|
23
|
+
* plugins and it refused the whole run.
|
|
24
|
+
*/
|
|
25
|
+
function expandTargets(dir) {
|
|
26
|
+
if (existsSync(join(dir, 'package.json'))) return [dir];
|
|
27
|
+
let entries = [];
|
|
28
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { entries = []; }
|
|
29
|
+
const packages = entries
|
|
30
|
+
.filter((e) => e.isDirectory() && existsSync(join(dir, e.name, 'package.json')))
|
|
31
|
+
.map((e) => join(dir, e.name));
|
|
32
|
+
if (packages.length === 0) fail('not a package directory (no package.json): ' + dir);
|
|
33
|
+
return packages;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const USAGE = [
|
|
37
|
+
'dsh-plugin-lint — conformance checks for DeepSeek Harness plugins',
|
|
38
|
+
'',
|
|
39
|
+
'Usage',
|
|
40
|
+
' dsh-plugin-lint [dir ...] [options]',
|
|
41
|
+
'',
|
|
42
|
+
'A directory without its own package.json is treated as a container, and every',
|
|
43
|
+
'immediate subdirectory that is a package is linted.',
|
|
44
|
+
'',
|
|
45
|
+
'Options',
|
|
46
|
+
' --format <fmt> text | json | json-envelope | sarif | github | junit | badge',
|
|
47
|
+
' --json legacy JSON report (object for one package, array for several)',
|
|
48
|
+
' --sarif SARIF 2.1.0 report on stdout',
|
|
49
|
+
' --badge SVG badge on stdout',
|
|
50
|
+
' --github GitHub Actions annotations',
|
|
51
|
+
' --junit JUnit XML report',
|
|
52
|
+
' -o, --output-file <f> write the formatted output to a file',
|
|
53
|
+
' --dsh-version <ver> target DSH runtime for I1 and the E6 peer check',
|
|
54
|
+
' --phase <phase> publish (default) | author (pre-build source tree)',
|
|
55
|
+
' --profile <dir> offline admission scan of an installed DSH profile',
|
|
56
|
+
' --changed [ref] lint only packages touched since ref (default HEAD)',
|
|
57
|
+
' --pack [pm] pack the package and lint the real tarball (auto|npm|pnpm|yarn|bun)',
|
|
58
|
+
' --from-npm <spec> pack a published package from npm and lint the tarball',
|
|
59
|
+
' --staged lint only packages with staged changes',
|
|
60
|
+
' --fail-on <level> error | warn | info: exit 1 when a finding at or above the level remains',
|
|
61
|
+
' --max-warnings <n> exit 1 when more than n warnings remain',
|
|
62
|
+
' --exit-code alias for --fail-on error',
|
|
63
|
+
' --baseline <file> suppress findings recorded in a baseline file',
|
|
64
|
+
' --write-baseline [f] write a fresh baseline (default .dsh-plugin-lint-baseline.json)',
|
|
65
|
+
' --prune with --baseline: drop entries that no longer occur',
|
|
66
|
+
' --init [dir] write a starter workflow, baseline, and agent skill',
|
|
67
|
+
' --force with --init: overwrite existing files',
|
|
68
|
+
' --rules list every rule with its level',
|
|
69
|
+
' --explain <rule> show one rule',
|
|
70
|
+
' --version print the package version',
|
|
71
|
+
' --no-color disable ANSI colour',
|
|
72
|
+
' --quiet print only the summary line',
|
|
73
|
+
' -h, --help show this message',
|
|
74
|
+
'',
|
|
75
|
+
'Exit codes: 0 ok · 1 findings at the selected gate · 2 run failed',
|
|
76
|
+
].join('\n');
|
|
77
|
+
|
|
78
|
+
const FORMATS = new Set(['text', 'json', 'json-envelope', 'sarif', 'github', 'junit', 'badge']);
|
|
79
|
+
const LEVELS = ['error', 'warn', 'info'];
|
|
80
|
+
|
|
81
|
+
function parseArgs(argv) {
|
|
82
|
+
const options = {
|
|
83
|
+
dirs: [], format: 'text', outputFile: undefined, dshVersion: undefined, phase: 'publish',
|
|
84
|
+
failOn: undefined, maxWarnings: undefined, baselinePath: undefined, writeBaseline: false,
|
|
85
|
+
prune: false, init: undefined, force: false, profile: undefined, changed: false, changedRef: undefined, staged: false, pack: undefined, npmSpecs: [], color: process.stdout.isTTY === true, quiet: false,
|
|
86
|
+
help: false, version: false, rules: false, explain: undefined, envelope: false,
|
|
87
|
+
};
|
|
88
|
+
for (let i = 0; i < argv.length; i++) {
|
|
89
|
+
const a = argv[i];
|
|
90
|
+
if (a === '-h' || a === '--help') options.help = true;
|
|
91
|
+
else if (a === '--version') options.version = true;
|
|
92
|
+
else if (a === '--rules') options.rules = true;
|
|
93
|
+
else if (a === '--explain') { options.explain = argv[++i]; if (!options.explain) fail('--explain needs a rule id'); }
|
|
94
|
+
else if (a === '--format') {
|
|
95
|
+
options.format = argv[++i];
|
|
96
|
+
if (!FORMATS.has(options.format)) fail('--format must be one of: ' + [...FORMATS].join(', '));
|
|
97
|
+
if (options.format === 'json-envelope') { options.format = 'json'; options.envelope = true; }
|
|
98
|
+
}
|
|
99
|
+
else if (a === '--json') { options.format = 'json'; options.envelope = false; }
|
|
100
|
+
else if (a === '--json-envelope') { options.format = 'json'; options.envelope = true; }
|
|
101
|
+
else if (a === '--sarif') options.format = 'sarif';
|
|
102
|
+
else if (a === '--badge') options.format = 'badge';
|
|
103
|
+
else if (a === '--github') options.format = 'github';
|
|
104
|
+
else if (a === '--junit') options.format = 'junit';
|
|
105
|
+
else if (a === '--output-file' || a === '-o') { options.outputFile = argv[++i]; if (!options.outputFile) fail(a + ' needs a path'); }
|
|
106
|
+
else if (a === '--dsh-version') { options.dshVersion = argv[++i]; if (!options.dshVersion) fail('--dsh-version needs a value'); }
|
|
107
|
+
else if (a === '--phase') {
|
|
108
|
+
options.phase = argv[++i];
|
|
109
|
+
if (!['author', 'publish'].includes(options.phase)) fail('--phase must be author or publish');
|
|
110
|
+
}
|
|
111
|
+
else if (a === '--fail-on') {
|
|
112
|
+
options.failOn = argv[++i];
|
|
113
|
+
if (!LEVELS.includes(options.failOn)) fail('--fail-on must be error, warn, or info');
|
|
114
|
+
}
|
|
115
|
+
else if (a === '--max-warnings') {
|
|
116
|
+
options.maxWarnings = Number(argv[++i]);
|
|
117
|
+
if (!Number.isInteger(options.maxWarnings) || options.maxWarnings < 0) fail('--max-warnings needs a non-negative integer');
|
|
118
|
+
}
|
|
119
|
+
else if (a === '--exit-code') options.failOn = 'error';
|
|
120
|
+
else if (a === '--baseline') { options.baselinePath = argv[++i]; if (!options.baselinePath) fail('--baseline needs a path'); }
|
|
121
|
+
else if (a === '--write-baseline') {
|
|
122
|
+
options.writeBaseline = true;
|
|
123
|
+
const next = argv[i + 1];
|
|
124
|
+
if (next && !next.startsWith('-')) { options.baselinePath = next; i += 1; }
|
|
125
|
+
}
|
|
126
|
+
else if (a === '--prune') options.prune = true;
|
|
127
|
+
else if (a === '--init') {
|
|
128
|
+
options.init = true;
|
|
129
|
+
const next = argv[i + 1];
|
|
130
|
+
if (next && !next.startsWith('-')) { options.initDir = next; i += 1; }
|
|
131
|
+
}
|
|
132
|
+
else if (a === '--force') options.force = true;
|
|
133
|
+
else if (a === '--changed') {
|
|
134
|
+
options.changed = true;
|
|
135
|
+
const next = argv[i + 1];
|
|
136
|
+
if (next && !next.startsWith('-')) { options.changedRef = next; i += 1; }
|
|
137
|
+
}
|
|
138
|
+
else if (a === '--staged') { options.changed = true; options.staged = true; }
|
|
139
|
+
else if (a === '--pack') {
|
|
140
|
+
const next = argv[i + 1];
|
|
141
|
+
if (next && ['auto', 'npm', 'pnpm', 'yarn', 'bun'].includes(next)) { options.pack = next; i += 1; }
|
|
142
|
+
else options.pack = 'auto';
|
|
143
|
+
}
|
|
144
|
+
else if (a === '--from-npm') { const spec = argv[++i]; if (!spec) fail('--from-npm needs a package spec'); options.npmSpecs.push(spec); }
|
|
145
|
+
else if (a === '--profile') { options.profile = argv[++i]; if (!options.profile) fail('--profile needs a directory'); }
|
|
146
|
+
else if (a === '--no-color') options.color = false;
|
|
147
|
+
else if (a === '--quiet') options.quiet = true;
|
|
148
|
+
else if (a.startsWith('-')) fail('unknown option: ' + a);
|
|
149
|
+
else options.dirs.push(a);
|
|
150
|
+
}
|
|
151
|
+
if (options.dirs.length === 0 && options.npmSpecs.length === 0) options.dirs.push('.');
|
|
152
|
+
if (process.env.NO_COLOR !== undefined) options.color = false;
|
|
153
|
+
return options;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function fail(message) {
|
|
157
|
+
console.error('dsh-plugin-lint: ' + message);
|
|
158
|
+
process.exit(2);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function listRules() {
|
|
162
|
+
return RULES.map((rule) => rule.id.padEnd(3) + ' ' + rule.level.padEnd(5) + ' ' + rule.title).join('\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function explainRule(id) {
|
|
166
|
+
const rule = RULES.find((entry) => entry.id === id);
|
|
167
|
+
if (!rule) fail('unknown rule: ' + id + ' (try --rules)');
|
|
168
|
+
return [
|
|
169
|
+
rule.id + ' (' + rule.level + ') — ' + rule.title,
|
|
170
|
+
'doc: ' + rule.doc,
|
|
171
|
+
'docs: https://github.com/shidesheng0218/dsh-plugin-lint/blob/main/docs/rules/' + rule.id + '.md',
|
|
172
|
+
].join('\n');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function initFiles(options) {
|
|
176
|
+
const dir = resolve(options.initDir || options.dirs[0] || '.');
|
|
177
|
+
const workflow = join(dir, '.github', 'workflows', 'dsh-plugin-lint.yml');
|
|
178
|
+
const baseline = join(dir, '.dsh-plugin-lint-baseline.json');
|
|
179
|
+
const skill = join(dir, '.claude', 'skills', 'dsh-plugin-lint', 'SKILL.md');
|
|
180
|
+
for (const path of [workflow, baseline, skill]) {
|
|
181
|
+
if (existsSync(path) && !options.force) fail(path + ' already exists (use --force to overwrite)');
|
|
182
|
+
}
|
|
183
|
+
mkdirSync(dirname(workflow), { recursive: true });
|
|
184
|
+
writeFileSync(workflow, [
|
|
185
|
+
'name: dsh-plugin-lint',
|
|
186
|
+
'on:',
|
|
187
|
+
' pull_request:',
|
|
188
|
+
' push:',
|
|
189
|
+
' branches: [main]',
|
|
190
|
+
'permissions:',
|
|
191
|
+
' contents: read',
|
|
192
|
+
' pull-requests: write # the sticky comment',
|
|
193
|
+
'jobs:',
|
|
194
|
+
' lint:',
|
|
195
|
+
' runs-on: ubuntu-latest',
|
|
196
|
+
' steps:',
|
|
197
|
+
' - uses: actions/checkout@v4',
|
|
198
|
+
' - uses: shidesheng0218/dsh-plugin-lint@main',
|
|
199
|
+
' with:',
|
|
200
|
+
' path: .',
|
|
201
|
+
' fail-on: error',
|
|
202
|
+
' baseline: .dsh-plugin-lint-baseline.json',
|
|
203
|
+
' comment: true',
|
|
204
|
+
'',
|
|
205
|
+
].join('\n'));
|
|
206
|
+
writeBaseline(baseline, createBaseline([]));
|
|
207
|
+
mkdirSync(dirname(skill), { recursive: true });
|
|
208
|
+
writeFileSync(skill, [
|
|
209
|
+
'---',
|
|
210
|
+
'name: dsh-plugin-lint',
|
|
211
|
+
'description: Verify a DeepSeek Harness plugin before committing or publishing. Use when editing package.json, cordis.patch.yml, client.js, or locale files.',
|
|
212
|
+
'---',
|
|
213
|
+
'',
|
|
214
|
+
'# DSH plugin conformance',
|
|
215
|
+
'',
|
|
216
|
+
'After changing a manifest or plugin code, run:',
|
|
217
|
+
'',
|
|
218
|
+
'```bash',
|
|
219
|
+
'npx @shidesheng0218/dsh-plugin-lint . --fail-on warn',
|
|
220
|
+
'```',
|
|
221
|
+
'',
|
|
222
|
+
'- Before the build (TypeScript tree): add `--phase author`.',
|
|
223
|
+
'- Before publishing: `--pack` lints the tarball npm would publish; `--from-npm <spec>` lints a published one.',
|
|
224
|
+
'- With a host version: `--dsh-version <version>` makes E6 test the host\'s real peer gate.',
|
|
225
|
+
'- Explain a finding with `--explain <rule>`; the long form is `docs/rules/<rule>.md`.',
|
|
226
|
+
'',
|
|
227
|
+
'Do not silence E6 by dropping or widening a peer range unless the target host actually accepts it. The baseline is for known findings, not for hiding new ones: `--write-baseline` records, `--prune` drops stale entries.',
|
|
228
|
+
'',
|
|
229
|
+
].join('\n'));
|
|
230
|
+
console.log('created ' + workflow);
|
|
231
|
+
console.log('created ' + baseline + ' (run with --write-baseline to record existing findings)');
|
|
232
|
+
console.log('created ' + skill);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const options = parseArgs(process.argv.slice(2));
|
|
236
|
+
if (options.help) { console.log(USAGE); process.exit(0); }
|
|
237
|
+
if (options.version) { console.log(VERSION); process.exit(0); }
|
|
238
|
+
if (options.rules) { console.log(listRules()); process.exit(0); }
|
|
239
|
+
if (options.explain) { console.log(explainRule(options.explain)); process.exit(0); }
|
|
240
|
+
if (options.writeBaseline && options.prune) fail('--write-baseline and --prune are different operations');
|
|
241
|
+
if (options.prune && !options.baselinePath) fail('--prune needs --baseline');
|
|
242
|
+
if (options.init) { initFiles(options); process.exit(0); }
|
|
243
|
+
|
|
244
|
+
if (options.changed && options.profile) fail('--changed cannot be combined with --profile');
|
|
245
|
+
|
|
246
|
+
if (options.changed) {
|
|
247
|
+
let files;
|
|
248
|
+
try {
|
|
249
|
+
files = changedFiles({ cwd: process.cwd(), ref: options.changedRef, staged: options.staged });
|
|
250
|
+
} catch (error) {
|
|
251
|
+
fail('git diff failed: ' + error.message);
|
|
252
|
+
}
|
|
253
|
+
const roots = packageRoots(files, process.cwd());
|
|
254
|
+
if (roots.length === 0) fail('no package.json found for the changed files');
|
|
255
|
+
options.dirs = roots;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (options.profile) {
|
|
259
|
+
if (!['text', 'json'].includes(options.format)) fail('--profile supports --format text or json');
|
|
260
|
+
let scan;
|
|
261
|
+
try {
|
|
262
|
+
scan = scanProfile(resolve(options.profile), { dshVersion: options.dshVersion });
|
|
263
|
+
} catch (error) {
|
|
264
|
+
fail(error.message);
|
|
265
|
+
}
|
|
266
|
+
const output = options.format === 'json' ? JSON.stringify(scan, null, 2) : formatProfileScan(scan);
|
|
267
|
+
if (options.outputFile) {
|
|
268
|
+
mkdirSync(dirname(resolve(options.outputFile)), { recursive: true });
|
|
269
|
+
writeFileSync(options.outputFile, output + '\n');
|
|
270
|
+
console.error('dsh-plugin-lint: wrote ' + options.outputFile);
|
|
271
|
+
} else {
|
|
272
|
+
console.log(output);
|
|
273
|
+
}
|
|
274
|
+
if (options.failOn && scan.summary.willReject > 0) process.exit(1);
|
|
275
|
+
process.exit(0);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if ((options.pack || options.npmSpecs.length || options.dirs.some((dir) => dir.endsWith('.tgz'))) && options.phase === 'author') {
|
|
279
|
+
fail('--pack, --from-npm, and .tgz inputs describe a published artifact; use --phase publish');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const cleanups = [];
|
|
283
|
+
process.on('exit', () => {
|
|
284
|
+
for (const cleanup of cleanups) {
|
|
285
|
+
try { cleanup(); } catch { /* best effort */ }
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const reports = [];
|
|
290
|
+
for (const spec of options.npmSpecs) {
|
|
291
|
+
try {
|
|
292
|
+
const packed = packFromNpm(spec);
|
|
293
|
+
cleanups.push(packed.cleanup);
|
|
294
|
+
const extracted = extractTarballTo(packed.tarball);
|
|
295
|
+
cleanups.push(extracted.cleanup);
|
|
296
|
+
reports.push(lintPackage(extracted.root, { dshVersion: options.dshVersion, phase: 'publish', source: packed.source }));
|
|
297
|
+
} catch (error) {
|
|
298
|
+
fail(error.message);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
for (const dir of options.dirs) {
|
|
302
|
+
if (dir.endsWith('.tgz')) {
|
|
303
|
+
try {
|
|
304
|
+
const extracted = extractTarballTo(resolve(dir));
|
|
305
|
+
cleanups.push(extracted.cleanup);
|
|
306
|
+
reports.push(lintPackage(extracted.root, { dshVersion: options.dshVersion, phase: 'publish', source: 'tarball:' + dir }));
|
|
307
|
+
} catch (error) {
|
|
308
|
+
fail(error.message);
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
for (const target of expandTargets(resolve(dir))) {
|
|
313
|
+
try {
|
|
314
|
+
if (options.pack) {
|
|
315
|
+
const packed = packDirectory(target, { manager: options.pack });
|
|
316
|
+
cleanups.push(packed.cleanup);
|
|
317
|
+
const extracted = extractTarballTo(packed.tarball);
|
|
318
|
+
cleanups.push(extracted.cleanup);
|
|
319
|
+
reports.push(lintPackage(extracted.root, { dshVersion: options.dshVersion, phase: 'publish', source: 'pack:' + packed.manager }));
|
|
320
|
+
} else {
|
|
321
|
+
reports.push(lintPackage(target, { dshVersion: options.dshVersion, phase: options.phase }));
|
|
322
|
+
}
|
|
323
|
+
} catch (error) {
|
|
324
|
+
fail(error.message);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
let active = reports;
|
|
330
|
+
let suppressed = 0;
|
|
331
|
+
let baseline;
|
|
332
|
+
const baselinePath = options.baselinePath || (options.writeBaseline ? '.dsh-plugin-lint-baseline.json' : undefined);
|
|
333
|
+
if (options.writeBaseline) {
|
|
334
|
+
baseline = createBaseline(reports);
|
|
335
|
+
writeBaseline(baselinePath, baseline);
|
|
336
|
+
console.error('dsh-plugin-lint: wrote baseline ' + baselinePath + ' (' + baseline.findings.length + ' finding(s))');
|
|
337
|
+
} else if (options.prune) {
|
|
338
|
+
baseline = pruneBaseline(readBaseline(baselinePath), reports);
|
|
339
|
+
writeBaseline(baselinePath, baseline);
|
|
340
|
+
console.error('dsh-plugin-lint: pruned baseline ' + baselinePath + ' (' + baseline.findings.length + ' finding(s))');
|
|
341
|
+
} else if (baselinePath && existsSync(baselinePath)) {
|
|
342
|
+
const applied = applyBaseline(reports, readBaseline(baselinePath), { path: baselinePath });
|
|
343
|
+
active = applied.reports;
|
|
344
|
+
suppressed = applied.suppressed;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function render() {
|
|
348
|
+
if (options.format === 'json') {
|
|
349
|
+
return JSON.stringify(options.envelope
|
|
350
|
+
? { schemaVersion: 1, reports: active, baseline: baselinePath ? { path: baselinePath, suppressed } : undefined }
|
|
351
|
+
: (active.length === 1 ? active[0] : active), null, 2);
|
|
352
|
+
}
|
|
353
|
+
if (options.format === 'sarif') return JSON.stringify(toSarif(active), null, 2);
|
|
354
|
+
if (options.format === 'github') return toGithub(active);
|
|
355
|
+
if (options.format === 'junit') return toJunit(active);
|
|
356
|
+
if (options.format === 'badge') return active.map((report) => toBadge(report)).join('\n');
|
|
357
|
+
const text = active.map((report) => (options.quiet
|
|
358
|
+
? report.target.name + ': ' + report.summary.error + ' error · ' + report.summary.warn + ' warn · ' + report.summary.info + ' info'
|
|
359
|
+
: formatText(report, { color: options.color }))).join('\n');
|
|
360
|
+
return suppressed > 0 ? text + '\n (' + suppressed + ' finding(s) suppressed by baseline)' : text;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const output = render();
|
|
364
|
+
if (options.outputFile) {
|
|
365
|
+
mkdirSync(dirname(resolve(options.outputFile)), { recursive: true });
|
|
366
|
+
writeFileSync(options.outputFile, output + '\n');
|
|
367
|
+
console.error('dsh-plugin-lint: wrote ' + options.outputFile);
|
|
368
|
+
} else if (output.length > 0) {
|
|
369
|
+
console.log(output);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const totals = active.reduce((sum, report) => {
|
|
373
|
+
sum.error += report.summary.error;
|
|
374
|
+
sum.warn += report.summary.warn;
|
|
375
|
+
sum.info += report.summary.info;
|
|
376
|
+
return sum;
|
|
377
|
+
}, { error: 0, warn: 0, info: 0 });
|
|
378
|
+
if (options.failOn === 'error' && totals.error > 0) process.exit(1);
|
|
379
|
+
if (options.failOn === 'warn' && (totals.error > 0 || totals.warn > 0)) process.exit(1);
|
|
380
|
+
if (options.failOn === 'info' && (totals.error > 0 || totals.warn > 0 || totals.info > 0)) process.exit(1);
|
|
381
|
+
if (options.maxWarnings !== undefined && totals.warn > options.maxWarnings) process.exit(1);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { exportPublicArtifact } from '../src/matrix/export.js';
|
|
5
|
+
import { syncMatrix } from '../src/matrix/sync.js';
|
|
6
|
+
import { runVerification } from '../src/matrix/verify.js';
|
|
7
|
+
|
|
8
|
+
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
9
|
+
const USAGE = [
|
|
10
|
+
'dsh-plugin-matrix — reproducible matrix data tools (experimental)',
|
|
11
|
+
'',
|
|
12
|
+
'Usage',
|
|
13
|
+
' dsh-plugin-matrix export --cells <dir> --conflicts <file> [--out matrix/public] [--run-id id] [--expected-cells n]',
|
|
14
|
+
' dsh-plugin-matrix sync --source <dir-or-https-url> --out <dir>',
|
|
15
|
+
' dsh-plugin-matrix verify [--cells dir] [--sample 0.2] [--seed 1] [--all] [--out report.json]',
|
|
16
|
+
'',
|
|
17
|
+
'Export validates and sanitizes local v2 evidence. Sync reads only bytes, verifies',
|
|
18
|
+
'hashes and schemas, then atomically replaces the output. Neither command executes artifacts.',
|
|
19
|
+
].join('\n');
|
|
20
|
+
|
|
21
|
+
const argv = process.argv.slice(2);
|
|
22
|
+
if (argv.includes('-h') || argv.includes('--help') || argv.length === 0) {
|
|
23
|
+
console.log(USAGE);
|
|
24
|
+
process.exit(argv.length === 0 ? 2 : 0);
|
|
25
|
+
}
|
|
26
|
+
const [command, ...rest] = argv;
|
|
27
|
+
const argOf = (name, fallback) => {
|
|
28
|
+
const index = rest.indexOf(name);
|
|
29
|
+
return index >= 0 ? rest[index + 1] : fallback;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
if (command === 'export') {
|
|
34
|
+
const result = await exportPublicArtifact({
|
|
35
|
+
cellsDir: argOf('--cells', join(ROOT, 'matrix/cells')),
|
|
36
|
+
conflictsPath: argOf('--conflicts', join(ROOT, 'matrix/index/conflicts.json')),
|
|
37
|
+
outDir: argOf('--out', join(ROOT, 'matrix/public')),
|
|
38
|
+
runId: argOf('--run-id', 'local-matrix'),
|
|
39
|
+
expectedCells: argOf('--expected-cells') === undefined ? undefined : Number(argOf('--expected-cells')),
|
|
40
|
+
});
|
|
41
|
+
console.log(`exported ${result.cells} cells and ${result.filesWritten} files`);
|
|
42
|
+
process.exitCode = 0;
|
|
43
|
+
} else if (command === 'sync') {
|
|
44
|
+
const source = argOf('--source');
|
|
45
|
+
const outDir = argOf('--out');
|
|
46
|
+
if (!source || !outDir) {
|
|
47
|
+
console.error('dsh-plugin-matrix: sync requires --source and --out');
|
|
48
|
+
process.exit(2);
|
|
49
|
+
}
|
|
50
|
+
const result = await syncMatrix({ source, outDir });
|
|
51
|
+
console.log(`synced ${result.filesWritten} files from ${source}`);
|
|
52
|
+
process.exitCode = 0;
|
|
53
|
+
} else if (command === 'verify') {
|
|
54
|
+
const out = argOf('--out', join(ROOT, 'matrix/index/verification.json'));
|
|
55
|
+
const { report } = await runVerification({
|
|
56
|
+
cellsDir: argOf('--cells', join(ROOT, 'matrix/cells')),
|
|
57
|
+
cacheRoot: argOf('--cache', join(ROOT, '.matrix-cache')),
|
|
58
|
+
sample: Number(argOf('--sample', '0.2')),
|
|
59
|
+
seed: Number(argOf('--seed', '1')),
|
|
60
|
+
all: rest.includes('--all'),
|
|
61
|
+
concurrency: Number(argOf('--concurrency', '2')),
|
|
62
|
+
backend: rest.includes('--unsafe-host') ? 'host-unsafe' : 'auto',
|
|
63
|
+
workflow: argOf('--workflow', 'local'),
|
|
64
|
+
runId: argOf('--run-id', 'local'),
|
|
65
|
+
runUrl: argOf('--run-url', ''),
|
|
66
|
+
out,
|
|
67
|
+
});
|
|
68
|
+
console.log(`checked ${report.checked} | confirmed ${report.confirmed} | disputed ${report.disputed} | errors ${report.errors}`);
|
|
69
|
+
console.log('report written to ' + out);
|
|
70
|
+
process.exitCode = report.errors > 0 ? 2 : 0;
|
|
71
|
+
} else {
|
|
72
|
+
console.error(USAGE);
|
|
73
|
+
process.exit(2);
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error(`${error.code || 'MATRIX_DATA_FAILED'}: ${error.message}`);
|
|
77
|
+
process.exitCode = 2;
|
|
78
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { loadPackage } from '../src/load.js';
|
|
5
|
+
import { classifyCompatibilityDeclaration } from '../src/upgrade/compatibility.js';
|
|
6
|
+
import { planUpgrade } from '../src/upgrade/planner.js';
|
|
7
|
+
|
|
8
|
+
const USAGE = [
|
|
9
|
+
'dsh-plugin-upgrade-plan — matrix-based DSH upgrade planner (experimental)',
|
|
10
|
+
'',
|
|
11
|
+
'Usage',
|
|
12
|
+
' dsh-plugin-upgrade-plan --from <version> --to <version> --installed <profile-dir> [options]',
|
|
13
|
+
' dsh-plugin-upgrade-plan --check-compatibility <plugin-dir> --target <version> [options]',
|
|
14
|
+
'',
|
|
15
|
+
'Options',
|
|
16
|
+
' --from <version> current DSH version (printed in the report)',
|
|
17
|
+
' --to <version> target DSH version (analysis mode, required)',
|
|
18
|
+
' --installed <dir> profile directory whose dsh.profile.bundles are checked',
|
|
19
|
+
' --matrix <dir> matrix cells directory or public artifact (default: ./matrix/cells)',
|
|
20
|
+
' --composition <mode> exact composition to use: headless or web',
|
|
21
|
+
' --check-compatibility <dir> plugin directory to check against --target',
|
|
22
|
+
' --target <version> target DSH version for --check-compatibility',
|
|
23
|
+
' --json print only the JSON report on stdout',
|
|
24
|
+
' --auto-fix print, but never execute, an npm install command',
|
|
25
|
+
' -h, --help show this message',
|
|
26
|
+
'',
|
|
27
|
+
'Exit codes',
|
|
28
|
+
' analysis: 0 no action needed · 1 update/no compatible version · 2 run failed',
|
|
29
|
+
' check: 0 compatible · 1 incompatible · 2 unknown/no declaration',
|
|
30
|
+
'',
|
|
31
|
+
'Only recent, verified, active evidence for the exact version, DSH, and composition',
|
|
32
|
+
'can produce an upgrade recommendation. Legacy, self-reported, mixed, or unknown',
|
|
33
|
+
'evidence is never upgraded to a safe conclusion.',
|
|
34
|
+
].join('\n');
|
|
35
|
+
|
|
36
|
+
function die(message) {
|
|
37
|
+
console.error('dsh-plugin-upgrade-plan: ' + message);
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseArgs(argv) {
|
|
42
|
+
const options = { json: false, autoFix: false, help: false };
|
|
43
|
+
const valueFlags = {
|
|
44
|
+
'--from': 'from',
|
|
45
|
+
'--to': 'to',
|
|
46
|
+
'--installed': 'installed',
|
|
47
|
+
'--check-compatibility': 'checkCompatibility',
|
|
48
|
+
'--target': 'target',
|
|
49
|
+
'--matrix': 'matrix',
|
|
50
|
+
'--composition': 'composition',
|
|
51
|
+
};
|
|
52
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
53
|
+
let arg = argv[index];
|
|
54
|
+
let inline;
|
|
55
|
+
const equals = arg.indexOf('=');
|
|
56
|
+
if (arg.startsWith('--') && equals > 2) {
|
|
57
|
+
inline = arg.slice(equals + 1);
|
|
58
|
+
arg = arg.slice(0, equals);
|
|
59
|
+
}
|
|
60
|
+
if (arg === '-h' || arg === '--help') { options.help = true; continue; }
|
|
61
|
+
if (arg === '--json') { options.json = true; continue; }
|
|
62
|
+
if (arg === '--auto-fix') { options.autoFix = true; continue; }
|
|
63
|
+
if (Object.hasOwn(valueFlags, arg)) {
|
|
64
|
+
const value = inline !== undefined ? inline : argv[++index];
|
|
65
|
+
if (value === undefined || value === '' || (inline === undefined && value.startsWith('-'))) die(arg + ' needs a value');
|
|
66
|
+
options[valueFlags[arg]] = value;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (arg.startsWith('-') && arg !== '-') die('unknown option: ' + arg);
|
|
70
|
+
die('unexpected argument: ' + arg);
|
|
71
|
+
}
|
|
72
|
+
return options;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function printAnalysis(report, options) {
|
|
76
|
+
const { summary, categories } = report;
|
|
77
|
+
console.log('\nDSH Upgrade Impact Analysis (experimental)');
|
|
78
|
+
console.log(` ${report.from} → ${report.to} · composition: ${report.composition || 'unknown'}\n`);
|
|
79
|
+
console.log(`Profile: ${report.profile.path} (${summary.total} plugin(s), source: ${report.profile.source})`);
|
|
80
|
+
console.log(`Matrix: ${report.matrix.dir} (${report.matrix.cells} cell(s), DSH versions: ${report.matrix.dshVersions.join(', ')})\n`);
|
|
81
|
+
console.log('='.repeat(70));
|
|
82
|
+
console.log('UPGRADE COMPATIBILITY REPORT');
|
|
83
|
+
console.log('='.repeat(70) + '\n');
|
|
84
|
+
if (summary.total === 0) console.log('No DSH plugins found in this profile; there is nothing to plan.\n');
|
|
85
|
+
for (const plugin of categories.compatible) console.log(`✅ ${plugin.name}@${plugin.version}: verified active on ${report.to}`);
|
|
86
|
+
for (const plugin of categories.untested) console.log(`❔ ${plugin.name}@${plugin.version}: ${plugin.reason}`);
|
|
87
|
+
for (const plugin of categories.mixed) console.log(`❔ ${plugin.name}@${plugin.version}: ${plugin.reason}`);
|
|
88
|
+
for (const plugin of categories.needsUpdate) {
|
|
89
|
+
console.log(`⚠️ ${plugin.name}: ${plugin.version} → ${plugin.recommendedVersion} (verified active on ${report.to})`);
|
|
90
|
+
}
|
|
91
|
+
if (options.autoFix && report.installCommand) console.log(`🔧 Auto-fix command:\n\n ${report.installCommand}\n`);
|
|
92
|
+
for (const plugin of categories.noCompatibleVersion) {
|
|
93
|
+
console.log(`❌ ${plugin.name}@${plugin.version}: no compatible tested version (tested: ${plugin.testedVersions.join(', ')})`);
|
|
94
|
+
}
|
|
95
|
+
for (const plugin of categories.unknown) console.log(`❓ ${plugin.name}${plugin.version ? '@' + plugin.version : ''}: ${plugin.reason}`);
|
|
96
|
+
for (const plugin of categories.firstParty) console.log(`🛡️ ${plugin.name}@${plugin.version}: first-party, not covered by the community matrix`);
|
|
97
|
+
console.log('\n📈 Summary:');
|
|
98
|
+
console.log(` Community plugins: ${summary.community}/${summary.total}`);
|
|
99
|
+
console.log(` Compatible: ${summary.compatible} · Need action: ${summary.needAction} · Unknown: ${summary.unknown} · Mixed: ${summary.mixed} · Untested: ${summary.untested}`);
|
|
100
|
+
console.log(` First-party: ${summary.firstParty}\n`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function runCheckCompatibility(options) {
|
|
104
|
+
const dir = options.checkCompatibility;
|
|
105
|
+
let pkg;
|
|
106
|
+
try { pkg = loadPackage(dir); } catch (error) { die('cannot load plugin ' + JSON.stringify(dir) + ': ' + error.message); }
|
|
107
|
+
const manifest = pkg.manifest || {};
|
|
108
|
+
const declared = manifest.dsh?.compatibility?.dshReleases;
|
|
109
|
+
const declaredRecord = declared !== null && typeof declared === 'object' && !Array.isArray(declared) ? declared : undefined;
|
|
110
|
+
const targetVersion = options.target;
|
|
111
|
+
let status;
|
|
112
|
+
let declaredStatus;
|
|
113
|
+
let reason;
|
|
114
|
+
if (declaredRecord === undefined || Object.keys(declaredRecord).length === 0) {
|
|
115
|
+
status = 'unknown';
|
|
116
|
+
reason = 'the plugin does not declare dsh.compatibility.dshReleases';
|
|
117
|
+
} else if (!Object.hasOwn(declaredRecord, targetVersion)) {
|
|
118
|
+
status = 'unknown';
|
|
119
|
+
reason = 'the plugin does not declare compatibility with DSH ' + targetVersion + ' (declares: ' + Object.keys(declaredRecord).join(', ') + ')';
|
|
120
|
+
} else {
|
|
121
|
+
declaredStatus = declaredRecord[targetVersion];
|
|
122
|
+
const classification = classifyCompatibilityDeclaration(declaredStatus);
|
|
123
|
+
status = classification;
|
|
124
|
+
reason = classification === 'unknown'
|
|
125
|
+
? 'the compatibility declaration for DSH ' + targetVersion + ' is pending or unrecognized'
|
|
126
|
+
: 'the plugin declares ' + classification + ' with DSH ' + targetVersion;
|
|
127
|
+
}
|
|
128
|
+
const payload = {
|
|
129
|
+
mode: 'check-compatibility',
|
|
130
|
+
plugin: { name: manifest.name, version: manifest.version, path: dir },
|
|
131
|
+
dshVersion: targetVersion,
|
|
132
|
+
status,
|
|
133
|
+
declaredStatus: declaredStatus ?? null,
|
|
134
|
+
declared: declaredRecord ?? null,
|
|
135
|
+
reason,
|
|
136
|
+
};
|
|
137
|
+
if (options.json) {
|
|
138
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
139
|
+
} else {
|
|
140
|
+
console.log(`\nChecking ${manifest.name || dir}@${manifest.version || 'unknown'} with DSH ${targetVersion}\n`);
|
|
141
|
+
console.log(reason);
|
|
142
|
+
if (declaredStatus !== undefined) console.log(' Status: ' + (typeof declaredStatus === 'string' ? declaredStatus : JSON.stringify(declaredStatus)));
|
|
143
|
+
console.log('\nExit codes: 0 compatible · 1 incompatible · 2 unknown/no data.\n');
|
|
144
|
+
}
|
|
145
|
+
process.exit(status === 'compatible' ? 0 : status === 'incompatible' ? 1 : 2);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function runAnalysis(options) {
|
|
149
|
+
if (options.from === undefined || options.to === undefined) die('analysis mode needs --from <version> and --to <version> (or use --check-compatibility)');
|
|
150
|
+
if (options.installed === undefined) die('analysis mode needs --installed <profile-dir>; the matrix alone cannot name installed plugins');
|
|
151
|
+
if (options.composition !== undefined && !['headless', 'web'].includes(options.composition)) die('--composition must be headless or web');
|
|
152
|
+
const matrixDir = options.matrix || join(process.cwd(), 'matrix', 'cells');
|
|
153
|
+
try {
|
|
154
|
+
const report = await planUpgrade({ profileDir: options.installed, matrixDir, from: options.from, to: options.to, composition: options.composition });
|
|
155
|
+
if (options.json) console.log(JSON.stringify(report, null, 2));
|
|
156
|
+
else printAnalysis(report, options);
|
|
157
|
+
process.exit(report.summary.needAction > 0 ? 1 : 0);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
die(error.message);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const options = parseArgs(process.argv.slice(2));
|
|
164
|
+
if (options.help) {
|
|
165
|
+
console.log(USAGE);
|
|
166
|
+
process.exit(0);
|
|
167
|
+
}
|
|
168
|
+
const checkMode = options.checkCompatibility !== undefined || options.target !== undefined;
|
|
169
|
+
const analysisMode = options.from !== undefined || options.to !== undefined || options.installed !== undefined;
|
|
170
|
+
if (checkMode && analysisMode) die('choose one mode: analysis (--from/--to/--installed) or --check-compatibility/--target');
|
|
171
|
+
if (options.matrix !== undefined && !existsSync(options.matrix)) die('matrix directory not found: ' + options.matrix);
|
|
172
|
+
if (checkMode) {
|
|
173
|
+
if (options.checkCompatibility === undefined) die('--target needs --check-compatibility <plugin-dir>');
|
|
174
|
+
if (options.target === undefined) die('--check-compatibility needs --target <version>');
|
|
175
|
+
runCheckCompatibility(options);
|
|
176
|
+
} else if (analysisMode) {
|
|
177
|
+
await runAnalysis(options);
|
|
178
|
+
} else {
|
|
179
|
+
die('missing arguments; run with --help for usage');
|
|
180
|
+
}
|