pycodegate 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.
- pycodegate/__init__.py +3 -0
- pycodegate/_compat.py +15 -0
- pycodegate/api.py +26 -0
- pycodegate/cli.py +131 -0
- pycodegate/config.py +81 -0
- pycodegate/constants.py +32 -0
- pycodegate/discover.py +114 -0
- pycodegate/output.py +270 -0
- pycodegate/profile.py +107 -0
- pycodegate/rules/__init__.py +38 -0
- pycodegate/rules/architecture.py +121 -0
- pycodegate/rules/base.py +23 -0
- pycodegate/rules/complexity.py +87 -0
- pycodegate/rules/correctness.py +131 -0
- pycodegate/rules/dead_code.py +58 -0
- pycodegate/rules/dependencies.py +107 -0
- pycodegate/rules/django.py +144 -0
- pycodegate/rules/fastapi.py +70 -0
- pycodegate/rules/flask.py +115 -0
- pycodegate/rules/imports.py +96 -0
- pycodegate/rules/performance.py +104 -0
- pycodegate/rules/security.py +167 -0
- pycodegate/rules/structure.py +242 -0
- pycodegate/scan.py +201 -0
- pycodegate/score.py +69 -0
- pycodegate/types.py +65 -0
- pycodegate/utils/__init__.py +1 -0
- pycodegate/utils/ast_helpers.py +19 -0
- pycodegate/utils/badge.py +51 -0
- pycodegate/utils/diff.py +24 -0
- pycodegate/utils/file_discovery.py +62 -0
- pycodegate/utils/fixer.py +32 -0
- pycodegate/utils/precommit.py +48 -0
- pycodegate-0.1.0.dist-info/METADATA +357 -0
- pycodegate-0.1.0.dist-info/RECORD +38 -0
- pycodegate-0.1.0.dist-info/WHEEL +4 -0
- pycodegate-0.1.0.dist-info/entry_points.txt +2 -0
- pycodegate-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Security rules: eval, exec, pickle, yaml, secrets, weak hashes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from pycodegate.rules.base import BaseRules
|
|
9
|
+
from pycodegate.types import Category, Diagnostic, Severity
|
|
10
|
+
|
|
11
|
+
# Patterns that suggest a hardcoded secret
|
|
12
|
+
_SECRET_VAR_PATTERNS = re.compile(
|
|
13
|
+
r"(api_key|apikey|secret|password|passwd|token|auth_token|private_key|"
|
|
14
|
+
r"access_key|secret_key|credentials)",
|
|
15
|
+
re.IGNORECASE,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Patterns that look like actual secret values (not empty/placeholder)
|
|
19
|
+
_SECRET_VALUE_MIN_LENGTH = 7
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SecurityRules(BaseRules):
|
|
23
|
+
"""Security-related checks."""
|
|
24
|
+
|
|
25
|
+
def check(self, source: str, filename: str) -> list[Diagnostic]:
|
|
26
|
+
tree = self._parse(source)
|
|
27
|
+
if tree is None:
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
diags: list[Diagnostic] = []
|
|
31
|
+
diags.extend(self._check_eval_exec(tree, filename))
|
|
32
|
+
diags.extend(self._check_pickle(tree, filename))
|
|
33
|
+
diags.extend(self._check_yaml(tree, filename))
|
|
34
|
+
diags.extend(self._check_hardcoded_secrets(tree, filename))
|
|
35
|
+
diags.extend(self._check_weak_hash(tree, filename))
|
|
36
|
+
return diags
|
|
37
|
+
|
|
38
|
+
def _check_eval_exec(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
39
|
+
diags: list[Diagnostic] = []
|
|
40
|
+
for node in ast.walk(tree):
|
|
41
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
|
42
|
+
if node.func.id == "eval":
|
|
43
|
+
diags.append(
|
|
44
|
+
Diagnostic(
|
|
45
|
+
file_path=filename,
|
|
46
|
+
rule="no-eval",
|
|
47
|
+
severity=Severity.ERROR,
|
|
48
|
+
category=Category.SECURITY,
|
|
49
|
+
message="Avoid eval() — it executes arbitrary code",
|
|
50
|
+
help="Use ast.literal_eval() for safe parsing of literals",
|
|
51
|
+
line=node.lineno,
|
|
52
|
+
column=node.col_offset,
|
|
53
|
+
cost=3.0,
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
elif node.func.id == "exec":
|
|
57
|
+
diags.append(
|
|
58
|
+
Diagnostic(
|
|
59
|
+
file_path=filename,
|
|
60
|
+
rule="no-exec",
|
|
61
|
+
severity=Severity.ERROR,
|
|
62
|
+
category=Category.SECURITY,
|
|
63
|
+
message="Avoid exec() — it executes arbitrary code",
|
|
64
|
+
help="Refactor to avoid dynamic code execution",
|
|
65
|
+
line=node.lineno,
|
|
66
|
+
column=node.col_offset,
|
|
67
|
+
cost=3.0,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return diags
|
|
71
|
+
|
|
72
|
+
def _check_pickle(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
73
|
+
diags: list[Diagnostic] = []
|
|
74
|
+
for node in ast.walk(tree):
|
|
75
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
|
76
|
+
if node.func.attr in ("load", "loads") and isinstance(node.func.value, ast.Name):
|
|
77
|
+
if node.func.value.id == "pickle":
|
|
78
|
+
diags.append(
|
|
79
|
+
Diagnostic(
|
|
80
|
+
file_path=filename,
|
|
81
|
+
rule="no-pickle-load",
|
|
82
|
+
severity=Severity.ERROR,
|
|
83
|
+
category=Category.SECURITY,
|
|
84
|
+
message="pickle.load() can execute arbitrary code on untrusted data",
|
|
85
|
+
help="Use JSON or a safe serialization format for untrusted data",
|
|
86
|
+
line=node.lineno,
|
|
87
|
+
column=node.col_offset,
|
|
88
|
+
cost=3.0,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
return diags
|
|
92
|
+
|
|
93
|
+
def _check_yaml(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
94
|
+
diags: list[Diagnostic] = []
|
|
95
|
+
for node in ast.walk(tree):
|
|
96
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
|
97
|
+
if (
|
|
98
|
+
node.func.attr == "load"
|
|
99
|
+
and isinstance(node.func.value, ast.Name)
|
|
100
|
+
and node.func.value.id == "yaml"
|
|
101
|
+
):
|
|
102
|
+
has_loader = any(kw.arg == "Loader" for kw in node.keywords)
|
|
103
|
+
if not has_loader:
|
|
104
|
+
diags.append(
|
|
105
|
+
Diagnostic(
|
|
106
|
+
file_path=filename,
|
|
107
|
+
rule="no-unsafe-yaml-load",
|
|
108
|
+
severity=Severity.ERROR,
|
|
109
|
+
category=Category.SECURITY,
|
|
110
|
+
message="yaml.load() without Loader is unsafe — can execute arbitrary code",
|
|
111
|
+
help="Use yaml.safe_load() or pass Loader=yaml.SafeLoader",
|
|
112
|
+
line=node.lineno,
|
|
113
|
+
column=node.col_offset,
|
|
114
|
+
cost=3.0,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
return diags
|
|
118
|
+
|
|
119
|
+
def _check_hardcoded_secrets(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
120
|
+
diags: list[Diagnostic] = []
|
|
121
|
+
for node in ast.walk(tree):
|
|
122
|
+
if not isinstance(node, ast.Assign):
|
|
123
|
+
continue
|
|
124
|
+
if not isinstance(node.value, ast.Constant) or not isinstance(node.value.value, str):
|
|
125
|
+
continue
|
|
126
|
+
if len(node.value.value) < _SECRET_VALUE_MIN_LENGTH:
|
|
127
|
+
continue
|
|
128
|
+
for target in node.targets:
|
|
129
|
+
if isinstance(target, ast.Name) and _SECRET_VAR_PATTERNS.search(target.id):
|
|
130
|
+
diags.append(
|
|
131
|
+
Diagnostic(
|
|
132
|
+
file_path=filename,
|
|
133
|
+
rule="no-hardcoded-secret",
|
|
134
|
+
severity=Severity.ERROR,
|
|
135
|
+
category=Category.SECURITY,
|
|
136
|
+
message=f"Hardcoded secret in '{target.id}' — use environment variables",
|
|
137
|
+
help="Use os.environ or a .env file via python-dotenv",
|
|
138
|
+
line=node.lineno,
|
|
139
|
+
column=node.col_offset,
|
|
140
|
+
cost=3.0,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return diags
|
|
144
|
+
|
|
145
|
+
def _check_weak_hash(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
146
|
+
diags: list[Diagnostic] = []
|
|
147
|
+
for node in ast.walk(tree):
|
|
148
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
|
149
|
+
if (
|
|
150
|
+
node.func.attr in ("md5", "sha1")
|
|
151
|
+
and isinstance(node.func.value, ast.Name)
|
|
152
|
+
and node.func.value.id == "hashlib"
|
|
153
|
+
):
|
|
154
|
+
diags.append(
|
|
155
|
+
Diagnostic(
|
|
156
|
+
file_path=filename,
|
|
157
|
+
rule="no-weak-hash",
|
|
158
|
+
severity=Severity.WARNING,
|
|
159
|
+
category=Category.SECURITY,
|
|
160
|
+
message=f"hashlib.{node.func.attr}() is cryptographically weak",
|
|
161
|
+
help="Use hashlib.sha256() or hashlib.sha3_256() instead",
|
|
162
|
+
line=node.lineno,
|
|
163
|
+
column=node.col_offset,
|
|
164
|
+
cost=1.0,
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
return diags
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Structure rules: project-level health checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pycodegate.types import Category, Diagnostic, Severity
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StructureRules:
|
|
12
|
+
"""Project-level structure health checks."""
|
|
13
|
+
|
|
14
|
+
def check_project(self, project_path: str, source_files: list[str]) -> list[Diagnostic]:
|
|
15
|
+
"""Run all structure checks on the project."""
|
|
16
|
+
path = Path(project_path)
|
|
17
|
+
diags: list[Diagnostic] = []
|
|
18
|
+
|
|
19
|
+
diags.extend(self._check_large_files(source_files))
|
|
20
|
+
diags.extend(self._check_tests(path, source_files))
|
|
21
|
+
diags.extend(self._check_readme(path))
|
|
22
|
+
diags.extend(self._check_license(path))
|
|
23
|
+
diags.extend(self._check_gitignore(path))
|
|
24
|
+
diags.extend(self._check_linter_config(path))
|
|
25
|
+
diags.extend(self._check_type_checker(path))
|
|
26
|
+
diags.extend(self._check_type_coverage(source_files))
|
|
27
|
+
|
|
28
|
+
return diags
|
|
29
|
+
|
|
30
|
+
def _check_large_files(self, files: list[str]) -> list[Diagnostic]:
|
|
31
|
+
diags = []
|
|
32
|
+
for f in files:
|
|
33
|
+
try:
|
|
34
|
+
line_count = len(Path(f).read_text().splitlines())
|
|
35
|
+
if line_count > 1000:
|
|
36
|
+
diags.append(
|
|
37
|
+
Diagnostic(
|
|
38
|
+
file_path=f,
|
|
39
|
+
rule="structure/large-file",
|
|
40
|
+
severity=Severity.WARNING,
|
|
41
|
+
category=Category.STRUCTURE,
|
|
42
|
+
message=f"File is {line_count} lines (max 1000)",
|
|
43
|
+
help="Split into smaller, focused modules",
|
|
44
|
+
line=1,
|
|
45
|
+
cost=1.0,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
except (OSError, UnicodeDecodeError):
|
|
49
|
+
pass
|
|
50
|
+
return diags
|
|
51
|
+
|
|
52
|
+
def _check_tests(self, path: Path, source_files: list[str]) -> list[Diagnostic]:
|
|
53
|
+
test_files = [
|
|
54
|
+
f
|
|
55
|
+
for f in source_files
|
|
56
|
+
if Path(f).stem.startswith("test_") or Path(f).stem.endswith("_test") or "/tests/" in f
|
|
57
|
+
]
|
|
58
|
+
src_files = [f for f in source_files if f not in test_files]
|
|
59
|
+
|
|
60
|
+
if not test_files:
|
|
61
|
+
return [
|
|
62
|
+
Diagnostic(
|
|
63
|
+
file_path=str(path),
|
|
64
|
+
rule="structure/no-tests",
|
|
65
|
+
severity=Severity.WARNING,
|
|
66
|
+
category=Category.STRUCTURE,
|
|
67
|
+
message="No test files found",
|
|
68
|
+
help="Add tests to improve code reliability",
|
|
69
|
+
line=0,
|
|
70
|
+
cost=3.0,
|
|
71
|
+
)
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
# Test ratio check
|
|
75
|
+
test_lines = sum(
|
|
76
|
+
len(Path(f).read_text().splitlines()) for f in test_files if Path(f).exists()
|
|
77
|
+
)
|
|
78
|
+
src_lines = sum(
|
|
79
|
+
len(Path(f).read_text().splitlines()) for f in src_files if Path(f).exists()
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if src_lines > 0:
|
|
83
|
+
ratio = test_lines / src_lines
|
|
84
|
+
if ratio < 0.1:
|
|
85
|
+
return [
|
|
86
|
+
Diagnostic(
|
|
87
|
+
file_path=str(path),
|
|
88
|
+
rule="structure/low-test-ratio",
|
|
89
|
+
severity=Severity.WARNING,
|
|
90
|
+
category=Category.STRUCTURE,
|
|
91
|
+
message=f"Test:source ratio is {ratio:.1f} (< 0.3)",
|
|
92
|
+
help="Add more tests to improve coverage",
|
|
93
|
+
line=0,
|
|
94
|
+
cost=2.0,
|
|
95
|
+
)
|
|
96
|
+
]
|
|
97
|
+
elif ratio < 0.3:
|
|
98
|
+
return [
|
|
99
|
+
Diagnostic(
|
|
100
|
+
file_path=str(path),
|
|
101
|
+
rule="structure/low-test-ratio",
|
|
102
|
+
severity=Severity.WARNING,
|
|
103
|
+
category=Category.STRUCTURE,
|
|
104
|
+
message=f"Test:source ratio is {ratio:.1f} (< 0.3)",
|
|
105
|
+
help="Add more tests to improve coverage",
|
|
106
|
+
line=0,
|
|
107
|
+
cost=1.0,
|
|
108
|
+
)
|
|
109
|
+
]
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
def _check_readme(self, path: Path) -> list[Diagnostic]:
|
|
113
|
+
for name in ["README.md", "README.rst", "README", "readme.md"]:
|
|
114
|
+
if (path / name).exists():
|
|
115
|
+
return []
|
|
116
|
+
return [
|
|
117
|
+
Diagnostic(
|
|
118
|
+
file_path=str(path),
|
|
119
|
+
rule="structure/no-readme",
|
|
120
|
+
severity=Severity.WARNING,
|
|
121
|
+
category=Category.STRUCTURE,
|
|
122
|
+
message="No README file found",
|
|
123
|
+
help="Add a README.md describing your project",
|
|
124
|
+
line=0,
|
|
125
|
+
cost=1.0,
|
|
126
|
+
)
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
def _check_license(self, path: Path) -> list[Diagnostic]:
|
|
130
|
+
for name in ["LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "COPYING"]:
|
|
131
|
+
if (path / name).exists():
|
|
132
|
+
return []
|
|
133
|
+
return [
|
|
134
|
+
Diagnostic(
|
|
135
|
+
file_path=str(path),
|
|
136
|
+
rule="structure/no-license",
|
|
137
|
+
severity=Severity.WARNING,
|
|
138
|
+
category=Category.STRUCTURE,
|
|
139
|
+
message="No LICENSE file found",
|
|
140
|
+
help="Add a LICENSE file to clarify usage terms",
|
|
141
|
+
line=0,
|
|
142
|
+
cost=0.5,
|
|
143
|
+
)
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
def _check_gitignore(self, path: Path) -> list[Diagnostic]:
|
|
147
|
+
if (path / ".gitignore").exists():
|
|
148
|
+
return []
|
|
149
|
+
return [
|
|
150
|
+
Diagnostic(
|
|
151
|
+
file_path=str(path),
|
|
152
|
+
rule="structure/no-gitignore",
|
|
153
|
+
severity=Severity.WARNING,
|
|
154
|
+
category=Category.STRUCTURE,
|
|
155
|
+
message="No .gitignore file found",
|
|
156
|
+
help="Add a .gitignore to exclude build artifacts and secrets",
|
|
157
|
+
line=0,
|
|
158
|
+
cost=0.5,
|
|
159
|
+
)
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
def _check_linter_config(self, path: Path) -> list[Diagnostic]:
|
|
163
|
+
# Check for ruff, flake8, pylint configs
|
|
164
|
+
if (path / "ruff.toml").exists() or (path / ".flake8").exists():
|
|
165
|
+
return []
|
|
166
|
+
pyproject = path / "pyproject.toml"
|
|
167
|
+
if pyproject.exists():
|
|
168
|
+
content = pyproject.read_text()
|
|
169
|
+
if "[tool.ruff]" in content or "[tool.flake8]" in content or "[tool.pylint]" in content:
|
|
170
|
+
return []
|
|
171
|
+
return [
|
|
172
|
+
Diagnostic(
|
|
173
|
+
file_path=str(path),
|
|
174
|
+
rule="structure/no-linter-config",
|
|
175
|
+
severity=Severity.WARNING,
|
|
176
|
+
category=Category.STRUCTURE,
|
|
177
|
+
message="No linter configuration found",
|
|
178
|
+
help="Add ruff or flake8 config to enforce code style",
|
|
179
|
+
line=0,
|
|
180
|
+
cost=0.5,
|
|
181
|
+
)
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
def _check_type_checker(self, path: Path) -> list[Diagnostic]:
|
|
185
|
+
if (
|
|
186
|
+
(path / "mypy.ini").exists()
|
|
187
|
+
or (path / "pyrightconfig.json").exists()
|
|
188
|
+
or (path / ".mypy.ini").exists()
|
|
189
|
+
):
|
|
190
|
+
return []
|
|
191
|
+
pyproject = path / "pyproject.toml"
|
|
192
|
+
if pyproject.exists():
|
|
193
|
+
content = pyproject.read_text()
|
|
194
|
+
if "[tool.mypy]" in content or "[tool.pyright]" in content:
|
|
195
|
+
return []
|
|
196
|
+
return [
|
|
197
|
+
Diagnostic(
|
|
198
|
+
file_path=str(path),
|
|
199
|
+
rule="structure/no-type-checker",
|
|
200
|
+
severity=Severity.WARNING,
|
|
201
|
+
category=Category.STRUCTURE,
|
|
202
|
+
message="No type checker configuration found",
|
|
203
|
+
help="Add mypy or pyright config for type safety",
|
|
204
|
+
line=0,
|
|
205
|
+
cost=0.5,
|
|
206
|
+
)
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
def _check_type_coverage(self, source_files: list[str]) -> list[Diagnostic]:
|
|
210
|
+
"""Check what fraction of functions have type annotations."""
|
|
211
|
+
total_funcs = 0
|
|
212
|
+
annotated_funcs = 0
|
|
213
|
+
for f in source_files:
|
|
214
|
+
# Skip test files
|
|
215
|
+
if Path(f).stem.startswith("test_") or Path(f).stem.endswith("_test") or "/tests/" in f:
|
|
216
|
+
continue
|
|
217
|
+
try:
|
|
218
|
+
tree = ast.parse(Path(f).read_text())
|
|
219
|
+
except (SyntaxError, OSError, UnicodeDecodeError):
|
|
220
|
+
continue
|
|
221
|
+
for node in ast.walk(tree):
|
|
222
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
223
|
+
total_funcs += 1
|
|
224
|
+
if node.returns or any(a.annotation for a in node.args.args if a.arg != "self"):
|
|
225
|
+
annotated_funcs += 1
|
|
226
|
+
|
|
227
|
+
if total_funcs > 0:
|
|
228
|
+
coverage = annotated_funcs / total_funcs
|
|
229
|
+
if coverage < 0.5:
|
|
230
|
+
return [
|
|
231
|
+
Diagnostic(
|
|
232
|
+
file_path="project",
|
|
233
|
+
rule="structure/low-type-coverage",
|
|
234
|
+
severity=Severity.WARNING,
|
|
235
|
+
category=Category.STRUCTURE,
|
|
236
|
+
message=f"Only {coverage:.0%} of functions have type annotations (< 50%)",
|
|
237
|
+
help="Add return type and parameter annotations to functions",
|
|
238
|
+
line=0,
|
|
239
|
+
cost=1.0,
|
|
240
|
+
)
|
|
241
|
+
]
|
|
242
|
+
return []
|
pycodegate/scan.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Scan orchestration: runs lint + dead code in parallel, produces ScanResult."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import time
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from pycodegate.discover import discover_project
|
|
12
|
+
from pycodegate.profile import PROFILES, detect_profile
|
|
13
|
+
from pycodegate.rules import get_all_rule_sets, get_framework_rules
|
|
14
|
+
from pycodegate.rules.dead_code import DeadCodeRules
|
|
15
|
+
from pycodegate.rules.dependencies import DependencyRules
|
|
16
|
+
from pycodegate.rules.imports import ImportsRules
|
|
17
|
+
from pycodegate.rules.structure import StructureRules
|
|
18
|
+
from pycodegate.score import calculate_score
|
|
19
|
+
from pycodegate.types import Diagnostic, ScanResult
|
|
20
|
+
from pycodegate.utils.diff import get_changed_files
|
|
21
|
+
from pycodegate.utils.file_discovery import find_python_files
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from pycodegate.config import Config
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def scan_project(
|
|
28
|
+
project_path: str,
|
|
29
|
+
config: Config,
|
|
30
|
+
diff_base: str | None = None,
|
|
31
|
+
) -> ScanResult:
|
|
32
|
+
"""Run full scan and return results."""
|
|
33
|
+
start = time.monotonic()
|
|
34
|
+
project = discover_project(project_path)
|
|
35
|
+
|
|
36
|
+
# Resolve profile: config/CLI override takes precedence over auto-detection
|
|
37
|
+
if config.profile and config.profile in PROFILES:
|
|
38
|
+
profile = PROFILES[config.profile]
|
|
39
|
+
else:
|
|
40
|
+
profile = detect_profile(project_path)
|
|
41
|
+
|
|
42
|
+
files = _resolve_files(project_path, diff_base)
|
|
43
|
+
all_diags = _run_checks(files, project.framework, project_path, config)
|
|
44
|
+
all_diags = _apply_filters(all_diags, config, project_path, profile.suppressed_rules)
|
|
45
|
+
max_deduction_overrides = _build_max_deduction_overrides(
|
|
46
|
+
profile.max_deduction_overrides, config.max_deduction
|
|
47
|
+
)
|
|
48
|
+
score = calculate_score(all_diags, max_deduction_overrides=max_deduction_overrides)
|
|
49
|
+
elapsed = int((time.monotonic() - start) * 1000)
|
|
50
|
+
|
|
51
|
+
return ScanResult(
|
|
52
|
+
score=score,
|
|
53
|
+
diagnostics=all_diags,
|
|
54
|
+
project=project,
|
|
55
|
+
elapsed_ms=elapsed,
|
|
56
|
+
profile=profile.name,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _resolve_files(project_path: str, diff_base: str | None) -> list[Path]:
|
|
61
|
+
"""Determine which files to scan."""
|
|
62
|
+
if diff_base:
|
|
63
|
+
files = get_changed_files(project_path, diff_base)
|
|
64
|
+
if files is not None:
|
|
65
|
+
return files
|
|
66
|
+
return find_python_files(project_path)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_checks(
|
|
70
|
+
files: list[Path],
|
|
71
|
+
framework: str | None,
|
|
72
|
+
project_path: str,
|
|
73
|
+
config: Config,
|
|
74
|
+
) -> list[Diagnostic]:
|
|
75
|
+
"""Run lint + dead code + imports + structure + dependency checks in parallel."""
|
|
76
|
+
str_files = [str(f) for f in files]
|
|
77
|
+
with ThreadPoolExecutor(max_workers=5) as executor:
|
|
78
|
+
lint_future = executor.submit(_run_lint, files, framework, config) if config.lint else None
|
|
79
|
+
dead_code_future = (
|
|
80
|
+
executor.submit(_run_dead_code, project_path, config) if config.dead_code else None
|
|
81
|
+
)
|
|
82
|
+
imports_future = (
|
|
83
|
+
executor.submit(_run_imports, project_path, str_files) if str_files else None
|
|
84
|
+
)
|
|
85
|
+
structure_future = (
|
|
86
|
+
executor.submit(_run_structure, project_path, str_files)
|
|
87
|
+
if config.lint and str_files
|
|
88
|
+
else None
|
|
89
|
+
)
|
|
90
|
+
dependencies_future = executor.submit(_run_dependencies, project_path)
|
|
91
|
+
|
|
92
|
+
lint_diags = lint_future.result() if lint_future else []
|
|
93
|
+
dead_code_diags = dead_code_future.result() if dead_code_future else []
|
|
94
|
+
imports_diags = imports_future.result() if imports_future else []
|
|
95
|
+
structure_diags = structure_future.result() if structure_future else []
|
|
96
|
+
dependencies_diags = dependencies_future.result()
|
|
97
|
+
|
|
98
|
+
return lint_diags + dead_code_diags + imports_diags + structure_diags + dependencies_diags
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _apply_filters(
|
|
102
|
+
diags: list[Diagnostic],
|
|
103
|
+
config: Config,
|
|
104
|
+
project_path: str = ".",
|
|
105
|
+
suppressed_rules: frozenset[str] | None = None,
|
|
106
|
+
) -> list[Diagnostic]:
|
|
107
|
+
"""Apply ignore_rules, profile suppressed_rules, ignore_files, and per_file_suppress filters."""
|
|
108
|
+
combined_ignore = set(config.ignore_rules)
|
|
109
|
+
if suppressed_rules:
|
|
110
|
+
combined_ignore |= suppressed_rules
|
|
111
|
+
if combined_ignore:
|
|
112
|
+
diags = [d for d in diags if d.rule not in combined_ignore]
|
|
113
|
+
root = Path(project_path).resolve()
|
|
114
|
+
if config.ignore_files:
|
|
115
|
+
diags = [d for d in diags if not _matches_ignore(d.file_path, config.ignore_files, root)]
|
|
116
|
+
if config.per_file_suppress:
|
|
117
|
+
diags = [
|
|
118
|
+
d for d in diags if not _matches_per_file_suppress(d, config.per_file_suppress, root)
|
|
119
|
+
]
|
|
120
|
+
return diags
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _matches_per_file_suppress(
|
|
124
|
+
d: Diagnostic, per_file_suppress: dict[str, list[str]], root: Path
|
|
125
|
+
) -> bool:
|
|
126
|
+
"""Check if a diagnostic is suppressed by per_file_suppress config."""
|
|
127
|
+
p = Path(d.file_path)
|
|
128
|
+
try:
|
|
129
|
+
rel = str(p.resolve().relative_to(root))
|
|
130
|
+
except ValueError:
|
|
131
|
+
rel = d.file_path
|
|
132
|
+
return any(
|
|
133
|
+
d.rule in rules and (fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(d.file_path, pat))
|
|
134
|
+
for pat, rules in per_file_suppress.items()
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _build_max_deduction_overrides(
|
|
139
|
+
profile_overrides: dict[str, int], config_overrides: dict[str, int]
|
|
140
|
+
) -> dict[str, int] | None:
|
|
141
|
+
"""Merge profile and config max_deduction overrides, config takes precedence.
|
|
142
|
+
|
|
143
|
+
Profile overrides use title-case keys (e.g. "Security").
|
|
144
|
+
Config overrides use lowercase keys (e.g. "security") which are title-cased here.
|
|
145
|
+
"""
|
|
146
|
+
merged: dict[str, int] = dict(profile_overrides)
|
|
147
|
+
for key, val in config_overrides.items():
|
|
148
|
+
merged[key.title()] = val
|
|
149
|
+
return merged or None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _matches_ignore(file_path: str, patterns: list[str], root: Path) -> bool:
|
|
153
|
+
"""Check if a file path matches any ignore pattern (absolute or relative)."""
|
|
154
|
+
p = Path(file_path)
|
|
155
|
+
# Try relative path from project root
|
|
156
|
+
try:
|
|
157
|
+
rel = str(p.resolve().relative_to(root))
|
|
158
|
+
except ValueError:
|
|
159
|
+
rel = file_path
|
|
160
|
+
return any(fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(file_path, pat) for pat in patterns)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _run_lint(
|
|
164
|
+
files: list[Path],
|
|
165
|
+
framework: str | None,
|
|
166
|
+
config: Config,
|
|
167
|
+
) -> list[Diagnostic]:
|
|
168
|
+
"""Run all rule sets against all files."""
|
|
169
|
+
rule_sets = get_all_rule_sets() + get_framework_rules(framework)
|
|
170
|
+
diags: list[Diagnostic] = []
|
|
171
|
+
|
|
172
|
+
for file_path in files:
|
|
173
|
+
try:
|
|
174
|
+
source = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
175
|
+
except (OSError, UnicodeDecodeError):
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
for rules in rule_sets:
|
|
179
|
+
diags.extend(rules.check(source, str(file_path)))
|
|
180
|
+
|
|
181
|
+
return diags
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _run_dead_code(project_path: str, config: Config) -> list[Diagnostic]:
|
|
185
|
+
"""Run dead code detection."""
|
|
186
|
+
return DeadCodeRules().check_project(project_path)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _run_imports(project_path: str, source_files: list[str]) -> list[Diagnostic]:
|
|
190
|
+
"""Run circular import detection."""
|
|
191
|
+
return ImportsRules().check_project(project_path, source_files)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _run_structure(project_path: str, source_files: list[str]) -> list[Diagnostic]:
|
|
195
|
+
"""Run project-level structure checks."""
|
|
196
|
+
return StructureRules().check_project(project_path, source_files)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _run_dependencies(project_path: str) -> list[Diagnostic]:
|
|
200
|
+
"""Run dependency vulnerability checks."""
|
|
201
|
+
return DependencyRules().check_project(project_path)
|
pycodegate/score.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Score calculation from diagnostics using weighted category-budget scoring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
|
|
7
|
+
from pycodegate.constants import (
|
|
8
|
+
CATEGORY_WEIGHTS,
|
|
9
|
+
FRAMEWORK_CATEGORY_MAP,
|
|
10
|
+
LABEL_CRITICAL,
|
|
11
|
+
LABEL_EXCELLENT,
|
|
12
|
+
LABEL_GREAT,
|
|
13
|
+
LABEL_NEEDS_WORK,
|
|
14
|
+
)
|
|
15
|
+
from pycodegate.types import Diagnostic, Score
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def calculate_score(
|
|
19
|
+
diagnostics: list[Diagnostic], max_deduction_overrides: dict | None = None
|
|
20
|
+
) -> Score:
|
|
21
|
+
"""Calculate a 0-100 health score from diagnostics using category budgets.
|
|
22
|
+
|
|
23
|
+
Each category has a weight that determines its maximum deduction budget.
|
|
24
|
+
Within a category, the top 3 findings are counted at full cost; additional
|
|
25
|
+
findings apply diminishing returns (10% each) to reward fixing top issues.
|
|
26
|
+
"""
|
|
27
|
+
# 1. Compute max_deduction per category from weights
|
|
28
|
+
total_weight = sum(CATEGORY_WEIGHTS.values())
|
|
29
|
+
max_deductions = {cat: round(w / total_weight * 100) for cat, w in CATEGORY_WEIGHTS.items()}
|
|
30
|
+
|
|
31
|
+
# Fix rounding: adjust highest-weight category so sum == 100
|
|
32
|
+
diff = 100 - sum(max_deductions.values())
|
|
33
|
+
if diff != 0:
|
|
34
|
+
highest = max(CATEGORY_WEIGHTS, key=CATEGORY_WEIGHTS.get)
|
|
35
|
+
max_deductions[highest] += diff
|
|
36
|
+
|
|
37
|
+
# Apply overrides
|
|
38
|
+
if max_deduction_overrides:
|
|
39
|
+
for cat, val in max_deduction_overrides.items():
|
|
40
|
+
if cat in max_deductions:
|
|
41
|
+
max_deductions[cat] = val
|
|
42
|
+
|
|
43
|
+
# 2. Group diagnostics by resolved category
|
|
44
|
+
by_category: dict = defaultdict(list)
|
|
45
|
+
for d in diagnostics:
|
|
46
|
+
resolved = FRAMEWORK_CATEGORY_MAP.get(d.category, d.category)
|
|
47
|
+
by_category[resolved].append(d)
|
|
48
|
+
|
|
49
|
+
# 3. Calculate deduction per category with diminishing returns
|
|
50
|
+
total_deduction = 0.0
|
|
51
|
+
for cat, diags in by_category.items():
|
|
52
|
+
costs = sorted([d.cost for d in diags], reverse=True)
|
|
53
|
+
# Top 3 at full cost, rest at 10%
|
|
54
|
+
cat_total = sum(c if i < 3 else c * 0.1 for i, c in enumerate(costs))
|
|
55
|
+
cap = max_deductions.get(cat, 10)
|
|
56
|
+
total_deduction += min(cat_total, cap)
|
|
57
|
+
|
|
58
|
+
value = max(0, round(100 - total_deduction))
|
|
59
|
+
|
|
60
|
+
if value >= 90:
|
|
61
|
+
label = LABEL_EXCELLENT
|
|
62
|
+
elif value >= 75:
|
|
63
|
+
label = LABEL_GREAT
|
|
64
|
+
elif value >= 50:
|
|
65
|
+
label = LABEL_NEEDS_WORK
|
|
66
|
+
else:
|
|
67
|
+
label = LABEL_CRITICAL
|
|
68
|
+
|
|
69
|
+
return Score(value=value, label=label)
|