@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.
Files changed (67) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.md +734 -2
  4. package/bin/dsh-plugin-analyze.mjs +378 -0
  5. package/bin/dsh-plugin-lint.mjs +381 -0
  6. package/bin/dsh-plugin-matrix.mjs +78 -0
  7. package/bin/dsh-plugin-upgrade-plan.mjs +180 -0
  8. package/package.json +62 -4
  9. package/schema/dsh-manifest.schema.json +134 -0
  10. package/scripts/lib/probe.mjs +34 -0
  11. package/scripts/probe-boot.mjs +123 -0
  12. package/src/composition.js +1 -0
  13. package/src/core/baseline.js +98 -0
  14. package/src/core/changed.js +41 -0
  15. package/src/core/load.js +145 -0
  16. package/src/core/mask.js +207 -0
  17. package/src/core/pack.js +95 -0
  18. package/src/core/packlist.js +186 -0
  19. package/src/core/peer.js +39 -0
  20. package/src/core/profile-scan.js +121 -0
  21. package/src/core/report.js +174 -0
  22. package/src/core/rules.js +581 -0
  23. package/src/core/semver.js +192 -0
  24. package/src/core/tarball.js +90 -0
  25. package/src/data/event-types.json +393 -0
  26. package/src/experimental/conflicts.js +315 -0
  27. package/src/experimental/index.js +4 -0
  28. package/src/experimental/performance.js +515 -0
  29. package/src/experimental/security.js +462 -0
  30. package/src/index.js +74 -0
  31. package/src/load.js +1 -0
  32. package/src/mask.js +1 -0
  33. package/src/matrix/cache.js +19 -0
  34. package/src/matrix/cell.js +79 -0
  35. package/src/matrix/cells.js +58 -0
  36. package/src/matrix/conflicts.js +210 -0
  37. package/src/matrix/export.js +155 -0
  38. package/src/matrix/index.js +71 -0
  39. package/src/matrix/manifest.js +17 -0
  40. package/src/matrix/migrate.js +41 -0
  41. package/src/matrix/run.js +241 -0
  42. package/src/matrix/sanitize.js +42 -0
  43. package/src/matrix/schema.js +178 -0
  44. package/src/matrix/site.js +61 -0
  45. package/src/matrix/sync.js +72 -0
  46. package/src/matrix/verify.js +152 -0
  47. package/src/performance.js +1 -0
  48. package/src/probe/artifact-resolver.js +161 -0
  49. package/src/probe/classify.js +8 -0
  50. package/src/probe/dsh-adapter.js +212 -0
  51. package/src/probe/environment/docker.js +270 -0
  52. package/src/probe/environment/host-unsafe.js +39 -0
  53. package/src/probe/environment/materialize.js +22 -0
  54. package/src/probe/environment.js +50 -0
  55. package/src/probe/installed-artifact.js +17 -0
  56. package/src/probe/probe.js +278 -0
  57. package/src/report.js +1 -0
  58. package/src/rules.js +1 -0
  59. package/src/security.js +1 -0
  60. package/src/shared/errors.js +7 -0
  61. package/src/shared/hash.js +31 -0
  62. package/src/shared/status.js +12 -0
  63. package/src/shared/validate.js +14 -0
  64. package/src/shared/version.js +4 -0
  65. package/src/upgrade/compatibility.js +14 -0
  66. package/src/upgrade/planner.js +119 -0
  67. package/src/upgrade/profile.js +82 -0
