@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,462 @@
1
+ /**
2
+ * Security deep analysis rules — EXPERIMENTAL.
3
+ *
4
+ * These heuristics go beyond capability disclosure and look at how a capability
5
+ * appears to be used. They are static pattern matches over source text, not
6
+ * runtime observation: every rule caps at warning level, a finding is a review
7
+ * hint rather than a verdict, and a clean run says nothing about safety.
8
+ *
9
+ * @module security
10
+ */
11
+
12
+ import { EXPERIMENTAL_SCHEMA_VERSION } from '../shared/version.js';
13
+
14
+ /**
15
+ * Security-focused rule definitions for the lint engine.
16
+ * These extend the base RULES with deeper (experimental) analysis.
17
+ */
18
+ export const SECURITY_RULES = [
19
+ {
20
+ id: 'S1',
21
+ level: 'warn',
22
+ title: 'session data sent to external domains',
23
+ category: 'data-exfiltration',
24
+ check(ctx, options, push) {
25
+ for (const file of ctx.jsFiles) {
26
+ const exfiltrations = detectDataExfiltration(file, ['session', 'agent', 'messages']);
27
+ for (const ex of exfiltrations) {
28
+ push({
29
+ file: file.path,
30
+ line: ex.line,
31
+ message: `Potentially sending ${ex.dataType} to external URL: ${ex.url}`,
32
+ evidence: ex.snippet,
33
+ hint: 'Review whether sensitive session data should be transmitted externally. Ensure proper user consent and encryption.',
34
+ });
35
+ }
36
+ }
37
+ },
38
+ },
39
+
40
+ {
41
+ id: 'S2',
42
+ level: 'warn',
43
+ title: 'credentials stored in localStorage or sessionStorage',
44
+ category: 'credential-handling',
45
+ check(ctx, options, push) {
46
+ for (const file of ctx.jsFiles) {
47
+ const storagePattern = /(localStorage|sessionStorage)\s*\.\s*setItem\s*\(\s*['"]([^'"]*(?:token|key|secret|password|auth|credential)[^'"]*)['"]/gi;
48
+ let m;
49
+ while ((m = storagePattern.exec(file.masked)) !== null) {
50
+ push({
51
+ file: file.path,
52
+ line: lineOfIndex(file.masked, m.index),
53
+ message: `Storing sensitive data (${m[2]}) in ${m[1]}`,
54
+ evidence: m[0],
55
+ hint: 'Browser storage is not encrypted and accessible to all scripts. Use secure credential storage APIs or context-provided auth mechanisms.',
56
+ });
57
+ }
58
+ }
59
+ },
60
+ },
61
+
62
+ {
63
+ id: 'S3',
64
+ level: 'warn',
65
+ title: 'eval() or Function() constructor used',
66
+ category: 'code-injection',
67
+ check(ctx, options, push) {
68
+ for (const file of ctx.jsFiles) {
69
+ // The structural view blanks string interiors, so a documented "eval(" in
70
+ // a string literal is not mistaken for a call site.
71
+ const source = file.structural ?? file.masked ?? file.text;
72
+ const evalPattern = /\beval\s*\(/g;
73
+ const functionPattern = /\bnew\s+Function\s*\(/g;
74
+
75
+ let m;
76
+ while ((m = evalPattern.exec(source)) !== null) {
77
+ push({
78
+ file: file.path,
79
+ line: lineOfIndex(file.masked, m.index),
80
+ message: 'Use of eval() detected',
81
+ evidence: extractContext(file.text, m.index, 50),
82
+ hint: 'eval() executes arbitrary code and can be a security risk. Consider safer alternatives like JSON.parse() or structured data processing.',
83
+ });
84
+ }
85
+
86
+ while ((m = functionPattern.exec(source)) !== null) {
87
+ push({
88
+ file: file.path,
89
+ line: lineOfIndex(file.masked, m.index),
90
+ message: 'Use of Function() constructor detected',
91
+ evidence: extractContext(file.text, m.index, 50),
92
+ hint: 'Dynamic function construction can execute arbitrary code. Consider structured alternatives.',
93
+ });
94
+ }
95
+ }
96
+ },
97
+ },
98
+
99
+ {
100
+ id: 'S4',
101
+ level: 'warn',
102
+ title: 'innerHTML used with unsanitized data',
103
+ category: 'xss',
104
+ check(ctx, options, push) {
105
+ const clientFile = ctx.jsFiles.find((f) => f.path === ctx.entries?.client);
106
+ if (!clientFile) return;
107
+
108
+ // The masked view keeps string literals intact but blanks comments, so a
109
+ // documented example in a comment is not a finding.
110
+ const source = clientFile.masked ?? clientFile.text;
111
+ const innerHTMLPattern = /\.innerHTML\s*=\s*([^;]+)/g;
112
+ let m;
113
+
114
+ while ((m = innerHTMLPattern.exec(source)) !== null) {
115
+ const assignment = m[1].trim();
116
+ const isSanitized = /\b(?:DOMPurify|sanitize|escapeHtml)\b/.test(assignment);
117
+ if (isSanitized) continue;
118
+ // A plain string literal is safe; a template literal is safe only when it
119
+ // has no interpolation. Everything else is dynamic and worth a look.
120
+ const plainString = /^(?:'[^']*'|"[^"]*")\s*$/.test(assignment);
121
+ if (plainString) continue;
122
+ const template = /^`([\s\S]*)`\s*$/.exec(assignment);
123
+ if (template && !/\$\{/.test(template[1])) continue;
124
+ push({
125
+ file: clientFile.path,
126
+ line: lineOfIndex(clientFile.text, m.index),
127
+ message: 'innerHTML assigned a value that is not a static string',
128
+ evidence: m[0],
129
+ hint: 'Use textContent for plain text, or sanitize HTML with a library like DOMPurify before assigning to innerHTML.',
130
+ });
131
+ }
132
+ },
133
+ },
134
+
135
+ {
136
+ id: 'S5',
137
+ level: 'info',
138
+ title: 'makes network requests to external domains',
139
+ category: 'network-integrity',
140
+ check(ctx, options, push) {
141
+ // The DSH manifest has no declared-capability field, so this rule cannot
142
+ // compare against a declaration. It reports what the code does, as an
143
+ // inventory entry, and says nothing about whether that use is appropriate.
144
+ const seen = new Map();
145
+ const domainPattern = /(?:fetch|axios)\s*(?:\.\s*(?:get|post|put|delete))?\s*\(\s*['"`](https?:\/\/[^'"`]+)['"`]/g;
146
+
147
+ for (const file of ctx.jsFiles) {
148
+ let m;
149
+ while ((m = domainPattern.exec(file.masked ?? file.text)) !== null) {
150
+ try {
151
+ const url = new URL(m[1]);
152
+ if (!seen.has(url.hostname)) {
153
+ seen.set(url.hostname, { file: file.path, line: lineOfIndex(file.text, m.index), url: m[1] });
154
+ }
155
+ } catch {
156
+ // Invalid URL
157
+ }
158
+ }
159
+ }
160
+
161
+ for (const [domain, site] of [...seen.entries()].slice(0, 10)) {
162
+ push({
163
+ file: site.file,
164
+ line: site.line,
165
+ message: 'calls ' + site.url,
166
+ evidence: domain,
167
+ hint: 'Observed literal URL in the code. This is an inventory entry, not a declaration check: the DSH manifest has no dsh.capabilities field.',
168
+ });
169
+ }
170
+ },
171
+ },
172
+
173
+ {
174
+ id: 'S6',
175
+ level: 'warn',
176
+ title: 'shell command injection risk',
177
+ category: 'command-injection',
178
+ check(ctx, options, push) {
179
+ for (const file of ctx.jsFiles) {
180
+ // Check for exec/spawn with string interpolation
181
+ const execPatterns = [
182
+ /exec\s*\(\s*`[^`]*\$\{/g,
183
+ /spawn\s*\(\s*`[^`]*\$\{/g,
184
+ /exec\s*\(\s*['"][^'"]*\+/g,
185
+ ];
186
+
187
+ for (const pattern of execPatterns) {
188
+ let m;
189
+ while ((m = pattern.exec(file.masked)) !== null) {
190
+ push({
191
+ file: file.path,
192
+ line: lineOfIndex(file.masked, m.index),
193
+ message: 'Potential command injection: shell command built with string concatenation',
194
+ evidence: extractContext(file.text, m.index, 60),
195
+ hint: 'Use spawn() with array arguments instead of exec() with strings. Validate and sanitize all user input.',
196
+ });
197
+ }
198
+ }
199
+ }
200
+ },
201
+ },
202
+
203
+ {
204
+ id: 'S7',
205
+ level: 'warn',
206
+ title: 'file system operations outside workspace',
207
+ category: 'fs-access',
208
+ check(ctx, options, push) {
209
+ for (const file of ctx.jsFiles) {
210
+ // Look for fs operations with hardcoded absolute paths
211
+ const fsWithAbsPath = /(?:readFile|writeFile|unlink|rmdir|rm)\s*\(\s*['"`](\/[^'"`]+|[A-Z]:\\[^'"`]+)['"`]/g;
212
+ let m;
213
+
214
+ while ((m = fsWithAbsPath.exec(file.masked ?? file.text)) !== null) {
215
+ // Exclude common safe paths
216
+ const path = m[1];
217
+ if (path.startsWith('/tmp') || path.startsWith('/var/tmp')) continue;
218
+
219
+ push({
220
+ file: file.path,
221
+ line: lineOfIndex(file.masked, m.index),
222
+ message: `File system access to hardcoded absolute path: ${path}`,
223
+ evidence: extractContext(file.text, m.index, 60),
224
+ hint: 'Plugins should operate within the workspace. Use ctx.workspace.root or relative paths.',
225
+ });
226
+ }
227
+ }
228
+ },
229
+ },
230
+
231
+ {
232
+ id: 'S8',
233
+ level: 'info',
234
+ title: 'uses cryptographic functions',
235
+ category: 'crypto-usage',
236
+ check(ctx, options, push) {
237
+ for (const file of ctx.jsFiles) {
238
+ const cryptoPattern = /\bcrypto\s*\.\s*(createHash|createHmac|createCipher|randomBytes|pbkdf2|scrypt)/g;
239
+ const findings = new Map();
240
+
241
+ let m;
242
+ while ((m = cryptoPattern.exec(file.masked ?? file.text)) !== null) {
243
+ const fn = m[1];
244
+ if (!findings.has(fn)) findings.set(fn, []);
245
+ findings.get(fn).push(lineOfIndex(file.masked, m.index));
246
+ }
247
+
248
+ for (const [fn, lines] of findings) {
249
+ push({
250
+ file: file.path,
251
+ line: lines[0],
252
+ message: `Uses crypto.${fn}() (${lines.length} occurrence(s))`,
253
+ evidence: `Lines: ${lines.slice(0, 5).join(', ')}`,
254
+ hint: 'Ensure cryptographic operations use secure parameters (e.g., sufficient iterations for pbkdf2, secure algorithm for createCipher).',
255
+ });
256
+ }
257
+ }
258
+ },
259
+ },
260
+
261
+ {
262
+ id: 'S9',
263
+ level: 'warn',
264
+ title: 'accesses process.env without validation',
265
+ category: 'environment-access',
266
+ check(ctx, options, push) {
267
+ for (const file of ctx.jsFiles) {
268
+ // The structural view blanks string interiors: a documented variable name
269
+ // inside a string is not an access.
270
+ const source = file.structural ?? file.masked ?? file.text;
271
+ const envPattern = /process\s*\.\s*env\s*\.\s*([A-Z_][A-Z0-9_]*)/g;
272
+ const accessed = new Set();
273
+
274
+ let m;
275
+ while ((m = envPattern.exec(source)) !== null) {
276
+ accessed.add(m[1]);
277
+ }
278
+
279
+ if (accessed.size > 5) {
280
+ push({
281
+ file: file.path,
282
+ line: 1,
283
+ message: `Accesses ${accessed.size} environment variables`,
284
+ evidence: `Variables: ${Array.from(accessed).slice(0, 10).join(', ')}`,
285
+ hint: 'Validate and provide defaults for environment variables. Document required environment variables in README.',
286
+ });
287
+ }
288
+ }
289
+ },
290
+ },
291
+
292
+ {
293
+ id: 'S10',
294
+ level: 'warn',
295
+ title: 'uses insecure HTTP in production code',
296
+ category: 'transport-security',
297
+ check(ctx, options, push) {
298
+ // Only a URL used as a request target counts: an XML namespace such as
299
+ // "http://www.w3.org/2000/svg" is a string literal, not a network call.
300
+ const requestPattern = /(?:fetch|axios|https?\s*\.\s*request|new\s+URL)\s*(?:\.\s*\w+\s*)?\(\s*['"`](http:\/\/(?!localhost|127\.0\.0\.1|0\.0\.0\.0)[^'"`]+)['"`]/g;
301
+ for (const file of ctx.jsFiles) {
302
+ const source = file.masked ?? file.text;
303
+ let m;
304
+
305
+ while ((m = requestPattern.exec(source)) !== null) {
306
+ push({
307
+ file: file.path,
308
+ line: lineOfIndex(file.text, m.index),
309
+ message: `Insecure HTTP request target: ${m[1]}`,
310
+ evidence: extractContext(file.text, m.index, 50),
311
+ hint: 'Use HTTPS for network requests to keep data integrity and confidentiality.',
312
+ });
313
+ }
314
+ }
315
+ },
316
+ },
317
+ ];
318
+
319
+ export const SECURITY_LIMITS = [
320
+ 'Experimental static heuristics over source text: a finding describes a pattern, not observed behaviour, and is not a security verdict.',
321
+ 'Rules read the masked source: comments are blanked; string literals stay intact where a URL or storage key is itself the evidence.',
322
+ 'No rule in this module is error-level, and a clean run does not mean the plugin is safe.',
323
+ ];
324
+
325
+ /**
326
+ * Detect potential data exfiltration patterns.
327
+ */
328
+ function detectDataExfiltration(file, sensitiveDataTypes) {
329
+ const results = [];
330
+
331
+ // Look for fetch/axios calls that reference sensitive data
332
+ const fetchPattern = /(?:fetch|axios\s*\.\s*(?:post|put))\s*\(\s*['"`]([^'"`]+)['"`][^)]*\{[^}]*body[^}]*:/g;
333
+
334
+ let m;
335
+ while ((m = fetchPattern.exec(file.masked)) !== null) {
336
+ const url = m[1];
337
+ const callSite = m.index;
338
+ const contextEnd = Math.min(callSite + 300, file.masked.length);
339
+ const context = file.masked.slice(callSite, contextEnd);
340
+
341
+ // Check if sensitive data types appear in the context
342
+ for (const dataType of sensitiveDataTypes) {
343
+ const dataPattern = new RegExp(`\\b${dataType}\\b`, 'i');
344
+ if (dataPattern.test(context)) {
345
+ // Check if URL is external
346
+ if (url.startsWith('http://') || url.startsWith('https://')) {
347
+ results.push({
348
+ line: lineOfIndex(file.masked, callSite),
349
+ url,
350
+ dataType,
351
+ snippet: extractContext(file.text, callSite, 100)
352
+ });
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ return results;
359
+ }
360
+
361
+ /**
362
+ * Extract context around a position for evidence display.
363
+ */
364
+ function extractContext(text, index, maxLen) {
365
+ const start = Math.max(0, index - Math.floor(maxLen / 2));
366
+ const end = Math.min(text.length, index + Math.ceil(maxLen / 2));
367
+ let snippet = text.slice(start, end);
368
+
369
+ // Clean up for display
370
+ snippet = snippet.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
371
+
372
+ if (start > 0) snippet = '...' + snippet;
373
+ if (end < text.length) snippet = snippet + '...';
374
+
375
+ return snippet;
376
+ }
377
+
378
+ /**
379
+ * Find line number from character index.
380
+ */
381
+ function lineOfIndex(text, index) {
382
+ return text.slice(0, index).split('\n').length;
383
+ }
384
+
385
+ /**
386
+ * Run security analysis on a loaded package.
387
+ */
388
+ export function analyzeSecurityDeep(pkg) {
389
+ const findings = [];
390
+
391
+ const ctx = {
392
+ ...pkg,
393
+ byPath: (rel) => pkg.jsFiles.find((f) => f.path === rel),
394
+ };
395
+
396
+ for (const rule of SECURITY_RULES) {
397
+ const push = (finding) => {
398
+ findings.push({
399
+ rule: rule.id,
400
+ level: rule.level,
401
+ title: rule.title,
402
+ category: rule.category,
403
+ experimental: true,
404
+ ...finding
405
+ });
406
+ };
407
+
408
+ try {
409
+ rule.check(ctx, {}, push);
410
+ } catch (error) {
411
+ findings.push({
412
+ rule: rule.id,
413
+ level: 'info',
414
+ title: rule.title,
415
+ category: rule.category,
416
+ experimental: true,
417
+ file: 'package.json',
418
+ line: 1,
419
+ message: 'Security rule did not complete: ' + error.message,
420
+ evidence: '',
421
+ hint: 'This may be a bug in dsh-plugin-lint.',
422
+ });
423
+ }
424
+ }
425
+
426
+ // Categorize findings
427
+ const byCategory = {};
428
+ for (const finding of findings) {
429
+ const cat = finding.category || 'other';
430
+ if (!byCategory[cat]) byCategory[cat] = [];
431
+ byCategory[cat].push(finding);
432
+ }
433
+
434
+ const summary = {
435
+ total: findings.length,
436
+ error: findings.filter(f => f.level === 'error').length,
437
+ warn: findings.filter(f => f.level === 'warn').length,
438
+ info: findings.filter(f => f.level === 'info').length,
439
+ };
440
+
441
+ return {
442
+ schemaVersion: EXPERIMENTAL_SCHEMA_VERSION,
443
+ experimental: true,
444
+ findings,
445
+ byCategory,
446
+ summary,
447
+ riskLevel: assessOverallRisk(summary, byCategory),
448
+ limits: SECURITY_LIMITS,
449
+ };
450
+ }
451
+
452
+ /**
453
+ * Assess overall security risk level.
454
+ */
455
+ function assessOverallRisk(summary, byCategory) {
456
+ // Security findings are heuristic, so none of them is error-level; the risk
457
+ // level is a summary of how many warnings a package carries, not a verdict.
458
+ if (summary.warn >= 10) return 'high';
459
+ if (summary.warn >= 4) return 'medium';
460
+ if (summary.warn >= 1) return 'low';
461
+ return 'minimal';
462
+ }
package/src/index.js CHANGED
@@ -1,10 +1,13 @@
1
- import { loadPackage } from './load.js';
2
- import { RULES, EVENT_TYPES, localeMeta, DOC } from './rules.js';
1
+ import { loadPackage } from './core/load.js';
2
+ import { buildPacklist } from './core/packlist.js';
3
+ import { RULES, EVENT_TYPES, localeMeta, DOC } from './core/rules.js';
4
+ import { rulesetIdentity } from './core/baseline.js';
5
+ import { ToolError } from './shared/errors.js';
3
6
 
4
7
  export { RULES, EVENT_TYPES, DOC };
5
- export { maskSource } from './mask.js';
6
- export { loadPackage, filesCover } from './load.js';
7
- export { formatText, toSarif } from './report.js';
8
+ export { maskSource } from './core/mask.js';
9
+ export { loadPackage, filesCover } from './core/load.js';
10
+ export { formatText, toSarif, toBadge } from './core/report.js';
8
11
 
9
12
  const LEVEL_ORDER = { error: 0, warn: 1, info: 2 };
10
13
 
@@ -16,10 +19,11 @@ const LIMITS = [
16
19
  ];
17
20
 
18
21
  export function lintPackage(dir, options = {}) {
19
- const pkg = loadPackage(dir);
22
+ const pkg = loadPackage(dir, options);
20
23
  const manifestText = pkg.text('package.json') || '';
21
24
  const ctx = Object.assign({}, pkg, {
22
25
  manifestText,
26
+ packlist: buildPacklist(dir, pkg.manifest),
23
27
  byPath: (rel) => pkg.jsFiles.find((f) => f.path === rel),
24
28
  localeFiles: () => (pkg.entries.localeDir ? localeMeta(dir, pkg.entries.localeDir) : []),
25
29
  });
@@ -32,17 +36,9 @@ export function lintPackage(dir, options = {}) {
32
36
  try {
33
37
  rule.check(ctx, options, push);
34
38
  } catch (error) {
35
- findings.push({
36
- rule: rule.id,
37
- level: 'info',
38
- title: rule.title,
39
- doc: rule.doc,
40
- file: 'package.json',
41
- line: 1,
42
- message: 'rule did not complete: ' + error.message,
43
- evidence: '',
44
- hint: 'This is a bug in dsh-plugin-lint; please report it.',
45
- });
39
+ const failure = new ToolError('RULE_FAILED', 'lint rule ' + rule.id + ' failed', { cause: error });
40
+ failure.rule = rule.id;
41
+ throw failure;
46
42
  }
47
43
  }
48
44
 
@@ -63,12 +59,14 @@ export function lintPackage(dir, options = {}) {
63
59
  name: pkg.manifest.name,
64
60
  version: pkg.manifest.version,
65
61
  path: dir,
62
+ ...(options.source ? { source: options.source } : {}),
66
63
  },
67
64
  dsh: {
68
65
  declared: declared ? Object.keys(declared) : [],
69
66
  verified: [],
70
67
  },
71
68
  checks: RULES.length,
69
+ ruleset: rulesetIdentity(),
72
70
  summary,
73
71
  findings,
74
72
  limits: LIMITS,
package/src/load.js CHANGED
@@ -1,116 +1 @@
1
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
- import { join, relative, sep } from 'node:path';
3
- import { maskSource } from './mask.js';
4
-
5
- /** Directories that never hold shipped plugin code. */
6
- const SKIP_DIRS = new Set([
7
- 'node_modules', '.git', '.github', '.husky', '.dsh-testkit', 'coverage', 'fixtures',
8
- 'test', 'tests', '__tests__', '__snapshots__', 'dist-test',
9
- ]);
10
-
11
- /** Guard rails so one huge vendored file cannot stall a run. */
12
- const MAX_FILE_BYTES = 3 * 1024 * 1024;
13
- const MAX_FILES = 3000;
14
-
15
- export function loadPackage(dir) {
16
- const manifestPath = join(dir, 'package.json');
17
- if (!existsSync(manifestPath)) throw new Error('not a package directory (no package.json): ' + dir);
18
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
19
-
20
- const norm = (p) => (p === undefined ? undefined : String(p).replace(/^\.\//, ''));
21
- // Node resolves an undeclared package entry to index.js, so the fallback is
22
- // correct for resolution — but "nothing was declared" is not the same as
23
- // "index.js was declared and is missing". A static asset package (measured:
24
- // @deepseek-ai/dsh-web-frontend exports only ./dist/*) has no host entry at all,
25
- // and flagging it would be a false positive.
26
- const declaredHost = firstString(manifest.main) || exportTarget(manifest.exports, '.');
27
- const entries = {
28
- host: norm(declaredHost || 'index.js'),
29
- hostDeclared: Boolean(declaredHost || (manifest.dsh && manifest.dsh.bundle)),
30
- client: norm(exportTarget(manifest.exports, './client') || (manifest.dsh && manifest.dsh.client ? 'client.js' : undefined)),
31
- patch: norm(manifest.dsh && manifest.dsh.bundle ? firstString(manifest.dsh.bundle.patch) : undefined),
32
- icon: norm(firstString(manifest.icon)),
33
- localeDir: existsSync(join(dir, 'locale')) ? 'locale' : undefined,
34
- };
35
-
36
- const jsFiles = [];
37
- collect(dir, dir, jsFiles);
38
-
39
- return {
40
- dir,
41
- manifest,
42
- manifestPath,
43
- entries,
44
- jsFiles,
45
- files: Array.isArray(manifest.files) ? manifest.files : undefined,
46
- exists: (rel) => rel !== undefined && existsSync(join(dir, rel)),
47
- text: (rel) => (existsSync(join(dir, rel)) ? readFileSync(join(dir, rel), 'utf8') : undefined),
48
- json: (rel) => {
49
- const t = existsSync(join(dir, rel)) ? readFileSync(join(dir, rel), 'utf8') : undefined;
50
- if (t === undefined) return undefined;
51
- try { return JSON.parse(t); } catch { return undefined; }
52
- },
53
- };
54
- }
55
-
56
- function collect(root, current, out) {
57
- let names;
58
- try { names = readdirSync(current, { withFileTypes: true }); } catch { return; }
59
- for (const dirent of names) {
60
- const abs = join(current, dirent.name);
61
- if (dirent.isDirectory()) {
62
- if (SKIP_DIRS.has(dirent.name)) continue;
63
- collect(root, abs, out);
64
- continue;
65
- }
66
- if (!/\.(js|mjs|cjs|jsx)$/.test(dirent.name)) continue;
67
- let size = 0;
68
- try { size = statSync(abs).size; } catch { continue; }
69
- if (size > MAX_FILE_BYTES) continue;
70
- if (out.length >= MAX_FILES) return;
71
- const text = readFileSync(abs, 'utf8');
72
- const rel = relative(root, abs).split(sep).join('/');
73
- const { masked, structural, strings, lineStarts } = maskSource(text);
74
- out.push({ path: rel, text, masked, structural, strings, lineStarts, bytes: size });
75
- }
76
- }
77
-
78
- function firstString(value) {
79
- return typeof value === 'string' && value !== '' ? value : undefined;
80
- }
81
-
82
- /** Resolve an exports subpath to a file path, accepting the string and {default} forms. */
83
- export function exportTarget(exportsField, subpath) {
84
- if (exportsField === undefined || exportsField === null) return undefined;
85
- if (typeof exportsField === 'string') return subpath === '.' ? exportsField : undefined;
86
- const entry = exportsField[subpath];
87
- if (entry === undefined) return undefined;
88
- if (typeof entry === 'string') return entry;
89
- if (entry && typeof entry === 'object') return firstString(entry.default) || firstString(entry.import) || firstString(entry.require);
90
- return undefined;
91
- }
92
-
93
- /**
94
- * npm packing semantics, simplified: an entry covers a path when it is the same
95
- * file, a parent directory, or a glob whose pattern matches. \`files\` absent means
96
- * everything is published.
97
- */
98
- export function filesCover(files, relPath) {
99
- if (files === undefined || files.length === 0) return true;
100
- relPath = String(relPath).replace(/^\.\//, '');
101
- for (const raw of files) {
102
- const pattern = String(raw).replace(/^\.\//, '').replace(/\/+$/, '');
103
- if (pattern === '' ) continue;
104
- if (pattern === relPath) return true;
105
- if (relPath.startsWith(pattern + '/')) return true;
106
- if (pattern.includes('*')) {
107
- const re = new RegExp('^' + pattern
108
- .replace(/[.+^$()|[\]{}\\]/g, '\\$&')
109
- .replace(/\*\*\//g, '(?:.*/)?')
110
- .replace(/\*\*/g, '.*')
111
- .replace(/(?<!\.)\*/g, '[^/]*') + '$');
112
- if (re.test(relPath)) return true;
113
- }
114
- }
115
- return false;
116
- }
1
+ export * from './core/load.js';