@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,95 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { extractTarball } from './tarball.js';
6
+
7
+ export function detectPackageManager(dir) {
8
+ if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
9
+ if (existsSync(join(dir, 'yarn.lock'))) return 'yarn';
10
+ if (existsSync(join(dir, 'bun.lockb')) || existsSync(join(dir, 'bun.lock'))) return 'bun';
11
+ return 'npm';
12
+ }
13
+
14
+ function run(command, args, cwd) {
15
+ const env = { ...process.env, npm_config_ignore_scripts: 'true', YARN_ENABLE_SCRIPTS: 'false' };
16
+ // Package managers are Node programs; under NODE_V8_COVERAGE they would add
17
+ // their own internals to the caller's coverage report.
18
+ delete env.NODE_V8_COVERAGE;
19
+ return execFileSync(command, args, {
20
+ cwd,
21
+ encoding: 'utf8',
22
+ stdio: ['ignore', 'pipe', 'pipe'],
23
+ env,
24
+ });
25
+ }
26
+
27
+ function findTarball(dir) {
28
+ const names = readdirSync(dir).filter((name) => name.endsWith('.tgz'));
29
+ if (names.length === 0) throw new Error('the package manager did not produce a .tgz');
30
+ return join(dir, names[0]);
31
+ }
32
+
33
+ /** Pack a local package and return the tarball path plus a cleanup handle. */
34
+ export function packDirectory(dir, { manager = 'auto', ignoreScripts = true } = {}) {
35
+ const pm = manager === 'auto' ? detectPackageManager(dir) : manager;
36
+ const out = mkdtempSync(join(tmpdir(), 'dsh-pack-'));
37
+ const flags = ignoreScripts ? ['--ignore-scripts'] : [];
38
+ try {
39
+ if (pm === 'npm') run('npm', ['pack', ...flags, '--pack-destination', out], dir);
40
+ else if (pm === 'pnpm') {
41
+ // pnpm pack has no --ignore-scripts flag; the config form is the supported switch.
42
+ const noScripts = ignoreScripts ? ['--config.ignore-scripts=true'] : [];
43
+ run('pnpm', ['pack', ...noScripts, '--pack-destination', out], dir);
44
+ } else if (pm === 'bun') run('bun', ['pm', 'pack', ...flags, '--destination', out], dir);
45
+ else if (pm === 'yarn') {
46
+ // Yarn Berry disables lifecycle scripts through YARN_ENABLE_SCRIPTS (set in run()).
47
+ const target = join(out, 'package.tgz');
48
+ try {
49
+ run('yarn', ['pack', '--filename', target], dir);
50
+ } catch {
51
+ run('yarn', ['pack', '--out', target], dir);
52
+ }
53
+ } else {
54
+ throw new Error('unknown package manager: ' + pm);
55
+ }
56
+ return { tarball: findTarball(out), manager: pm, cleanup: () => rmSync(out, { recursive: true, force: true }) };
57
+ } catch (error) {
58
+ rmSync(out, { recursive: true, force: true });
59
+ throw new Error('pack failed with ' + pm + ': ' + String(error.stderr || error.message).trim().slice(0, 400));
60
+ }
61
+ }
62
+
63
+ /** Download a published package with npm pack and return the tarball. */
64
+ export function packFromNpm(spec) {
65
+ const out = mkdtempSync(join(tmpdir(), 'dsh-npm-pack-'));
66
+ try {
67
+ run('npm', ['pack', spec, '--ignore-scripts', '--pack-destination', out], process.cwd());
68
+ return { tarball: findTarball(out), source: 'npm:' + spec, cleanup: () => rmSync(out, { recursive: true, force: true }) };
69
+ } catch (error) {
70
+ rmSync(out, { recursive: true, force: true });
71
+ throw new Error('npm pack ' + spec + ' failed: ' + String(error.stderr || error.message).trim().slice(0, 400));
72
+ }
73
+ }
74
+
75
+ export function findPackageRoot(dir) {
76
+ if (existsSync(join(dir, 'package.json'))) return dir;
77
+ const children = readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory());
78
+ for (const child of children) {
79
+ const path = join(dir, child.name);
80
+ if (existsSync(join(path, 'package.json'))) return path;
81
+ }
82
+ throw new Error('the tarball contains no package.json');
83
+ }
84
+
85
+ /** Extract a tarball into a fresh temp directory and return the package root. */
86
+ export function extractTarballTo(dir) {
87
+ const out = mkdtempSync(join(tmpdir(), 'dsh-tarball-'));
88
+ try {
89
+ extractTarball(readFileSync(dir), out);
90
+ return { root: findPackageRoot(out), cleanup: () => rmSync(out, { recursive: true, force: true }) };
91
+ } catch (error) {
92
+ rmSync(out, { recursive: true, force: true });
93
+ throw error;
94
+ }
95
+ }
@@ -0,0 +1,186 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { basename, join, relative } from 'node:path';
3
+
4
+ const SKIP_DIRS = new Set(['.git', 'node_modules', 'CVS', '.svn', '.hg']);
5
+ const REGEX_SPECIAL = new Set(['.', '+', '^', '$', '(', ')', '|', '{', '}', '\\', '/']);
6
+
7
+ function normalizePath(value) {
8
+ return String(value).replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
9
+ }
10
+
11
+ export function globToRegExp(pattern) {
12
+ const glob = normalizePath(pattern);
13
+ let source = '^';
14
+ for (let index = 0; index < glob.length; index += 1) {
15
+ const char = glob[index];
16
+ if (char === '*') {
17
+ if (glob[index + 1] === '*') {
18
+ while (glob[index + 1] === '*') index += 1;
19
+ if (glob[index + 1] === '/') {
20
+ index += 1;
21
+ source += '(?:.*/)?';
22
+ } else {
23
+ source += '.*';
24
+ }
25
+ } else {
26
+ source += '[^/]*';
27
+ }
28
+ continue;
29
+ }
30
+ if (char === '?') {
31
+ source += '[^/]';
32
+ continue;
33
+ }
34
+ if (char === '[') {
35
+ const close = glob.indexOf(']', index + 1);
36
+ if (close < 0) throw new Error(`unsupported glob: ${pattern}`);
37
+ let content = glob.slice(index + 1, close);
38
+ if (content.startsWith('!')) content = '^' + content.slice(1);
39
+ source += '[' + content + ']';
40
+ index = close;
41
+ continue;
42
+ }
43
+ source += REGEX_SPECIAL.has(char) ? '\\' + char : char;
44
+ }
45
+ return new RegExp(source + '$');
46
+ }
47
+
48
+ function matches(pattern, relPath) {
49
+ const normalized = normalizePath(pattern);
50
+ if (!normalized) return false;
51
+ if (normalized === relPath || relPath.startsWith(normalized + '/')) return true;
52
+ return normalized.includes('*') || normalized.includes('?') || normalized.includes('[')
53
+ ? globToRegExp(normalized).test(relPath)
54
+ : false;
55
+ }
56
+
57
+ function ruleState(rules, relPath, initial) {
58
+ let state = initial;
59
+ for (const raw of rules) {
60
+ let pattern = String(raw).trim();
61
+ if (!pattern) continue;
62
+ const negated = pattern.startsWith('!');
63
+ if (negated) pattern = pattern.slice(1);
64
+ if (matches(pattern, relPath)) state = !negated;
65
+ }
66
+ return state;
67
+ }
68
+
69
+ function intrinsic(relPath, main) {
70
+ if (relPath === 'package.json' || relPath === main) return true;
71
+ return /^(?:readme|license|licence|copying|notice)(?:\.|$)/i.test(relPath);
72
+ }
73
+
74
+ function alwaysIgnored(relPath) {
75
+ const name = basename(relPath);
76
+ return name === '.npmrc'
77
+ || name === '.gitignore'
78
+ || name === '.npmignore'
79
+ || name === '.DS_Store'
80
+ || name === 'npm-debug.log'
81
+ || name === 'yarn-error.log'
82
+ || name === 'package-lock.json'
83
+ || name === 'config.gypi'
84
+ || name === '.lock-wscript'
85
+ || /^\.wafpickle-/.test(name)
86
+ || /^\..*\.swp$/.test(name)
87
+ || name.endsWith('.orig');
88
+ }
89
+
90
+ function readIgnoreFile(path) {
91
+ try {
92
+ return readFileSync(path, 'utf8').split(/\r?\n/)
93
+ .map((line) => line.trim())
94
+ .filter((line) => line && !line.startsWith('#'));
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /** npm uses .npmignore in a directory, falling back to .gitignore. */
101
+ function ignoreFileIn(dir) {
102
+ const npmignore = join(dir, '.npmignore');
103
+ if (existsSync(npmignore)) return npmignore;
104
+ const gitignore = join(dir, '.gitignore');
105
+ if (existsSync(gitignore)) return gitignore;
106
+ return undefined;
107
+ }
108
+
109
+ export function buildPacklist(dir, manifest) {
110
+ const paths = new Set();
111
+ let complete = true;
112
+ if (manifest.files !== undefined && !Array.isArray(manifest.files)) complete = false;
113
+ if ((manifest.bundleDependencies || manifest.bundledDependencies || []).length > 0) complete = false;
114
+ const main = typeof manifest.main === 'string' && manifest.main ? normalizePath(manifest.main) : undefined;
115
+ if (main?.startsWith('../')) complete = false;
116
+
117
+ // npm-packlist disables the ROOT .npmignore/.gitignore as soon as package.json
118
+ // has a `files` array (the array is the complete selection). Nested ignore
119
+ // files still apply, which is why the groups are collected per directory below.
120
+ const hasFiles = Array.isArray(manifest.files);
121
+ const rootGroups = [];
122
+ if (!hasFiles) {
123
+ const rootFile = ignoreFileIn(dir);
124
+ if (rootFile) {
125
+ const rules = readIgnoreFile(rootFile);
126
+ if (rules === null) complete = false;
127
+ else rootGroups.push({ base: dir, rules });
128
+ }
129
+ }
130
+
131
+ function walk(current, inherited) {
132
+ let entries;
133
+ try {
134
+ entries = readdirSync(current, { withFileTypes: true });
135
+ } catch {
136
+ complete = false;
137
+ return;
138
+ }
139
+ let groups = inherited;
140
+ if (current !== dir) {
141
+ const nested = ignoreFileIn(current);
142
+ if (nested) {
143
+ const rules = readIgnoreFile(nested);
144
+ if (rules === null) complete = false;
145
+ else groups = [...inherited, { base: current, rules }];
146
+ }
147
+ }
148
+ for (const entry of entries) {
149
+ const absolute = join(current, entry.name);
150
+ const relPath = normalizePath(relative(dir, absolute));
151
+ if (entry.isSymbolicLink()) {
152
+ complete = false;
153
+ continue;
154
+ }
155
+ if (entry.isDirectory()) {
156
+ if (!SKIP_DIRS.has(entry.name)) walk(absolute, groups);
157
+ continue;
158
+ }
159
+ if (!entry.isFile() || alwaysIgnored(relPath)) continue;
160
+ try {
161
+ const included = manifest.files === undefined
162
+ ? true
163
+ : Array.isArray(manifest.files) && ruleState(manifest.files, relPath, false);
164
+ let ignored = false;
165
+ for (const group of groups) {
166
+ const rel = normalizePath(relative(group.base, absolute));
167
+ if (rel === '' || rel.startsWith('..')) continue;
168
+ if (ruleState(group.rules, rel, false)) {
169
+ ignored = true;
170
+ break;
171
+ }
172
+ }
173
+ if (intrinsic(relPath, main) || (included && !ignored)) paths.add(relPath);
174
+ } catch {
175
+ complete = false;
176
+ }
177
+ }
178
+ }
179
+
180
+ walk(dir, rootGroups);
181
+ return { paths, complete };
182
+ }
183
+
184
+ export function hasPackedFile(packlist, relPath) {
185
+ return packlist.paths.has(normalizePath(relPath));
186
+ }
@@ -0,0 +1,39 @@
1
+ import { satisfies } from './semver.js';
2
+
3
+ /** DSH peer declarations the host checks before importing a plugin. */
4
+ export function dshPeers(manifest) {
5
+ return Object.entries((manifest && manifest.peerDependencies) || {})
6
+ .filter(([name]) => name === '@deepseek-ai/dsh' || name.startsWith('@deepseek-ai/dsh-'))
7
+ .map(([name, range]) => ({ name, range: String(range) }));
8
+ }
9
+
10
+ /**
11
+ * Evaluate the host's admission rule for one target runtime.
12
+ *
13
+ * The host requires every DSH peer range to accept the single runtime version
14
+ * and lets prereleases participate. Missing peers impose no constraint; a range
15
+ * this module cannot decide is reported as 'unknown', never as a rejection.
16
+ */
17
+ export function evaluatePeers(manifest, runtime) {
18
+ const peers = dshPeers(manifest);
19
+ const result = {
20
+ runtime: runtime || null,
21
+ peers,
22
+ verdict: 'no-constraint',
23
+ unsatisfied: [],
24
+ undecidable: [],
25
+ };
26
+ if (!runtime || peers.length === 0) return result;
27
+ for (const peer of peers) {
28
+ // The host substitutes the runtime version for workspace:^/~/* before
29
+ // comparing, so those ranges always match.
30
+ if (['workspace:^', 'workspace:~', 'workspace:*'].includes(peer.range)) continue;
31
+ const verdict = satisfies(runtime, peer.range);
32
+ if (verdict === null) result.undecidable.push(peer);
33
+ else if (verdict === false) result.unsatisfied.push(peer);
34
+ }
35
+ result.verdict = result.unsatisfied.length > 0 ? 'will-reject'
36
+ : result.undecidable.length > 0 ? 'unknown'
37
+ : 'will-load';
38
+ return result;
39
+ }
@@ -0,0 +1,121 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { evaluatePeers } from './peer.js';
4
+
5
+ function readJson(path) {
6
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return undefined; }
7
+ }
8
+
9
+ /** The host gates on the app-boot package version; fall back to the dsh package. */
10
+ export function profileRuntimeVersion(profileDir, override) {
11
+ if (override) return override;
12
+ for (const path of [
13
+ join(profileDir, 'node_modules', '@deepseek-ai', 'dsh-app-boot', 'package.json'),
14
+ join(profileDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json'),
15
+ ]) {
16
+ const version = readJson(path)?.version;
17
+ if (typeof version === 'string' && version) return version;
18
+ }
19
+ return undefined;
20
+ }
21
+
22
+ function declaredBundles(manifest) {
23
+ const declared = manifest?.dsh?.profile?.bundles;
24
+ if (!Array.isArray(declared)) return [];
25
+ const names = [];
26
+ for (const entry of declared) {
27
+ const name = typeof entry === 'string'
28
+ ? entry
29
+ : entry?.name || entry?.id || entry?.package || entry?.bundle || entry?.plugin;
30
+ if (typeof name === 'string' && name && !names.includes(name)) names.push(name);
31
+ }
32
+ return names;
33
+ }
34
+
35
+ /** dsh.profile.bundles when present, otherwise every package in node_modules. */
36
+ export function installedBundles(profileDir) {
37
+ const manifest = readJson(join(profileDir, 'package.json'));
38
+ const declared = declaredBundles(manifest);
39
+ if (declared.length > 0) return declared;
40
+ const nodeModules = join(profileDir, 'node_modules');
41
+ if (!existsSync(nodeModules)) return [];
42
+ const names = [];
43
+ for (const entry of readdirSync(nodeModules, { withFileTypes: true })) {
44
+ if (entry.name.startsWith('.')) continue;
45
+ const path = join(nodeModules, entry.name);
46
+ if (entry.name.startsWith('@')) {
47
+ let children = [];
48
+ try { children = readdirSync(path, { withFileTypes: true }); } catch { children = []; }
49
+ for (const child of children) names.push(entry.name + '/' + child.name);
50
+ } else {
51
+ names.push(entry.name);
52
+ }
53
+ }
54
+ return names;
55
+ }
56
+
57
+ function allowCommand(profileDir, name, version, runtime) {
58
+ if (!version || !runtime) return undefined;
59
+ return 'dsh plugin --profile ' + basename(profileDir)
60
+ + ' allow-version ' + name + '@' + version
61
+ + ' --dsh-version ' + runtime + ' --accept-risk';
62
+ }
63
+
64
+ /**
65
+ * Offline admission scan of an installed profile. It reads manifests only; it
66
+ * never imports a plugin or touches the running host.
67
+ */
68
+ export function scanProfile(profileDir, { dshVersion } = {}) {
69
+ if (!existsSync(join(profileDir, 'package.json'))) {
70
+ throw new Error('not a profile directory (no package.json): ' + profileDir);
71
+ }
72
+ const runtime = profileRuntimeVersion(profileDir, dshVersion);
73
+ const plugins = [];
74
+ for (const name of installedBundles(profileDir)) {
75
+ const manifest = readJson(join(profileDir, 'node_modules', ...name.split('/'), 'package.json'));
76
+ if (!manifest) continue;
77
+ const peers = evaluatePeers(manifest, runtime);
78
+ plugins.push({
79
+ name,
80
+ version: typeof manifest.version === 'string' ? manifest.version : null,
81
+ firstParty: name.startsWith('@deepseek-ai/'),
82
+ runtime: runtime || null,
83
+ verdict: runtime ? peers.verdict : 'unknown',
84
+ unsatisfied: peers.unsatisfied,
85
+ undecidable: peers.undecidable,
86
+ allowCommand: peers.verdict === 'will-reject' ? allowCommand(profileDir, name, manifest.version, runtime) : undefined,
87
+ });
88
+ }
89
+ const summary = {
90
+ total: plugins.length,
91
+ willReject: plugins.filter((plugin) => plugin.verdict === 'will-reject').length,
92
+ willLoad: plugins.filter((plugin) => plugin.verdict === 'will-load').length,
93
+ noConstraint: plugins.filter((plugin) => plugin.verdict === 'no-constraint').length,
94
+ unknown: plugins.filter((plugin) => plugin.verdict === 'unknown').length,
95
+ firstParty: plugins.filter((plugin) => plugin.firstParty).length,
96
+ };
97
+ return { schemaVersion: 1, mode: 'profile-scan', profile: profileDir, runtime: runtime || null, plugins, summary };
98
+ }
99
+
100
+ export function formatProfileScan(scan) {
101
+ const lines = [
102
+ 'profile ' + scan.profile + ' · runtime ' + (scan.runtime || 'unknown'),
103
+ ' ' + scan.summary.total + ' bundle(s) · ' + scan.summary.willReject + ' will-reject · '
104
+ + scan.summary.willLoad + ' will-load · ' + scan.summary.unknown + ' unknown',
105
+ ];
106
+ for (const plugin of scan.plugins) {
107
+ if (plugin.firstParty && plugin.verdict !== 'will-reject') continue;
108
+ if (plugin.verdict === 'will-reject') {
109
+ lines.push(' ✗ ' + plugin.name + '@' + (plugin.version || '?') + ' will-reject');
110
+ for (const peer of plugin.unsatisfied) {
111
+ lines.push(' ' + peer.name + ' "' + peer.range + '" does not accept ' + scan.runtime);
112
+ }
113
+ if (plugin.allowCommand) lines.push(' grant an exact exemption: ' + plugin.allowCommand);
114
+ } else if (plugin.verdict === 'unknown') {
115
+ lines.push(' ? ' + plugin.name + '@' + (plugin.version || '?') + ' unknown (no runtime version or an undecidable range)');
116
+ } else {
117
+ lines.push(' ✓ ' + plugin.name + '@' + (plugin.version || '?') + ' ' + plugin.verdict);
118
+ }
119
+ }
120
+ return lines.join('\n');
121
+ }
@@ -0,0 +1,174 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { isAbsolute, join, relative } from 'node:path';
3
+ import { fingerprint } from './baseline.js';
4
+
5
+ const COLOR = {
6
+ error: '\u001b[31m',
7
+ warn: '\u001b[33m',
8
+ info: '\u001b[36m',
9
+ dim: '\u001b[2m',
10
+ bold: '\u001b[1m',
11
+ reset: '\u001b[0m',
12
+ };
13
+
14
+ const LABEL = { error: 'error', warn: 'warn ', info: 'info ' };
15
+
16
+ /**
17
+ * An SVG badge for a README. The badge carries the verdict, not just the brand:
18
+ * "0 error · 1 warn · compatible with 2 DSH versions". A reader should learn
19
+ * something from the badge alone.
20
+ */
21
+ export function toBadge(report) {
22
+ const s = report.summary;
23
+ const declared = (report.dsh && report.dsh.declared) || [];
24
+ const versionText = declared.length > 0 ? ' · ' + declared.length + ' DSH' : '';
25
+ const label = s.error + ' error · ' + s.warn + ' warn' + versionText;
26
+ const color = s.error > 0 ? '#cf222e' : s.warn > 0 ? '#bf8700' : '#1a7f37';
27
+ const left = 'dsh-plugin-lint';
28
+ const width = 90 + label.length * 6.2;
29
+ const leftWidth = 96;
30
+ const rightWidth = width - leftWidth;
31
+ return [
32
+ '<svg xmlns="http://www.w3.org/2000/svg" width="' + Math.round(width) + '" height="20" role="img" aria-label="' + left + ': ' + label + '">',
33
+ ' <title>' + left + ': ' + label + '</title>',
34
+ ' <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>',
35
+ ' <clipPath id="r"><rect width="' + Math.round(width) + '" height="20" rx="3" fill="#fff"/></clipPath>',
36
+ ' <g clip-path="url(#r)">',
37
+ ' <rect width="' + leftWidth + '" height="20" fill="#555"/>',
38
+ ' <rect x="' + leftWidth + '" width="' + Math.round(rightWidth) + '" height="20" fill="' + color + '"/>',
39
+ ' <rect width="' + Math.round(width) + '" height="20" fill="url(#s)"/>',
40
+ ' </g>',
41
+ ' <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">',
42
+ ' <text aria-hidden="true" x="' + (leftWidth * 5) + '" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="' + (leftWidth * 10 - 100) + '">' + left + '</text>',
43
+ ' <text x="' + (leftWidth * 5) + '" y="140" transform="scale(.1)" fill="#fff" textLength="' + (leftWidth * 10 - 100) + '">' + left + '</text>',
44
+ ' <text aria-hidden="true" x="' + ((leftWidth + rightWidth / 2) * 10) + '" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="' + (rightWidth * 10 - 100) + '">' + label + '</text>',
45
+ ' <text x="' + ((leftWidth + rightWidth / 2) * 10) + '" y="140" transform="scale(.1)" fill="#fff" textLength="' + (rightWidth * 10 - 100) + '">' + label + '</text>',
46
+ ' </g>',
47
+ '</svg>',
48
+ ].join('\n');
49
+ }
50
+
51
+ export function formatText(report, options = {}) {
52
+ const paint = (level, text) => (options.color ? COLOR[level] + text + COLOR.reset : text);
53
+ const dim = (text) => (options.color ? COLOR.dim + text + COLOR.reset : text);
54
+ const lines = [];
55
+ const t = report.target;
56
+ lines.push((options.color ? COLOR.bold : '') + t.name + '@' + t.version
57
+ + (options.color ? COLOR.reset : '') + dim(' ' + report.checks + ' checks'));
58
+ lines.push('');
59
+
60
+ if (report.findings.length === 0) {
61
+ lines.push(' ' + paint('info', 'clean') + ' no findings');
62
+ }
63
+ for (const f of report.findings) {
64
+ const where = f.file ? f.file + (f.line ? ':' + f.line : '') : '';
65
+ lines.push(' ' + paint(f.level, f.rule + ' ' + LABEL[f.level]) + ' ' + f.title);
66
+ if (where) lines.push(' ' + dim(where));
67
+ if (f.message) lines.push(' ' + f.message);
68
+ if (f.evidence) lines.push(' ' + dim('evidence: ' + f.evidence));
69
+ if (f.hint) lines.push(' ' + dim('why: ' + f.hint));
70
+ lines.push(' ' + dim('doc: ' + f.doc));
71
+ lines.push('');
72
+ }
73
+
74
+ const s = report.summary;
75
+ lines.push(' ' + s.error + ' error · ' + s.warn + ' warn · ' + s.info + ' info');
76
+ return lines.join('\n');
77
+ }
78
+
79
+ function uriFor(report, file) {
80
+ const absolute = join(report.target.path, file || 'package.json');
81
+ let cwd = process.cwd();
82
+ let target = absolute;
83
+ try { cwd = realpathSync(cwd); } catch { /* keep the logical path */ }
84
+ try { target = realpathSync(absolute); } catch { /* the finding may point at a virtual path */ }
85
+ const rel = relative(cwd, target).replace(/\\/g, '/');
86
+ return rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : target.replace(/\\/g, '/');
87
+ }
88
+
89
+ function ruleHelpUri(id) {
90
+ return 'https://github.com/shidesheng0218/dsh-plugin-lint/blob/main/docs/rules/' + id + '.md';
91
+ }
92
+
93
+ export function toSarif(reports) {
94
+ const list = Array.isArray(reports) ? reports : [reports];
95
+ const ruleIndex = new Map();
96
+ const rules = [];
97
+ const results = [];
98
+ for (const report of list) {
99
+ for (const f of report.findings) {
100
+ if (!ruleIndex.has(f.rule)) {
101
+ ruleIndex.set(f.rule, rules.length);
102
+ rules.push({
103
+ id: f.rule,
104
+ name: f.rule,
105
+ shortDescription: { text: f.title },
106
+ helpUri: ruleHelpUri(f.rule),
107
+ properties: { doc: f.doc, level: f.level },
108
+ });
109
+ }
110
+ results.push({
111
+ ruleId: f.rule,
112
+ ruleIndex: ruleIndex.get(f.rule),
113
+ level: f.level === 'error' ? 'error' : f.level === 'warn' ? 'warning' : 'note',
114
+ message: { text: f.message + (f.hint ? ' — ' + f.hint : '') },
115
+ partialFingerprints: { 'dshPluginLint/v1': fingerprint(f) },
116
+ locations: [{
117
+ physicalLocation: {
118
+ artifactLocation: { uri: uriFor(report, f.file), uriBaseId: '%SRCROOT%' },
119
+ region: { startLine: f.line || 1 },
120
+ },
121
+ }],
122
+ });
123
+ }
124
+ }
125
+ return {
126
+ version: '2.1.0',
127
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
128
+ runs: [{
129
+ tool: { driver: { name: 'dsh-plugin-lint', informationUri: 'https://github.com/shidesheng0218/dsh-plugin-lint', rules } },
130
+ results,
131
+ }],
132
+ };
133
+ }
134
+
135
+ function escapeData(value) {
136
+ return String(value).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
137
+ }
138
+
139
+ function escapeProperty(value) {
140
+ return escapeData(value).replace(/:/g, '%3A').replace(/,/g, '%2C');
141
+ }
142
+
143
+ export function toGithub(reports) {
144
+ const lines = [];
145
+ for (const report of reports) {
146
+ for (const f of report.findings) {
147
+ const level = f.level === 'error' ? 'error' : f.level === 'warn' ? 'warning' : 'notice';
148
+ const properties = ['file=' + escapeProperty(uriFor(report, f.file))];
149
+ if (f.line) properties.push('line=' + f.line);
150
+ properties.push('title=' + escapeProperty(f.rule + ' ' + f.title));
151
+ lines.push('::' + level + ' ' + properties.join(',') + '::' + escapeData(f.message + (f.hint ? ' — ' + f.hint : '')));
152
+ }
153
+ }
154
+ return lines.join('\n');
155
+ }
156
+
157
+ function xml(value) {
158
+ return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
159
+ }
160
+
161
+ export function toJunit(reports) {
162
+ const suites = reports.map((report) => {
163
+ const failures = report.findings.filter((finding) => finding.level !== 'info').length;
164
+ const cases = report.findings.map((finding) => {
165
+ const name = finding.rule + ' ' + (finding.file || 'package.json') + (finding.line ? ':' + finding.line : '');
166
+ const body = finding.level === 'info'
167
+ ? ''
168
+ : '<failure type="' + xml(finding.rule) + '" message="' + xml(finding.message) + '">' + xml(finding.evidence || '') + '</failure>';
169
+ return '<testcase classname="' + xml(report.target.name) + '" name="' + xml(name) + '">' + body + '</testcase>';
170
+ }).join('');
171
+ return '<testsuite name="' + xml(report.target.name) + '" tests="' + report.findings.length + '" failures="' + failures + '">' + cases + '</testsuite>';
172
+ }).join('');
173
+ return '<?xml version="1.0" encoding="UTF-8"?>\n<testsuites>' + suites + '</testsuites>';
174
+ }