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.
- package/README.md +121 -0
- package/package.json +65 -0
- package/src/cli/commands/scan.js +83 -0
- package/src/cli/index.js +49 -0
- package/src/cli/options.js +18 -0
- package/src/config/ConfigLoader.js +102 -0
- package/src/config/DefaultConfig.js +33 -0
- package/src/index.js +34 -0
- package/src/parser/ASTUtils.js +241 -0
- package/src/parser/BabelParser.js +66 -0
- package/src/parser/ComponentDetector.js +53 -0
- package/src/reporters/HtmlReporter.js +42 -0
- package/src/reporters/JsonReporter.js +52 -0
- package/src/reporters/TerminalReporter.js +91 -0
- package/src/reporters/templates/report.html.js +655 -0
- package/src/rules/base/AbstractRule.js +136 -0
- package/src/rules/engine/RuleEngine.js +67 -0
- package/src/rules/implementations/R001_MissingKeyProp.js +97 -0
- package/src/rules/implementations/R002_InfiniteUseEffect.js +86 -0
- package/src/rules/implementations/R003_IncorrectDepsArray.js +112 -0
- package/src/rules/implementations/R004_LargeComponent.js +47 -0
- package/src/rules/implementations/R005_InlineCallback.js +58 -0
- package/src/rules/implementations/R006_HeavyRenderCalc.js +86 -0
- package/src/rules/implementations/R007_MemoCandidate.js +87 -0
- package/src/rules/implementations/R008_NestedComponent.js +65 -0
- package/src/rules/implementations/R009_UnusedState.js +62 -0
- package/src/rules/index.js +37 -0
- package/src/rules/registry/RuleRegistry.js +67 -0
- package/src/scanner/FileScanner.js +42 -0
- package/src/scanner/ProjectScanner.js +116 -0
- package/src/score/CategoryScorer.js +53 -0
- package/src/score/ScoreCalculator.js +97 -0
- package/src/score/ScoreWeights.js +33 -0
- 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 };
|