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,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file rules/implementations/R007_MemoCandidate.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const { AbstractRule } = require('../base/AbstractRule');
|
|
8
|
+
const { ComponentDetector } = require('../../parser/ComponentDetector');
|
|
9
|
+
const traverse = require('@babel/traverse').default;
|
|
10
|
+
const t = require('@babel/types');
|
|
11
|
+
|
|
12
|
+
class R007_MemoCandidate extends AbstractRule {
|
|
13
|
+
constructor() {
|
|
14
|
+
super({
|
|
15
|
+
id: 'R007',
|
|
16
|
+
name: 'React.memo Candidate',
|
|
17
|
+
description: 'Detects pure components (no state, no effects, only props) that might benefit from React.memo.',
|
|
18
|
+
severity: 'info',
|
|
19
|
+
category: 'rendering',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
detect(context) {
|
|
24
|
+
const violations = [];
|
|
25
|
+
const components = ComponentDetector.extract(context.ast);
|
|
26
|
+
|
|
27
|
+
for (const comp of components) {
|
|
28
|
+
let hasHooks = false;
|
|
29
|
+
|
|
30
|
+
// Traverse inside the component to look for hooks
|
|
31
|
+
comp.path.traverse({
|
|
32
|
+
CallExpression(innerPath) {
|
|
33
|
+
if (t.isIdentifier(innerPath.node.callee)) {
|
|
34
|
+
if (innerPath.node.callee.name.startsWith('use')) {
|
|
35
|
+
hasHooks = true;
|
|
36
|
+
innerPath.stop();
|
|
37
|
+
}
|
|
38
|
+
} else if (t.isMemberExpression(innerPath.node.callee) && t.isIdentifier(innerPath.node.callee.property)) {
|
|
39
|
+
if (innerPath.node.callee.property.name.startsWith('use')) {
|
|
40
|
+
hasHooks = true;
|
|
41
|
+
innerPath.stop();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!hasHooks) {
|
|
48
|
+
// Pure component!
|
|
49
|
+
// Does it use React.memo already? We can check the parent of the component node.
|
|
50
|
+
let isMemoized = false;
|
|
51
|
+
const parentNode = comp.path.parent;
|
|
52
|
+
|
|
53
|
+
if (t.isCallExpression(parentNode) && t.isIdentifier(parentNode.callee, {name: 'memo'})) {
|
|
54
|
+
isMemoized = true;
|
|
55
|
+
} else if (t.isCallExpression(parentNode) && t.isMemberExpression(parentNode.callee) && t.isIdentifier(parentNode.callee.property, {name: 'memo'})) {
|
|
56
|
+
isMemoized = true;
|
|
57
|
+
} else if (t.isVariableDeclarator(parentNode) && t.isCallExpression(parentNode.init) &&
|
|
58
|
+
(t.isIdentifier(parentNode.init.callee, {name: 'memo'}) ||
|
|
59
|
+
(t.isMemberExpression(parentNode.init.callee) && t.isIdentifier(parentNode.init.callee.property, {name: 'memo'})))) {
|
|
60
|
+
isMemoized = true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!isMemoized) {
|
|
64
|
+
// Check if the component actually accepts props
|
|
65
|
+
const hasProps = comp.path.node.params && comp.path.node.params.length > 0;
|
|
66
|
+
|
|
67
|
+
if (hasProps) {
|
|
68
|
+
violations.push(
|
|
69
|
+
this.createViolation({
|
|
70
|
+
filePath: context.filePath,
|
|
71
|
+
line: comp.startLine,
|
|
72
|
+
column: 0,
|
|
73
|
+
message: `This component appears to be pure and receives props.`,
|
|
74
|
+
suggestion: `React.memo may reduce unnecessary re-renders if parent renders frequently.`,
|
|
75
|
+
componentName: comp.name,
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return violations;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { R007_MemoCandidate };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file rules/implementations/R008_NestedComponent.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const { AbstractRule } = require('../base/AbstractRule');
|
|
8
|
+
const { ComponentDetector } = require('../../parser/ComponentDetector');
|
|
9
|
+
const { getCodeSnippet } = require('../../parser/ASTUtils');
|
|
10
|
+
const t = require('@babel/types');
|
|
11
|
+
|
|
12
|
+
class R008_NestedComponent extends AbstractRule {
|
|
13
|
+
constructor() {
|
|
14
|
+
super({
|
|
15
|
+
id: 'R008',
|
|
16
|
+
name: 'Nested Component Declarations',
|
|
17
|
+
description: 'Detects components defined inside other components, which forces unmount/remount on every render.',
|
|
18
|
+
severity: 'critical',
|
|
19
|
+
category: 'architecture',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
detect(context) {
|
|
24
|
+
const violations = [];
|
|
25
|
+
const components = ComponentDetector.extract(context.ast);
|
|
26
|
+
|
|
27
|
+
// Build a map of components by their path for easy lookup
|
|
28
|
+
const compPaths = new Set(components.map(c => c.path));
|
|
29
|
+
|
|
30
|
+
for (const comp of components) {
|
|
31
|
+
// Find components whose parent path is ALSO a component
|
|
32
|
+
let current = comp.path.parentPath;
|
|
33
|
+
let isNested = false;
|
|
34
|
+
let parentCompName = '';
|
|
35
|
+
|
|
36
|
+
while (current) {
|
|
37
|
+
if (compPaths.has(current)) {
|
|
38
|
+
isNested = true;
|
|
39
|
+
const parentComp = components.find(c => c.path === current);
|
|
40
|
+
if (parentComp) parentCompName = parentComp.name;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
current = current.parentPath;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (isNested) {
|
|
47
|
+
violations.push(
|
|
48
|
+
this.createViolation({
|
|
49
|
+
filePath: context.filePath,
|
|
50
|
+
line: comp.startLine,
|
|
51
|
+
column: 0,
|
|
52
|
+
message: `Component "${comp.name}" is declared inside "${parentCompName}".`,
|
|
53
|
+
suggestion: `Move "${comp.name}" outside of "${parentCompName}". Nested declarations cause React to destroy and recreate the DOM on every render.`,
|
|
54
|
+
codeSnippet: getCodeSnippet(context.lines, comp.startLine),
|
|
55
|
+
componentName: comp.name,
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return violations;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { R008_NestedComponent };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file rules/implementations/R009_UnusedState.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 R009_UnusedState extends AbstractRule {
|
|
13
|
+
constructor() {
|
|
14
|
+
super({
|
|
15
|
+
id: 'R009',
|
|
16
|
+
name: 'Unused React State',
|
|
17
|
+
description: 'Detects useState variables that are never referenced.',
|
|
18
|
+
severity: 'warning',
|
|
19
|
+
category: 'maintainability',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
detect(context) {
|
|
24
|
+
const violations = [];
|
|
25
|
+
|
|
26
|
+
traverse(context.ast, {
|
|
27
|
+
VariableDeclarator: (path) => {
|
|
28
|
+
if (
|
|
29
|
+
t.isCallExpression(path.node.init) &&
|
|
30
|
+
(t.isIdentifier(path.node.init.callee, { name: 'useState' }) ||
|
|
31
|
+
(t.isMemberExpression(path.node.init.callee) && t.isIdentifier(path.node.init.callee.property, { name: 'useState' }))) &&
|
|
32
|
+
t.isArrayPattern(path.node.id) &&
|
|
33
|
+
path.node.id.elements.length > 0
|
|
34
|
+
) {
|
|
35
|
+
const stateVar = path.node.id.elements[0];
|
|
36
|
+
|
|
37
|
+
if (t.isIdentifier(stateVar)) {
|
|
38
|
+
const name = stateVar.name;
|
|
39
|
+
const binding = path.scope.getBinding(name);
|
|
40
|
+
|
|
41
|
+
if (binding && !binding.referenced) {
|
|
42
|
+
violations.push(
|
|
43
|
+
this.createViolation({
|
|
44
|
+
filePath: context.filePath,
|
|
45
|
+
line: path.node.loc ? path.node.loc.start.line : 0,
|
|
46
|
+
column: path.node.loc ? path.node.loc.start.column : 0,
|
|
47
|
+
message: `State variable "${name}" is never used.`,
|
|
48
|
+
suggestion: 'Remove unused state to improve maintainability and avoid confusion.',
|
|
49
|
+
codeSnippet: getCodeSnippet(context.lines, path.node.loc ? path.node.loc.start.line : 0),
|
|
50
|
+
})
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return violations;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { R009_UnusedState };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file rules/index.js
|
|
3
|
+
* @description
|
|
4
|
+
* Bootstraps the Rule Registry by loading and registering all built-in rules.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const { ruleRegistry } = require('./registry/RuleRegistry');
|
|
10
|
+
const { RuleEngine } = require('./engine/RuleEngine');
|
|
11
|
+
|
|
12
|
+
// Import all rules
|
|
13
|
+
const { R001_MissingKeyProp } = require('./implementations/R001_MissingKeyProp');
|
|
14
|
+
const { R002_InfiniteUseEffect } = require('./implementations/R002_InfiniteUseEffect');
|
|
15
|
+
const { R003_IncorrectDepsArray } = require('./implementations/R003_IncorrectDepsArray');
|
|
16
|
+
const { R004_LargeComponent } = require('./implementations/R004_LargeComponent');
|
|
17
|
+
const { R005_InlineCallback } = require('./implementations/R005_InlineCallback');
|
|
18
|
+
const { R006_HeavyRenderCalc } = require('./implementations/R006_HeavyRenderCalc');
|
|
19
|
+
const { R007_MemoCandidate } = require('./implementations/R007_MemoCandidate');
|
|
20
|
+
const { R008_NestedComponent } = require('./implementations/R008_NestedComponent');
|
|
21
|
+
const { R009_UnusedState } = require('./implementations/R009_UnusedState');
|
|
22
|
+
|
|
23
|
+
// Register them
|
|
24
|
+
ruleRegistry.register(new R001_MissingKeyProp());
|
|
25
|
+
ruleRegistry.register(new R002_InfiniteUseEffect());
|
|
26
|
+
ruleRegistry.register(new R003_IncorrectDepsArray());
|
|
27
|
+
ruleRegistry.register(new R004_LargeComponent());
|
|
28
|
+
ruleRegistry.register(new R005_InlineCallback());
|
|
29
|
+
ruleRegistry.register(new R006_HeavyRenderCalc());
|
|
30
|
+
ruleRegistry.register(new R007_MemoCandidate());
|
|
31
|
+
ruleRegistry.register(new R008_NestedComponent());
|
|
32
|
+
ruleRegistry.register(new R009_UnusedState());
|
|
33
|
+
|
|
34
|
+
module.exports = {
|
|
35
|
+
ruleRegistry,
|
|
36
|
+
RuleEngine,
|
|
37
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file rules/registry/RuleRegistry.js
|
|
3
|
+
* @description
|
|
4
|
+
* Manages the registration and lifecycle of all analysis rules.
|
|
5
|
+
* Provides lookup capabilities and handles enable/disable filtering.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
class RuleRegistry {
|
|
11
|
+
constructor() {
|
|
12
|
+
/** @type {Map<string, import('../../base/AbstractRule').AbstractRule>} */
|
|
13
|
+
this.rules = new Map();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register a new rule instance.
|
|
18
|
+
* @param {import('../../base/AbstractRule').AbstractRule} ruleInstance
|
|
19
|
+
*/
|
|
20
|
+
register(ruleInstance) {
|
|
21
|
+
if (this.rules.has(ruleInstance.id)) {
|
|
22
|
+
throw new Error(`Rule with ID ${ruleInstance.id} is already registered.`);
|
|
23
|
+
}
|
|
24
|
+
this.rules.set(ruleInstance.id, ruleInstance);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get all registered rules.
|
|
29
|
+
* @returns {import('../../base/AbstractRule').AbstractRule[]}
|
|
30
|
+
*/
|
|
31
|
+
getAllRules() {
|
|
32
|
+
return Array.from(this.rules.values());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get rules that should be active for a given configuration.
|
|
37
|
+
* Handles enable-lists and disable-lists.
|
|
38
|
+
* @param {import('../../../types').ResolvedConfig} config
|
|
39
|
+
* @returns {import('../../base/AbstractRule').AbstractRule[]}
|
|
40
|
+
*/
|
|
41
|
+
getActiveRules(config) {
|
|
42
|
+
return this.getAllRules().filter((rule) => {
|
|
43
|
+
// If rule is explicitly disabled, skip it
|
|
44
|
+
if (config.disabledRules.has(rule.id)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
// If an allowlist is provided, only include rules in the allowlist
|
|
48
|
+
if (config.enabledRules !== null && !config.enabledRules.has(rule.id)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get a specific rule by ID.
|
|
57
|
+
* @param {string} id
|
|
58
|
+
* @returns {import('../../base/AbstractRule').AbstractRule | undefined}
|
|
59
|
+
*/
|
|
60
|
+
getRule(id) {
|
|
61
|
+
return this.rules.get(id);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Export a singleton instance
|
|
66
|
+
const ruleRegistry = new RuleRegistry();
|
|
67
|
+
module.exports = { ruleRegistry, RuleRegistry };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file scanner/FileScanner.js
|
|
3
|
+
* @description
|
|
4
|
+
* Discovers React source files (.js, .jsx, .ts, .tsx) in the given directory.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fg = require('fast-glob');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
class FileScanner {
|
|
13
|
+
/**
|
|
14
|
+
* Find all React source files in the project.
|
|
15
|
+
* @param {string} rootDir
|
|
16
|
+
* @param {import('../types').ResolvedConfig} config
|
|
17
|
+
* @returns {Promise<string[]>} List of absolute file paths
|
|
18
|
+
*/
|
|
19
|
+
static async discoverFiles(rootDir, config) {
|
|
20
|
+
// Build ignore patterns
|
|
21
|
+
const ignore = config.ignoreDirs.map(dir => `**/${dir}/**`);
|
|
22
|
+
|
|
23
|
+
// We only care about JS/TS/JSX/TSX files
|
|
24
|
+
const patterns = [
|
|
25
|
+
'**/*.js',
|
|
26
|
+
'**/*.jsx',
|
|
27
|
+
'**/*.ts',
|
|
28
|
+
'**/*.tsx',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const files = await fg(patterns, {
|
|
32
|
+
cwd: rootDir,
|
|
33
|
+
ignore,
|
|
34
|
+
absolute: true,
|
|
35
|
+
dot: false,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return files;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { FileScanner };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file scanner/ProjectScanner.js
|
|
3
|
+
* @description
|
|
4
|
+
* Orchestrates the full project scan: discovers files, parses them,
|
|
5
|
+
* runs the rule engine, and collects the final results.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { FileScanner } = require('./FileScanner');
|
|
13
|
+
const { parseFile } = require('../parser/BabelParser');
|
|
14
|
+
const { RuleEngine } = require('../rules/engine/RuleEngine');
|
|
15
|
+
const { ComponentDetector } = require('../parser/ComponentDetector');
|
|
16
|
+
|
|
17
|
+
class ProjectScanner {
|
|
18
|
+
/**
|
|
19
|
+
* Run a full project scan.
|
|
20
|
+
* @param {string} rootDir
|
|
21
|
+
* @param {import('../types').ResolvedConfig} config
|
|
22
|
+
* @returns {Promise<import('../types').ScanResult>}
|
|
23
|
+
*/
|
|
24
|
+
static async scan(rootDir, config) {
|
|
25
|
+
const startedAt = new Date().toISOString();
|
|
26
|
+
const startTime = Date.now();
|
|
27
|
+
|
|
28
|
+
const filePaths = await FileScanner.discoverFiles(rootDir, config);
|
|
29
|
+
|
|
30
|
+
/** @type {import('../types').FileAnalysisResult[]} */
|
|
31
|
+
const files = [];
|
|
32
|
+
|
|
33
|
+
const allViolations = [];
|
|
34
|
+
let parseErrors = 0;
|
|
35
|
+
|
|
36
|
+
for (const filePath of filePaths) {
|
|
37
|
+
const fileStartTime = Date.now();
|
|
38
|
+
const relativePath = path.relative(rootDir, filePath);
|
|
39
|
+
const source = fs.readFileSync(filePath, 'utf-8');
|
|
40
|
+
const lines = source.split(/\r?\n/);
|
|
41
|
+
|
|
42
|
+
const { ast, error } = parseFile(filePath, source);
|
|
43
|
+
|
|
44
|
+
if (error || !ast) {
|
|
45
|
+
parseErrors++;
|
|
46
|
+
files.push({
|
|
47
|
+
filePath,
|
|
48
|
+
relativePath,
|
|
49
|
+
violations: [],
|
|
50
|
+
analysisDurationMs: Date.now() - fileStartTime,
|
|
51
|
+
parsed: false,
|
|
52
|
+
parseError: error ? error.message : 'Unknown parse error',
|
|
53
|
+
lineCount: lines.length,
|
|
54
|
+
componentNames: [],
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// We only care about files that actually have components for some rules,
|
|
60
|
+
// but we run all rules and let them decide. We extract component names just for the report.
|
|
61
|
+
const components = ComponentDetector.extract(ast);
|
|
62
|
+
const componentNames = components.map(c => c.name);
|
|
63
|
+
|
|
64
|
+
const context = {
|
|
65
|
+
ast,
|
|
66
|
+
filePath,
|
|
67
|
+
fileContent: source,
|
|
68
|
+
lines,
|
|
69
|
+
config,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const violations = RuleEngine.run(context);
|
|
73
|
+
allViolations.push(...violations);
|
|
74
|
+
|
|
75
|
+
files.push({
|
|
76
|
+
filePath,
|
|
77
|
+
relativePath,
|
|
78
|
+
violations,
|
|
79
|
+
analysisDurationMs: Date.now() - fileStartTime,
|
|
80
|
+
parsed: true,
|
|
81
|
+
lineCount: lines.length,
|
|
82
|
+
componentNames,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const durationMs = Date.now() - startTime;
|
|
87
|
+
|
|
88
|
+
// Aggregate stats
|
|
89
|
+
const violationsBySeverity = { critical: 0, warning: 0, info: 0 };
|
|
90
|
+
const violationsByCategory = { rendering: 0, hooks: 0, architecture: 0, maintainability: 0 };
|
|
91
|
+
const violationsByRule = {};
|
|
92
|
+
|
|
93
|
+
for (const v of allViolations) {
|
|
94
|
+
violationsBySeverity[v.severity] = (violationsBySeverity[v.severity] || 0) + 1;
|
|
95
|
+
violationsByCategory[v.category] = (violationsByCategory[v.category] || 0) + 1;
|
|
96
|
+
violationsByRule[v.ruleId] = (violationsByRule[v.ruleId] || 0) + 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
scanRoot: rootDir,
|
|
101
|
+
startedAt,
|
|
102
|
+
completedAt: new Date().toISOString(),
|
|
103
|
+
durationMs,
|
|
104
|
+
files,
|
|
105
|
+
totalFiles: filePaths.length,
|
|
106
|
+
parseErrors,
|
|
107
|
+
allViolations,
|
|
108
|
+
totalViolations: allViolations.length,
|
|
109
|
+
violationsBySeverity,
|
|
110
|
+
violationsByCategory,
|
|
111
|
+
violationsByRule,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { ProjectScanner };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file score/CategoryScorer.js
|
|
3
|
+
* @description
|
|
4
|
+
* Calculates the score for an individual category.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const { SEVERITY_PENALTY, CATEGORY_WEIGHT } = require('./ScoreWeights');
|
|
10
|
+
|
|
11
|
+
class CategoryScorer {
|
|
12
|
+
/**
|
|
13
|
+
* Calculate a grade based on a numeric score.
|
|
14
|
+
* @param {number} score
|
|
15
|
+
* @returns {import('../types').Grade}
|
|
16
|
+
*/
|
|
17
|
+
static getGrade(score) {
|
|
18
|
+
if (score >= 90) return 'A';
|
|
19
|
+
if (score >= 75) return 'B';
|
|
20
|
+
if (score >= 60) return 'C';
|
|
21
|
+
if (score >= 40) return 'D';
|
|
22
|
+
return 'F';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Calculate the score for a specific category.
|
|
27
|
+
* @param {import('../types').Category} category
|
|
28
|
+
* @param {import('../types').RuleViolation[]} violations
|
|
29
|
+
* @returns {import('../types').CategoryScore}
|
|
30
|
+
*/
|
|
31
|
+
static scoreCategory(category, violations) {
|
|
32
|
+
const categoryViolations = violations.filter(v => v.category === category);
|
|
33
|
+
|
|
34
|
+
let penalty = 0;
|
|
35
|
+
for (const v of categoryViolations) {
|
|
36
|
+
const base = SEVERITY_PENALTY[v.severity] || 0;
|
|
37
|
+
const weight = CATEGORY_WEIGHT[category] || 1;
|
|
38
|
+
penalty += (base * weight);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const score = Math.max(0, Math.round(100 - penalty));
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
category,
|
|
45
|
+
score,
|
|
46
|
+
grade: this.getGrade(score),
|
|
47
|
+
violations: categoryViolations.length,
|
|
48
|
+
penaltyApplied: penalty,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { CategoryScorer };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file score/ScoreCalculator.js
|
|
3
|
+
* @description
|
|
4
|
+
* Calculates the overall performance score and aggregates category scores.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const { CategoryScorer } = require('./CategoryScorer');
|
|
10
|
+
const { SEVERITY_PENALTY, CATEGORY_WEIGHT } = require('./ScoreWeights');
|
|
11
|
+
|
|
12
|
+
class ScoreCalculator {
|
|
13
|
+
/**
|
|
14
|
+
* Get human-readable label for a score.
|
|
15
|
+
* @param {number} score
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
static getLabel(score) {
|
|
19
|
+
if (score >= 90) return 'Excellent';
|
|
20
|
+
if (score >= 75) return 'Good';
|
|
21
|
+
if (score >= 60) return 'Needs Work';
|
|
22
|
+
if (score >= 40) return 'Poor';
|
|
23
|
+
return 'Critical';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get top recommendation based on violations.
|
|
28
|
+
* @param {import('../types').RuleViolation[]} violations
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
static getTopRecommendation(violations) {
|
|
32
|
+
if (violations.length === 0) return 'Keep up the great work!';
|
|
33
|
+
|
|
34
|
+
const critical = violations.filter(v => v.severity === 'critical');
|
|
35
|
+
if (critical.length > 0) {
|
|
36
|
+
return `Fix ${critical.length} critical issues immediately to prevent infinite loops and broken UI.`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rendering = violations.filter(v => v.category === 'rendering' && v.severity === 'warning');
|
|
40
|
+
if (rendering.length > 0) {
|
|
41
|
+
return `Focus on reducing unnecessary rerenders by fixing keys and memoizing heavy calculations.`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return 'Review the warnings to further optimize component performance.';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compute the full performance score.
|
|
49
|
+
* @param {import('../types').RuleViolation[]} violations
|
|
50
|
+
* @returns {import('../types').PerformanceScore}
|
|
51
|
+
*/
|
|
52
|
+
static compute(violations) {
|
|
53
|
+
let totalPenalty = 0;
|
|
54
|
+
let criticalPenalty = 0;
|
|
55
|
+
|
|
56
|
+
const penaltyBySeverity = { critical: 0, warning: 0, info: 0 };
|
|
57
|
+
|
|
58
|
+
for (const v of violations) {
|
|
59
|
+
const base = SEVERITY_PENALTY[v.severity] || 0;
|
|
60
|
+
const weight = CATEGORY_WEIGHT[v.category] || 1;
|
|
61
|
+
const penalty = base * weight;
|
|
62
|
+
|
|
63
|
+
totalPenalty += penalty;
|
|
64
|
+
penaltyBySeverity[v.severity] += penalty;
|
|
65
|
+
|
|
66
|
+
if (v.severity === 'critical') {
|
|
67
|
+
criticalPenalty += penalty;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const overallScore = Math.max(0, Math.round(100 - totalPenalty));
|
|
72
|
+
|
|
73
|
+
const categories = [
|
|
74
|
+
CategoryScorer.scoreCategory('rendering', violations),
|
|
75
|
+
CategoryScorer.scoreCategory('hooks', violations),
|
|
76
|
+
CategoryScorer.scoreCategory('architecture', violations),
|
|
77
|
+
CategoryScorer.scoreCategory('maintainability', violations),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const estimatedImprovementOnCriticalFix = Math.min(100 - overallScore, Math.round(criticalPenalty));
|
|
81
|
+
const estimatedImprovementOnAllFix = Math.min(100 - overallScore, Math.round(totalPenalty));
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
overall: overallScore,
|
|
85
|
+
grade: CategoryScorer.getGrade(overallScore),
|
|
86
|
+
label: this.getLabel(overallScore),
|
|
87
|
+
categories,
|
|
88
|
+
totalPenalty,
|
|
89
|
+
penaltyBySeverity,
|
|
90
|
+
estimatedImprovementOnCriticalFix,
|
|
91
|
+
estimatedImprovementOnAllFix,
|
|
92
|
+
topRecommendation: this.getTopRecommendation(violations),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { ScoreCalculator };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file score/ScoreWeights.js
|
|
3
|
+
* @description
|
|
4
|
+
* Defines the constant weights and penalties for the scoring algorithm.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Base penalty applied per violation based on severity.
|
|
11
|
+
* @type {Record<import('../types').Severity, number>}
|
|
12
|
+
*/
|
|
13
|
+
const SEVERITY_PENALTY = {
|
|
14
|
+
critical: 15,
|
|
15
|
+
warning: 5,
|
|
16
|
+
info: 1,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Multiplier applied to the penalty based on category.
|
|
21
|
+
* @type {Record<import('../types').Category, number>}
|
|
22
|
+
*/
|
|
23
|
+
const CATEGORY_WEIGHT = {
|
|
24
|
+
rendering: 1.5,
|
|
25
|
+
hooks: 1.3,
|
|
26
|
+
architecture: 1.0,
|
|
27
|
+
maintainability: 0.7,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
SEVERITY_PENALTY,
|
|
32
|
+
CATEGORY_WEIGHT,
|
|
33
|
+
};
|