@@ -0,0 +1,207 @@
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
+ // Two cases the naive "previous character" test gets wrong:
29
+ // a++ / b - the first slash is division, and treating it as a regex would
30
+ // swallow the following "//" comment into the masked view.
31
+ // return /re/ - the previous character is a letter, but a keyword allows a
32
+ // regex here.
33
+ const REGEX_KEYWORDS = /(?:^|[^\w$])(?:return|typeof|instanceof|case|delete|void|throw|new|in|of|do|else|yield|await)$/;
34
+ const regexAllowed = (idx) => {
35
+ let k = idx - 1;
36
+ while (k >= 0 && (maskedChars[k] === SP || maskedChars[k] === NL || maskedChars[k] === '\t')) k -= 1;
37
+ if (k < 0) return true;
38
+ const prev = maskedChars[k];
39
+ if ('(,=:[!&|?{};+-*%<>~^'.indexOf(prev) >= 0) {
40
+ if ((prev === '+' || prev === '-') && maskedChars[k - 1] === prev) return false;
41
+ return true;
42
+ }
43
+ if (!/[\w$]/.test(prev)) return false;
44
+ let wordStart = k;
45
+ while (wordStart >= 0 && /[\w$]/.test(maskedChars[wordStart])) wordStart -= 1;
46
+ return REGEX_KEYWORDS.test(maskedChars.slice(wordStart + 1, k + 1).join(''));
47
+ };
48
+
49
+ let i = 0;
50
+ const n = text.length;
51
+ while (i < n) {
52
+ const c = text[i];
53
+ const next = text[i + 1];
54
+
55
+ if (c === '/' && next === '/') {
56
+ const from = i;
57
+ while (i < n && text[i] !== NL) i++;
58
+ blank(from, i, maskedChars);
59
+ blank(from, i, structuralChars);
60
+ continue;
61
+ }
62
+ if (c === '/' && next === '*') {
63
+ const from = i;
64
+ i += 2;
65
+ while (i < n && !(text[i] === '*' && text[i + 1] === '/')) i++;
66
+ i = Math.min(n, i + 2);
67
+ blank(from, i, maskedChars);
68
+ blank(from, i, structuralChars);
69
+ continue;
70
+ }
71
+
72
+ // The literal delimiter itself, not a boolean: the closing delimiter must
73
+ // compare equal to it.
74
+ const quote = c === '"' ? '"' : c === "'" ? "'" : c === BACKTICK ? BACKTICK : undefined;
75
+ if (quote !== undefined) {
76
+ const from = i;
77
+ i += 1;
78
+ const contentFrom = i;
79
+ while (i < n) {
80
+ if (text[i] === '\\') { i += 2; continue; }
81
+ if (text[i] === quote) break;
82
+ if (text[i] === NL && quote !== BACKTICK) break; // unterminated literal
83
+ i += 1;
84
+ }
85
+ const contentTo = Math.min(i, n);
86
+ const end = Math.min(i + 1, n);
87
+ strings.push({ value: text.slice(contentFrom, contentTo), start: from, end, line: lineOfIndex(lineStarts, from) });
88
+ blank(contentFrom, contentTo, structuralChars);
89
+ i = end;
90
+ continue;
91
+ }
92
+
93
+ if (c === '/' && regexAllowed(i)) {
94
+ const from = i;
95
+ i += 1;
96
+ let inClass = false;
97
+ while (i < n) {
98
+ if (text[i] === '\\') { i += 2; continue; }
99
+ if (text[i] === '[') inClass = true;
100
+ else if (text[i] === ']') inClass = false;
101
+ else if (text[i] === '/' && !inClass) { i += 1; break; }
102
+ else if (text[i] === NL) break;
103
+ i += 1;
104
+ }
105
+ // A regex body is data, not code: braces and quotes inside it must not
106
+ // reach the structural view, and a literal "session.append(" inside it
107
+ // must not look like a call site.
108
+ blank(from, i, maskedChars);
109
+ blank(from, i, structuralChars);
110
+ continue;
111
+ }
112
+ i += 1;
113
+ }
114
+ return { masked: maskedChars.join(''), structural: structuralChars.join(''), strings, lineStarts };
115
+ }
116
+
117
+ export function lineOfIndex(lineStarts, index) {
118
+ let lo = 0;
119
+ let hi = lineStarts.length - 1;
120
+ while (lo < hi) {
121
+ const mid = (lo + hi + 1) >> 1;
122
+ if (lineStarts[mid] <= index) lo = mid;
123
+ else hi = mid - 1;
124
+ }
125
+ return lo + 1;
126
+ }
127
+
128
+ /** The first string literal starting at or after \`from\` within \`window\` characters. */
129
+ export function stringAfter(strings, from, window) {
130
+ for (const s of strings) {
131
+ if (s.start >= from) return s.start - from <= window ? s : undefined;
132
+ }
133
+ return undefined;
134
+ }
135
+
136
+ /** Literal types passed to Session.append(type, data). */
137
+ export function appendCallSites(masked, strings) {
138
+ const out = [];
139
+ // Session receivers the host exposes: bare `session`, ctx.session,
140
+ // agent.session and this.session. A property named `session` on an unrelated
141
+ // object (formData.session) is not a Session, so only these names count.
142
+ const re = /(?<![\w$.])(?:(?:ctx|agent|this)\s*\.\s*)?session\s*\.\s*append\s*\(\s*/g;
143
+ let m;
144
+ while ((m = re.exec(masked)) !== null) {
145
+ if (insideString(strings, m.index)) continue;
146
+ const s = stringAt(strings, m.index + m[0].length);
147
+ if (!s) continue;
148
+ if (s.value.includes('${')) continue;
149
+ out.push({ type: s.value, index: s.start, line: s.line });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /** True when `index` falls inside a string literal (where the text is data). */
155
+ function insideString(strings, index) {
156
+ let lo = 0;
157
+ let hi = strings.length - 1;
158
+ while (lo <= hi) {
159
+ const mid = (lo + hi) >> 1;
160
+ const s = strings[mid];
161
+ if (index < s.start) hi = mid - 1;
162
+ else if (index >= s.end) lo = mid + 1;
163
+ else return true;
164
+ }
165
+ return false;
166
+ }
167
+
168
+ /** The string literal whose opening quote is exactly at `index`, if any. */
169
+ function stringAt(strings, index) {
170
+ let lo = 0;
171
+ let hi = strings.length - 1;
172
+ while (lo <= hi) {
173
+ const mid = (lo + hi) >> 1;
174
+ const s = strings[mid];
175
+ if (s.start < index) lo = mid + 1;
176
+ else if (s.start > index) hi = mid - 1;
177
+ else return s;
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ /** The balanced brace block that starts at or after `index`, on the structural view. */
183
+ export function blockAfter(structural, index) {
184
+ const open = structural.indexOf('{', index);
185
+ if (open < 0) return undefined;
186
+ let depth = 0;
187
+ for (let i = open; i < structural.length; i++) {
188
+ const c = structural[i];
189
+ if (c === '{') depth += 1;
190
+ else if (c === '}') {
191
+ depth -= 1;
192
+ if (depth === 0) return { start: open, end: i + 1, body: structural.slice(open + 1, i) };
193
+ }
194
+ }
195
+ return undefined;
196
+ }
197
+
198
+ /** Brace depth at a character index, computed on the structural view. */
199
+ export function braceDepthAt(structural, index) {
200
+ let depth = 0;
201
+ for (let k = 0; k < index && k < structural.length; k++) {
202
+ const c = structural[k];
203
+ if (c === '{' || c === '(' || c === '[') depth += 1;
204
+ else if (c === '}' || c === ')' || c === ']') depth -= 1;
205
+ }
206
+ return depth;
207
+ }
@@ -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
+ }