renderpilot 1.0.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 (34) hide show
  1. package/README.md +121 -0
  2. package/package.json +65 -0
  3. package/src/cli/commands/scan.js +83 -0
  4. package/src/cli/index.js +49 -0
  5. package/src/cli/options.js +18 -0
  6. package/src/config/ConfigLoader.js +102 -0
  7. package/src/config/DefaultConfig.js +33 -0
  8. package/src/index.js +34 -0
  9. package/src/parser/ASTUtils.js +241 -0
  10. package/src/parser/BabelParser.js +66 -0
  11. package/src/parser/ComponentDetector.js +53 -0
  12. package/src/reporters/HtmlReporter.js +42 -0
  13. package/src/reporters/JsonReporter.js +52 -0
  14. package/src/reporters/TerminalReporter.js +91 -0
  15. package/src/reporters/templates/report.html.js +655 -0
  16. package/src/rules/base/AbstractRule.js +136 -0
  17. package/src/rules/engine/RuleEngine.js +67 -0
  18. package/src/rules/implementations/R001_MissingKeyProp.js +97 -0
  19. package/src/rules/implementations/R002_InfiniteUseEffect.js +86 -0
  20. package/src/rules/implementations/R003_IncorrectDepsArray.js +112 -0
  21. package/src/rules/implementations/R004_LargeComponent.js +47 -0
  22. package/src/rules/implementations/R005_InlineCallback.js +58 -0
  23. package/src/rules/implementations/R006_HeavyRenderCalc.js +86 -0
  24. package/src/rules/implementations/R007_MemoCandidate.js +87 -0
  25. package/src/rules/implementations/R008_NestedComponent.js +65 -0
  26. package/src/rules/implementations/R009_UnusedState.js +62 -0
  27. package/src/rules/index.js +37 -0
  28. package/src/rules/registry/RuleRegistry.js +67 -0
  29. package/src/scanner/FileScanner.js +42 -0
  30. package/src/scanner/ProjectScanner.js +116 -0
  31. package/src/score/CategoryScorer.js +53 -0
  32. package/src/score/ScoreCalculator.js +97 -0
  33. package/src/score/ScoreWeights.js +33 -0
  34. package/src/types/index.js +176 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * @file parser/ASTUtils.js
