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,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file rules/base/AbstractRule.js
|
|
3
|
+
* @description
|
|
4
|
+
* Abstract base class for all RenderPilot rules.
|
|
5
|
+
* Every rule extends this and implements the detect() method.
|
|
6
|
+
*
|
|
7
|
+
* Design principle: detect() is the only required override.
|
|
8
|
+
* fixSuggestion() and estimatedImpact() have sensible defaults
|
|
9
|
+
* that rules can override for more specific guidance.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Abstract base class for a RenderPilot analysis rule.
|
|
16
|
+
* @abstract
|
|
17
|
+
*/
|
|
18
|
+
class AbstractRule {
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} options - Rule metadata
|
|
21
|
+
* @param {string} options.id - Unique rule ID (e.g. "R001")
|
|
22
|
+
* @param {string} options.name - Human-readable rule name
|
|
23
|
+
* @param {string} options.description - What this rule detects
|
|
24
|
+
* @param {import('../../types').Severity} options.severity - Default severity
|
|
25
|
+
* @param {import('../../types').Category} options.category - Performance category
|
|
26
|
+
* @param {string} [options.docsUrl] - Documentation URL
|
|
27
|
+
*/
|
|
28
|
+
constructor({ id, name, description, severity, category, docsUrl }) {
|
|
29
|
+
if (new.target === AbstractRule) {
|
|
30
|
+
throw new Error('AbstractRule cannot be instantiated directly.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @type {string} */
|
|
34
|
+
this.id = id;
|
|
35
|
+
/** @type {string} */
|
|
36
|
+
this.name = name;
|
|
37
|
+
/** @type {string} */
|
|
38
|
+
this.description = description;
|
|
39
|
+
/** @type {import('../../types').Severity} */
|
|
40
|
+
this.severity = severity;
|
|
41
|
+
/** @type {import('../../types').Category} */
|
|
42
|
+
this.category = category;
|
|
43
|
+
/** @type {string} */
|
|
44
|
+
this.docsUrl = docsUrl || `https://renderpilot.dev/rules/${id.toLowerCase()}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Detect violations in the given rule context.
|
|
49
|
+
* Subclasses MUST implement this method.
|
|
50
|
+
*
|
|
51
|
+
* @abstract
|
|
52
|
+
* @param {import('../../types').RuleContext} context
|
|
53
|
+
* @returns {import('../../types').RuleViolation[]}
|
|
54
|
+
*/
|
|
55
|
+
// eslint-disable-next-line no-unused-vars
|
|
56
|
+
detect(_context) {
|
|
57
|
+
throw new Error(`Rule ${this.id} must implement detect()`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Generate a fix suggestion for a violation.
|
|
62
|
+
* Override for rule-specific guidance.
|
|
63
|
+
*
|
|
64
|
+
* @param {import('../../types').RuleViolation} _violation
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
// eslint-disable-next-line no-unused-vars
|
|
68
|
+
fixSuggestion(_violation) {
|
|
69
|
+
return 'Review the flagged code and apply performance best practices.';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Estimate the performance impact of a violation.
|
|
74
|
+
* Override to provide rule-specific estimates.
|
|
75
|
+
*
|
|
76
|
+
* @param {import('../../types').RuleViolation} _violation
|
|
77
|
+
* @returns {import('../../types').ImpactEstimate}
|
|
78
|
+
*/
|
|
79
|
+
// eslint-disable-next-line no-unused-vars
|
|
80
|
+
estimatedImpact(_violation) {
|
|
81
|
+
const levelMap = {
|
|
82
|
+
critical: 'critical',
|
|
83
|
+
warning: 'medium',
|
|
84
|
+
info: 'low',
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
level: levelMap[this.severity] || 'low',
|
|
88
|
+
description: 'Performance impact varies. Address to improve render efficiency.',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Helper: create a RuleViolation object.
|
|
94
|
+
* Rules call this instead of constructing raw objects.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} options
|
|
97
|
+
* @param {string} options.filePath
|
|
98
|
+
* @param {number} options.line
|
|
99
|
+
* @param {number} options.column
|
|
100
|
+
* @param {string} options.message
|
|
101
|
+
* @param {string} options.suggestion
|
|
102
|
+
* @param {string} [options.codeSnippet]
|
|
103
|
+
* @param {string} [options.componentName]
|
|
104
|
+
* @param {import('../../types').Severity} [options.severity] - Override default severity
|
|
105
|
+
* @returns {import('../../types').RuleViolation}
|
|
106
|
+
*/
|
|
107
|
+
createViolation({
|
|
108
|
+
filePath,
|
|
109
|
+
line,
|
|
110
|
+
column,
|
|
111
|
+
message,
|
|
112
|
+
suggestion,
|
|
113
|
+
codeSnippet,
|
|
114
|
+
componentName,
|
|
115
|
+
severity,
|
|
116
|
+
}) {
|
|
117
|
+
const effectiveSeverity = severity || this.severity;
|
|
118
|
+
const violation = {
|
|
119
|
+
ruleId: this.id,
|
|
120
|
+
ruleName: this.name,
|
|
121
|
+
severity: effectiveSeverity,
|
|
122
|
+
category: this.category,
|
|
123
|
+
filePath,
|
|
124
|
+
line,
|
|
125
|
+
column: column || 0,
|
|
126
|
+
message,
|
|
127
|
+
suggestion,
|
|
128
|
+
impact: this.estimatedImpact({ ruleId: this.id, severity: effectiveSeverity }),
|
|
129
|
+
codeSnippet,
|
|
130
|
+
componentName,
|
|
131
|
+
};
|
|
132
|
+
return violation;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { AbstractRule };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file rules/engine/RuleEngine.js
|
|
3
|
+
* @description
|
|
4
|
+
* The core execution engine. Takes an AST and configuration,
|
|
5
|
+
* runs all active rules against it, and aggregates the violations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const { ruleRegistry } = require('../registry/RuleRegistry');
|
|
11
|
+
const { getComponentName, isReactComponent } = require('../../parser/ASTUtils');
|
|
12
|
+
const traverse = require('@babel/traverse').default;
|
|
13
|
+
|
|
14
|
+
class RuleEngine {
|
|
15
|
+
/**
|
|
16
|
+
* Run all active rules on a given file.
|
|
17
|
+
*
|
|
18
|
+
* @param {import('../../types').RuleContext} context
|
|
19
|
+
* @returns {import('../../types').RuleViolation[]}
|
|
20
|
+
*/
|
|
21
|
+
static run(context) {
|
|
22
|
+
if (!context.ast) return [];
|
|
23
|
+
|
|
24
|
+
const activeRules = ruleRegistry.getActiveRules(context.config);
|
|
25
|
+
if (activeRules.length === 0) return [];
|
|
26
|
+
|
|
27
|
+
let violations = [];
|
|
28
|
+
|
|
29
|
+
// Pre-process: Find components to enrich the context if needed,
|
|
30
|
+
// but the actual detection is up to each rule.
|
|
31
|
+
// We pass the context directly to rules. Rules can traverse the AST themselves.
|
|
32
|
+
|
|
33
|
+
// Note: Some rules might benefit from a shared traversal, but to keep the
|
|
34
|
+
// architecture truly modular and decoupled, we let each rule run its own detect().
|
|
35
|
+
// Babel AST traversal is fast enough for our use cases, but if performance
|
|
36
|
+
// becomes an issue on massive codebases, we could transition to a visitor pattern
|
|
37
|
+
// where rules register visitors and the engine does a single pass.
|
|
38
|
+
|
|
39
|
+
// For now, each rule is self-contained.
|
|
40
|
+
for (const rule of activeRules) {
|
|
41
|
+
try {
|
|
42
|
+
const ruleViolations = rule.detect(context);
|
|
43
|
+
|
|
44
|
+
// Apply severity overrides if any
|
|
45
|
+
const override = context.config.severityOverrides[rule.id];
|
|
46
|
+
if (override) {
|
|
47
|
+
ruleViolations.forEach(v => {
|
|
48
|
+
v.severity = override;
|
|
49
|
+
// Re-evaluate impact if severity changed
|
|
50
|
+
v.impact = rule.estimatedImpact({ ...v, severity: override });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
violations.push(...ruleViolations);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error(`[RenderPilot] Error running rule ${rule.id} on ${context.filePath}:`, err);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Sort violations by line number
|
|
61
|
+
violations.sort((a, b) => a.line - b.line);
|
|
62
|
+
|
|
63
|
+
return violations;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { RuleEngine };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file rules/implementations/R001_MissingKeyProp.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const { AbstractRule } = require('../base/AbstractRule');
|
|
8
|
+
const { getCodeSnippet } = require('../../parser/ASTUtils');
|
|
9
|
+
const traverse = require('@babel/traverse').default;
|
|
10
|
+
const t = require('@babel/types');
|
|
11
|
+
|
|
12
|
+
class R001_MissingKeyProp extends AbstractRule {
|
|
13
|
+
constructor() {
|
|
14
|
+
super({
|
|
15
|
+
id: 'R001',
|
|
16
|
+
name: 'Missing Key Prop in Iterator',
|
|
17
|
+
description: 'Detects JSX elements returned from array iterations (like map) that are missing a "key" prop.',
|
|
18
|
+
severity: 'warning',
|
|
19
|
+
category: 'rendering',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
detect(context) {
|
|
24
|
+
const violations = [];
|
|
25
|
+
|
|
26
|
+
traverse(context.ast, {
|
|
27
|
+
CallExpression: (path) => {
|
|
28
|
+
const callee = path.node.callee;
|
|
29
|
+
|
|
30
|
+
// Check for .map()
|
|
31
|
+
if (
|
|
32
|
+
t.isMemberExpression(callee) &&
|
|
33
|
+
t.isIdentifier(callee.property, { name: 'map' })
|
|
34
|
+
) {
|
|
35
|
+
const callback = path.node.arguments[0];
|
|
36
|
+
|
|
37
|
+
if (
|
|
38
|
+
t.isArrowFunctionExpression(callback) ||
|
|
39
|
+
t.isFunctionExpression(callback)
|
|
40
|
+
) {
|
|
41
|
+
let jsxElement = null;
|
|
42
|
+
|
|
43
|
+
if (t.isJSXElement(callback.body) || t.isJSXFragment(callback.body)) {
|
|
44
|
+
// Implicit return: .map(() => <div />)
|
|
45
|
+
jsxElement = callback.body;
|
|
46
|
+
} else if (t.isBlockStatement(callback.body)) {
|
|
47
|
+
// Explicit return: .map(() => { return <div />; })
|
|
48
|
+
const returnStmt = callback.body.body.find(stmt => t.isReturnStatement(stmt));
|
|
49
|
+
if (returnStmt && (t.isJSXElement(returnStmt.argument) || t.isJSXFragment(returnStmt.argument))) {
|
|
50
|
+
jsxElement = returnStmt.argument;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (jsxElement) {
|
|
55
|
+
if (t.isJSXFragment(jsxElement)) {
|
|
56
|
+
// Fragments must use shorthand syntax or explicitly <React.Fragment key={...}>
|
|
57
|
+
// A bare <>...</> cannot have keys.
|
|
58
|
+
violations.push(
|
|
59
|
+
this.createViolation({
|
|
60
|
+
filePath: context.filePath,
|
|
61
|
+
line: jsxElement.loc ? jsxElement.loc.start.line : 0,
|
|
62
|
+
column: jsxElement.loc ? jsxElement.loc.start.column : 0,
|
|
63
|
+
message: 'Missing "key" prop. Fragments returned from map() must use <React.Fragment key={...}> instead of <>...</>.',
|
|
64
|
+
suggestion: 'Change <> to <React.Fragment key={uniqueId}>.',
|
|
65
|
+
codeSnippet: getCodeSnippet(context.lines, jsxElement.loc ? jsxElement.loc.start.line : 0),
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
} else if (t.isJSXElement(jsxElement)) {
|
|
69
|
+
// Check if 'key' exists
|
|
70
|
+
const hasKey = jsxElement.openingElement.attributes.some(
|
|
71
|
+
attr => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: 'key' })
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (!hasKey) {
|
|
75
|
+
violations.push(
|
|
76
|
+
this.createViolation({
|
|
77
|
+
filePath: context.filePath,
|
|
78
|
+
line: jsxElement.loc ? jsxElement.loc.start.line : 0,
|
|
79
|
+
column: jsxElement.loc ? jsxElement.loc.start.column : 0,
|
|
80
|
+
message: 'Missing "key" prop on JSX element returned from array.map().',
|
|
81
|
+
suggestion: 'Add a unique "key" prop to the root element returned by the map callback.',
|
|
82
|
+
codeSnippet: getCodeSnippet(context.lines, jsxElement.loc ? jsxElement.loc.start.line : 0),
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return violations;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { R001_MissingKeyProp };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file rules/implementations/R002_InfiniteUseEffect.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const { AbstractRule } = require('../base/AbstractRule');
|
|
8
|
+
const { getCodeSnippet, isHookCall, getDepsArray } = require('../../parser/ASTUtils');
|
|
9
|
+
const traverse = require('@babel/traverse').default;
|
|
10
|
+
const t = require('@babel/types');
|
|
11
|
+
|
|
12
|
+
class R002_InfiniteUseEffect extends AbstractRule {
|
|
13
|
+
constructor() {
|
|
14
|
+
super({
|
|
15
|
+
id: 'R002',
|
|
16
|
+
name: 'Infinite useEffect Loop',
|
|
17
|
+
description: 'Detects useEffect hooks without a dependency array that contain state updates, which causes infinite rerenders.',
|
|
18
|
+
severity: 'critical',
|
|
19
|
+
category: 'hooks',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
detect(context) {
|
|
24
|
+
const violations = [];
|
|
25
|
+
|
|
26
|
+
// Step 1: Find all state setters in the file so we know what they look like
|
|
27
|
+
const stateSetters = new Set();
|
|
28
|
+
traverse(context.ast, {
|
|
29
|
+
VariableDeclarator(path) {
|
|
30
|
+
if (
|
|
31
|
+
t.isCallExpression(path.node.init) &&
|
|
32
|
+
(t.isIdentifier(path.node.init.callee, { name: 'useState' }) ||
|
|
33
|
+
(t.isMemberExpression(path.node.init.callee) && t.isIdentifier(path.node.init.callee.property, { name: 'useState' }))) &&
|
|
34
|
+
t.isArrayPattern(path.node.id) &&
|
|
35
|
+
path.node.id.elements.length === 2 &&
|
|
36
|
+
t.isIdentifier(path.node.id.elements[1])
|
|
37
|
+
) {
|
|
38
|
+
stateSetters.add(path.node.id.elements[1].name);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Step 2: Check useEffects
|
|
44
|
+
traverse(context.ast, {
|
|
45
|
+
CallExpression: (path) => {
|
|
46
|
+
if (isHookCall(path.node, 'useEffect')) {
|
|
47
|
+
const deps = getDepsArray(path.node);
|
|
48
|
+
|
|
49
|
+
// If there is NO dependency array (not even an empty one)
|
|
50
|
+
if (!deps) {
|
|
51
|
+
const callback = path.node.arguments[0];
|
|
52
|
+
let callsStateSetter = false;
|
|
53
|
+
|
|
54
|
+
if (callback && (t.isArrowFunctionExpression(callback) || t.isFunctionExpression(callback))) {
|
|
55
|
+
traverse(callback, {
|
|
56
|
+
CallExpression(innerPath) {
|
|
57
|
+
if (t.isIdentifier(innerPath.node.callee) && stateSetters.has(innerPath.node.callee.name)) {
|
|
58
|
+
callsStateSetter = true;
|
|
59
|
+
innerPath.stop();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}, path.scope, null, path);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (callsStateSetter) {
|
|
66
|
+
violations.push(
|
|
67
|
+
this.createViolation({
|
|
68
|
+
filePath: context.filePath,
|
|
69
|
+
line: path.node.loc ? path.node.loc.start.line : 0,
|
|
70
|
+
column: path.node.loc ? path.node.loc.start.column : 0,
|
|
71
|
+
message: 'useEffect without a dependency array calls a state setter. This will cause an infinite render loop.',
|
|
72
|
+
suggestion: 'Add a dependency array [] to run only on mount, or include specific dependencies.',
|
|
73
|
+
codeSnippet: getCodeSnippet(context.lines, path.node.loc ? path.node.loc.start.line : 0),
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return violations;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { R002_InfiniteUseEffect };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file rules/implementations/R003_IncorrectDepsArray.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const { AbstractRule } = require('../base/AbstractRule');
|
|
8
|
+
const { getCodeSnippet, isHookCall, getDepsArray, collectIdentifiers, extractDepArrayNames } = require('../../parser/ASTUtils');
|
|
9
|
+
const traverse = require('@babel/traverse').default;
|
|
10
|
+
const t = require('@babel/types');
|
|
11
|
+
|
|
12
|
+
class R003_IncorrectDepsArray extends AbstractRule {
|
|
13
|
+
constructor() {
|
|
14
|
+
super({
|
|
15
|
+
id: 'R003',
|
|
16
|
+
name: 'Incorrect Dependency Array',
|
|
17
|
+
description: 'Detects variables referenced inside useEffect/useCallback/useMemo that are missing from the dependency array.',
|
|
18
|
+
severity: 'warning',
|
|
19
|
+
category: 'hooks',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
detect(context) {
|
|
24
|
+
const violations = [];
|
|
25
|
+
const hooksToCheck = ['useEffect', 'useCallback', 'useMemo'];
|
|
26
|
+
|
|
27
|
+
traverse(context.ast, {
|
|
28
|
+
CallExpression: (path) => {
|
|
29
|
+
const hookName = hooksToCheck.find(h => isHookCall(path.node, h));
|
|
30
|
+
if (hookName) {
|
|
31
|
+
const depsArray = getDepsArray(path.node);
|
|
32
|
+
|
|
33
|
+
if (depsArray) {
|
|
34
|
+
const callback = path.node.arguments[0];
|
|
35
|
+
if (callback && (t.isArrowFunctionExpression(callback) || t.isFunctionExpression(callback))) {
|
|
36
|
+
|
|
37
|
+
// 1. Get all variables referenced inside the callback
|
|
38
|
+
const referencedIds = new Set();
|
|
39
|
+
|
|
40
|
+
// Walk the callback body and collect variables that are NOT declared locally
|
|
41
|
+
path.get('arguments.0').traverse({
|
|
42
|
+
Identifier(innerPath) {
|
|
43
|
+
// Ignore object properties
|
|
44
|
+
if (t.isMemberExpression(innerPath.parent) && innerPath.parent.property === innerPath.node && !innerPath.parent.computed) return;
|
|
45
|
+
|
|
46
|
+
// Must be a reference, not a declaration
|
|
47
|
+
if (innerPath.isReferencedIdentifier()) {
|
|
48
|
+
const name = innerPath.node.name;
|
|
49
|
+
|
|
50
|
+
// Is it bound in the scope outside the hook, but inside the component?
|
|
51
|
+
const binding = innerPath.scope.getBinding(name);
|
|
52
|
+
|
|
53
|
+
// We only care about variables bound in the outer scope (e.g. component scope)
|
|
54
|
+
// If binding is null, it's a global (like window).
|
|
55
|
+
// If binding exists but it's bound at the file root (module scope), it's static, ignore.
|
|
56
|
+
if (binding && binding.scope !== path.scope.getProgramParent()) {
|
|
57
|
+
// Also ensure it's not bound *inside* the callback itself
|
|
58
|
+
const callbackScope = path.get('arguments.0').scope;
|
|
59
|
+
let isLocal = false;
|
|
60
|
+
let currentScope = binding.scope;
|
|
61
|
+
while(currentScope) {
|
|
62
|
+
if (currentScope === callbackScope) {
|
|
63
|
+
isLocal = true;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
currentScope = currentScope.parent;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!isLocal) {
|
|
70
|
+
referencedIds.add(name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// 2. Get dependencies explicitly listed
|
|
78
|
+
const listedDeps = extractDepArrayNames(depsArray);
|
|
79
|
+
|
|
80
|
+
// 3. Find missing dependencies
|
|
81
|
+
const missing = [];
|
|
82
|
+
for (const ref of referencedIds) {
|
|
83
|
+
if (!listedDeps.has(ref) && ref !== 'dispatch' && ref !== 'set' + ref.substring(3)) {
|
|
84
|
+
// Crude heuristic to ignore some setX functions, standard linter does this better with flow analysis
|
|
85
|
+
// but this works for 90% of cases
|
|
86
|
+
missing.push(ref);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (missing.length > 0) {
|
|
91
|
+
violations.push(
|
|
92
|
+
this.createViolation({
|
|
93
|
+
filePath: context.filePath,
|
|
94
|
+
line: depsArray.loc ? depsArray.loc.start.line : 0,
|
|
95
|
+
column: depsArray.loc ? depsArray.loc.start.column : 0,
|
|
96
|
+
message: `${hookName} has missing dependencies: ${missing.join(', ')}.`,
|
|
97
|
+
suggestion: `Include [${missing.join(', ')}] in the dependency array.`,
|
|
98
|
+
codeSnippet: getCodeSnippet(context.lines, depsArray.loc ? depsArray.loc.start.line : 0),
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return violations;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { R003_IncorrectDepsArray };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file rules/implementations/R004_LargeComponent.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const { AbstractRule } = require('../base/AbstractRule');
|
|
8
|
+
const { ComponentDetector } = require('../../parser/ComponentDetector');
|
|
9
|
+
|
|
10
|
+
class R004_LargeComponent extends AbstractRule {
|
|
11
|
+
constructor() {
|
|
12
|
+
super({
|
|
13
|
+
id: 'R004',
|
|
14
|
+
name: 'Large Component',
|
|
15
|
+
description: 'Warns when a component exceeds a configurable line count limit.',
|
|
16
|
+
severity: 'info',
|
|
17
|
+
category: 'architecture',
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
detect(context) {
|
|
22
|
+
const violations = [];
|
|
23
|
+
const maxLines = context.config.maxComponentLines || 300;
|
|
24
|
+
const components = ComponentDetector.extract(context.ast);
|
|
25
|
+
|
|
26
|
+
for (const comp of components) {
|
|
27
|
+
const lineCount = comp.endLine - comp.startLine + 1;
|
|
28
|
+
|
|
29
|
+
if (lineCount > maxLines) {
|
|
30
|
+
violations.push(
|
|
31
|
+
this.createViolation({
|
|
32
|
+
filePath: context.filePath,
|
|
33
|
+
line: comp.startLine,
|
|
34
|
+
column: 0,
|
|
35
|
+
message: `Component "${comp.name}" is ${lineCount} lines long, exceeding the limit of ${maxLines}.`,
|
|
36
|
+
suggestion: 'Consider breaking this component down into smaller, focused sub-components.',
|
|
37
|
+
componentName: comp.name,
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return violations;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { R004_LargeComponent };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file rules/implementations/R005_InlineCallback.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const { AbstractRule } = require('../base/AbstractRule');
|
|
8
|
+
const { getCodeSnippet } = require('../../parser/ASTUtils');
|
|
9
|
+
const traverse = require('@babel/traverse').default;
|
|
10
|
+
const t = require('@babel/types');
|
|
11
|
+
|
|
12
|
+
class R005_InlineCallback extends AbstractRule {
|
|
13
|
+
constructor() {
|
|
14
|
+
super({
|
|
15
|
+
id: 'R005',
|
|
16
|
+
name: 'Inline Callback Detection',
|
|
17
|
+
description: 'Detects inline arrow functions passed to DOM events or components, which cause unnecessary recreations.',
|
|
18
|
+
severity: 'info',
|
|
19
|
+
category: 'rendering',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
detect(context) {
|
|
24
|
+
const violations = [];
|
|
25
|
+
|
|
26
|
+
traverse(context.ast, {
|
|
27
|
+
JSXAttribute: (path) => {
|
|
28
|
+
const attrName = path.node.name.name;
|
|
29
|
+
|
|
30
|
+
// Target common event handlers like onClick, onChange, onSubmit
|
|
31
|
+
if (typeof attrName === 'string' && attrName.startsWith('on') && attrName[2] === attrName[2]?.toUpperCase()) {
|
|
32
|
+
const value = path.node.value;
|
|
33
|
+
|
|
34
|
+
if (t.isJSXExpressionContainer(value)) {
|
|
35
|
+
const expr = value.expression;
|
|
36
|
+
|
|
37
|
+
if (t.isArrowFunctionExpression(expr) || t.isFunctionExpression(expr)) {
|
|
38
|
+
violations.push(
|
|
39
|
+
this.createViolation({
|
|
40
|
+
filePath: context.filePath,
|
|
41
|
+
line: path.node.loc ? path.node.loc.start.line : 0,
|
|
42
|
+
column: path.node.loc ? path.node.loc.start.column : 0,
|
|
43
|
+
message: `Inline callback detected on ${attrName}. This creates a new function on every render.`,
|
|
44
|
+
suggestion: 'Consider useCallback if passing it to memoized children or if profiling indicates a performance issue.',
|
|
45
|
+
codeSnippet: getCodeSnippet(context.lines, path.node.loc ? path.node.loc.start.line : 0),
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return violations;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { R005_InlineCallback };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file rules/implementations/R006_HeavyRenderCalc.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const { AbstractRule } = require('../base/AbstractRule');
|
|
8
|
+
const { getCodeSnippet, isArrayMethod } = require('../../parser/ASTUtils');
|
|
9
|
+
const traverse = require('@babel/traverse').default;
|
|
10
|
+
const t = require('@babel/types');
|
|
11
|
+
|
|
12
|
+
class R006_HeavyRenderCalc extends AbstractRule {
|
|
13
|
+
constructor() {
|
|
14
|
+
super({
|
|
15
|
+
id: 'R006',
|
|
16
|
+
name: 'Heavy Calculations Inside Render',
|
|
17
|
+
description: 'Detects heavy array operations (filter, map, reduce, sort) occurring directly inside the render path, which blocks painting.',
|
|
18
|
+
severity: 'warning',
|
|
19
|
+
category: 'rendering',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
detect(context) {
|
|
24
|
+
const violations = [];
|
|
25
|
+
const heavyMethods = ['filter', 'reduce', 'sort', 'find'];
|
|
26
|
+
const { ComponentDetector } = require('../../parser/ComponentDetector');
|
|
27
|
+
const components = ComponentDetector.extract(context.ast);
|
|
28
|
+
|
|
29
|
+
traverse(context.ast, {
|
|
30
|
+
CallExpression: (path) => {
|
|
31
|
+
if (isArrayMethod(path.node, heavyMethods)) {
|
|
32
|
+
const line = path.node.loc ? path.node.loc.start.line : 0;
|
|
33
|
+
|
|
34
|
+
// Only proceed if this call is inside an actual React component
|
|
35
|
+
const enclosingComponent = components.find(c => line >= c.startLine && line <= c.endLine);
|
|
36
|
+
|
|
37
|
+
if (enclosingComponent) {
|
|
38
|
+
let isInHook = false;
|
|
39
|
+
|
|
40
|
+
let current = path.parentPath;
|
|
41
|
+
while (current && current.node !== enclosingComponent.path.node) {
|
|
42
|
+
// Check if we hit a hook boundary before hitting the component root
|
|
43
|
+
if (t.isCallExpression(current.node) &&
|
|
44
|
+
(t.isIdentifier(current.node.callee, {name: 'useEffect'}) ||
|
|
45
|
+
t.isIdentifier(current.node.callee, {name: 'useCallback'}) ||
|
|
46
|
+
t.isIdentifier(current.node.callee, {name: 'useMemo'}))) {
|
|
47
|
+
isInHook = true;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
current = current.parentPath;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!isInHook) {
|
|
54
|
+
const methodName = path.node.callee.property.name;
|
|
55
|
+
let message, suggestion;
|
|
56
|
+
|
|
57
|
+
if (methodName === 'sort') {
|
|
58
|
+
message = `Potential expensive calculation (.sort()). Array.sort() mutates the original array.`;
|
|
59
|
+
suggestion = `Use [...data].sort(...) to avoid mutating state, and consider useMemo if the array is large.`;
|
|
60
|
+
} else {
|
|
61
|
+
message = `Potential expensive calculation (.${methodName}()) inside render.`;
|
|
62
|
+
suggestion = `If this computation is costly or the array is large, consider useMemo.`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
violations.push(
|
|
66
|
+
this.createViolation({
|
|
67
|
+
filePath: context.filePath,
|
|
68
|
+
line: path.node.loc ? path.node.loc.start.line : 0,
|
|
69
|
+
column: path.node.loc ? path.node.loc.start.column : 0,
|
|
70
|
+
message,
|
|
71
|
+
suggestion,
|
|
72
|
+
codeSnippet: getCodeSnippet(context.lines, path.node.loc ? path.node.loc.start.line : 0),
|
|
73
|
+
componentName: enclosingComponent.name,
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return violations;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { R006_HeavyRenderCalc };
|