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
analyzers/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Tuple, Optional
|
|
5
|
+
from core.issue import Issue, Severity, Layer
|
|
6
|
+
|
|
7
|
+
class AngularAnalyzer:
|
|
8
|
+
def __init__(self, path: str):
|
|
9
|
+
self.path = Path(path)
|
|
10
|
+
self.issues: List[Issue] = []
|
|
11
|
+
|
|
12
|
+
def analyze(self) -> List[Issue]:
|
|
13
|
+
for f in self.path.rglob('*.ts'):
|
|
14
|
+
if any(x in str(f) for x in ['node_modules', '.spec.', 'dist/', '.d.ts', '.angular', '.git']):
|
|
15
|
+
continue
|
|
16
|
+
try:
|
|
17
|
+
src = f.read_text(encoding='utf-8', errors='ignore')
|
|
18
|
+
rel = str(f.relative_to(self.path)).replace('\\', '/')
|
|
19
|
+
lines = src.splitlines()
|
|
20
|
+
|
|
21
|
+
if '@Component' in src:
|
|
22
|
+
self._check_component(src, lines, rel)
|
|
23
|
+
if 'subscribe(' in src:
|
|
24
|
+
self._check_subscriptions(src, lines, rel)
|
|
25
|
+
if 'HttpClient' in src or 'http.get' in src.lower() or 'this.http.' in src:
|
|
26
|
+
self._check_http(src, lines, rel)
|
|
27
|
+
self._check_imports(src, lines, rel)
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
for f in self.path.rglob('*.html'):
|
|
32
|
+
if any(x in str(f) for x in ['node_modules', 'dist/', '.angular', '.git']):
|
|
33
|
+
continue
|
|
34
|
+
try:
|
|
35
|
+
src = f.read_text(encoding='utf-8', errors='ignore')
|
|
36
|
+
if not self._is_angular_template(f, src):
|
|
37
|
+
continue
|
|
38
|
+
rel = str(f.relative_to(self.path)).replace('\\', '/')
|
|
39
|
+
lines = src.splitlines()
|
|
40
|
+
self._check_template(src, lines, rel)
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
for f in self.path.rglob('*-routing.module.ts'):
|
|
45
|
+
if any(x in str(f) for x in ['node_modules', 'dist/', '.angular', '.git']):
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
src = f.read_text(encoding='utf-8', errors='ignore')
|
|
49
|
+
rel = str(f.relative_to(self.path)).replace('\\', '/')
|
|
50
|
+
lines = src.splitlines()
|
|
51
|
+
self._check_routing(src, lines, rel)
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
return self.issues
|
|
56
|
+
|
|
57
|
+
def _is_angular_template(self, f: Path, src: str) -> bool:
|
|
58
|
+
# Standard Angular component template convention: *.component.html
|
|
59
|
+
if f.name.endswith('.component.html'):
|
|
60
|
+
return True
|
|
61
|
+
# Angular workspace indicators
|
|
62
|
+
if (self.path / 'angular.json').exists():
|
|
63
|
+
return True
|
|
64
|
+
# Associated component TS file exists in the same folder
|
|
65
|
+
stem = f.stem
|
|
66
|
+
if (f.parent / f"{stem}.ts").exists() or (f.parent / f"{stem}.component.ts").exists():
|
|
67
|
+
return True
|
|
68
|
+
# Definite Angular template syntax directives
|
|
69
|
+
angular_directives = [
|
|
70
|
+
'*ngIf=', '*ngFor=', '[ngClass]=', '[ngStyle]=',
|
|
71
|
+
'(click)=', '[(ngModel)]=', '<router-outlet',
|
|
72
|
+
'@defer', '@for ('
|
|
73
|
+
]
|
|
74
|
+
return any(d in src for d in angular_directives)
|
|
75
|
+
|
|
76
|
+
def _is_suppressed(self, lines: List[str], line_idx: int, rule_id: str) -> bool:
|
|
77
|
+
"""Checks for // perf-ignore [RULE_ID] on current or previous line."""
|
|
78
|
+
check_lines = []
|
|
79
|
+
if 0 <= line_idx < len(lines):
|
|
80
|
+
check_lines.append(lines[line_idx])
|
|
81
|
+
if 0 <= line_idx - 1 < len(lines):
|
|
82
|
+
check_lines.append(lines[line_idx - 1])
|
|
83
|
+
for cl in check_lines:
|
|
84
|
+
if f'perf-ignore {rule_id}' in cl or 'perf-ignore-all' in cl:
|
|
85
|
+
return True
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
def _find_line(self, lines: List[str], regex_or_str: str) -> Tuple[int, str]:
|
|
89
|
+
for idx, line in enumerate(lines, 1):
|
|
90
|
+
if isinstance(regex_or_str, str) and regex_or_str in line:
|
|
91
|
+
return idx, line.strip()
|
|
92
|
+
elif hasattr(regex_or_str, 'search') and regex_or_str.search(line):
|
|
93
|
+
return idx, line.strip()
|
|
94
|
+
return 1, (lines[0].strip() if lines else '')
|
|
95
|
+
|
|
96
|
+
def _check_component(self, src: str, lines: List[str], rel: str):
|
|
97
|
+
# ANG001: Missing OnPush Change Detection
|
|
98
|
+
if '@Component' in src and 'ChangeDetectionStrategy.OnPush' not in src:
|
|
99
|
+
line_no, snippet = self._find_line(lines, '@Component')
|
|
100
|
+
if not self._is_suppressed(lines, line_no - 1, 'ANG001'):
|
|
101
|
+
self.issues.append(Issue(
|
|
102
|
+
id='ANG001',
|
|
103
|
+
title='Missing OnPush Change Detection Strategy',
|
|
104
|
+
description='Component uses default change detection. Angular dirty-checks the entire component subtree on every DOM event, timer, and HTTP response. OnPush restricts checks to @Input() reference changes or async pipe emissions.',
|
|
105
|
+
fix='Add `changeDetection: ChangeDetectionStrategy.OnPush` to @Component decorator.',
|
|
106
|
+
code_before='@Component({\n selector: "app-feature",\n templateUrl: "./feature.component.html"\n})\nexport class FeatureComponent {}',
|
|
107
|
+
code_after='@Component({\n selector: "app-feature",\n templateUrl: "./feature.component.html",\n changeDetection: ChangeDetectionStrategy.OnPush\n})\nexport class FeatureComponent {}',
|
|
108
|
+
file=rel,
|
|
109
|
+
line_number=line_no,
|
|
110
|
+
code_snippet=snippet,
|
|
111
|
+
category='DOM Re-renders',
|
|
112
|
+
doc_url='https://angular.dev/best-practices/runtime-performance#onpush-change-detection',
|
|
113
|
+
severity=Severity.HIGH,
|
|
114
|
+
layer=Layer.FRONTEND,
|
|
115
|
+
impact=9,
|
|
116
|
+
effort=3,
|
|
117
|
+
occurrences=1,
|
|
118
|
+
perf_gain='Up to 65% reduction in DOM re-renders for data-heavy UIs'
|
|
119
|
+
))
|
|
120
|
+
|
|
121
|
+
# ANG002: Direct Input Mutation
|
|
122
|
+
input_props = re.findall(r'@Input\(\)\s+(?:public\s+|readonly\s+)?(\w+)', src)
|
|
123
|
+
for prop in input_props:
|
|
124
|
+
push_pattern = rf'this\.{prop}\.push\('
|
|
125
|
+
m = re.search(push_pattern, src)
|
|
126
|
+
if m:
|
|
127
|
+
line_no, snippet = self._find_line(lines, f'this.{prop}.push(')
|
|
128
|
+
if not self._is_suppressed(lines, line_no - 1, 'ANG002'):
|
|
129
|
+
self.issues.append(Issue(
|
|
130
|
+
id='ANG002',
|
|
131
|
+
title=f'Direct Mutation of @Input Property `{prop}`',
|
|
132
|
+
description=f'Mutating `this.{prop}.push(...)` alters internal state without changing object reference. With OnPush, Angular will fail to detect changes, leading to stale DOM updates.',
|
|
133
|
+
fix=f'Use immutable array spreading: `this.{prop} = [...this.{prop}, item];`',
|
|
134
|
+
code_before=f'this.{prop}.push(newItem); // Fails OnPush change detection',
|
|
135
|
+
code_after=f'this.{prop} = [...this.{prop}, newItem]; // Emits new object reference',
|
|
136
|
+
file=rel,
|
|
137
|
+
line_number=line_no,
|
|
138
|
+
code_snippet=snippet,
|
|
139
|
+
category='Change Detection',
|
|
140
|
+
doc_url='https://angular.dev/best-practices/runtime-performance',
|
|
141
|
+
severity=Severity.HIGH,
|
|
142
|
+
layer=Layer.FRONTEND,
|
|
143
|
+
impact=7,
|
|
144
|
+
effort=2,
|
|
145
|
+
occurrences=1,
|
|
146
|
+
perf_gain='Guarantees correct change propagation under OnPush'
|
|
147
|
+
))
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
# ANG003: detectChanges inside loop
|
|
151
|
+
for idx, line in enumerate(lines, 1):
|
|
152
|
+
if 'detectChanges()' in line:
|
|
153
|
+
# Check previous 8 lines for loop declarations
|
|
154
|
+
prev_block = '\n'.join(lines[max(0, idx - 8):idx])
|
|
155
|
+
if any(loop_k in prev_block for loop_k in ['for (', 'for(', 'forEach(', '.map(']):
|
|
156
|
+
if not self._is_suppressed(lines, idx - 1, 'ANG003'):
|
|
157
|
+
self.issues.append(Issue(
|
|
158
|
+
id='ANG003',
|
|
159
|
+
title='detectChanges() Synchronously Called Inside Loop',
|
|
160
|
+
description='Triggering change detection inside a loop forces synchronous DOM calculation on every single item iteration, locking the browser UI thread.',
|
|
161
|
+
fix='Batch updates and call `markForCheck()` or trigger `detectChanges()` once after loop terminates.',
|
|
162
|
+
code_before='items.forEach(item => {\n this.updateItem(item);\n this.cdr.detectChanges(); // N synchronous renders!\n});',
|
|
163
|
+
code_after='items.forEach(item => this.updateItem(item));\nthis.cdr.markForCheck(); // Batched single cycle',
|
|
164
|
+
file=rel,
|
|
165
|
+
line_number=idx,
|
|
166
|
+
code_snippet=line.strip(),
|
|
167
|
+
category='Render Cycle',
|
|
168
|
+
severity=Severity.CRITICAL,
|
|
169
|
+
layer=Layer.FRONTEND,
|
|
170
|
+
impact=8,
|
|
171
|
+
effort=2,
|
|
172
|
+
occurrences=1,
|
|
173
|
+
perf_gain='Eliminates N redundant render cycles'
|
|
174
|
+
))
|
|
175
|
+
break
|
|
176
|
+
|
|
177
|
+
def _check_subscriptions(self, src: str, lines: List[str], rel: str):
|
|
178
|
+
# ANG004: Observable subscription leak
|
|
179
|
+
has_take_until = 'takeUntil(' in src or 'takeUntilDestroyed(' in src or 'take(1)' in src or 'first()' in src
|
|
180
|
+
if not has_take_until and '@Component' in src:
|
|
181
|
+
for idx, line in enumerate(lines, 1):
|
|
182
|
+
if '.subscribe(' in line and not self._is_suppressed(lines, idx - 1, 'ANG004'):
|
|
183
|
+
self.issues.append(Issue(
|
|
184
|
+
id='ANG004',
|
|
185
|
+
title='Observable Subscription Memory Leak (Missing Teardown)',
|
|
186
|
+
description='Subscription created without takeUntilDestroyed, take(1), or unsubscribe. Retains component instance and view DOM tree in memory after route navigation.',
|
|
187
|
+
fix='Use Angular 16+ `takeUntilDestroyed(this.destroyRef)` or use the `| async` template pipe.',
|
|
188
|
+
code_before='ngOnInit() {\n this.service.data$.subscribe(d => this.data = d);\n}',
|
|
189
|
+
code_after='private destroyRef = inject(DestroyRef);\n\nngOnInit() {\n this.service.data$.pipe(\n takeUntilDestroyed(this.destroyRef)\n ).subscribe(d => this.data = d);\n}',
|
|
190
|
+
file=rel,
|
|
191
|
+
line_number=idx,
|
|
192
|
+
code_snippet=line.strip(),
|
|
193
|
+
category='Memory Leak',
|
|
194
|
+
doc_url='https://angular.dev/guide/signals/rxjs-interop',
|
|
195
|
+
severity=Severity.CRITICAL,
|
|
196
|
+
layer=Layer.FRONTEND,
|
|
197
|
+
impact=9,
|
|
198
|
+
effort=3,
|
|
199
|
+
occurrences=src.count('.subscribe('),
|
|
200
|
+
perf_gain='Prevents progressive memory growth across route transitions'
|
|
201
|
+
))
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
# ANG005: Nested subscribe callback hell
|
|
205
|
+
for idx, line in enumerate(lines, 1):
|
|
206
|
+
if '.subscribe(' in line:
|
|
207
|
+
chunk = '\n'.join(lines[idx:min(len(lines), idx + 8)])
|
|
208
|
+
if '.subscribe(' in chunk:
|
|
209
|
+
if not self._is_suppressed(lines, idx - 1, 'ANG005'):
|
|
210
|
+
self.issues.append(Issue(
|
|
211
|
+
id='ANG005',
|
|
212
|
+
title='Nested subscribe() Callback Anti-pattern',
|
|
213
|
+
description='Subscribing inside a subscribe handler causes race conditions, unhandled rejections, and disables automatic request cancellation.',
|
|
214
|
+
fix='Flatten using RxJS higher-order mapping operators like `switchMap` or `concatMap`.',
|
|
215
|
+
code_before='this.route.params.subscribe(p => {\n this.api.getUser(p.id).subscribe(u => this.user = u);\n});',
|
|
216
|
+
code_after='this.route.params.pipe(\n switchMap(p => this.api.getUser(p.id))\n).subscribe(u => this.user = u);',
|
|
217
|
+
file=rel,
|
|
218
|
+
line_number=idx,
|
|
219
|
+
code_snippet=line.strip(),
|
|
220
|
+
category='Concurrency & Flow',
|
|
221
|
+
severity=Severity.HIGH,
|
|
222
|
+
layer=Layer.FRONTEND,
|
|
223
|
+
impact=7,
|
|
224
|
+
effort=3,
|
|
225
|
+
occurrences=1,
|
|
226
|
+
perf_gain='Eliminates race conditions and redundant network requests'
|
|
227
|
+
))
|
|
228
|
+
break
|
|
229
|
+
|
|
230
|
+
def _check_http(self, src: str, lines: List[str], rel: str):
|
|
231
|
+
# ANG006: Unpaginated list fetch
|
|
232
|
+
has_get = 'http.get(' in src.lower() or 'this.http.get' in src.lower()
|
|
233
|
+
has_pagination = any(p in src.lower() for p in ['page', 'limit', 'offset', 'pagesize', 'skip', 'take'])
|
|
234
|
+
if has_get and not has_pagination and any(k in src.lower() for k in ['items', 'orders', 'users', 'list', 'all', 'records']):
|
|
235
|
+
line_no, snippet = self._find_line(lines, re.compile(r'http\.get', re.IGNORECASE))
|
|
236
|
+
if not self._is_suppressed(lines, line_no - 1, 'ANG006'):
|
|
237
|
+
self.issues.append(Issue(
|
|
238
|
+
id='ANG006',
|
|
239
|
+
title='Unpaginated HTTP Collection Request (Full Dataset Fetch)',
|
|
240
|
+
description='HTTP GET fetches entire data collections without pagination parameters. As tables grow, payload size balloons from KB to tens of MBs, blocking client CPU.',
|
|
241
|
+
fix='Add query parameters for page and limit; paginate on both server and client.',
|
|
242
|
+
code_before='this.http.get<Order[]>("/api/orders").subscribe(data => this.orders = data);',
|
|
243
|
+
code_after='this.http.get<Paged<Order>>("/api/orders", {\n params: { page: this.page, limit: 25 }\n}).subscribe(res => this.orders = res.items);',
|
|
244
|
+
file=rel,
|
|
245
|
+
line_number=line_no,
|
|
246
|
+
code_snippet=snippet,
|
|
247
|
+
category='Network & Bundle',
|
|
248
|
+
severity=Severity.CRITICAL,
|
|
249
|
+
layer=Layer.FRONTEND,
|
|
250
|
+
impact=10,
|
|
251
|
+
effort=4,
|
|
252
|
+
occurrences=1,
|
|
253
|
+
perf_gain='Reduces payload by 80%+ and cuts Time-to-Interactive'
|
|
254
|
+
))
|
|
255
|
+
|
|
256
|
+
# ANG007: No debounce on user input
|
|
257
|
+
if ('valueChanges' in src or 'fromEvent' in src) and 'debounceTime' not in src:
|
|
258
|
+
line_no, snippet = self._find_line(lines, 'valueChanges')
|
|
259
|
+
if line_no == 1:
|
|
260
|
+
line_no, snippet = self._find_line(lines, 'fromEvent')
|
|
261
|
+
if not self._is_suppressed(lines, line_no - 1, 'ANG007'):
|
|
262
|
+
self.issues.append(Issue(
|
|
263
|
+
id='ANG007',
|
|
264
|
+
title='Missing debounceTime on Reactive Form / Input Stream',
|
|
265
|
+
description='Form `valueChanges` triggers downstream API requests or expensive filter calculations on every single keystroke.',
|
|
266
|
+
fix='Add `debounceTime(300)` and `distinctUntilChanged()` into the pipe operator.',
|
|
267
|
+
code_before='this.searchControl.valueChanges.pipe(\n switchMap(q => this.api.search(q))\n).subscribe();',
|
|
268
|
+
code_after='this.searchControl.valueChanges.pipe(\n debounceTime(300),\n distinctUntilChanged(),\n switchMap(q => this.api.search(q))\n).subscribe();',
|
|
269
|
+
file=rel,
|
|
270
|
+
line_number=line_no,
|
|
271
|
+
code_snippet=snippet,
|
|
272
|
+
category='Network Optimization',
|
|
273
|
+
severity=Severity.HIGH,
|
|
274
|
+
layer=Layer.FRONTEND,
|
|
275
|
+
impact=7,
|
|
276
|
+
effort=1,
|
|
277
|
+
occurrences=1,
|
|
278
|
+
perf_gain='Cuts redundant network requests by up to 85%'
|
|
279
|
+
))
|
|
280
|
+
|
|
281
|
+
def _check_template(self, src: str, lines: List[str], rel: str):
|
|
282
|
+
# ANG009: *ngFor without trackBy or @for without track
|
|
283
|
+
ngfor_matches = len(re.findall(r'\*ngFor\s*=\s*["\']', src))
|
|
284
|
+
trackby_matches = len(re.findall(r'trackBy\s*:', src)) + len(re.findall(r'trackBy\s*=', src))
|
|
285
|
+
for_without_track = re.findall(r'@for\s*\([^;)]+\)', src) # missing track
|
|
286
|
+
|
|
287
|
+
if ngfor_matches > trackby_matches:
|
|
288
|
+
line_no, snippet = self._find_line(lines, re.compile(r'\*ngFor\s*=\s*["\']'))
|
|
289
|
+
if not self._is_suppressed(lines, line_no - 1, 'ANG009'):
|
|
290
|
+
self.issues.append(Issue(
|
|
291
|
+
id='ANG009',
|
|
292
|
+
title=f'*ngFor Loop Missing trackBy Identifier ({ngfor_matches - trackby_matches} un-tracked)',
|
|
293
|
+
description='Without trackBy, Angular destroys and recreates all DOM nodes in the list when data refreshes, causing UI stutter and input focus loss.',
|
|
294
|
+
fix='Use modern Angular `@for (item of items; track item.id)` or add `trackBy: trackById`.',
|
|
295
|
+
code_before='<div *ngFor="let item of items">{{ item.name }}</div>',
|
|
296
|
+
code_after='@for (item of items; track item.id) {\n <div>{{ item.name }}</div>\n}',
|
|
297
|
+
file=rel,
|
|
298
|
+
line_number=line_no,
|
|
299
|
+
code_snippet=snippet,
|
|
300
|
+
category='DOM Performance',
|
|
301
|
+
doc_url='https://angular.dev/guide/templates/control-flow#for-loop',
|
|
302
|
+
severity=Severity.CRITICAL,
|
|
303
|
+
layer=Layer.FRONTEND,
|
|
304
|
+
impact=8,
|
|
305
|
+
effort=1,
|
|
306
|
+
occurrences=ngfor_matches - trackby_matches,
|
|
307
|
+
perf_gain='Reduces DOM node re-creations by up to 99%'
|
|
308
|
+
))
|
|
309
|
+
|
|
310
|
+
# ANG011: Method calls in template interpolation
|
|
311
|
+
method_calls = re.findall(r'{{\s*([a-zA-Z0-9_]+)\(', src)
|
|
312
|
+
if len(method_calls) >= 2:
|
|
313
|
+
line_no, snippet = self._find_line(lines, re.compile(r'{{\s*[a-zA-Z0-9_]+\('))
|
|
314
|
+
if not self._is_suppressed(lines, line_no - 1, 'ANG011'):
|
|
315
|
+
self.issues.append(Issue(
|
|
316
|
+
id='ANG011',
|
|
317
|
+
title=f'Method Calls in Template Interpolation ({len(method_calls)} detected)',
|
|
318
|
+
description='Invoking methods in template bindings evaluates the method on every single change detection tick (hundreds of times per second during interactions).',
|
|
319
|
+
fix='Replace method calls with pure Pipes or pre-computed signal/component properties.',
|
|
320
|
+
code_before='<div>{{ formatPrice(item.price) }}</div> <!-- Runs every cycle -->',
|
|
321
|
+
code_after='<div>{{ item.price | currency }}</div> <!-- Cached pure pipe -->',
|
|
322
|
+
file=rel,
|
|
323
|
+
line_number=line_no,
|
|
324
|
+
code_snippet=snippet,
|
|
325
|
+
category='DOM Re-renders',
|
|
326
|
+
severity=Severity.HIGH,
|
|
327
|
+
layer=Layer.FRONTEND,
|
|
328
|
+
impact=7,
|
|
329
|
+
effort=2,
|
|
330
|
+
occurrences=len(method_calls),
|
|
331
|
+
perf_gain='Eliminates repeated execution of heavy template methods'
|
|
332
|
+
))
|
|
333
|
+
|
|
334
|
+
# ANG014: Missing @defer for heavy components
|
|
335
|
+
if '<app-' in src and '@defer' not in src and len(re.findall(r'<app-[\w-]+', src)) >= 3:
|
|
336
|
+
line_no, snippet = self._find_line(lines, re.compile(r'<app-[\w-]+'))
|
|
337
|
+
if not self._is_suppressed(lines, line_no - 1, 'ANG014'):
|
|
338
|
+
self.issues.append(Issue(
|
|
339
|
+
id='ANG014',
|
|
340
|
+
title='Heavy Subcomponents Rendered Without @defer (Lazy Viewport)',
|
|
341
|
+
description='Non-critical below-the-fold components are loaded synchronously in the initial bundle. Angular 17+ deferrable views delay JS download until visible.',
|
|
342
|
+
fix='Wrap heavy below-the-fold components in `@defer (on viewport) { ... }`.',
|
|
343
|
+
code_before='<app-heavy-chart [data]="chartData" />',
|
|
344
|
+
code_after='@defer (on viewport) {\n <app-heavy-chart [data]="chartData" />\n} @placeholder {\n <div class="chart-skeleton">Loading chart...</div>\n}',
|
|
345
|
+
file=rel,
|
|
346
|
+
line_number=line_no,
|
|
347
|
+
code_snippet=snippet,
|
|
348
|
+
category='Network & Bundle',
|
|
349
|
+
doc_url='https://angular.dev/guide/templates/defer',
|
|
350
|
+
severity=Severity.MEDIUM,
|
|
351
|
+
layer=Layer.FRONTEND,
|
|
352
|
+
impact=7,
|
|
353
|
+
effort=2,
|
|
354
|
+
occurrences=1,
|
|
355
|
+
perf_gain='Reduces initial JS chunk size and speeds up Largest Contentful Paint (LCP)'
|
|
356
|
+
))
|
|
357
|
+
|
|
358
|
+
def _check_imports(self, src: str, lines: List[str], rel: str):
|
|
359
|
+
# ANG012: Full library wildcard imports
|
|
360
|
+
if 'import * as _' in src or "from 'lodash'" in src or "from 'rxjs/Rx'" in src:
|
|
361
|
+
line_no, snippet = self._find_line(lines, re.compile(r"(?:from 'lodash'|import \* as _|from 'rxjs/Rx')"))
|
|
362
|
+
if not self._is_suppressed(lines, line_no - 1, 'ANG012'):
|
|
363
|
+
self.issues.append(Issue(
|
|
364
|
+
id='ANG012',
|
|
365
|
+
title='Unoptimized Full Library Import (Bundle Bloat)',
|
|
366
|
+
description='Importing entire libraries like `lodash` or legacy RxJS bundles disables tree-shaking and adds 70KB+ unnecessary JS to client downloads.',
|
|
367
|
+
fix='Import individual functions: `import debounce from "lodash/debounce";` or use native JS.',
|
|
368
|
+
code_before='import _ from "lodash";\n_.cloneDeep(data);',
|
|
369
|
+
code_after='import cloneDeep from "lodash/cloneDeep";\ncloneDeep(data); // Or structuredClone(data)',
|
|
370
|
+
file=rel,
|
|
371
|
+
line_number=line_no,
|
|
372
|
+
code_snippet=snippet,
|
|
373
|
+
category='Bundle Optimization',
|
|
374
|
+
severity=Severity.MEDIUM,
|
|
375
|
+
layer=Layer.FRONTEND,
|
|
376
|
+
impact=6,
|
|
377
|
+
effort=2,
|
|
378
|
+
occurrences=1,
|
|
379
|
+
perf_gain='Saves 50KB-80KB in production bundle size'
|
|
380
|
+
))
|
|
381
|
+
|
|
382
|
+
def _check_routing(self, src: str, lines: List[str], rel: str):
|
|
383
|
+
# ANG013: Eager routing without lazy loading
|
|
384
|
+
eager_routes = re.findall(r'component:\s*\w+Component', src)
|
|
385
|
+
lazy_routes = re.findall(r'loadChildren|loadComponent', src)
|
|
386
|
+
if len(eager_routes) >= 3 and len(lazy_routes) == 0:
|
|
387
|
+
line_no, snippet = self._find_line(lines, 'component:')
|
|
388
|
+
if not self._is_suppressed(lines, line_no - 1, 'ANG013'):
|
|
389
|
+
self.issues.append(Issue(
|
|
390
|
+
id='ANG013',
|
|
391
|
+
title=f'All {len(eager_routes)} Routes Loaded Eagerly at Startup',
|
|
392
|
+
description='All application feature modules load on initial page visit. Users download, parse, and compile code for routes they may never navigate to.',
|
|
393
|
+
fix='Convert route definitions to use `loadComponent` or `loadChildren` with dynamic `import()`.',
|
|
394
|
+
code_before='{ path: "admin", component: AdminComponent }',
|
|
395
|
+
code_after='{ path: "admin", loadComponent: () => import("./admin/admin.component").then(m => m.AdminComponent) }',
|
|
396
|
+
file=rel,
|
|
397
|
+
line_number=line_no,
|
|
398
|
+
code_snippet=snippet,
|
|
399
|
+
category='Bundle Optimization',
|
|
400
|
+
severity=Severity.HIGH,
|
|
401
|
+
layer=Layer.FRONTEND,
|
|
402
|
+
impact=8,
|
|
403
|
+
effort=3,
|
|
404
|
+
occurrences=len(eager_routes),
|
|
405
|
+
perf_gain='Cuts initial bundle by 40-60% via automatic code splitting'
|
|
406
|
+
))
|
|
@@ -0,0 +1,122 @@
|
|
|
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 AwsAnalyzer:
|
|
8
|
+
def __init__(self, path: str):
|
|
9
|
+
self.path = Path(path)
|
|
10
|
+
self.issues: List[Issue] = []
|
|
11
|
+
|
|
12
|
+
def analyze(self) -> List[Issue]:
|
|
13
|
+
target_extensions = ['*.json', '*.yml', '*.yaml', '*.tf']
|
|
14
|
+
for ext in target_extensions:
|
|
15
|
+
for f in self.path.rglob(ext):
|
|
16
|
+
if any(x in str(f) for x in ['node_modules', 'dist/', '.git', 'package-lock.json']):
|
|
17
|
+
continue
|
|
18
|
+
try:
|
|
19
|
+
src = f.read_text(encoding='utf-8', errors='ignore')
|
|
20
|
+
rel = str(f.relative_to(self.path)).replace('\\', '/')
|
|
21
|
+
lines = src.splitlines()
|
|
22
|
+
self._check_aws_config(src, lines, rel)
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
return self.issues
|
|
26
|
+
|
|
27
|
+
def _is_suppressed(self, lines: List[str], line_idx: int, rule_id: str) -> bool:
|
|
28
|
+
check_lines = []
|
|
29
|
+
if 0 <= line_idx < len(lines):
|
|
30
|
+
check_lines.append(lines[line_idx])
|
|
31
|
+
if 0 <= line_idx - 1 < len(lines):
|
|
32
|
+
check_lines.append(lines[line_idx - 1])
|
|
33
|
+
for cl in check_lines:
|
|
34
|
+
if f'perf-ignore {rule_id}' in cl or 'perf-ignore-all' in cl:
|
|
35
|
+
return True
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
def _find_line(self, lines: List[str], regex_or_str) -> Tuple[int, str]:
|
|
39
|
+
for idx, line in enumerate(lines, 1):
|
|
40
|
+
if isinstance(regex_or_str, str) and regex_or_str.lower() in line.lower():
|
|
41
|
+
return idx, line.strip()
|
|
42
|
+
elif hasattr(regex_or_str, 'search') and regex_or_str.search(line):
|
|
43
|
+
return idx, line.strip()
|
|
44
|
+
return 1, (lines[0].strip() if lines else '')
|
|
45
|
+
|
|
46
|
+
def _check_aws_config(self, src: str, lines: List[str], rel: str):
|
|
47
|
+
sl = src.lower()
|
|
48
|
+
|
|
49
|
+
# AWS001: Lambda memory too low (<512MB)
|
|
50
|
+
mem_match = re.search(r'memorysize["\s:]+([0-9]+)', sl)
|
|
51
|
+
if mem_match:
|
|
52
|
+
val = int(mem_match.group(1))
|
|
53
|
+
if val < 512:
|
|
54
|
+
line_no, snippet = self._find_line(lines, re.compile(r'memorysize', re.IGNORECASE))
|
|
55
|
+
if not self._is_suppressed(lines, line_no - 1, 'AWS001'):
|
|
56
|
+
self.issues.append(Issue(
|
|
57
|
+
id='AWS001',
|
|
58
|
+
title=f'Lambda Memory Allocated Too Low ({val} MB)',
|
|
59
|
+
description=f'Lambda configured with only {val} MB memory. AWS CPU allocation scales proportionally with memory. Functions with <512MB memory suffer from throttled CPU, running 3-4x slower.',
|
|
60
|
+
fix='Increase MemorySize to 512 MB - 1024 MB. Faster completion often results in equal or lower net AWS cost.',
|
|
61
|
+
code_before=f'MemorySize: {val} # Under-allocated CPU',
|
|
62
|
+
code_after='MemorySize: 512 # Full vCPU core access; up to 65% faster completion',
|
|
63
|
+
file=rel,
|
|
64
|
+
line_number=line_no,
|
|
65
|
+
code_snippet=snippet,
|
|
66
|
+
category='Serverless Latency',
|
|
67
|
+
doc_url='https://docs.aws.amazon.com/lambda/latest/dg/configuration-function-common.html#configuration-memory-console',
|
|
68
|
+
severity=Severity.HIGH,
|
|
69
|
+
layer=Layer.INFRA,
|
|
70
|
+
impact=7,
|
|
71
|
+
effort=1,
|
|
72
|
+
occurrences=1,
|
|
73
|
+
perf_gain='Cuts function runtime duration by up to 60%'
|
|
74
|
+
))
|
|
75
|
+
|
|
76
|
+
# AWS002: Static assets served without CloudFront CDN
|
|
77
|
+
has_s3 = 's3' in sl and any(w in sl for w in ['website', 'static', 'hosting', 'bucket'])
|
|
78
|
+
has_cdn = 'cloudfront' in sl or 'distribution' in sl
|
|
79
|
+
if has_s3 and not has_cdn:
|
|
80
|
+
line_no, snippet = self._find_line(lines, 's3')
|
|
81
|
+
if not self._is_suppressed(lines, line_no - 1, 'AWS002'):
|
|
82
|
+
self.issues.append(Issue(
|
|
83
|
+
id='AWS002',
|
|
84
|
+
title='Static S3 Hosting Without CloudFront Edge CDN',
|
|
85
|
+
description='Frontend static assets are served directly from an S3 bucket in a single region. Distant global users experience 200-500ms latency on every asset download.',
|
|
86
|
+
fix='Deploy an Amazon CloudFront distribution in front of S3 with gzip/brotli compression enabled.',
|
|
87
|
+
code_before='# Assets served from single S3 bucket region\n# Global users: 300ms+ roundtrip',
|
|
88
|
+
code_after='# CloudFront CDN distribution with edge caching\n# Global users: 15-30ms from local edge',
|
|
89
|
+
file=rel,
|
|
90
|
+
line_number=line_no,
|
|
91
|
+
code_snippet=snippet,
|
|
92
|
+
category='Edge & CDN',
|
|
93
|
+
severity=Severity.HIGH,
|
|
94
|
+
layer=Layer.INFRA,
|
|
95
|
+
impact=8,
|
|
96
|
+
effort=3,
|
|
97
|
+
occurrences=1,
|
|
98
|
+
perf_gain='Reduces static asset latency from ~350ms to 20ms globally'
|
|
99
|
+
))
|
|
100
|
+
|
|
101
|
+
# AWS005: Lambda directly connecting to RDS without RDS Proxy
|
|
102
|
+
if ('lambda' in sl or 'serverless' in sl) and ('rds' in sl or 'postgres' in sl or 'mysql' in sl) and 'proxy' not in sl:
|
|
103
|
+
line_no, snippet = self._find_line(lines, re.compile(r'(?:rds|postgres|mysql)', re.IGNORECASE))
|
|
104
|
+
if not self._is_suppressed(lines, line_no - 1, 'AWS005'):
|
|
105
|
+
self.issues.append(Issue(
|
|
106
|
+
id='AWS005',
|
|
107
|
+
title='Serverless Lambda Connecting to RDS Without RDS Proxy',
|
|
108
|
+
description='Lambda functions executing concurrent requests spawn hundreds of unpooled database connections, quickly exhausting RDS connection limits and leading to connection refusal errors.',
|
|
109
|
+
fix='Place an Amazon RDS Proxy between Lambda and RDS to pool and multiplex database connections.',
|
|
110
|
+
code_before='DB_HOST: "my-rds-cluster.rds.amazonaws.com" # Direct unpooled connection',
|
|
111
|
+
code_after='DB_HOST: "my-proxy.proxy-xxx.rds.amazonaws.com" # Multiplexed pool via RDS Proxy',
|
|
112
|
+
file=rel,
|
|
113
|
+
line_number=line_no,
|
|
114
|
+
code_snippet=snippet,
|
|
115
|
+
category='Connection Pooling',
|
|
116
|
+
severity=Severity.HIGH,
|
|
117
|
+
layer=Layer.INFRA,
|
|
118
|
+
impact=9,
|
|
119
|
+
effort=4,
|
|
120
|
+
occurrences=1,
|
|
121
|
+
perf_gain='Prevents database connection exhaustion during traffic bursts'
|
|
122
|
+
))
|