3
+ * @description
4
+ * Reusable AST traversal helpers shared across all rules.
5
+ * Centralizing these prevents each rule from reimplementing
6
+ * common AST patterns, keeping rule code focused on detection logic.
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const traverse = require('@babel/traverse').default;
12
+ const t = require('@babel/types');
13
+
14
+ // ─── JSX Helpers ─────────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Check if a JSX element has a specific prop by name.
18
+ * @param {import('@babel/types').JSXOpeningElement} openingElement
19
+ * @param {string} propName
20
+ * @returns {boolean}
21
+ */
22
+ function jsxHasProp(openingElement, propName) {
23
+ return openingElement.attributes.some(
24
+ (attr) => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: propName })
25
+ );
26
+ }
27
+
28
+ /**
29
+ * Get the value of a JSX attribute.
30
+ * @param {import('@babel/types').JSXOpeningElement} openingElement
31
+ * @param {string} propName
32
+ * @returns {import('@babel/types').Node|null}
33
+ */
34
+ function getJSXPropValue(openingElement, propName) {
35
+ const attr = openingElement.attributes.find(
36
+ (a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name, { name: propName })
37
+ );
38
+ return attr ? attr.value : null;
39
+ }
40
+
41
+ // ─── Function / Component Helpers ────────────────────────────────────────────
42
+
43
+ /**
44
+ * Determine if a node is a React function component.
45
+ * Heuristics: starts with uppercase, returns JSX.
46
+ * @param {import('@babel/types').Node} node
47
+ * @param {string} name - Detected name of the function
48
+ * @returns {boolean}
49
+ */
50
+ function isReactComponent(node, name) {
51
+ if (!name) return false;
52
+ // Must start with uppercase letter
53
+ if (!/^[A-Z]/.test(name)) return false;
54
+ // Must be some kind of function
55
+ return (
56
+ t.isFunctionDeclaration(node) ||
57
+ t.isFunctionExpression(node) ||
58
+ t.isArrowFunctionExpression(node)
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Extract the name from a function node in common React component patterns:
64
+ * - function MyComponent() {}
65
+ * - const MyComponent = () => {}
66
+ * - const MyComponent = function() {}
67
+ * - export default function MyComponent() {}
68
+ *
69
+ * @param {import('@babel/types').Node} node
70
+ * @param {import('@babel/traverse').NodePath} path
71
+ * @returns {string|null}
72
+ */
73
+ function getComponentName(node, path) {
74
+ // Named function declaration
75
+ if (t.isFunctionDeclaration(node) && node.id) {
76
+ return node.id.name;
77
+ }
78
+
79
+ // Arrow function or function expression assigned to variable
80
+ if (
81
+ (t.isArrowFunctionExpression(node) || t.isFunctionExpression(node)) &&
82
+ path.parent
83
+ ) {
84
+ if (t.isVariableDeclarator(path.parent) && t.isIdentifier(path.parent.id)) {
85
+ return path.parent.id.name;
86
+ }
87
+ // Named function expression
88
+ if (t.isFunctionExpression(node) && node.id) {
89
+ return node.id.name;
90
+ }
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ // ─── Hook Helpers ─────────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Check if a CallExpression is a call to a named hook.
100
+ * @param {import('@babel/types').Node} node
101
+ * @param {string} hookName - e.g. 'useEffect', 'useState'
102
+ * @returns {boolean}
103
+ */
104
+ function isHookCall(node, hookName) {
105
+ if (!t.isCallExpression(node)) return false;
106
+ const callee = node.callee;
107
+ // Direct call: useEffect(...)
108
+ if (t.isIdentifier(callee, { name: hookName })) return true;
109
+ // Namespaced call: React.useEffect(...)
110
+ if (
111
+ t.isMemberExpression(callee) &&
112
+ t.isIdentifier(callee.property, { name: hookName })
113
+ ) {
114
+ return true;
115
+ }
116
+ return false;
117
+ }
118
+
119
+ /**
120
+ * Get the dependency array from a useEffect / useCallback / useMemo call.
121
+ * Returns null if the dep array argument is not present.
122
+ * @param {import('@babel/types').CallExpression} node
123
+ * @returns {import('@babel/types').ArrayExpression|null}
124
+ */
125
+ function getDepsArray(node) {
126
+ if (node.arguments.length < 2) return null;
127
+ const depsArg = node.arguments[1];
128
+ return t.isArrayExpression(depsArg) ? depsArg : null;
129
+ }
130
+
131
+ // ─── Traversal Helpers ────────────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Collect all Identifier names referenced inside a given AST node.
135
+ * Used to find which variables a callback depends on.
136
+ * @param {import('@babel/types').Node} node
137
+ * @returns {Set<string>}
138
+ */
139
+ function collectIdentifiers(node) {
140
+ const names = new Set();
141
+ traverse(node, {
142
+ Identifier(innerPath) {
143
+ // Skip property keys in member expressions (obj.foo — 'foo' is not a reference)
144
+ if (
145
+ t.isMemberExpression(innerPath.parent) &&
146
+ innerPath.parent.property === innerPath.node &&
147
+ !innerPath.parent.computed
148
+ ) {
149
+ return;
150
+ }
151
+ // Skip JSX attribute names
152
+ if (t.isJSXAttribute(innerPath.parent)) return;
153
+ names.add(innerPath.node.name);
154
+ },
155
+ }, null, {});
156
+ return names;
157
+ }
158
+
159
+ /**
160
+ * Extract the names from an ArrayExpression (dep array).
161
+ * Only extracts simple Identifier elements (ignores complex expressions).
162
+ * @param {import('@babel/types').ArrayExpression} arrayExpr
163
+ * @returns {Set<string>}
164
+ */
165
+ function extractDepArrayNames(arrayExpr) {
166
+ const names = new Set();
167
+ for (const element of arrayExpr.elements) {
168
+ if (element && t.isIdentifier(element)) {
169
+ names.add(element.name);
170
+ } else if (element && t.isMemberExpression(element)) {
171
+ // e.g. obj.prop — extract the root identifier
172
+ let current = element;
173
+ while (t.isMemberExpression(current)) {
174
+ current = current.object;
175
+ }
176
+ if (t.isIdentifier(current)) names.add(current.name);
177
+ }
178
+ }
179
+ return names;
180
+ }
181
+
182
+ /**
183
+ * Get a short code snippet from source lines around a given line number.
184
+ * @param {string[]} lines - Source file lines
185
+ * @param {number} line - 1-indexed line number
186
+ * @param {number} [context=1] - Number of lines before/after to include
187
+ * @returns {string}
188
+ */
189
+ function getCodeSnippet(lines, line, context = 1) {
190
+ const start = Math.max(0, line - 1 - context);
191
+ const end = Math.min(lines.length - 1, line - 1 + context);
192
+ return lines.slice(start, end + 1).join('\n').trim();
193
+ }
194
+
195
+ /**
196
+ * Check if a node contains JSX (returns JSX or has JSX children).
197
+ * @param {import('@babel/types').Node} node
198
+ * @returns {boolean}
199
+ */
200
+ function containsJSX(node) {
201
+ let found = false;
202
+ try {
203
+ traverse(node, {
204
+ JSXElement() { found = true; },
205
+ JSXFragment() { found = true; },
206
+ }, null, {});
207
+ } catch (_) {
208
+ // traverse may throw on detached nodes — safe to ignore
209
+ }
210
+ return found;
211
+ }
212
+
213
+ /**
214
+ * Check if a CallExpression is an array method (map, filter, etc.)
215
+ * @param {import('@babel/types').Node} node
216
+ * @param {string[]} methods
217
+ * @returns {boolean}
218
+ */
219
+ function isArrayMethod(node, methods) {
220
+ if (!t.isCallExpression(node)) return false;
221
+ const { callee } = node;
222
+ if (!t.isMemberExpression(callee)) return false;
223
+ const propName = t.isIdentifier(callee.property)
224
+ ? callee.property.name
225
+ : null;
226
+ return propName !== null && methods.includes(propName);
227
+ }
228
+
229
+ module.exports = {
230
+ jsxHasProp,
231
+ getJSXPropValue,
232
+ isReactComponent,
233
+ getComponentName,
234
+ isHookCall,
235
+ getDepsArray,
236
+ collectIdentifiers,
237
+ extractDepArrayNames,
238
+ getCodeSnippet,
239
+ containsJSX,
240
+ isArrayMethod,
241
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @file parser/BabelParser.js
3
+ * @description
4
+ * Wrapper around @babel/parser that handles JS, TS, JSX, and TSX.
5
+ * Provides consistent error handling and plugin selection.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const { parse } = require('@babel/parser');
11
+
12
+ /**
13
+ * Babel parser plugins enabled for all files.
14
+ * Handles JSX, TypeScript, and common modern JS syntax.
15
+ * @type {import('@babel/parser').ParserPlugin[]}
16
+ */
17
+ const BASE_PLUGINS = [
18
+ 'jsx',
19
+ 'typescript',
20
+ 'decorators-legacy',
21
+ 'classProperties',
22
+ 'classPrivateProperties',
23
+ 'classPrivateMethods',
24
+ 'optionalChaining',
25
+ 'nullishCoalescingOperator',
26
+ 'objectRestSpread',
27
+ 'dynamicImport',
28
+ 'importMeta',
29
+ 'exportDefaultFrom',
30
+ 'exportNamespaceFrom',
31
+ 'asyncGenerators',
32
+ 'logicalAssignment',
33
+ 'numericSeparator',
34
+ 'optionalCatchBinding',
35
+ ];
36
+
37
+ /**
38
+ * Parse a source file into a Babel AST.
39
+ * Returns a result object with either an AST or an error — never throws.
40
+ *
41
+ * @param {string} filePath - Absolute path to the file (used for error messages)
42
+ * @param {string} source - Raw source code content
43
+ * @returns {{ ast: import('@babel/parser').ParseResult<import('@babel/types').File>|null, error: Error|null }}
44
+ */
45
+ function parseFile(filePath, source) {
46
+ try {
47
+ const ast = parse(source, {
48
+ sourceType: 'module',
49
+ allowImportExportEverywhere: true,
50
+ allowReturnOutsideFunction: true,
51
+ allowSuperOutsideMethod: true,
52
+ allowUndeclaredExports: true,
53
+ errorRecovery: true, // Continue parsing even on syntax errors
54
+ plugins: BASE_PLUGINS,
55
+ });
56
+
57
+ return { ast, error: null };
58
+ } catch (err) {
59
+ return {
60
+ ast: null,
61
+ error: new Error(`Parse error in ${filePath}: ${err.message}`),
62
+ };
63
+ }
64
+ }
65
+
66
+ module.exports = { parseFile };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @file parser/ComponentDetector.js
3
+ * @description
4
+ * Utility to identify all React component declarations within an AST.
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const traverse = require('@babel/traverse').default;
10
+ const { isReactComponent, getComponentName } = require('./ASTUtils');
11
+
12
+ /**
13
+ * Represents a detected React component in a file.
14
+ * @typedef {Object} ComponentInfo
15
+ * @property {string} name - Component name
16
+ * @property {number} startLine - 1-indexed start line
17
+ * @property {number} endLine - 1-indexed end line
18
+ * @property {import('@babel/traverse').NodePath} path - Babel AST path
19
+ */
20
+
21
+ class ComponentDetector {
22
+ /**
23
+ * Extract all React components from an AST.
24
+ * @param {import('@babel/parser').ParseResult<import('@babel/types').File>} ast
25
+ * @returns {ComponentInfo[]}
26
+ */
27
+ static extract(ast) {
28
+ const components = [];
29
+
30
+ traverse(ast, {
31
+ enter(path) {
32
+ const node = path.node;
33
+ const name = getComponentName(node, path);
34
+
35
+ if (name && isReactComponent(node, name)) {
36
+ // Additional heuristic: does it contain JSX? (optional, but good for filtering out plain utility functions with capitalized names)
37
+ // For now, we trust the uppercase name convention.
38
+
39
+ components.push({
40
+ name,
41
+ startLine: node.loc ? node.loc.start.line : 0,
42
+ endLine: node.loc ? node.loc.end.line : 0,
43
+ path
44
+ });
45
+ }
46
+ }
47
+ });
48
+
49
+ return components;
50
+ }
51
+ }
52
+
53
+ module.exports = { ComponentDetector };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @file reporters/HtmlReporter.js
3
+ * @description
4
+ * Generates an interactive HTML report by injecting scan results into a template.
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { getHtmlTemplate } = require('./templates/report.html');
12
+
13
+ class HtmlReporter {
14
+ /**
15
+ * Generate an HTML report file.
16
+ * @param {import('../types').ReportData} reportData
17
+ * @returns {string} Absolute path to the generated HTML file
18
+ */
19
+ static generate(reportData) {
20
+ const outputDir = path.resolve(process.cwd(), reportData.config.outputDir);
21
+
22
+ if (!fs.existsSync(outputDir)) {
23
+ fs.mkdirSync(outputDir, { recursive: true });
24
+ }
25
+
26
+ const outputPath = path.join(outputDir, 'renderpilot-report.html');
27
+
28
+ // Inject the raw JSON data into the window object of the HTML template
29
+ const jsonString = JSON.stringify(reportData, null, 2);
30
+
31
+ // Escape script tags in JSON to prevent XSS / broken HTML
32
+ const safeJsonString = jsonString.replace(/</g, '\\u003c');
33
+
34
+ const htmlContent = getHtmlTemplate(safeJsonString);
35
+
36
+ fs.writeFileSync(outputPath, htmlContent, 'utf-8');
37
+
38
+ return outputPath;
39
+ }
40
+ }
41
+
42
+ module.exports = { HtmlReporter };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @file reporters/JsonReporter.js
3
+ * @description
4
+ * Generates a machine-readable JSON report of the analysis.
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ class JsonReporter {
13
+ /**
14
+ * Write the scan results to a JSON file.
15
+ * @param {import('../types').ReportData} reportData
16
+ */
17
+ static generate(reportData) {
18
+ const outputDir = path.resolve(process.cwd(), reportData.config.outputDir);
19
+
20
+ if (!fs.existsSync(outputDir)) {
21
+ fs.mkdirSync(outputDir, { recursive: true });
22
+ }
23
+
24
+ const outputPath = path.join(outputDir, 'renderpilot-report.json');
25
+
26
+ // Create a clean JSON structure
27
+ const jsonOutput = {
28
+ version: reportData.version,
29
+ scan: {
30
+ root: reportData.scan.scanRoot,
31
+ startedAt: reportData.scan.startedAt,
32
+ completedAt: reportData.scan.completedAt,
33
+ durationMs: reportData.scan.durationMs,
34
+ stats: {
35
+ totalFiles: reportData.scan.totalFiles,
36
+ totalViolations: reportData.scan.totalViolations,
37
+ parseErrors: reportData.scan.parseErrors,
38
+ violationsBySeverity: reportData.scan.violationsBySeverity,
39
+ violationsByCategory: reportData.scan.violationsByCategory,
40
+ }
41
+ },
42
+ score: reportData.score,
43
+ violations: reportData.scan.allViolations
44
+ };
45
+
46
+ fs.writeFileSync(outputPath, JSON.stringify(jsonOutput, null, 2), 'utf-8');
47
+
48
+ return outputPath;
49
+ }
50
+ }
51
+
52
+ module.exports = { JsonReporter };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @file reporters/TerminalReporter.js
3
+ * @description
4
+ * Formats and prints a beautiful CLI report using chalk.
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const chalk = require('chalk');
10
+ const path = require('path');
11
+
12
+ class TerminalReporter {
13
+ /**
14
+ * Print the terminal report.
15
+ * @param {import('../types').ReportData} reportData
16
+ */
17
+ static generate({ scan, score }) {
18
+ console.log('\n' + chalk.bold.cyan('✈ RenderPilot Analysis Report'));
19
+ console.log(chalk.dim('=================================================='));
20
+
21
+ if (scan.totalViolations === 0) {
22
+ console.log(`\n🎉 ${chalk.green('No performance issues found!')}`);
23
+ console.log(`Scanned ${scan.totalFiles} files in ${scan.durationMs}ms.\n`);
24
+ return;
25
+ }
26
+
27
+ // Group violations by file for readable output
28
+ const filesWithIssues = scan.files.filter(f => f.violations.length > 0);
29
+
30
+ for (const file of filesWithIssues) {
31
+ console.log(`\n📄 ${chalk.bold.underline(file.relativePath)}`);
32
+
33
+ for (const v of file.violations) {
34
+ const severityColor = this.getSeverityColor(v.severity);
35
+ const icon = this.getSeverityIcon(v.severity);
36
+
37
+ console.log(` ${icon} ${chalk.dim(`${v.line}:${v.column}`)} ${severityColor(v.message)} ${chalk.dim(`(${v.ruleId})`)}`);
38
+
39
+ if (v.codeSnippet) {
40
+ console.log(chalk.dim(` > ${v.codeSnippet.split('\n')[0].trim()}`));
41
+ }
42
+
43
+ console.log(` 💡 ${chalk.italic(v.suggestion)}`);
44
+ }
45
+ }
46
+
47
+ console.log('\n' + chalk.dim('=================================================='));
48
+ console.log(chalk.bold('🏆 Performance Score'));
49
+ console.log(chalk.dim('=================================================='));
50
+
51
+ const gradeColor = this.getGradeColor(score.grade);
52
+ console.log(`\n Overall Score: ${gradeColor(score.overall)}/100 (${gradeColor(score.grade)}) — ${score.label}`);
53
+
54
+ console.log('\n Category Breakdown:');
55
+ for (const cat of score.categories) {
56
+ const catColor = this.getGradeColor(cat.grade);
57
+ const name = cat.category.charAt(0).toUpperCase() + cat.category.slice(1);
58
+ console.log(` • ${name.padEnd(15)} : ${catColor(cat.score.toString().padStart(3))} (${cat.grade}) [${cat.violations} issues]`);
59
+ }
60
+
61
+ console.log('\n' + chalk.dim('=================================================='));
62
+ console.log(`⏱️ Scanned ${scan.totalFiles} files in ${scan.durationMs}ms.`);
63
+
64
+ if (score.topRecommendation) {
65
+ console.log(`\n🎯 ${chalk.bold('Top Recommendation:')} ${score.topRecommendation}`);
66
+ }
67
+ console.log();
68
+ }
69
+
70
+ static getSeverityColor(severity) {
71
+ if (severity === 'critical') return chalk.red.bold;
72
+ if (severity === 'warning') return chalk.yellow;
73
+ return chalk.blue;
74
+ }
75
+
76
+ static getSeverityIcon(severity) {
77
+ if (severity === 'critical') return chalk.red('✖');
78
+ if (severity === 'warning') return chalk.yellow('⚠');
79
+ return chalk.blue('ℹ');
80
+ }
81
+
82
+ static getGradeColor(grade) {
83
+ if (grade === 'A') return chalk.green.bold;
84
+ if (grade === 'B') return chalk.green;
85
+ if (grade === 'C') return chalk.yellow;
86
+ if (grade === 'D') return chalk.keyword('orange');
87
+ return chalk.red.bold;
88
+ }
89
+ }
90
+
91
+ module.exports = { TerminalReporter };