@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,515 @@
1
+ /**
2
+ * Performance analysis for plugins — EXPERIMENTAL.
3
+ *
4
+ * This module reads the files a plugin would publish and derives size,
5
+ * complexity, DOM and network indicators from source text. It does not measure
6
+ * startup time, memory, or runtime behaviour; the 0-100 score is a heuristic
7
+ * summary of the static indicators below.
8
+ *
9
+ * @module performance
10
+ */
11
+
12
+ const LIMITS = [
13
+ 'Static analysis of the files on disk: no startup time, memory, or runtime measurement is performed.',
14
+ 'Bundle size approximates npm packing but does not resolve every npm files[] rule.',
15
+ 'The 0-100 score is a heuristic summary; a high score is not proof of a fast plugin.',
16
+ ];
17
+
18
+ import { statSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { buildPacklist } from '../core/packlist.js';
21
+ import { maskSource } from '../core/mask.js';
22
+ import { EXPERIMENTAL_SCHEMA_VERSION } from '../shared/version.js';
23
+
24
+ /**
25
+ * Analyze performance characteristics of a package.
26
+ * @param {object} pkg - loaded package from loadPackage()
27
+ * @returns {object} performance metrics
28
+ */
29
+ export function analyzePerformance(pkg) {
30
+ const metrics = {
31
+ schemaVersion: EXPERIMENTAL_SCHEMA_VERSION,
32
+ bundleSize: calculateBundleSize(pkg),
33
+ complexity: analyzeComplexity(pkg),
34
+ domImpact: estimateDOMImpact(pkg),
35
+ networkCalls: detectNetworkCalls(pkg),
36
+ heavyDependencies: analyzeHeavyPatterns(pkg),
37
+ score: 0
38
+ };
39
+
40
+ // Calculate overall performance score (0-100, higher is better)
41
+ metrics.score = calculatePerformanceScore(metrics);
42
+ metrics.rating = ratePerformance(metrics.score);
43
+ metrics.recommendations = generateRecommendations(metrics);
44
+ metrics.experimental = true;
45
+ metrics.limits = LIMITS;
46
+
47
+ if (pkg.jsFiles.length === 0) {
48
+ // A package with no JavaScript is not a 100/100 plugin: there is nothing to
49
+ // judge, so say so instead of rewarding the absence of evidence.
50
+ metrics.score = 0;
51
+ metrics.rating = 'insufficient-data';
52
+ metrics.recommendations.unshift({
53
+ issue: 'No JavaScript files were analyzed',
54
+ suggestion: 'The score only describes files on disk; an empty analysis is not a clean bill of health.',
55
+ impact: 'info',
56
+ });
57
+ }
58
+
59
+ return metrics;
60
+ }
61
+
62
+ /**
63
+ * Calculate total bundle size including all shipped files.
64
+ */
65
+ function calculateBundleSize(pkg) {
66
+ let totalBytes = 0;
67
+ let jsBytes = 0;
68
+ const largestFiles = [];
69
+ const packlist = buildPacklist(pkg.dir, pkg.manifest);
70
+
71
+ for (const relPath of packlist.paths) {
72
+ const stat = statSync(join(pkg.dir, relPath));
73
+ totalBytes += stat.size;
74
+ if (/\.(js|mjs|cjs|jsx)$/.test(relPath)) jsBytes += stat.size;
75
+ largestFiles.push({ path: relPath, bytes: stat.size, kb: Math.round(stat.size / 1024) });
76
+ }
77
+
78
+ largestFiles.sort((a, b) => b.bytes - a.bytes);
79
+ return {
80
+ total: totalBytes,
81
+ totalMB: (totalBytes / (1024 * 1024)).toFixed(2),
82
+ js: jsBytes,
83
+ jsMB: (jsBytes / (1024 * 1024)).toFixed(2),
84
+ fileCount: packlist.paths.size,
85
+ largest: largestFiles.slice(0, 5),
86
+ thresholds: {
87
+ small: totalBytes < 100 * 1024,
88
+ medium: totalBytes < 1024 * 1024,
89
+ large: totalBytes >= 1024 * 1024,
90
+ },
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Analyze code complexity indicators.
96
+ */
97
+ function analyzeComplexity(pkg) {
98
+ let totalLines = 0;
99
+ let totalFunctions = 0;
100
+ let deepNesting = 0;
101
+ let longFunctions = 0;
102
+ const complexFiles = [];
103
+
104
+ for (const file of pkg.jsFiles) {
105
+ const lines = file.text.split('\n').length;
106
+ totalLines += lines;
107
+ // Comments and string interiors are blanked before patterns run, so a
108
+ // documented pattern in a comment is not counted as code.
109
+ const view = file.structural ?? maskSource(file.text).structural;
110
+
111
+ // Count function declarations
112
+ const funcMatches = view.match(/\bfunction\s+\w+|=>\s*\{|\bfunction\s*\(/g);
113
+ const funcCount = funcMatches ? funcMatches.length : 0;
114
+ totalFunctions += funcCount;
115
+
116
+ // Detect deep nesting (4+ levels)
117
+ const nestingLevel = maxNestingLevel(view);
118
+ if (nestingLevel >= 4) deepNesting++;
119
+
120
+ // Detect long functions (>100 lines)
121
+ const longFuncs = detectLongFunctions(view);
122
+ longFunctions += longFuncs;
123
+
124
+ const fileComplexity = calculateFileComplexity(file);
125
+ if (fileComplexity > 20) {
126
+ complexFiles.push({
127
+ path: file.path,
128
+ lines,
129
+ functions: funcCount,
130
+ complexity: fileComplexity
131
+ });
132
+ }
133
+ }
134
+
135
+ return {
136
+ totalLines,
137
+ avgLinesPerFile: pkg.jsFiles.length > 0 ? Math.round(totalLines / pkg.jsFiles.length) : 0,
138
+ totalFunctions,
139
+ deepNesting,
140
+ longFunctions,
141
+ complexFiles: complexFiles.sort((a, b) => b.complexity - a.complexity).slice(0, 5),
142
+ maintainability: rateComplexity(totalLines, totalFunctions, deepNesting, longFunctions)
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Estimate DOM manipulation impact (for client-side plugins).
148
+ */
149
+ function estimateDOMImpact(pkg) {
150
+ if (!pkg.entries.client) {
151
+ return { hasClient: false };
152
+ }
153
+
154
+ let domMutations = 0;
155
+ let directBodyAccess = 0;
156
+ let heavySelectors = 0;
157
+
158
+ const clientFile = pkg.jsFiles.find(f => f.path === pkg.entries.client);
159
+ if (!clientFile) {
160
+ return { hasClient: true, analyzed: false };
161
+ }
162
+
163
+ const patterns = {
164
+ mutations: [
165
+ /\bappendChild\b/g,
166
+ /\binsertBefore\b/g,
167
+ /\bremoveChild\b/g,
168
+ /\binnerHTML\s*=/g,
169
+ /\bcloneNode\b/g
170
+ ],
171
+ bodyAccess: [
172
+ /document\s*\.\s*body/g,
173
+ /document\s*\.\s*documentElement/g
174
+ ],
175
+ heavySelectors: [
176
+ /querySelectorAll\s*\(\s*['"]\*['"]/g, // Select all
177
+ /getElementsByTagName\s*\(\s*['"]\*['"]/g
178
+ ]
179
+ };
180
+
181
+ const view = clientFile.masked ?? clientFile.text;
182
+ for (const pattern of patterns.mutations) {
183
+ const matches = view.match(pattern);
184
+ domMutations += matches ? matches.length : 0;
185
+ }
186
+
187
+ for (const pattern of patterns.bodyAccess) {
188
+ const matches = view.match(pattern);
189
+ directBodyAccess += matches ? matches.length : 0;
190
+ }
191
+
192
+ for (const pattern of patterns.heavySelectors) {
193
+ const matches = view.match(pattern);
194
+ heavySelectors += matches ? matches.length : 0;
195
+ }
196
+
197
+ return {
198
+ hasClient: true,
199
+ analyzed: true,
200
+ domMutations,
201
+ directBodyAccess,
202
+ heavySelectors,
203
+ risk: (directBodyAccess > 0 || heavySelectors > 0) ? 'high' :
204
+ (domMutations > 10) ? 'medium' : 'low'
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Detect network call patterns.
210
+ */
211
+ function detectNetworkCalls(pkg) {
212
+ const calls = [];
213
+ const externalDomains = new Set();
214
+
215
+ const fetchPattern = /\bfetch\s*\(\s*['"`]([^'"`]+)['"`]/g;
216
+ const axiosPattern = /axios\s*\.\s*(?:get|post|put|delete)\s*\(\s*['"`]([^'"`]+)['"`]/g;
217
+
218
+ for (const file of pkg.jsFiles) {
219
+ // The masked view blanks comments, so a URL documented in a comment is not
220
+ // counted as a request.
221
+ const view = file.masked ?? file.text;
222
+ let m;
223
+
224
+ while ((m = fetchPattern.exec(view)) !== null) {
225
+ const url = m[1];
226
+ calls.push({
227
+ type: 'fetch',
228
+ url,
229
+ file: file.path,
230
+ line: lineOfIndex(file.text, m.index)
231
+ });
232
+
233
+ if (url.startsWith('http://') || url.startsWith('https://')) {
234
+ try {
235
+ const domain = new URL(url).hostname;
236
+ externalDomains.add(domain);
237
+ } catch {
238
+ // Invalid URL
239
+ }
240
+ }
241
+ }
242
+
243
+ while ((m = axiosPattern.exec(view)) !== null) {
244
+ const url = m[1];
245
+ calls.push({
246
+ type: 'axios',
247
+ url,
248
+ file: file.path,
249
+ line: lineOfIndex(file.text, m.index)
250
+ });
251
+
252
+ if (url.startsWith('http://') || url.startsWith('https://')) {
253
+ try {
254
+ const domain = new URL(url).hostname;
255
+ externalDomains.add(domain);
256
+ } catch {
257
+ // Invalid URL
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ return {
264
+ total: calls.length,
265
+ externalDomains: Array.from(externalDomains),
266
+ calls: calls.slice(0, 10), // Sample
267
+ risk: externalDomains.size > 3 ? 'high' :
268
+ externalDomains.size > 0 ? 'medium' : 'low'
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Detect performance-heavy patterns.
274
+ */
275
+ function analyzeHeavyPatterns(pkg) {
276
+ const issues = [];
277
+
278
+ for (const file of pkg.jsFiles) {
279
+ const view = file.masked ?? file.text;
280
+ // Synchronous fs operations
281
+ const syncFs = [
282
+ /\breadFileSync\b/g,
283
+ /\bwriteFileSync\b/g,
284
+ /\bexistsSync\b/g
285
+ ];
286
+
287
+ for (const pattern of syncFs) {
288
+ const matches = view.match(pattern);
289
+ if (matches && matches.length > 5) {
290
+ issues.push({
291
+ type: 'sync-fs',
292
+ file: file.path,
293
+ count: matches.length,
294
+ severity: 'medium'
295
+ });
296
+ break;
297
+ }
298
+ }
299
+
300
+ // Large JSON.parse in loops
301
+ if (/for\s*\([^)]*\)\s*\{[^}]*JSON\.parse/s.test(view)) {
302
+ issues.push({
303
+ type: 'json-parse-in-loop',
304
+ file: file.path,
305
+ severity: 'high'
306
+ });
307
+ }
308
+
309
+ // Recursive directory walks without limits
310
+ if (/readdirSync.*recursive/i.test(view)) {
311
+ issues.push({
312
+ type: 'unbounded-recursion',
313
+ file: file.path,
314
+ severity: 'high'
315
+ });
316
+ }
317
+
318
+ // Heavy crypto operations
319
+ if (/\bcrypto\s*\.\s*(?:pbkdf2|scrypt)/g.test(view)) {
320
+ issues.push({
321
+ type: 'heavy-crypto',
322
+ file: file.path,
323
+ severity: 'medium'
324
+ });
325
+ }
326
+ }
327
+
328
+ return issues;
329
+ }
330
+
331
+ /** Calculate cyclomatic complexity approximation for a file */
332
+ function calculateFileComplexity(file) {
333
+ const text = file.structural ?? maskSource(file.text).structural;
334
+ let complexity = 1; // Base complexity
335
+
336
+ // Count decision points
337
+ const patterns = [
338
+ /\bif\s*\(/g,
339
+ /\belse\s+if\s*\(/g,
340
+ /\bfor\s*\(/g,
341
+ /\bwhile\s*\(/g,
342
+ /\bcase\s+/g,
343
+ /\bcatch\s*\(/g,
344
+ /\?\s*[^:]+:/g, // Ternary
345
+ /&&/g,
346
+ /\|\|/g
347
+ ];
348
+
349
+ for (const pattern of patterns) {
350
+ const matches = text.match(pattern);
351
+ complexity += matches ? matches.length : 0;
352
+ }
353
+
354
+ return complexity;
355
+ }
356
+
357
+ /** Find maximum nesting level in code */
358
+ function maxNestingLevel(text) {
359
+ let maxDepth = 0;
360
+ let currentDepth = 0;
361
+
362
+ for (const char of text) {
363
+ if (char === '{') {
364
+ currentDepth++;
365
+ maxDepth = Math.max(maxDepth, currentDepth);
366
+ } else if (char === '}') {
367
+ currentDepth--;
368
+ }
369
+ }
370
+
371
+ return maxDepth;
372
+ }
373
+
374
+ /** Detect functions longer than 100 lines */
375
+ function detectLongFunctions(text) {
376
+ const funcStarts = [];
377
+ const funcPattern = /\bfunction\s+\w+|=>\s*\{|\bfunction\s*\(/g;
378
+ let m;
379
+
380
+ while ((m = funcPattern.exec(text)) !== null) {
381
+ funcStarts.push(m.index);
382
+ }
383
+
384
+ let longCount = 0;
385
+ for (const start of funcStarts) {
386
+ const endBrace = findMatchingBrace(text, start);
387
+ // Count actual lines in the body; the previous version compared character
388
+ // counts against a 100*80 estimate and mislabelled short functions.
389
+ if (text.slice(start, endBrace).split('\n').length > 100) longCount++;
390
+ }
391
+
392
+ return longCount;
393
+ }
394
+
395
+ /** Find matching closing brace */
396
+ function findMatchingBrace(text, start) {
397
+ const openIdx = text.indexOf('{', start);
398
+ if (openIdx === -1) return start;
399
+
400
+ let depth = 1;
401
+ let i = openIdx + 1;
402
+
403
+ while (i < text.length && depth > 0) {
404
+ if (text[i] === '{') depth++;
405
+ else if (text[i] === '}') depth--;
406
+ i++;
407
+ }
408
+
409
+ return i;
410
+ }
411
+
412
+ /** Rate complexity level */
413
+ function rateComplexity(lines, functions, deepNesting, longFunctions) {
414
+ const avgLinesPerFunc = functions > 0 ? lines / functions : 0;
415
+
416
+ if (deepNesting > 5 || longFunctions > 3 || avgLinesPerFunc > 100) {
417
+ return 'poor';
418
+ } else if (deepNesting > 2 || longFunctions > 1 || avgLinesPerFunc > 50) {
419
+ return 'fair';
420
+ } else {
421
+ return 'good';
422
+ }
423
+ }
424
+
425
+ /** Calculate overall performance score */
426
+ function calculatePerformanceScore(metrics) {
427
+ let score = 100;
428
+
429
+ // Bundle size penalty
430
+ const sizeMB = parseFloat(metrics.bundleSize.totalMB);
431
+ if (sizeMB > 5) score -= 30;
432
+ else if (sizeMB > 2) score -= 20;
433
+ else if (sizeMB > 1) score -= 10;
434
+
435
+ // Complexity penalty
436
+ if (metrics.complexity.maintainability === 'poor') score -= 20;
437
+ else if (metrics.complexity.maintainability === 'fair') score -= 10;
438
+
439
+ // DOM impact penalty
440
+ if (metrics.domImpact.risk === 'high') score -= 15;
441
+ else if (metrics.domImpact.risk === 'medium') score -= 7;
442
+
443
+ // Network calls penalty
444
+ if (metrics.networkCalls.risk === 'high') score -= 15;
445
+ else if (metrics.networkCalls.risk === 'medium') score -= 7;
446
+
447
+ // Heavy patterns penalty
448
+ score -= metrics.heavyDependencies.length * 5;
449
+
450
+ return Math.max(0, Math.min(100, score));
451
+ }
452
+
453
+ /** Rate performance level */
454
+ function ratePerformance(score) {
455
+ if (score >= 90) return 'excellent';
456
+ if (score >= 75) return 'good';
457
+ if (score >= 60) return 'fair';
458
+ if (score >= 40) return 'poor';
459
+ return 'critical';
460
+ }
461
+
462
+ /** Generate actionable recommendations */
463
+ function generateRecommendations(metrics) {
464
+ const recommendations = [];
465
+
466
+ if (parseFloat(metrics.bundleSize.totalMB) > 1) {
467
+ recommendations.push({
468
+ issue: 'Large bundle size',
469
+ suggestion: 'Consider code splitting or removing unused dependencies',
470
+ impact: 'high'
471
+ });
472
+ }
473
+
474
+ if (metrics.complexity.longFunctions > 0) {
475
+ recommendations.push({
476
+ issue: `${metrics.complexity.longFunctions} long functions detected`,
477
+ suggestion: 'Break down large functions into smaller, testable units',
478
+ impact: 'medium'
479
+ });
480
+ }
481
+
482
+ if (metrics.domImpact.directBodyAccess > 0) {
483
+ recommendations.push({
484
+ issue: 'Direct document.body manipulation',
485
+ suggestion: 'Use DSH slot system instead of direct DOM access',
486
+ impact: 'high'
487
+ });
488
+ }
489
+
490
+ if (metrics.networkCalls.externalDomains.length > 3) {
491
+ recommendations.push({
492
+ issue: `Calls to ${metrics.networkCalls.externalDomains.length} external domains`,
493
+ suggestion: 'Reduce third-party dependencies or batch requests',
494
+ impact: 'medium'
495
+ });
496
+ }
497
+
498
+ if (metrics.heavyDependencies.length > 0) {
499
+ const highSeverity = metrics.heavyDependencies.filter(d => d.severity === 'high');
500
+ if (highSeverity.length > 0) {
501
+ recommendations.push({
502
+ issue: `${highSeverity.length} performance-heavy patterns found`,
503
+ suggestion: 'Review identified patterns for async alternatives',
504
+ impact: 'high'
505
+ });
506
+ }
507
+ }
508
+
509
+ return recommendations;
510
+ }
511
+
512
+ /** Find line number from character index */
513
+ function lineOfIndex(text, index) {
514
+ return text.slice(0, index).split('\n').length;
515
+ }