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 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)