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,184 @@
|
|
|
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 SqlAnalyzer:
|
|
8
|
+
def __init__(self, path: str):
|
|
9
|
+
self.path = Path(path)
|
|
10
|
+
self.issues: List[Issue] = []
|
|
11
|
+
|
|
12
|
+
def analyze(self) -> List[Issue]:
|
|
13
|
+
# Scan dedicated .sql files
|
|
14
|
+
for f in self.path.rglob('*.sql'):
|
|
15
|
+
if any(x in str(f) for x in ['node_modules', 'dist/', '.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
|
+
self._check_sql_file(src, lines, rel)
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
# Scan .js and .ts files for inline SQL strings
|
|
26
|
+
for f in list(self.path.rglob('*.js')) + list(self.path.rglob('*.ts')):
|
|
27
|
+
if any(x in str(f) for x in ['node_modules', 'dist/', '.spec.', '.d.ts', '.git']):
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
src = f.read_text(encoding='utf-8', errors='ignore')
|
|
31
|
+
rel = str(f.relative_to(self.path)).replace('\\', '/')
|
|
32
|
+
lines = src.splitlines()
|
|
33
|
+
self._check_inline_sql(src, lines, rel)
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
return self.issues
|
|
38
|
+
|
|
39
|
+
def _is_suppressed(self, lines: List[str], line_idx: int, rule_id: str) -> bool:
|
|
40
|
+
check_lines = []
|
|
41
|
+
if 0 <= line_idx < len(lines):
|
|
42
|
+
check_lines.append(lines[line_idx])
|
|
43
|
+
if 0 <= line_idx - 1 < len(lines):
|
|
44
|
+
check_lines.append(lines[line_idx - 1])
|
|
45
|
+
for cl in check_lines:
|
|
46
|
+
if f'perf-ignore {rule_id}' in cl or 'perf-ignore-all' in cl:
|
|
47
|
+
return True
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def _find_line(self, lines: List[str], regex_or_str) -> Tuple[int, str]:
|
|
51
|
+
for idx, line in enumerate(lines, 1):
|
|
52
|
+
if isinstance(regex_or_str, str) and regex_or_str.lower() in line.lower():
|
|
53
|
+
return idx, line.strip()
|
|
54
|
+
elif hasattr(regex_or_str, 'search') and regex_or_str.search(line):
|
|
55
|
+
return idx, line.strip()
|
|
56
|
+
return 1, (lines[0].strip() if lines else '')
|
|
57
|
+
|
|
58
|
+
def _check_sql_file(self, src: str, lines: List[str], rel: str):
|
|
59
|
+
# SQL001: SELECT * Usage
|
|
60
|
+
select_star_count = len(re.findall(r'SELECT\s+\*\s+FROM', src, re.IGNORECASE))
|
|
61
|
+
if select_star_count > 0:
|
|
62
|
+
line_no, snippet = self._find_line(lines, re.compile(r'SELECT\s+\*\s+FROM', re.IGNORECASE))
|
|
63
|
+
if not self._is_suppressed(lines, line_no - 1, 'SQL001'):
|
|
64
|
+
self.issues.append(Issue(
|
|
65
|
+
id='SQL001',
|
|
66
|
+
title=f'SELECT * Over-fetching ({select_star_count} occurrences)',
|
|
67
|
+
description='Fetching all table columns with SELECT * wastes database memory, network bandwidth, and prevents index-only query execution plans.',
|
|
68
|
+
fix='Explicitly enumerate the necessary columns: `SELECT id, name, status FROM table`.',
|
|
69
|
+
code_before='SELECT * FROM orders WHERE user_id = ?;',
|
|
70
|
+
code_after='SELECT id, total_amount, status, created_at FROM orders WHERE user_id = ?;',
|
|
71
|
+
file=rel,
|
|
72
|
+
line_number=line_no,
|
|
73
|
+
code_snippet=snippet,
|
|
74
|
+
category='Database I/O',
|
|
75
|
+
severity=Severity.HIGH,
|
|
76
|
+
layer=Layer.DATABASE,
|
|
77
|
+
impact=7,
|
|
78
|
+
effort=2,
|
|
79
|
+
occurrences=select_star_count,
|
|
80
|
+
perf_gain='Reduces row serialization size and network transfer'
|
|
81
|
+
))
|
|
82
|
+
|
|
83
|
+
# SQL004: Missing indexes on foreign keys
|
|
84
|
+
table_defs = re.finditer(r'CREATE\s+TABLE\s+(\w+)\s*\(([^;]+)\)', src, re.IGNORECASE | re.DOTALL)
|
|
85
|
+
for tbl in table_defs:
|
|
86
|
+
tname = tbl.group(1)
|
|
87
|
+
tbody = tbl.group(2)
|
|
88
|
+
has_fk = re.search(r'(\w+_id|\w+_fk)\s+INT', tbody, re.IGNORECASE) or 'FOREIGN KEY' in tbody.upper()
|
|
89
|
+
has_idx = any(idx_kw in tbody.upper() for idx_kw in ['INDEX', 'KEY ', 'UNIQUE'])
|
|
90
|
+
if has_fk and not has_idx:
|
|
91
|
+
line_no, snippet = self._find_line(lines, re.compile(rf'CREATE\s+TABLE\s+{tname}', re.IGNORECASE))
|
|
92
|
+
if not self._is_suppressed(lines, line_no - 1, 'SQL004'):
|
|
93
|
+
self.issues.append(Issue(
|
|
94
|
+
id='SQL004',
|
|
95
|
+
title=f'Missing Foreign Key Index on Table `{tname}`',
|
|
96
|
+
description=f'Table `{tname}` defines relationship foreign keys without explicit B-Tree indexes. Queries using JOIN or WHERE on foreign keys will execute full table scans.',
|
|
97
|
+
fix=f'Add B-Tree indexes to foreign key columns: `INDEX idx_{tname.lower()}_fk (user_id)`.',
|
|
98
|
+
code_before=f'CREATE TABLE {tname} (\n id INT PRIMARY KEY,\n user_id INT -- No index: causes O(N) table scans\n);',
|
|
99
|
+
code_after=f'CREATE TABLE {tname} (\n id INT PRIMARY KEY,\n user_id INT,\n INDEX idx_{tname.lower()}_user (user_id)\n);',
|
|
100
|
+
file=rel,
|
|
101
|
+
line_number=line_no,
|
|
102
|
+
code_snippet=snippet,
|
|
103
|
+
category='Indexing & Optimization',
|
|
104
|
+
severity=Severity.CRITICAL,
|
|
105
|
+
layer=Layer.DATABASE,
|
|
106
|
+
impact=9,
|
|
107
|
+
effort=2,
|
|
108
|
+
occurrences=1,
|
|
109
|
+
perf_gain='Converts O(N) table scans into O(log N) B-Tree seeks'
|
|
110
|
+
))
|
|
111
|
+
|
|
112
|
+
# SQL007: Deep OFFSET pagination antipattern
|
|
113
|
+
if re.search(r'OFFSET\s+[1-9]\d{3,}', src, re.IGNORECASE):
|
|
114
|
+
line_no, snippet = self._find_line(lines, re.compile(r'OFFSET\s+[1-9]\d{3,}', re.IGNORECASE))
|
|
115
|
+
if not self._is_suppressed(lines, line_no - 1, 'SQL007'):
|
|
116
|
+
self.issues.append(Issue(
|
|
117
|
+
id='SQL007',
|
|
118
|
+
title='Deep OFFSET Pagination Antipattern (O(N) Discard Waste)',
|
|
119
|
+
description='High OFFSET values force the database engine to fetch and discard thousands of rows before returning the requested slice. Response latency degrades proportionally with page depth.',
|
|
120
|
+
fix='Use keyset / cursor-based pagination: `WHERE id > :last_id ORDER BY id LIMIT 50`.',
|
|
121
|
+
code_before='SELECT id, name FROM orders ORDER BY id LIMIT 50 OFFSET 10000;',
|
|
122
|
+
code_after='SELECT id, name FROM orders WHERE id > :cursor ORDER BY id LIMIT 50;',
|
|
123
|
+
file=rel,
|
|
124
|
+
line_number=line_no,
|
|
125
|
+
code_snippet=snippet,
|
|
126
|
+
category='Query Performance',
|
|
127
|
+
severity=Severity.HIGH,
|
|
128
|
+
layer=Layer.DATABASE,
|
|
129
|
+
impact=8,
|
|
130
|
+
effort=3,
|
|
131
|
+
occurrences=1,
|
|
132
|
+
perf_gain='Constant O(1) query time regardless of pagination depth'
|
|
133
|
+
))
|
|
134
|
+
|
|
135
|
+
def _check_inline_sql(self, src: str, lines: List[str], rel: str):
|
|
136
|
+
# SQL005: String concatenation in SQL queries
|
|
137
|
+
concat_matches = re.finditer(r'[`\'"](SELECT[^`\'"]+)[`\'"]\s*\+', src, re.IGNORECASE)
|
|
138
|
+
for m in concat_matches:
|
|
139
|
+
matched_text = m.group(0)
|
|
140
|
+
line_no, snippet = self._find_line(lines, matched_text[:20])
|
|
141
|
+
if not self._is_suppressed(lines, line_no - 1, 'SQL005'):
|
|
142
|
+
self.issues.append(Issue(
|
|
143
|
+
id='SQL005',
|
|
144
|
+
title='SQL Built via String Concatenation (Disables Execution Plan Cache)',
|
|
145
|
+
description='Dynamic string concatenation generates a unique SQL text for every argument, preventing the database from reusing compiled execution plans. Also opens SQL injection risk.',
|
|
146
|
+
fix='Use parameterized queries with `?` or `$1` placeholders so execution plans are cached.',
|
|
147
|
+
code_before='const sql = "SELECT * FROM users WHERE status = \'" + status + "\'";',
|
|
148
|
+
code_after='const sql = "SELECT id, name, status FROM users WHERE status = ?";\nawait db.query(sql, [status]);',
|
|
149
|
+
file=rel,
|
|
150
|
+
line_number=line_no,
|
|
151
|
+
code_snippet=snippet,
|
|
152
|
+
category='Execution Plan Cache',
|
|
153
|
+
severity=Severity.HIGH,
|
|
154
|
+
layer=Layer.DATABASE,
|
|
155
|
+
impact=8,
|
|
156
|
+
effort=2,
|
|
157
|
+
occurrences=1,
|
|
158
|
+
perf_gain='Enables query plan caching and eliminates SQL injection'
|
|
159
|
+
))
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
# SQL006: LIKE with leading wildcard
|
|
163
|
+
leading_like = re.search(r"LIKE\s+['\"]%[^'\"]+['\"]", src, re.IGNORECASE)
|
|
164
|
+
if leading_like:
|
|
165
|
+
line_no, snippet = self._find_line(lines, re.compile(r"LIKE\s+['\"]%", re.IGNORECASE))
|
|
166
|
+
if not self._is_suppressed(lines, line_no - 1, 'SQL006'):
|
|
167
|
+
self.issues.append(Issue(
|
|
168
|
+
id='SQL006',
|
|
169
|
+
title='SQL LIKE Query with Leading Wildcard (%query)',
|
|
170
|
+
description='A leading wildcard pattern like `LIKE "%keyword"` prevents the database engine from using B-Tree indexes, triggering a full table scan across all rows.',
|
|
171
|
+
fix='Use trailing wildcard `LIKE "keyword%"` or implement Full-Text Search (FTS) / trigram indexing.',
|
|
172
|
+
code_before='SELECT id, title FROM articles WHERE title LIKE "%performance%";',
|
|
173
|
+
code_after='-- Use Full-Text index:\nSELECT id, title FROM articles WHERE MATCH(title) AGAINST(? IN NATURAL LANGUAGE MODE);',
|
|
174
|
+
file=rel,
|
|
175
|
+
line_number=line_no,
|
|
176
|
+
code_snippet=snippet,
|
|
177
|
+
category='Indexing & Search',
|
|
178
|
+
severity=Severity.MEDIUM,
|
|
179
|
+
layer=Layer.DATABASE,
|
|
180
|
+
impact=7,
|
|
181
|
+
effort=3,
|
|
182
|
+
occurrences=1,
|
|
183
|
+
perf_gain='Avoids full table scan on text searches'
|
|
184
|
+
))
|
core/__init__.py
ADDED
|
File without changes
|
core/issue.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
class Severity(Enum):
|
|
6
|
+
CRITICAL = 'critical'
|
|
7
|
+
HIGH = 'high'
|
|
8
|
+
MEDIUM = 'medium'
|
|
9
|
+
LOW = 'low'
|
|
10
|
+
|
|
11
|
+
class Layer(Enum):
|
|
12
|
+
FRONTEND = 'frontend'
|
|
13
|
+
BACKEND = 'backend'
|
|
14
|
+
DATABASE = 'database'
|
|
15
|
+
INFRA = 'infra'
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Issue:
|
|
19
|
+
id: str
|
|
20
|
+
title: str
|
|
21
|
+
description: str
|
|
22
|
+
fix: str
|
|
23
|
+
code_before: str
|
|
24
|
+
code_after: str
|
|
25
|
+
file: str
|
|
26
|
+
severity: Severity
|
|
27
|
+
layer: Layer
|
|
28
|
+
impact: int # 1-10
|
|
29
|
+
effort: int # 1-10
|
|
30
|
+
occurrences: int
|
|
31
|
+
perf_gain: str
|
|
32
|
+
line_number: Optional[int] = None
|
|
33
|
+
code_snippet: Optional[str] = None
|
|
34
|
+
category: str = 'Performance'
|
|
35
|
+
doc_url: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def priority_score(self) -> float:
|
|
39
|
+
"""Higher = fix first. Impact/Effort ratio."""
|
|
40
|
+
return round(self.impact / max(self.effort, 1), 2)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def severity_order(self) -> int:
|
|
44
|
+
order = {Severity.CRITICAL: 0, Severity.HIGH: 1, Severity.MEDIUM: 2, Severity.LOW: 3}
|
|
45
|
+
return order.get(self.severity, 3)
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict:
|
|
48
|
+
return {
|
|
49
|
+
'id': self.id,
|
|
50
|
+
'title': self.title,
|
|
51
|
+
'description': self.description,
|
|
52
|
+
'fix': self.fix,
|
|
53
|
+
'code_before': self.code_before,
|
|
54
|
+
'code_after': self.code_after,
|
|
55
|
+
'file': self.file,
|
|
56
|
+
'line_number': self.line_number,
|
|
57
|
+
'code_snippet': self.code_snippet,
|
|
58
|
+
'category': self.category,
|
|
59
|
+
'doc_url': self.doc_url,
|
|
60
|
+
'severity': self.severity.value,
|
|
61
|
+
'layer': self.layer.value,
|
|
62
|
+
'impact': self.impact,
|
|
63
|
+
'effort': self.effort,
|
|
64
|
+
'occurrences': self.occurrences,
|
|
65
|
+
'perf_gain': self.perf_gain,
|
|
66
|
+
'priority_score': self.priority_score,
|
|
67
|
+
}
|
core/scorer.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from typing import List, Dict, Any
|
|
3
|
+
from core.issue import Issue, Severity, Layer
|
|
4
|
+
|
|
5
|
+
class Scorer:
|
|
6
|
+
def compute(self, issues: List[Issue]) -> Dict[str, Any]:
|
|
7
|
+
severity_weights = {
|
|
8
|
+
Severity.CRITICAL: 22,
|
|
9
|
+
Severity.HIGH: 12,
|
|
10
|
+
Severity.MEDIUM: 5,
|
|
11
|
+
Severity.LOW: 2
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
def compute_layer_score(layer: Layer) -> int:
|
|
15
|
+
sub = [i for i in issues if i.layer == layer]
|
|
16
|
+
if not sub:
|
|
17
|
+
return 100
|
|
18
|
+
# Diminishing returns penalty calculation
|
|
19
|
+
raw_penalty = sum(severity_weights.get(i.severity, 2) * min(max(i.occurrences, 1), 5) for i in sub)
|
|
20
|
+
# Smooth asymptotic curve: 100 / (1 + (penalty / 38)^1.15)
|
|
21
|
+
damped_score = 100.0 / (1.0 + (raw_penalty / 38.0) ** 1.15)
|
|
22
|
+
return max(5, min(100, round(damped_score)))
|
|
23
|
+
|
|
24
|
+
frontend_score = compute_layer_score(Layer.FRONTEND)
|
|
25
|
+
backend_score = compute_layer_score(Layer.BACKEND)
|
|
26
|
+
db_score = compute_layer_score(Layer.DATABASE)
|
|
27
|
+
infra_score = compute_layer_score(Layer.INFRA)
|
|
28
|
+
|
|
29
|
+
overall = round(
|
|
30
|
+
frontend_score * 0.40 +
|
|
31
|
+
backend_score * 0.30 +
|
|
32
|
+
db_score * 0.20 +
|
|
33
|
+
infra_score * 0.10
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
by_sev = {s.value: 0 for s in Severity}
|
|
37
|
+
by_layer = {l.value: 0 for l in Layer}
|
|
38
|
+
for i in issues:
|
|
39
|
+
by_sev[i.severity.value] += 1
|
|
40
|
+
by_layer[i.layer.value] += 1
|
|
41
|
+
|
|
42
|
+
# File breakdown
|
|
43
|
+
file_issues: Dict[str, List[Issue]] = {}
|
|
44
|
+
for i in issues:
|
|
45
|
+
file_issues.setdefault(i.file, []).append(i)
|
|
46
|
+
|
|
47
|
+
worst_files = sorted(file_issues.items(), key=lambda x: (
|
|
48
|
+
-sum(severity_weights.get(ii.severity, 2) for ii in x[1])
|
|
49
|
+
))[:10]
|
|
50
|
+
|
|
51
|
+
file_scores = []
|
|
52
|
+
for fp, fi in worst_files:
|
|
53
|
+
file_raw = sum(severity_weights.get(ii.severity, 2) for ii in fi)
|
|
54
|
+
f_score = max(5, min(100, round(100.0 / (1.0 + (file_raw / 28.0) ** 1.15))))
|
|
55
|
+
file_scores.append({
|
|
56
|
+
'file': fp,
|
|
57
|
+
'issues': len(fi),
|
|
58
|
+
'score': f_score,
|
|
59
|
+
'critical': sum(1 for ii in fi if ii.severity == Severity.CRITICAL),
|
|
60
|
+
'high': sum(1 for ii in fi if ii.severity == Severity.HIGH),
|
|
61
|
+
'medium': sum(1 for ii in fi if ii.severity == Severity.MEDIUM),
|
|
62
|
+
'low': sum(1 for ii in fi if ii.severity == Severity.LOW)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
# Top issues sorted by priority_score (Impact/Effort) and severity
|
|
66
|
+
sorted_issues = sorted(issues, key=lambda i: (i.severity_order, -i.priority_score))
|
|
67
|
+
top3 = sorted_issues[:3]
|
|
68
|
+
|
|
69
|
+
# Projected impact calculations
|
|
70
|
+
has_dom_issues = any(i.id in ['ANG001', 'ANG003', 'ANG009', 'ANG010'] for i in issues)
|
|
71
|
+
has_db_loop = any(i.id in ['NODE004', 'SQL004', 'SQL003', 'NODE005'] for i in issues)
|
|
72
|
+
mem_leak_count = sum(1 for i in issues if i.id in ['ANG004', 'NODE009', 'NODE011'])
|
|
73
|
+
bundle_reduction = sum(45 if i.id == 'ANG013' else 25 if i.id == 'ANG012' else 0 for i in issues)
|
|
74
|
+
|
|
75
|
+
metrics = {
|
|
76
|
+
'dom_render_waste_pct': 65 if has_dom_issues else 0,
|
|
77
|
+
'db_query_reduction_pct': 85 if has_db_loop else 0,
|
|
78
|
+
'memory_leak_count': mem_leak_count,
|
|
79
|
+
'bundle_reduction_kb': bundle_reduction,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
'overall': overall,
|
|
84
|
+
'frontend': frontend_score,
|
|
85
|
+
'backend': backend_score,
|
|
86
|
+
'database': db_score,
|
|
87
|
+
'infra': infra_score,
|
|
88
|
+
'by_severity': by_sev,
|
|
89
|
+
'by_layer': by_layer,
|
|
90
|
+
'total_issues': len(issues),
|
|
91
|
+
'top3': [i.to_dict() for i in top3],
|
|
92
|
+
'worst_files': file_scores,
|
|
93
|
+
'metrics': metrics,
|
|
94
|
+
}
|