performance-optimizer 2.5.0__py3-none-any.whl
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.
- analyzers/__init__.py +0 -0
- analyzers/angular_analyzer.py +406 -0
- analyzers/aws_analyzer.py +122 -0
- analyzers/node_analyzer.py +306 -0
- analyzers/react_analyzer.py +214 -0
- analyzers/sql_analyzer.py +184 -0
- core/__init__.py +0 -0
- core/issue.py +67 -0
- core/scorer.py +94 -0
- optimizer.py +339 -0
- performance_optimizer-2.5.0.dist-info/METADATA +236 -0
- performance_optimizer-2.5.0.dist-info/RECORD +18 -0
- performance_optimizer-2.5.0.dist-info/WHEEL +5 -0
- performance_optimizer-2.5.0.dist-info/entry_points.txt +3 -0
- performance_optimizer-2.5.0.dist-info/licenses/LICENSE +21 -0
- performance_optimizer-2.5.0.dist-info/top_level.txt +4 -0
- reporter/__init__.py +0 -0
- reporter/html_reporter.py +1502 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Tuple
|
|
6
|
+
from core.issue import Issue, Severity, Layer
|
|
7
|
+
|
|
8
|
+
class NodeAnalyzer:
|
|
9
|
+
def __init__(self, path: str):
|
|
10
|
+
self.path = Path(path)
|
|
11
|
+
self.issues: List[Issue] = []
|
|
12
|
+
|
|
13
|
+
def analyze(self) -> List[Issue]:
|
|
14
|
+
for f in self.path.rglob('*.js'):
|
|
15
|
+
if any(x in str(f) for x in ['node_modules', 'dist/', '.min.js', 'test/', 'spec/', '.git']):
|
|
16
|
+
continue
|
|
17
|
+
try:
|
|
18
|
+
src = f.read_text(encoding='utf-8', errors='ignore')
|
|
19
|
+
rel = str(f.relative_to(self.path)).replace('\\', '/')
|
|
20
|
+
lines = src.splitlines()
|
|
21
|
+
|
|
22
|
+
self._check_async_patterns(src, lines, rel)
|
|
23
|
+
self._check_seneca(src, lines, rel)
|
|
24
|
+
self._check_db_patterns(src, lines, rel)
|
|
25
|
+
self._check_api_responses(src, lines, rel)
|
|
26
|
+
self._check_memory_and_events(src, lines, rel)
|
|
27
|
+
self._check_network(src, lines, rel)
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
for f in self.path.rglob('*.json'):
|
|
32
|
+
if any(x in str(f) for x in ['node_modules', 'dist/', '.git']):
|
|
33
|
+
continue
|
|
34
|
+
name = f.name.lower()
|
|
35
|
+
if 'config' in name or 'database' in name or 'db' in name:
|
|
36
|
+
try:
|
|
37
|
+
src = f.read_text(encoding='utf-8', errors='ignore')
|
|
38
|
+
rel = str(f.relative_to(self.path)).replace('\\', '/')
|
|
39
|
+
lines = src.splitlines()
|
|
40
|
+
self._check_db_config(src, lines, rel)
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
return self.issues
|
|
45
|
+
|
|
46
|
+
def _is_suppressed(self, lines: List[str], line_idx: int, rule_id: str) -> bool:
|
|
47
|
+
check_lines = []
|
|
48
|
+
if 0 <= line_idx < len(lines):
|
|
49
|
+
check_lines.append(lines[line_idx])
|
|
50
|
+
if 0 <= line_idx - 1 < len(lines):
|
|
51
|
+
check_lines.append(lines[line_idx - 1])
|
|
52
|
+
for cl in check_lines:
|
|
53
|
+
if f'perf-ignore {rule_id}' in cl or 'perf-ignore-all' in cl:
|
|
54
|
+
return True
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
def _find_line(self, lines: List[str], regex_or_str) -> Tuple[int, str]:
|
|
58
|
+
for idx, line in enumerate(lines, 1):
|
|
59
|
+
if isinstance(regex_or_str, str) and regex_or_str in line:
|
|
60
|
+
return idx, line.strip()
|
|
61
|
+
elif hasattr(regex_or_str, 'search') and regex_or_str.search(line):
|
|
62
|
+
return idx, line.strip()
|
|
63
|
+
return 1, (lines[0].strip() if lines else '')
|
|
64
|
+
|
|
65
|
+
def _check_async_patterns(self, src: str, lines: List[str], rel: str):
|
|
66
|
+
# NODE001: Sync fs calls inside request handlers or functions
|
|
67
|
+
# Distinguish top-level boot calls vs inside functions
|
|
68
|
+
in_fn = False
|
|
69
|
+
for idx, line in enumerate(lines, 1):
|
|
70
|
+
if any(k in line for k in ['function', '=>', 'async', 'class']):
|
|
71
|
+
in_fn = True
|
|
72
|
+
if in_fn and any(sync_call in line for sync_call in ['fs.readFileSync', 'fs.writeFileSync', 'fs.readdirSync']):
|
|
73
|
+
if not self._is_suppressed(lines, idx - 1, 'NODE001'):
|
|
74
|
+
self.issues.append(Issue(
|
|
75
|
+
id='NODE001',
|
|
76
|
+
title='Synchronous fs Call in Runtime Execution Path',
|
|
77
|
+
description='Calling synchronous filesystem methods (readFileSync / writeFileSync) inside functions blocks the Node.js event loop. All concurrent requests wait until disk I/O completes.',
|
|
78
|
+
fix='Switch to non-blocking `fs.promises.readFile` with `await` or use streams.',
|
|
79
|
+
code_before='const raw = fs.readFileSync(filePath, "utf8"); // Blocks event loop',
|
|
80
|
+
code_after='const raw = await fs.promises.readFile(filePath, "utf8"); // Non-blocking',
|
|
81
|
+
file=rel,
|
|
82
|
+
line_number=idx,
|
|
83
|
+
code_snippet=line.strip(),
|
|
84
|
+
category='Event Loop & Concurrency',
|
|
85
|
+
severity=Severity.HIGH,
|
|
86
|
+
layer=Layer.BACKEND,
|
|
87
|
+
impact=8,
|
|
88
|
+
effort=2,
|
|
89
|
+
occurrences=1,
|
|
90
|
+
perf_gain='Unblocks Node.js event loop for concurrent traffic'
|
|
91
|
+
))
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
# NODE002: Sequential await for independent promises
|
|
95
|
+
for i in range(len(lines) - 1):
|
|
96
|
+
line_a = lines[i]
|
|
97
|
+
line_b = lines[i + 1]
|
|
98
|
+
if re.search(r'const\s+(\w+)\s*=\s*await\s+\w+', line_a) and re.search(r'const\s+(\w+)\s*=\s*await\s+\w+', line_b):
|
|
99
|
+
var_a = re.search(r'const\s+(\w+)', line_a).group(1)
|
|
100
|
+
# If line_b does not reference var_a, they are likely independent
|
|
101
|
+
if var_a not in line_b:
|
|
102
|
+
if not self._is_suppressed(lines, i, 'NODE002'):
|
|
103
|
+
self.issues.append(Issue(
|
|
104
|
+
id='NODE002',
|
|
105
|
+
title='Sequential await on Independent Operations',
|
|
106
|
+
description='Consecutive awaits run sequentially, accumulating latency (e.g. 200ms + 150ms = 350ms). Running them concurrently reduces total latency to the slowest operation.',
|
|
107
|
+
fix='Parallelize using `Promise.all([op1(), op2()])`.',
|
|
108
|
+
code_before='const user = await getUser(userId);\nconst config = await getAppConfig(); // Waits for user!',
|
|
109
|
+
code_after='const [user, config] = await Promise.all([\n getUser(userId),\n getAppConfig()\n]); // Runs in parallel',
|
|
110
|
+
file=rel,
|
|
111
|
+
line_number=i + 1,
|
|
112
|
+
code_snippet=f"{line_a.strip()} \\n {line_b.strip()}",
|
|
113
|
+
category='Latency & Parallelism',
|
|
114
|
+
severity=Severity.HIGH,
|
|
115
|
+
layer=Layer.BACKEND,
|
|
116
|
+
impact=7,
|
|
117
|
+
effort=2,
|
|
118
|
+
occurrences=1,
|
|
119
|
+
perf_gain='Reduces aggregate async operation latency by up to 50%'
|
|
120
|
+
))
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
def _check_seneca(self, src: str, lines: List[str], rel: str):
|
|
124
|
+
if 'seneca' not in src.lower() and 'seneca' not in rel.lower():
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# SEN001: seneca.act inside loop
|
|
128
|
+
for idx, line in enumerate(lines, 1):
|
|
129
|
+
if ('.act(' in line or 'seneca.act' in line):
|
|
130
|
+
prev_block = '\n'.join(lines[max(0, idx - 8):idx])
|
|
131
|
+
if any(loop_k in prev_block for loop_k in ['for (', 'for(', 'forEach(', 'for await']):
|
|
132
|
+
if not self._is_suppressed(lines, idx - 1, 'SEN001'):
|
|
133
|
+
self.issues.append(Issue(
|
|
134
|
+
id='SEN001',
|
|
135
|
+
title='Seneca .act() Called Inside Loop (Sequential RPC)',
|
|
136
|
+
description='Firing microservice .act() calls inside a loop sequentially serializes network RPCs. 50 items at 20ms each = 1,000ms latency.',
|
|
137
|
+
fix='Batch commands into a single message (`cmd:batchUpdate`) or parallelize via `Promise.all`.',
|
|
138
|
+
code_before='for (const item of items) {\n await seneca.act({ role: "store", cmd: "update", item });\n}',
|
|
139
|
+
code_after='await seneca.act({ role: "store", cmd: "batchUpdate", items });',
|
|
140
|
+
file=rel,
|
|
141
|
+
line_number=idx,
|
|
142
|
+
code_snippet=line.strip(),
|
|
143
|
+
category='Microservice RPC',
|
|
144
|
+
severity=Severity.CRITICAL,
|
|
145
|
+
layer=Layer.BACKEND,
|
|
146
|
+
impact=9,
|
|
147
|
+
effort=4,
|
|
148
|
+
occurrences=1,
|
|
149
|
+
perf_gain='Reduces N×latency to max(latency) for batch operations'
|
|
150
|
+
))
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
def _check_db_patterns(self, src: str, lines: List[str], rel: str):
|
|
154
|
+
# NODE004: N+1 Database queries inside loop
|
|
155
|
+
for idx, line in enumerate(lines, 1):
|
|
156
|
+
if any(q in line for q in ['.query(', '.execute(', '.find(', '.findOne(', 'db.']):
|
|
157
|
+
prev_chunk = '\n'.join(lines[max(0, idx - 8):idx])
|
|
158
|
+
if any(loop_kw in prev_chunk for loop_kw in ['for (', 'for(', 'forEach(', '.map(', 'for await']):
|
|
159
|
+
if not self._is_suppressed(lines, idx - 1, 'NODE004'):
|
|
160
|
+
self.issues.append(Issue(
|
|
161
|
+
id='NODE004',
|
|
162
|
+
title='N+1 Database Query Pattern in Loop',
|
|
163
|
+
description='Database query executed inside iteration loop. Triggers 1 + N network roundtrips to the database, exhausting connection pools and causing massive latency spikes under load.',
|
|
164
|
+
fix='Use a single SQL `JOIN` query or `WHERE id IN (...)` to retrieve all related records at once.',
|
|
165
|
+
code_before='for (const order of orders) {\n order.user = await db.query("SELECT * FROM users WHERE id = ?", [order.userId]);\n}',
|
|
166
|
+
code_after='// Single JOIN query\nconst orders = await db.query(`\n SELECT o.*, u.name, u.email FROM orders o\n JOIN users u ON u.id = o.user_id\n`);',
|
|
167
|
+
file=rel,
|
|
168
|
+
line_number=idx,
|
|
169
|
+
code_snippet=line.strip(),
|
|
170
|
+
category='Database I/O',
|
|
171
|
+
severity=Severity.CRITICAL,
|
|
172
|
+
layer=Layer.BACKEND,
|
|
173
|
+
impact=10,
|
|
174
|
+
effort=3,
|
|
175
|
+
occurrences=1,
|
|
176
|
+
perf_gain='Replaces O(N) database queries with O(1)'
|
|
177
|
+
))
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
# NODE005: Unpaginated DB query
|
|
181
|
+
if ('SELECT *' in src or 'find({})' in src or 'findAll()' in src) and 'LIMIT' not in src and 'limit' not in src:
|
|
182
|
+
line_no, snippet = self._find_line(lines, re.compile(r'(?:SELECT\s+\*|find\(\{\}\)|findAll\(\))'))
|
|
183
|
+
if not self._is_suppressed(lines, line_no - 1, 'NODE005'):
|
|
184
|
+
self.issues.append(Issue(
|
|
185
|
+
id='NODE005',
|
|
186
|
+
title='Database Query Returns All Rows (No LIMIT / Pagination)',
|
|
187
|
+
description='Query lacks a LIMIT clause. On production datasets with tens of thousands of rows, this exhausts memory, locks DB cursors, and transmits massive payloads.',
|
|
188
|
+
fix='Add LIMIT and OFFSET or cursor-based pagination to the query.',
|
|
189
|
+
code_before='const users = await db.query("SELECT * FROM users");',
|
|
190
|
+
code_after='const users = await db.query("SELECT * FROM users LIMIT ? OFFSET ?", [limit, offset]);',
|
|
191
|
+
file=rel,
|
|
192
|
+
line_number=line_no,
|
|
193
|
+
code_snippet=snippet,
|
|
194
|
+
category='Database I/O',
|
|
195
|
+
severity=Severity.CRITICAL,
|
|
196
|
+
layer=Layer.BACKEND,
|
|
197
|
+
impact=10,
|
|
198
|
+
effort=2,
|
|
199
|
+
occurrences=1,
|
|
200
|
+
perf_gain='Caps query memory and network transfer to predictable bounds'
|
|
201
|
+
))
|
|
202
|
+
|
|
203
|
+
def _check_api_responses(self, src: str, lines: List[str], rel: str):
|
|
204
|
+
# NODE008: Missing compression middleware in Express
|
|
205
|
+
if 'express' in src and 'compression' not in src and ('listen(' in src or 'app.use' in src):
|
|
206
|
+
line_no, snippet = self._find_line(lines, re.compile(r'express\(\)'))
|
|
207
|
+
if not self._is_suppressed(lines, line_no - 1, 'NODE008'):
|
|
208
|
+
self.issues.append(Issue(
|
|
209
|
+
id='NODE008',
|
|
210
|
+
title='Express Server Missing Gzip/Brotli Response Compression',
|
|
211
|
+
description='HTTP JSON responses are sent uncompressed. Modern gzip or brotli compression reduces JSON payload sizes by 70% to 90%, speeding up API response times on mobile and slow networks.',
|
|
212
|
+
fix='Add `app.use(compression());` with the `compression` middleware.',
|
|
213
|
+
code_before='const app = express();\napp.use(routes);',
|
|
214
|
+
code_after='const compression = require("compression");\nconst app = express();\napp.use(compression());\napp.use(routes);',
|
|
215
|
+
file=rel,
|
|
216
|
+
line_number=line_no,
|
|
217
|
+
code_snippet=snippet,
|
|
218
|
+
category='Network Optimization',
|
|
219
|
+
severity=Severity.MEDIUM,
|
|
220
|
+
layer=Layer.BACKEND,
|
|
221
|
+
impact=6,
|
|
222
|
+
effort=1,
|
|
223
|
+
occurrences=1,
|
|
224
|
+
perf_gain='Reduces network payload sizes by 70-90%'
|
|
225
|
+
))
|
|
226
|
+
|
|
227
|
+
def _check_memory_and_events(self, src: str, lines: List[str], rel: str):
|
|
228
|
+
# NODE011: EventEmitter leak (on without removeListener/off)
|
|
229
|
+
on_count = len(re.findall(r'\.on\(', src))
|
|
230
|
+
off_count = len(re.findall(r'\.(?:off|removeListener|removeAllListeners)\(', src))
|
|
231
|
+
if on_count >= 3 and off_count == 0 and ('emitter' in src.lower() or 'event' in src.lower()):
|
|
232
|
+
line_no, snippet = self._find_line(lines, '.on(')
|
|
233
|
+
if not self._is_suppressed(lines, line_no - 1, 'NODE011'):
|
|
234
|
+
self.issues.append(Issue(
|
|
235
|
+
id='NODE011',
|
|
236
|
+
title='Potential EventEmitter Memory Leak (Missing Listener Teardown)',
|
|
237
|
+
description=f'Found {on_count} `.on(...)` listener registrations with no corresponding `.off()` or `.removeListener()`. Retains closures and objects in memory across request lifecycles.',
|
|
238
|
+
fix='Ensure event listeners are cleaned up or use `events.once()` for one-time events.',
|
|
239
|
+
code_before='emitter.on("data", handler); // Listener never removed',
|
|
240
|
+
code_after='emitter.once("data", handler); // Or emitter.off("data", handler) in cleanup',
|
|
241
|
+
file=rel,
|
|
242
|
+
line_number=line_no,
|
|
243
|
+
code_snippet=snippet,
|
|
244
|
+
category='Memory Leak',
|
|
245
|
+
severity=Severity.HIGH,
|
|
246
|
+
layer=Layer.BACKEND,
|
|
247
|
+
impact=7,
|
|
248
|
+
effort=2,
|
|
249
|
+
occurrences=on_count,
|
|
250
|
+
perf_gain='Eliminates progressive heap memory growth in long-running processes'
|
|
251
|
+
))
|
|
252
|
+
|
|
253
|
+
def _check_network(self, src: str, lines: List[str], rel: str):
|
|
254
|
+
# NODE012: Missing HTTP keep-alive for microservice/API clients
|
|
255
|
+
if ('axios' in src or 'fetch(' in src or 'http.request' in src) and 'keepAlive' not in src and 'Agent' not in src:
|
|
256
|
+
line_no, snippet = self._find_line(lines, re.compile(r'(?:axios|http\.request)'))
|
|
257
|
+
if line_no > 0 and not self._is_suppressed(lines, line_no - 1, 'NODE012'):
|
|
258
|
+
self.issues.append(Issue(
|
|
259
|
+
id='NODE012',
|
|
260
|
+
title='HTTP Requests Without Connection Reuse (Missing keepAlive: true)',
|
|
261
|
+
description='Outgoing HTTP requests create a new TCP + TLS handshake for every call. Enabling HTTP keep-alive reuses existing TCP sockets, eliminating 50-100ms connection overhead per RPC.',
|
|
262
|
+
fix='Configure `http.Agent({ keepAlive: true })` or pass `{ keepAlive: true }` to your HTTP client.',
|
|
263
|
+
code_before='const agent = new http.Agent(); // Default keepAlive: false',
|
|
264
|
+
code_after='const agent = new http.Agent({ keepAlive: true, maxSockets: 50 });',
|
|
265
|
+
file=rel,
|
|
266
|
+
line_number=line_no,
|
|
267
|
+
code_snippet=snippet,
|
|
268
|
+
category='Latency & Network',
|
|
269
|
+
severity=Severity.MEDIUM,
|
|
270
|
+
layer=Layer.BACKEND,
|
|
271
|
+
impact=6,
|
|
272
|
+
effort=2,
|
|
273
|
+
occurrences=1,
|
|
274
|
+
perf_gain='Saves 50ms-100ms TLS handshake latency on repeated service calls'
|
|
275
|
+
))
|
|
276
|
+
|
|
277
|
+
def _check_db_config(self, src: str, lines: List[str], rel: str):
|
|
278
|
+
# NODE010: Pool max too small
|
|
279
|
+
try:
|
|
280
|
+
cfg = json.loads(src)
|
|
281
|
+
pool = cfg.get('pool', cfg.get('database', {}).get('pool', {}))
|
|
282
|
+
if isinstance(pool, dict):
|
|
283
|
+
max_conn = pool.get('max', pool.get('maximum', None))
|
|
284
|
+
if max_conn is not None and int(max_conn) < 5:
|
|
285
|
+
line_no, snippet = self._find_line(lines, '"max"')
|
|
286
|
+
if not self._is_suppressed(lines, line_no - 1, 'NODE010'):
|
|
287
|
+
self.issues.append(Issue(
|
|
288
|
+
id='NODE010',
|
|
289
|
+
title=f'Database Connection Pool Too Small (max = {max_conn})',
|
|
290
|
+
description=f'Database pool max size configured to only {max_conn}. Under concurrent requests, operations queue waiting for an available DB connection, artificially inflating request latency.',
|
|
291
|
+
fix='Increase pool max to 10-20 connections based on CPU cores and RDS tier.',
|
|
292
|
+
code_before=f'"pool": {{ "max": {max_conn}, "min": 0 }}',
|
|
293
|
+
code_after='"pool": { "max": 15, "min": 2, "acquire": 30000, "idle": 10000 }',
|
|
294
|
+
file=rel,
|
|
295
|
+
line_number=line_no,
|
|
296
|
+
code_snippet=snippet,
|
|
297
|
+
category='Database Configuration',
|
|
298
|
+
severity=Severity.HIGH,
|
|
299
|
+
layer=Layer.BACKEND,
|
|
300
|
+
impact=8,
|
|
301
|
+
effort=1,
|
|
302
|
+
occurrences=1,
|
|
303
|
+
perf_gain='Eliminates connection queue wait delays under concurrent load'
|
|
304
|
+
))
|
|
305
|
+
except Exception:
|
|
306
|
+
pass
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Tuple
|
|
5
|
+
from core.issue import Issue, Severity, Layer
|
|
6
|
+
|
|
7
|
+
class ReactAnalyzer:
|
|
8
|
+
def __init__(self, path: str):
|
|
9
|
+
self.path = Path(path)
|
|
10
|
+
self.issues: List[Issue] = []
|
|
11
|
+
|
|
12
|
+
def analyze(self) -> List[Issue]:
|
|
13
|
+
extensions = ['*.jsx', '*.tsx', '*.js', '*.ts']
|
|
14
|
+
for ext in extensions:
|
|
15
|
+
for f in self.path.rglob(ext):
|
|
16
|
+
if any(x in str(f) for x in ['node_modules', '.spec.', '.test.', 'dist/', 'build/', '.git', '.d.ts']):
|
|
17
|
+
continue
|
|
18
|
+
try:
|
|
19
|
+
src = f.read_text(encoding='utf-8', errors='ignore')
|
|
20
|
+
# Only analyze files that look like React components or hooks
|
|
21
|
+
if not self._is_react_file(src):
|
|
22
|
+
continue
|
|
23
|
+
rel = str(f.relative_to(self.path)).replace('\\', '/')
|
|
24
|
+
lines = src.splitlines()
|
|
25
|
+
|
|
26
|
+
self._check_keys_in_map(src, lines, rel)
|
|
27
|
+
self._check_inline_objects_and_handlers(src, lines, rel)
|
|
28
|
+
self._check_use_effect(src, lines, rel)
|
|
29
|
+
self._check_memo_and_callbacks(src, lines, rel)
|
|
30
|
+
self._check_lazy_loading(src, lines, rel)
|
|
31
|
+
self._check_context_bloat(src, lines, rel)
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
return self.issues
|
|
36
|
+
|
|
37
|
+
def _is_react_file(self, src: str) -> bool:
|
|
38
|
+
return any(k in src for k in ['import React', 'from "react"', "from 'react'", 'useState', 'useEffect', '<div', '</', 'className='])
|
|
39
|
+
|
|
40
|
+
def _is_suppressed(self, lines: List[str], line_idx: int, rule_id: str) -> bool:
|
|
41
|
+
check_lines = []
|
|
42
|
+
if 0 <= line_idx < len(lines):
|
|
43
|
+
check_lines.append(lines[line_idx])
|
|
44
|
+
if 0 <= line_idx - 1 < len(lines):
|
|
45
|
+
check_lines.append(lines[line_idx - 1])
|
|
46
|
+
for cl in check_lines:
|
|
47
|
+
if f'perf-ignore {rule_id}' in cl or 'perf-ignore-all' in cl:
|
|
48
|
+
return True
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def _find_line(self, lines: List[str], regex_or_str) -> Tuple[int, str]:
|
|
52
|
+
for idx, line in enumerate(lines, 1):
|
|
53
|
+
if isinstance(regex_or_str, str) and regex_or_str in line:
|
|
54
|
+
return idx, line.strip()
|
|
55
|
+
elif hasattr(regex_or_str, 'search') and regex_or_str.search(line):
|
|
56
|
+
return idx, line.strip()
|
|
57
|
+
return 1, (lines[0].strip() if lines else '')
|
|
58
|
+
|
|
59
|
+
def _check_keys_in_map(self, src: str, lines: List[str], rel: str):
|
|
60
|
+
# REACT001: Array index as key in .map() or missing key
|
|
61
|
+
map_index_as_key = re.search(r'\.map\(\s*\((?:\w+,\s*(\w+))\)\s*=>\s*<[A-Za-z0-9_]+\s+[^>]*key=\{(\1)\}', src)
|
|
62
|
+
if map_index_as_key:
|
|
63
|
+
line_no, snippet = self._find_line(lines, re.compile(r'key=\{' + map_index_as_key.group(1) + r'\}'))
|
|
64
|
+
if not self._is_suppressed(lines, line_no - 1, 'REACT001'):
|
|
65
|
+
self.issues.append(Issue(
|
|
66
|
+
id='REACT001',
|
|
67
|
+
title='Array Index Used as React Key in List Rendering',
|
|
68
|
+
description='Using array index (`key={index}`) breaks React DOM reconciliation when items are reordered, inserted, or filtered. React re-renders and re-mounts DOM nodes instead of reusing them, degrading 60 FPS performance and breaking form input state.',
|
|
69
|
+
fix='Use stable unique IDs for keys: `key={item.id}`.',
|
|
70
|
+
code_before='items.map((item, index) => (\n <ListItem key={index} data={item} />\n));',
|
|
71
|
+
code_after='items.map(item => (\n <ListItem key={item.id} data={item} />\n));',
|
|
72
|
+
file=rel,
|
|
73
|
+
line_number=line_no,
|
|
74
|
+
code_snippet=snippet,
|
|
75
|
+
category='DOM Reconciliation',
|
|
76
|
+
doc_url='https://react.dev/learn/rendering-lists#why-does-react-need-keys',
|
|
77
|
+
severity=Severity.HIGH,
|
|
78
|
+
layer=Layer.FRONTEND,
|
|
79
|
+
impact=8,
|
|
80
|
+
effort=1,
|
|
81
|
+
occurrences=1,
|
|
82
|
+
perf_gain='Enables React to reuse DOM nodes during list mutations'
|
|
83
|
+
))
|
|
84
|
+
|
|
85
|
+
def _check_inline_objects_and_handlers(self, src: str, lines: List[str], rel: str):
|
|
86
|
+
# REACT002: Inline arrow functions or object literals inside heavy JSX list renders
|
|
87
|
+
inline_handlers = re.findall(r'<\w+[^>]+(?:onClick|onChange)=\{\s*\([^)]*\)\s*=>', src)
|
|
88
|
+
if len(inline_handlers) >= 3 and '.map(' in src:
|
|
89
|
+
line_no, snippet = self._find_line(lines, re.compile(r'(?:onClick|onChange)=\{\s*\([^)]*\)\s*=>'))
|
|
90
|
+
if not self._is_suppressed(lines, line_no - 1, 'REACT002'):
|
|
91
|
+
self.issues.append(Issue(
|
|
92
|
+
id='REACT002',
|
|
93
|
+
title=f'Inline Arrow Functions in JSX Render Loop ({len(inline_handlers)} instances)',
|
|
94
|
+
description='Passing inline arrow functions or new object literals to JSX props in loops allocates a new function instance on every render tick. This defeats `React.memo` child optimizations.',
|
|
95
|
+
fix='Extract handlers with `useCallback` or pass item identifier to a shared handler.',
|
|
96
|
+
code_before='<button onClick={() => handleDelete(item.id)}>Delete</button>',
|
|
97
|
+
code_after='const handleDelete = useCallback((id) => deleteItem(id), []);\n// In component:\n<DeleteItemButton id={item.id} onDelete={handleDelete} />',
|
|
98
|
+
file=rel,
|
|
99
|
+
line_number=line_no,
|
|
100
|
+
code_snippet=snippet,
|
|
101
|
+
category='Re-render Optimization',
|
|
102
|
+
doc_url='https://react.dev/reference/react/useCallback',
|
|
103
|
+
severity=Severity.MEDIUM,
|
|
104
|
+
layer=Layer.FRONTEND,
|
|
105
|
+
impact=7,
|
|
106
|
+
effort=2,
|
|
107
|
+
occurrences=len(inline_handlers),
|
|
108
|
+
perf_gain='Prevents cascading re-renders of memoized child components'
|
|
109
|
+
))
|
|
110
|
+
|
|
111
|
+
def _check_use_effect(self, src: str, lines: List[str], rel: str):
|
|
112
|
+
# REACT003: useEffect with missing dependency array (infinite render loop)
|
|
113
|
+
no_dep_effects = re.findall(r'useEffect\(\s*(?:async\s*)?\(\)\s*=>\s*\{[^}]+(?:\n\s*[^}]+)*\}\s*\)', src)
|
|
114
|
+
if no_dep_effects:
|
|
115
|
+
line_no, snippet = self._find_line(lines, 'useEffect(')
|
|
116
|
+
if not self._is_suppressed(lines, line_no - 1, 'REACT003'):
|
|
117
|
+
self.issues.append(Issue(
|
|
118
|
+
id='REACT003',
|
|
119
|
+
title='useEffect Missing Dependency Array (Executes on Every Render)',
|
|
120
|
+
description='useEffect called without a second dependency array `[]` executes after EVERY single component render. If it modifies state, it triggers an infinite re-render loop that locks the browser UI.',
|
|
121
|
+
fix='Add appropriate dependency array `[prop, state]` or `[]` for mount-only execution.',
|
|
122
|
+
code_before='useEffect(() => {\n fetchData();\n}); // Fires on every render tick!',
|
|
123
|
+
code_after='useEffect(() => {\n fetchData();\n}, [query]); // Only executes when query changes',
|
|
124
|
+
file=rel,
|
|
125
|
+
line_number=line_no,
|
|
126
|
+
code_snippet=snippet,
|
|
127
|
+
category='Lifecycle & Loops',
|
|
128
|
+
doc_url='https://react.dev/reference/react/useEffect#specifying-reactive-dependencies',
|
|
129
|
+
severity=Severity.CRITICAL,
|
|
130
|
+
layer=Layer.FRONTEND,
|
|
131
|
+
impact=10,
|
|
132
|
+
effort=1,
|
|
133
|
+
occurrences=len(no_dep_effects),
|
|
134
|
+
perf_gain='Eliminates continuous re-rendering loops and browser freezing'
|
|
135
|
+
))
|
|
136
|
+
|
|
137
|
+
def _check_memo_and_callbacks(self, src: str, lines: List[str], rel: str):
|
|
138
|
+
# REACT004: Heavy computational loops without useMemo
|
|
139
|
+
if ('filter(' in src or 'sort(' in src or 'reduce(' in src) and ('useMemo' not in src and 'useState' in src):
|
|
140
|
+
if any(k in src for k in ['items', 'records', 'data', 'products', 'table']):
|
|
141
|
+
line_no, snippet = self._find_line(lines, re.compile(r'\.(?:filter|sort|reduce)\('))
|
|
142
|
+
if line_no > 1 and not self._is_suppressed(lines, line_no - 1, 'REACT004'):
|
|
143
|
+
self.issues.append(Issue(
|
|
144
|
+
id='REACT004',
|
|
145
|
+
title='Expensive Array Calculation Unmemoized (Missing useMemo)',
|
|
146
|
+
description='Filtering, sorting, or reducing large arrays in the component render body recalculates on EVERY unrelated parent re-render or keystroke.',
|
|
147
|
+
fix='Wrap the expensive calculation in `useMemo(() => compute(), [data])`.',
|
|
148
|
+
code_before='const filtered = items.filter(i => i.active).sort((a,b) => b.val - a.val);',
|
|
149
|
+
code_after='const filtered = useMemo(() => {\n return items.filter(i => i.active).sort((a,b) => b.val - a.val);\n}, [items]);',
|
|
150
|
+
file=rel,
|
|
151
|
+
line_number=line_no,
|
|
152
|
+
code_snippet=snippet,
|
|
153
|
+
category='Render Performance',
|
|
154
|
+
doc_url='https://react.dev/reference/react/useMemo',
|
|
155
|
+
severity=Severity.HIGH,
|
|
156
|
+
layer=Layer.FRONTEND,
|
|
157
|
+
impact=7,
|
|
158
|
+
effort=2,
|
|
159
|
+
occurrences=1,
|
|
160
|
+
perf_gain='Avoids re-filtering and sorting thousands of items on unrelated state updates'
|
|
161
|
+
))
|
|
162
|
+
|
|
163
|
+
def _check_lazy_loading(self, src: str, lines: List[str], rel: str):
|
|
164
|
+
# REACT005: React Router routes without React.lazy()
|
|
165
|
+
if ('react-router' in src or 'react-router-dom' in src or '<Route' in src) and 'React.lazy' not in src and 'lazy(' not in src:
|
|
166
|
+
route_count = len(re.findall(r'<Route\s+', src))
|
|
167
|
+
if route_count >= 3:
|
|
168
|
+
line_no, snippet = self._find_line(lines, '<Route')
|
|
169
|
+
if not self._is_suppressed(lines, line_no - 1, 'REACT005'):
|
|
170
|
+
self.issues.append(Issue(
|
|
171
|
+
id='REACT005',
|
|
172
|
+
title=f'All {route_count} Routes Eagerly Bundled (Missing React.lazy)',
|
|
173
|
+
description='All route components are statically imported in the root router. The client downloads code for all screens at initial load, increasing TTI by 40-70%.',
|
|
174
|
+
fix='Use `const Dashboard = React.lazy(() => import("./Dashboard"));` wrapped in `<Suspense>`.',
|
|
175
|
+
code_before='import Dashboard from "./Dashboard";\n<Route path="/dash" element={<Dashboard />} />',
|
|
176
|
+
code_after='const Dashboard = React.lazy(() => import("./Dashboard"));\n<Suspense fallback={<Spinner />}>\n <Route path="/dash" element={<Dashboard />} />\n</Suspense>',
|
|
177
|
+
file=rel,
|
|
178
|
+
line_number=line_no,
|
|
179
|
+
code_snippet=snippet,
|
|
180
|
+
category='Bundle Size & Code Splitting',
|
|
181
|
+
doc_url='https://react.dev/reference/react/lazy',
|
|
182
|
+
severity=Severity.HIGH,
|
|
183
|
+
layer=Layer.FRONTEND,
|
|
184
|
+
impact=8,
|
|
185
|
+
effort=3,
|
|
186
|
+
occurrences=route_count,
|
|
187
|
+
perf_gain='Reduces initial JS bundle size by 40-60%'
|
|
188
|
+
))
|
|
189
|
+
|
|
190
|
+
def _check_context_bloat(self, src: str, lines: List[str], rel: str):
|
|
191
|
+
# REACT006: Context value passing new object literal without useMemo
|
|
192
|
+
context_provider = re.search(r'<\w+Context\.Provider\s+value=\{\{([^}]+)\}\}', src)
|
|
193
|
+
if context_provider:
|
|
194
|
+
line_no, snippet = self._find_line(lines, re.compile(r'<\w+Context\.Provider'))
|
|
195
|
+
if not self._is_suppressed(lines, line_no - 1, 'REACT006'):
|
|
196
|
+
self.issues.append(Issue(
|
|
197
|
+
id='REACT006',
|
|
198
|
+
title='React Context Value Recreated on Every Render (Missing useMemo)',
|
|
199
|
+
description='Passing `value={{ state, actions }}` creates a new object reference on every render, causing ALL consuming components in the entire subtree to re-render regardless of whether the actual values changed.',
|
|
200
|
+
fix='Wrap the context value in `useMemo(() => ({ state, actions }), [state])`.',
|
|
201
|
+
code_before='<AppContext.Provider value={{ user, settings }}>',
|
|
202
|
+
code_after='const value = useMemo(() => ({ user, settings }), [user, settings]);\n<AppContext.Provider value={value}>',
|
|
203
|
+
file=rel,
|
|
204
|
+
line_number=line_no,
|
|
205
|
+
code_snippet=snippet,
|
|
206
|
+
category='Context & State Performance',
|
|
207
|
+
doc_url='https://react.dev/reference/react/useContext#optimizing-re-renders-when-passing-objects-and-functions',
|
|
208
|
+
severity=Severity.HIGH,
|
|
209
|
+
layer=Layer.FRONTEND,
|
|
210
|
+
impact=8,
|
|
211
|
+
effort=2,
|
|
212
|
+
occurrences=1,
|
|
213
|
+
perf_gain='Stops accidental re-renders across the entire context subscriber tree'
|
|
214
|
+
))
|