qlint 0.1.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.
- pry/__init__.py +1 -0
- pry/cli.py +169 -0
- pry/core/__init__.py +0 -0
- pry/core/complexity.py +76 -0
- pry/core/duplicates.py +51 -0
- pry/core/metrics.py +91 -0
- pry/core/quality.py +42 -0
- pry/core/security.py +120 -0
- pry/core/smells.py +90 -0
- pry/core/traversal.py +47 -0
- pry/reports/__init__.py +0 -0
- pry/reports/report_html.py +131 -0
- pry/reports/report_json.py +48 -0
- qlint-0.1.0.dist-info/METADATA +444 -0
- qlint-0.1.0.dist-info/RECORD +19 -0
- qlint-0.1.0.dist-info/WHEEL +5 -0
- qlint-0.1.0.dist-info/entry_points.txt +2 -0
- qlint-0.1.0.dist-info/licenses/LICENSE +21 -0
- qlint-0.1.0.dist-info/top_level.txt +1 -0
pry/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
pry/cli.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""pry — multi-language code quality scanner."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
|
|
11
|
+
from pry.core.traversal import walk_codebase
|
|
12
|
+
from pry.core.metrics import analyze_file
|
|
13
|
+
from pry.core.complexity import analyze_complexity
|
|
14
|
+
from pry.core.smells import analyze_smells
|
|
15
|
+
from pry.core.security import scan_security
|
|
16
|
+
from pry.core.duplicates import find_duplicates
|
|
17
|
+
from pry.core.quality import calculate_quality_score
|
|
18
|
+
from pry.reports.report_json import generate_json
|
|
19
|
+
from pry.reports.report_html import generate_html
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _results_dir() -> str:
|
|
23
|
+
return os.path.join(os.getcwd(), 'scan_results')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def make_output_dir(target_path: str) -> str:
|
|
27
|
+
abs_path = os.path.abspath(target_path)
|
|
28
|
+
dirname = os.path.basename(abs_path.rstrip('/\\')) or 'scan'
|
|
29
|
+
slug = ''.join(c if c.isalnum() or c in '-' else '_' for c in dirname).strip('_')
|
|
30
|
+
short_hash = hashlib.sha1(abs_path.encode()).hexdigest()[:7]
|
|
31
|
+
out_dir = os.path.join(_results_dir(), f'{slug}_{short_hash}')
|
|
32
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
33
|
+
return out_dir
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def open_file(path: str) -> None:
|
|
37
|
+
system = platform.system()
|
|
38
|
+
try:
|
|
39
|
+
if system == 'Darwin':
|
|
40
|
+
subprocess.run(['open', path], check=False)
|
|
41
|
+
elif system == 'Windows':
|
|
42
|
+
os.startfile(path) # type: ignore[attr-defined]
|
|
43
|
+
else:
|
|
44
|
+
subprocess.run(['xdg-open', path], check=False)
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def scan(root: str, verbose: bool = False) -> dict:
|
|
50
|
+
print(f"Scanning: {root}", file=sys.stderr)
|
|
51
|
+
raw_files = walk_codebase(root)
|
|
52
|
+
print(f"Found {len(raw_files)} files", file=sys.stderr)
|
|
53
|
+
|
|
54
|
+
analyzed = []
|
|
55
|
+
for file_info in raw_files:
|
|
56
|
+
if verbose:
|
|
57
|
+
print(f" Analyzing: {file_info['relative_path']}", file=sys.stderr)
|
|
58
|
+
af = analyze_file(file_info)
|
|
59
|
+
af['complexity'] = analyze_complexity(af)
|
|
60
|
+
af['smells'] = analyze_smells(af)
|
|
61
|
+
af['security_issues'] = scan_security(af)
|
|
62
|
+
analyzed.append(af)
|
|
63
|
+
|
|
64
|
+
print("Running duplication analysis...", file=sys.stderr)
|
|
65
|
+
duplicates = find_duplicates(analyzed)
|
|
66
|
+
|
|
67
|
+
languages: dict = defaultdict(lambda: {'files': 0, 'lines': 0})
|
|
68
|
+
for f in analyzed:
|
|
69
|
+
languages[f['language']]['files'] += 1
|
|
70
|
+
languages[f['language']]['lines'] += f['metrics']['loc']
|
|
71
|
+
|
|
72
|
+
total_files = len(analyzed)
|
|
73
|
+
total_lines = sum(f['metrics']['loc'] for f in analyzed)
|
|
74
|
+
flagged = sum(f.get('complexity', {}).get('flagged_count', 0) for f in analyzed)
|
|
75
|
+
avg_c = sum(f.get('complexity', {}).get('avg_complexity', 0) for f in analyzed) / max(total_files, 1)
|
|
76
|
+
|
|
77
|
+
analysis = {
|
|
78
|
+
'root': os.path.abspath(root),
|
|
79
|
+
'files': analyzed,
|
|
80
|
+
'total_files': total_files,
|
|
81
|
+
'total_lines': total_lines,
|
|
82
|
+
'languages': dict(languages),
|
|
83
|
+
'duplicates': duplicates,
|
|
84
|
+
'total_smells': sum(len(f.get('smells', [])) for f in analyzed),
|
|
85
|
+
'total_security_issues': sum(len(f.get('security_issues', [])) for f in analyzed),
|
|
86
|
+
'complexity_summary': {'flagged_count': flagged, 'avg_complexity': round(avg_c, 2)},
|
|
87
|
+
}
|
|
88
|
+
analysis['quality'] = calculate_quality_score(analysis)
|
|
89
|
+
return analysis
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def print_summary(analysis: dict) -> None:
|
|
93
|
+
q = analysis['quality']
|
|
94
|
+
print(f"\n{'='*50}")
|
|
95
|
+
print(f" pry — Code Quality Report")
|
|
96
|
+
print(f"{'='*50}")
|
|
97
|
+
print(f" Grade: {q['grade']} ({q['score']}/100)")
|
|
98
|
+
print(f" Files: {analysis['total_files']}")
|
|
99
|
+
print(f" Total Lines: {analysis['total_lines']:,}")
|
|
100
|
+
print(f" Languages: {', '.join(analysis['languages'].keys())}")
|
|
101
|
+
print(f" Security Issues: {analysis['total_security_issues']}")
|
|
102
|
+
print(f" Code Smells: {analysis['total_smells']}")
|
|
103
|
+
print(f" Dup Blocks: {analysis['duplicates'].get('total_duplicate_blocks', 0)}")
|
|
104
|
+
print(f"{'='*50}\n")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def prompt_path() -> str:
|
|
108
|
+
print("pry — no path specified.")
|
|
109
|
+
while True:
|
|
110
|
+
raw = input("Enter directory to scan (or 'q' to quit): ").strip()
|
|
111
|
+
if raw.lower() in ('q', 'quit', 'exit'):
|
|
112
|
+
sys.exit(0)
|
|
113
|
+
path = os.path.expanduser(raw)
|
|
114
|
+
if os.path.isdir(path):
|
|
115
|
+
return path
|
|
116
|
+
print(f" Not a directory: '{raw}'. Please try again.")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main() -> None:
|
|
120
|
+
parser = argparse.ArgumentParser(
|
|
121
|
+
prog='pry',
|
|
122
|
+
description='pry — multi-language code quality scanner',
|
|
123
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
124
|
+
epilog='''\
|
|
125
|
+
examples:
|
|
126
|
+
pry # interactive: prompts for path
|
|
127
|
+
pry /path/to/repo # scan and open HTML report
|
|
128
|
+
pry /path/to/repo --no-open
|
|
129
|
+
pry /path/to/repo --json-only
|
|
130
|
+
pry /path/to/repo -v # verbose per-file output
|
|
131
|
+
''',
|
|
132
|
+
)
|
|
133
|
+
parser.add_argument('path', nargs='?', help='Directory to scan (prompts if omitted)')
|
|
134
|
+
parser.add_argument('--output', '-o', help='Custom JSON output path')
|
|
135
|
+
parser.add_argument('--html', help='Custom HTML output path')
|
|
136
|
+
parser.add_argument('--json-only', action='store_true', help='Skip HTML, print JSON to stdout')
|
|
137
|
+
parser.add_argument('--no-open', action='store_true', help='Do not auto-open HTML report')
|
|
138
|
+
parser.add_argument('--verbose', '-v', action='store_true', help='Show per-file progress')
|
|
139
|
+
parser.add_argument('--version', action='version', version='pry 0.1.0')
|
|
140
|
+
args = parser.parse_args()
|
|
141
|
+
|
|
142
|
+
target = args.path
|
|
143
|
+
if not target:
|
|
144
|
+
target = prompt_path()
|
|
145
|
+
else:
|
|
146
|
+
target = os.path.expanduser(target)
|
|
147
|
+
if not os.path.isdir(target):
|
|
148
|
+
print(f"pry: '{target}' is not a directory", file=sys.stderr)
|
|
149
|
+
sys.exit(1)
|
|
150
|
+
|
|
151
|
+
analysis = scan(target, verbose=args.verbose)
|
|
152
|
+
print_summary(analysis)
|
|
153
|
+
|
|
154
|
+
if args.json_only:
|
|
155
|
+
print(generate_json(analysis))
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
out_dir = make_output_dir(target)
|
|
159
|
+
json_path = args.output or os.path.join(out_dir, 'report.json')
|
|
160
|
+
html_path = args.html or os.path.join(out_dir, 'report.html')
|
|
161
|
+
|
|
162
|
+
generate_json(analysis, output_path=json_path)
|
|
163
|
+
generate_html(analysis, output_path=html_path)
|
|
164
|
+
|
|
165
|
+
print(f"JSON: {json_path}", file=sys.stderr)
|
|
166
|
+
print(f"HTML: {html_path}", file=sys.stderr)
|
|
167
|
+
|
|
168
|
+
if not args.no_open:
|
|
169
|
+
open_file(html_path)
|
pry/core/__init__.py
ADDED
|
File without changes
|
pry/core/complexity.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ComplexityVisitor(ast.NodeVisitor):
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self.functions = []
|
|
8
|
+
|
|
9
|
+
def _calc_complexity(self, node):
|
|
10
|
+
complexity = 1
|
|
11
|
+
for child in ast.walk(node):
|
|
12
|
+
if isinstance(child, (ast.If, ast.While, ast.For, ast.ExceptHandler,
|
|
13
|
+
ast.With, ast.Assert, ast.comprehension)):
|
|
14
|
+
complexity += 1
|
|
15
|
+
elif isinstance(child, ast.BoolOp):
|
|
16
|
+
complexity += len(child.values) - 1
|
|
17
|
+
return complexity
|
|
18
|
+
|
|
19
|
+
def visit_FunctionDef(self, node):
|
|
20
|
+
complexity = self._calc_complexity(node)
|
|
21
|
+
self.functions.append({
|
|
22
|
+
'name': node.name,
|
|
23
|
+
'line': node.lineno,
|
|
24
|
+
'complexity': complexity,
|
|
25
|
+
'flagged': complexity > 10,
|
|
26
|
+
})
|
|
27
|
+
self.generic_visit(node)
|
|
28
|
+
|
|
29
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def analyze_python_complexity(content: str) -> dict:
|
|
33
|
+
try:
|
|
34
|
+
tree = ast.parse(content)
|
|
35
|
+
visitor = ComplexityVisitor()
|
|
36
|
+
visitor.visit(tree)
|
|
37
|
+
functions = visitor.functions
|
|
38
|
+
if not functions:
|
|
39
|
+
return {'functions': [], 'avg_complexity': 0, 'max_complexity': 0, 'flagged_count': 0}
|
|
40
|
+
avg = sum(f['complexity'] for f in functions) / len(functions)
|
|
41
|
+
max_c = max(f['complexity'] for f in functions)
|
|
42
|
+
return {
|
|
43
|
+
'functions': functions,
|
|
44
|
+
'avg_complexity': round(avg, 2),
|
|
45
|
+
'max_complexity': max_c,
|
|
46
|
+
'flagged_count': sum(1 for f in functions if f['flagged']),
|
|
47
|
+
}
|
|
48
|
+
except SyntaxError:
|
|
49
|
+
return {'functions': [], 'avg_complexity': 0, 'max_complexity': 0, 'flagged_count': 0}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def analyze_generic_complexity(content: str, language: str) -> dict:
|
|
53
|
+
decision_patterns = {
|
|
54
|
+
'JavaScript': r'\b(if|else if|while|for|switch|catch|&&|\|\|)\b',
|
|
55
|
+
'TypeScript': r'\b(if|else if|while|for|switch|catch|&&|\|\|)\b',
|
|
56
|
+
'Java': r'\b(if|else if|while|for|switch|catch|&&|\|\|)\b',
|
|
57
|
+
'Go': r'\b(if|else if|for|switch|select|&&|\|\|)\b',
|
|
58
|
+
}
|
|
59
|
+
pattern = decision_patterns.get(language)
|
|
60
|
+
if not pattern:
|
|
61
|
+
return {'avg_complexity': 0, 'max_complexity': 0, 'flagged_count': 0}
|
|
62
|
+
count = len(re.findall(pattern, content))
|
|
63
|
+
complexity = 1 + count
|
|
64
|
+
return {
|
|
65
|
+
'avg_complexity': complexity,
|
|
66
|
+
'max_complexity': complexity,
|
|
67
|
+
'flagged_count': 1 if complexity > 10 else 0,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def analyze_complexity(file_info: dict) -> dict:
|
|
72
|
+
lang = file_info['language']
|
|
73
|
+
content = file_info.get('content', '')
|
|
74
|
+
if lang == 'Python':
|
|
75
|
+
return analyze_python_complexity(content)
|
|
76
|
+
return analyze_generic_complexity(content, lang)
|
pry/core/duplicates.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _normalize_line(line: str) -> str:
|
|
6
|
+
return line.strip()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _chunk_lines(lines: list[str], size: int = 6) -> list[tuple]:
|
|
10
|
+
normalized = [_normalize_line(l) for l in lines]
|
|
11
|
+
chunks = []
|
|
12
|
+
for i in range(len(normalized) - size + 1):
|
|
13
|
+
block = normalized[i:i + size]
|
|
14
|
+
if any(b for b in block):
|
|
15
|
+
chunks.append((i + 1, tuple(block)))
|
|
16
|
+
return chunks
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_duplicates(all_files: list[dict]) -> dict:
|
|
20
|
+
chunk_map = defaultdict(list)
|
|
21
|
+
|
|
22
|
+
for file_info in all_files:
|
|
23
|
+
content = file_info.get('content', '')
|
|
24
|
+
lines = content.splitlines()
|
|
25
|
+
if len(lines) < 6:
|
|
26
|
+
continue
|
|
27
|
+
for line_num, chunk in _chunk_lines(lines):
|
|
28
|
+
h = hashlib.md5('\n'.join(chunk).encode()).hexdigest()
|
|
29
|
+
chunk_map[h].append({
|
|
30
|
+
'file': file_info['relative_path'],
|
|
31
|
+
'line': line_num,
|
|
32
|
+
'preview': chunk[0][:80],
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
duplicates = {k: v for k, v in chunk_map.items() if len(v) > 1}
|
|
36
|
+
|
|
37
|
+
file_dup_counts = defaultdict(int)
|
|
38
|
+
for locations in duplicates.values():
|
|
39
|
+
seen_files = set()
|
|
40
|
+
for loc in locations:
|
|
41
|
+
if loc['file'] not in seen_files:
|
|
42
|
+
file_dup_counts[loc['file']] += 1
|
|
43
|
+
seen_files.add(loc['file'])
|
|
44
|
+
|
|
45
|
+
top_offenders = sorted(file_dup_counts.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
'total_duplicate_blocks': len(duplicates),
|
|
49
|
+
'top_offenders': [{'file': f, 'duplicate_blocks': c} for f, c in top_offenders],
|
|
50
|
+
'duplication_percentage': round(len(duplicates) / max(sum(len(chunk_map[k]) for k in chunk_map) / 6, 1) * 100, 1) if chunk_map else 0,
|
|
51
|
+
}
|
pry/core/metrics.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
COMMENT_PATTERNS = {
|
|
6
|
+
'Python': r'^\s*#',
|
|
7
|
+
'JavaScript': r'^\s*//',
|
|
8
|
+
'TypeScript': r'^\s*//',
|
|
9
|
+
'Java': r'^\s*//',
|
|
10
|
+
'Go': r'^\s*//',
|
|
11
|
+
'C': r'^\s*//',
|
|
12
|
+
'C++': r'^\s*//',
|
|
13
|
+
'C#': r'^\s*//',
|
|
14
|
+
'Ruby': r'^\s*#',
|
|
15
|
+
'Shell': r'^\s*#',
|
|
16
|
+
'PHP': r'^\s*(//)|(#)',
|
|
17
|
+
'Rust': r'^\s*//',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def count_lines(content: str, language: str) -> dict:
|
|
22
|
+
lines = content.splitlines()
|
|
23
|
+
total = len(lines)
|
|
24
|
+
blank = sum(1 for l in lines if not l.strip())
|
|
25
|
+
comment_pattern = COMMENT_PATTERNS.get(language)
|
|
26
|
+
if comment_pattern:
|
|
27
|
+
comment = sum(1 for l in lines if re.match(comment_pattern, l))
|
|
28
|
+
else:
|
|
29
|
+
comment = 0
|
|
30
|
+
return {
|
|
31
|
+
'total': total,
|
|
32
|
+
'code': total - blank - comment,
|
|
33
|
+
'blank': blank,
|
|
34
|
+
'comment': comment,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def count_functions_classes_python(content: str) -> dict:
|
|
39
|
+
try:
|
|
40
|
+
tree = ast.parse(content)
|
|
41
|
+
functions = sum(1 for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)))
|
|
42
|
+
classes = sum(1 for n in ast.walk(tree) if isinstance(n, ast.ClassDef))
|
|
43
|
+
return {'functions': functions, 'classes': classes}
|
|
44
|
+
except SyntaxError:
|
|
45
|
+
return {'functions': 0, 'classes': 0}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def count_functions_classes_generic(content: str, language: str) -> dict:
|
|
49
|
+
patterns = {
|
|
50
|
+
'JavaScript': (r'\bfunction\s+\w+\s*\(', r'\bclass\s+\w+'),
|
|
51
|
+
'TypeScript': (r'\bfunction\s+\w+\s*\(', r'\bclass\s+\w+'),
|
|
52
|
+
'Java': (r'\b(?:public|private|protected|static)?\s+\w+\s+\w+\s*\(', r'\bclass\s+\w+'),
|
|
53
|
+
'Go': (r'\bfunc\s+\w+', r'\btype\s+\w+\s+struct'),
|
|
54
|
+
'Ruby': (r'\bdef\s+\w+', r'\bclass\s+\w+'),
|
|
55
|
+
}
|
|
56
|
+
if language not in patterns:
|
|
57
|
+
return {'functions': 0, 'classes': 0}
|
|
58
|
+
func_pat, class_pat = patterns[language]
|
|
59
|
+
return {
|
|
60
|
+
'functions': len(re.findall(func_pat, content, re.MULTILINE)),
|
|
61
|
+
'classes': len(re.findall(class_pat, content, re.MULTILINE)),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def analyze_file(file_info: dict) -> dict:
|
|
66
|
+
try:
|
|
67
|
+
with open(file_info['path'], 'r', encoding='utf-8', errors='ignore') as f:
|
|
68
|
+
content = f.read()
|
|
69
|
+
except (PermissionError, OSError):
|
|
70
|
+
return {**file_info, 'metrics': {'loc': 0, 'comments': 0, 'blank': 0, 'code': 0, 'functions': 0, 'classes': 0}, 'content': ''}
|
|
71
|
+
|
|
72
|
+
lang = file_info['language']
|
|
73
|
+
line_counts = count_lines(content, lang)
|
|
74
|
+
|
|
75
|
+
if lang == 'Python':
|
|
76
|
+
sym_counts = count_functions_classes_python(content)
|
|
77
|
+
else:
|
|
78
|
+
sym_counts = count_functions_classes_generic(content, lang)
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
**file_info,
|
|
82
|
+
'content': content,
|
|
83
|
+
'metrics': {
|
|
84
|
+
'loc': line_counts['total'],
|
|
85
|
+
'code': line_counts['code'],
|
|
86
|
+
'comments': line_counts['comment'],
|
|
87
|
+
'blank': line_counts['blank'],
|
|
88
|
+
'functions': sym_counts['functions'],
|
|
89
|
+
'classes': sym_counts['classes'],
|
|
90
|
+
}
|
|
91
|
+
}
|
pry/core/quality.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
def _complexity_penalty(analysis: dict) -> float:
|
|
2
|
+
flagged = analysis.get('complexity_summary', {}).get('flagged_count', 0)
|
|
3
|
+
total_funcs = max(
|
|
4
|
+
sum(f['metrics'].get('functions', 0) for f in analysis.get('files', [])), 1
|
|
5
|
+
)
|
|
6
|
+
return min(flagged / total_funcs, 1.0) * 30
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _duplication_penalty(analysis: dict) -> float:
|
|
10
|
+
dup_pct = analysis.get('duplicates', {}).get('duplication_percentage', 0)
|
|
11
|
+
return min(dup_pct / 100, 1.0) * 25
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _smells_penalty(analysis: dict) -> float:
|
|
15
|
+
files = analysis.get('files', [])
|
|
16
|
+
per_file = sum(len(f.get('smells', [])) for f in files) / max(len(files), 1)
|
|
17
|
+
return min(per_file / 5, 1.0) * 25
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _security_penalty(analysis: dict) -> float:
|
|
21
|
+
files = analysis.get('files', [])
|
|
22
|
+
critical = sum(1 for f in files for i in f.get('security_issues', []) if i['severity'] == 'critical')
|
|
23
|
+
errors = sum(1 for f in files for i in f.get('security_issues', []) if i['severity'] == 'error')
|
|
24
|
+
return min((critical * 10 + errors * 3) / 20, 1.0) * 20
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _grade(score: int) -> str:
|
|
28
|
+
if score >= 90: return 'A'
|
|
29
|
+
if score >= 80: return 'B'
|
|
30
|
+
if score >= 70: return 'C'
|
|
31
|
+
if score >= 60: return 'D'
|
|
32
|
+
return 'F'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def calculate_quality_score(analysis: dict) -> dict:
|
|
36
|
+
score = 100.0
|
|
37
|
+
score -= _complexity_penalty(analysis)
|
|
38
|
+
score -= _duplication_penalty(analysis)
|
|
39
|
+
score -= _smells_penalty(analysis)
|
|
40
|
+
score -= _security_penalty(analysis)
|
|
41
|
+
rounded = round(max(0.0, min(100.0, score)))
|
|
42
|
+
return {'score': rounded, 'grade': _grade(rounded)}
|
pry/core/security.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
SECRET_PATTERNS = [
|
|
5
|
+
(r'(?i)(api[_-]?key|apikey)\s*[=:]\s*["\']([A-Za-z0-9_\-]{16,})["\']', 'Hardcoded API key'),
|
|
6
|
+
(r'(?i)(password|passwd|pwd)\s*[=:]\s*["\']([^"\']{6,})["\']', 'Hardcoded password'),
|
|
7
|
+
(r'(?i)(secret[_-]?key|secret)\s*[=:]\s*["\']([A-Za-z0-9_\-]{16,})["\']', 'Hardcoded secret'),
|
|
8
|
+
(r'(?i)(token|auth[_-]?token)\s*[=:]\s*["\']([A-Za-z0-9_\-\.]{20,})["\']', 'Hardcoded token'),
|
|
9
|
+
(r'(?i)(aws[_-]?access[_-]?key|aws[_-]?secret)\s*[=:]\s*["\']([A-Z0-9]{16,})["\']', 'AWS credentials'),
|
|
10
|
+
(r'(?i)(private[_-]?key)\s*[=:]\s*["\']([A-Za-z0-9_\-]{16,})["\']', 'Hardcoded private key'),
|
|
11
|
+
(r'-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----', 'Embedded private key'),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
JS_TS_PATTERNS = [
|
|
15
|
+
(r'\beval\s*\(', 'Use of eval()'),
|
|
16
|
+
(r'\bnew\s+Function\s*\(', 'Use of Function constructor'),
|
|
17
|
+
(r'\bdocument\.write\s*\(', 'Use of document.write()'),
|
|
18
|
+
(r'innerHTML\s*=', 'Direct innerHTML assignment (XSS risk)'),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _PyDangerousCallVisitor(ast.NodeVisitor):
|
|
23
|
+
_DIRECT = {
|
|
24
|
+
'eval': 'Use of eval()',
|
|
25
|
+
'exec': 'Use of exec()',
|
|
26
|
+
'__import__': 'Dynamic import via __import__',
|
|
27
|
+
}
|
|
28
|
+
_ATTR = {
|
|
29
|
+
('pickle', 'loads'): 'Unsafe pickle deserialization',
|
|
30
|
+
('pickle', 'load'): 'Unsafe pickle deserialization',
|
|
31
|
+
('os', 'system'): 'Use of os.system()',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
self.issues = []
|
|
36
|
+
|
|
37
|
+
def visit_Call(self, node):
|
|
38
|
+
if isinstance(node.func, ast.Name) and node.func.id in self._DIRECT:
|
|
39
|
+
self.issues.append({
|
|
40
|
+
'type': 'dangerous_function',
|
|
41
|
+
'severity': 'error',
|
|
42
|
+
'line': node.lineno,
|
|
43
|
+
'message': self._DIRECT[node.func.id],
|
|
44
|
+
})
|
|
45
|
+
elif isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name):
|
|
46
|
+
key = (node.func.value.id, node.func.attr)
|
|
47
|
+
if key == ('subprocess', 'call'):
|
|
48
|
+
has_shell = any(
|
|
49
|
+
kw.arg == 'shell' and isinstance(kw.value, ast.Constant) and kw.value.value is True
|
|
50
|
+
for kw in node.keywords
|
|
51
|
+
)
|
|
52
|
+
if has_shell:
|
|
53
|
+
self.issues.append({
|
|
54
|
+
'type': 'dangerous_function',
|
|
55
|
+
'severity': 'error',
|
|
56
|
+
'line': node.lineno,
|
|
57
|
+
'message': 'Shell injection risk: subprocess.call with shell=True',
|
|
58
|
+
})
|
|
59
|
+
elif key in self._ATTR:
|
|
60
|
+
self.issues.append({
|
|
61
|
+
'type': 'dangerous_function',
|
|
62
|
+
'severity': 'error',
|
|
63
|
+
'line': node.lineno,
|
|
64
|
+
'message': self._ATTR[key],
|
|
65
|
+
})
|
|
66
|
+
self.generic_visit(node)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _scan_python_dangerous(content: str) -> list[dict]:
|
|
70
|
+
try:
|
|
71
|
+
tree = ast.parse(content)
|
|
72
|
+
visitor = _PyDangerousCallVisitor()
|
|
73
|
+
visitor.visit(tree)
|
|
74
|
+
return visitor.issues
|
|
75
|
+
except SyntaxError:
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _outside_string(line: str, pos: int) -> bool:
|
|
80
|
+
"""Heuristic: true when pos appears to be in code, not a quoted string."""
|
|
81
|
+
before = line[:pos]
|
|
82
|
+
return (before.count('"') % 2 == 0) and (before.count("'") % 2 == 0)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _scan_js_ts_dangerous(content: str) -> list[dict]:
|
|
86
|
+
issues = []
|
|
87
|
+
for i, line in enumerate(content.splitlines(), 1):
|
|
88
|
+
for pattern, message in JS_TS_PATTERNS:
|
|
89
|
+
m = re.search(pattern, line)
|
|
90
|
+
if m and _outside_string(line, m.start()):
|
|
91
|
+
issues.append({
|
|
92
|
+
'type': 'dangerous_function',
|
|
93
|
+
'severity': 'error',
|
|
94
|
+
'line': i,
|
|
95
|
+
'message': message,
|
|
96
|
+
})
|
|
97
|
+
return issues
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def scan_security(file_info: dict) -> list[dict]:
|
|
101
|
+
content = file_info.get('content', '')
|
|
102
|
+
lang = file_info['language']
|
|
103
|
+
issues = []
|
|
104
|
+
|
|
105
|
+
for i, line in enumerate(content.splitlines(), 1):
|
|
106
|
+
for pattern, message in SECRET_PATTERNS:
|
|
107
|
+
if re.search(pattern, line):
|
|
108
|
+
issues.append({
|
|
109
|
+
'type': 'secret',
|
|
110
|
+
'severity': 'critical',
|
|
111
|
+
'line': i,
|
|
112
|
+
'message': message,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
if lang == 'Python':
|
|
116
|
+
issues += _scan_python_dangerous(content)
|
|
117
|
+
elif lang in ('JavaScript', 'TypeScript'):
|
|
118
|
+
issues += _scan_js_ts_dangerous(content)
|
|
119
|
+
|
|
120
|
+
return issues
|
pry/core/smells.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SmellVisitor(ast.NodeVisitor):
|
|
6
|
+
def __init__(self, lines: list[str]):
|
|
7
|
+
self.lines = lines
|
|
8
|
+
self.smells = []
|
|
9
|
+
|
|
10
|
+
def _get_nesting(self, node):
|
|
11
|
+
depth = 0
|
|
12
|
+
for child in ast.walk(node):
|
|
13
|
+
if isinstance(child, (ast.If, ast.For, ast.While, ast.With, ast.Try)):
|
|
14
|
+
depth = max(depth, self._node_depth(child))
|
|
15
|
+
return depth
|
|
16
|
+
|
|
17
|
+
def _node_depth(self, node, current=0):
|
|
18
|
+
max_d = current
|
|
19
|
+
for child in ast.iter_child_nodes(node):
|
|
20
|
+
if isinstance(child, (ast.If, ast.For, ast.While, ast.With, ast.Try)):
|
|
21
|
+
max_d = max(max_d, self._node_depth(child, current + 1))
|
|
22
|
+
return max_d
|
|
23
|
+
|
|
24
|
+
def visit_FunctionDef(self, node):
|
|
25
|
+
end_line = getattr(node, 'end_lineno', node.lineno + len(self.lines))
|
|
26
|
+
length = end_line - node.lineno + 1
|
|
27
|
+
if length > 50:
|
|
28
|
+
self.smells.append({
|
|
29
|
+
'type': 'long_function',
|
|
30
|
+
'severity': 'warning' if length <= 100 else 'error',
|
|
31
|
+
'line': node.lineno,
|
|
32
|
+
'message': f'Function "{node.name}" is {length} lines (limit: 50)',
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
args_count = len(node.args.args) + len(node.args.kwonlyargs)
|
|
36
|
+
if args_count > 5:
|
|
37
|
+
self.smells.append({
|
|
38
|
+
'type': 'long_parameter_list',
|
|
39
|
+
'severity': 'warning',
|
|
40
|
+
'line': node.lineno,
|
|
41
|
+
'message': f'Function "{node.name}" has {args_count} parameters (limit: 5)',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
nesting = self._node_depth(node)
|
|
45
|
+
if nesting > 4:
|
|
46
|
+
self.smells.append({
|
|
47
|
+
'type': 'deep_nesting',
|
|
48
|
+
'severity': 'warning',
|
|
49
|
+
'line': node.lineno,
|
|
50
|
+
'message': f'Function "{node.name}" has nesting depth {nesting} (limit: 4)',
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
self.generic_visit(node)
|
|
54
|
+
|
|
55
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def analyze_python_smells(content: str) -> list[dict]:
|
|
59
|
+
lines = content.splitlines()
|
|
60
|
+
try:
|
|
61
|
+
tree = ast.parse(content)
|
|
62
|
+
visitor = SmellVisitor(lines)
|
|
63
|
+
visitor.visit(tree)
|
|
64
|
+
return visitor.smells
|
|
65
|
+
except SyntaxError:
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def analyze_generic_smells(content: str, language: str) -> list[dict]:
|
|
70
|
+
smells = []
|
|
71
|
+
lines = content.splitlines()
|
|
72
|
+
for i, line in enumerate(lines, 1):
|
|
73
|
+
stripped = line.expandtabs()
|
|
74
|
+
depth = (len(stripped) - len(stripped.lstrip())) // 4
|
|
75
|
+
if depth > 4:
|
|
76
|
+
smells.append({
|
|
77
|
+
'type': 'deep_nesting',
|
|
78
|
+
'severity': 'warning',
|
|
79
|
+
'line': i,
|
|
80
|
+
'message': f'Indentation depth {depth} exceeds limit (4)',
|
|
81
|
+
})
|
|
82
|
+
return smells
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def analyze_smells(file_info: dict) -> list[dict]:
|
|
86
|
+
lang = file_info['language']
|
|
87
|
+
content = file_info.get('content', '')
|
|
88
|
+
if lang == 'Python':
|
|
89
|
+
return analyze_python_smells(content)
|
|
90
|
+
return analyze_generic_smells(content, lang)
|