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
pycodegate/profile.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Project profile detection and rule/scoring adjustments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pycodegate._compat import tomllib
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Profile:
|
|
13
|
+
name: str # "cli", "web", "library", "script"
|
|
14
|
+
suppressed_rules: frozenset[str] = field(default_factory=frozenset)
|
|
15
|
+
max_deduction_overrides: dict[str, int] = field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Profile definitions
|
|
19
|
+
PROFILES = {
|
|
20
|
+
"web": Profile(name="web"),
|
|
21
|
+
"cli": Profile(
|
|
22
|
+
name="cli",
|
|
23
|
+
suppressed_rules=frozenset({"no-subprocess", "no-shell-exec"}),
|
|
24
|
+
max_deduction_overrides={"Security": 15},
|
|
25
|
+
),
|
|
26
|
+
"library": Profile(name="library"),
|
|
27
|
+
"script": Profile(
|
|
28
|
+
name="script",
|
|
29
|
+
suppressed_rules=frozenset({"structure/no-tests", "structure/no-license"}),
|
|
30
|
+
max_deduction_overrides={"Structure": 5},
|
|
31
|
+
),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def detect_profile(project_path: str) -> Profile:
|
|
36
|
+
"""Auto-detect project profile from project structure and dependencies."""
|
|
37
|
+
path = Path(project_path)
|
|
38
|
+
pyproject = path / "pyproject.toml"
|
|
39
|
+
|
|
40
|
+
deps: set[str] = set()
|
|
41
|
+
has_build_system = False
|
|
42
|
+
has_scripts = False
|
|
43
|
+
|
|
44
|
+
if pyproject.exists():
|
|
45
|
+
try:
|
|
46
|
+
with open(pyproject, "rb") as f:
|
|
47
|
+
data = tomllib.load(f)
|
|
48
|
+
# Collect dependency names
|
|
49
|
+
for dep in data.get("project", {}).get("dependencies", []):
|
|
50
|
+
deps.add(
|
|
51
|
+
dep.split("[")[0]
|
|
52
|
+
.split(">")[0]
|
|
53
|
+
.split("<")[0]
|
|
54
|
+
.split("=")[0]
|
|
55
|
+
.split("!")[0]
|
|
56
|
+
.strip()
|
|
57
|
+
.lower()
|
|
58
|
+
)
|
|
59
|
+
has_build_system = "build-system" in data
|
|
60
|
+
has_scripts = bool(data.get("project", {}).get("scripts"))
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
# Also check requirements.txt
|
|
65
|
+
for req_file in ["requirements.txt", "requirements-dev.txt"]:
|
|
66
|
+
req_path = path / req_file
|
|
67
|
+
if req_path.exists():
|
|
68
|
+
try:
|
|
69
|
+
for line in req_path.read_text().splitlines():
|
|
70
|
+
line = line.strip()
|
|
71
|
+
if line and not line.startswith("#") and not line.startswith("-"):
|
|
72
|
+
deps.add(
|
|
73
|
+
line.split("[")[0]
|
|
74
|
+
.split(">")[0]
|
|
75
|
+
.split("<")[0]
|
|
76
|
+
.split("=")[0]
|
|
77
|
+
.split("!")[0]
|
|
78
|
+
.strip()
|
|
79
|
+
.lower()
|
|
80
|
+
)
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
# Web frameworks
|
|
85
|
+
web_deps = {"flask", "django", "fastapi", "starlette", "tornado", "aiohttp", "sanic", "bottle"}
|
|
86
|
+
if deps & web_deps:
|
|
87
|
+
return PROFILES["web"]
|
|
88
|
+
|
|
89
|
+
# CLI tools
|
|
90
|
+
cli_deps = {"click", "typer", "fire", "cement", "cliff", "argparse"}
|
|
91
|
+
if (deps & cli_deps) or has_scripts:
|
|
92
|
+
return PROFILES["cli"]
|
|
93
|
+
|
|
94
|
+
# Library
|
|
95
|
+
if has_build_system and not has_scripts:
|
|
96
|
+
return PROFILES["library"]
|
|
97
|
+
|
|
98
|
+
# Script: no __init__.py and few .py files
|
|
99
|
+
py_files = list(path.glob("*.py"))
|
|
100
|
+
has_init = (path / "__init__.py").exists() or any(
|
|
101
|
+
(path / d / "__init__.py").exists() for d in ["src", "lib"]
|
|
102
|
+
)
|
|
103
|
+
if not has_init and len(py_files) <= 5:
|
|
104
|
+
return PROFILES["script"]
|
|
105
|
+
|
|
106
|
+
# Default to library
|
|
107
|
+
return PROFILES["library"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Rule registry for python-doctor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pycodegate.rules.architecture import ArchitectureRules
|
|
6
|
+
from pycodegate.rules.base import BaseRules
|
|
7
|
+
from pycodegate.rules.complexity import ComplexityRules
|
|
8
|
+
from pycodegate.rules.correctness import CorrectnessRules
|
|
9
|
+
from pycodegate.rules.dependencies import DependencyRules as DependencyRules
|
|
10
|
+
from pycodegate.rules.django import DjangoRules
|
|
11
|
+
from pycodegate.rules.fastapi import FastAPIRules
|
|
12
|
+
from pycodegate.rules.flask import FlaskRules
|
|
13
|
+
from pycodegate.rules.imports import ImportsRules as ImportsRules
|
|
14
|
+
from pycodegate.rules.performance import PerformanceRules
|
|
15
|
+
from pycodegate.rules.security import SecurityRules
|
|
16
|
+
from pycodegate.rules.structure import StructureRules as StructureRules
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_all_rule_sets() -> list[BaseRules]:
|
|
20
|
+
"""Return all available rule sets."""
|
|
21
|
+
return [
|
|
22
|
+
SecurityRules(),
|
|
23
|
+
PerformanceRules(),
|
|
24
|
+
ArchitectureRules(),
|
|
25
|
+
CorrectnessRules(),
|
|
26
|
+
ComplexityRules(),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_framework_rules(framework: str | None) -> list[BaseRules]:
|
|
31
|
+
"""Return framework-specific rule sets."""
|
|
32
|
+
_framework_map: dict[str, type[BaseRules]] = {
|
|
33
|
+
"django": DjangoRules,
|
|
34
|
+
"fastapi": FastAPIRules,
|
|
35
|
+
"flask": FlaskRules,
|
|
36
|
+
}
|
|
37
|
+
cls = _framework_map.get(framework or "")
|
|
38
|
+
return [cls()] if cls else []
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Architecture rules: giant modules, deep nesting, god functions, too many args."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
|
|
7
|
+
from pycodegate.rules.base import BaseRules
|
|
8
|
+
from pycodegate.types import Category, Diagnostic, Severity
|
|
9
|
+
|
|
10
|
+
MAX_MODULE_LINES = 500
|
|
11
|
+
MAX_FUNCTION_LINES = 50
|
|
12
|
+
MAX_NESTING_DEPTH = 5
|
|
13
|
+
MAX_ARGUMENTS = 7
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ArchitectureRules(BaseRules):
|
|
17
|
+
"""Architecture-level checks."""
|
|
18
|
+
|
|
19
|
+
def check(self, source: str, filename: str) -> list[Diagnostic]:
|
|
20
|
+
tree = self._parse(source)
|
|
21
|
+
if tree is None:
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
diags: list[Diagnostic] = []
|
|
25
|
+
diags.extend(self._check_giant_module(source, filename))
|
|
26
|
+
diags.extend(self._check_deep_nesting(tree, filename))
|
|
27
|
+
diags.extend(self._check_god_functions(tree, filename))
|
|
28
|
+
diags.extend(self._check_too_many_args(tree, filename))
|
|
29
|
+
return diags
|
|
30
|
+
|
|
31
|
+
def _check_giant_module(self, source: str, filename: str) -> list[Diagnostic]:
|
|
32
|
+
lines = source.count("\n") + 1
|
|
33
|
+
if lines > MAX_MODULE_LINES:
|
|
34
|
+
return [
|
|
35
|
+
Diagnostic(
|
|
36
|
+
file_path=filename,
|
|
37
|
+
rule="no-giant-module",
|
|
38
|
+
severity=Severity.WARNING,
|
|
39
|
+
category=Category.ARCHITECTURE,
|
|
40
|
+
message=f"Module has {lines} lines (max {MAX_MODULE_LINES}) — consider splitting",
|
|
41
|
+
help="Extract related functions into separate modules",
|
|
42
|
+
line=1,
|
|
43
|
+
cost=1.0,
|
|
44
|
+
)
|
|
45
|
+
]
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
def _check_deep_nesting(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
49
|
+
diags: list[Diagnostic] = []
|
|
50
|
+
self._walk_nesting(tree, 0, filename, diags)
|
|
51
|
+
return diags
|
|
52
|
+
|
|
53
|
+
def _walk_nesting(
|
|
54
|
+
self, node: ast.AST, depth: int, filename: str, diags: list[Diagnostic]
|
|
55
|
+
) -> None:
|
|
56
|
+
nesting_nodes = (ast.If, ast.For, ast.While, ast.With, ast.Try)
|
|
57
|
+
if isinstance(node, nesting_nodes):
|
|
58
|
+
depth += 1
|
|
59
|
+
if depth >= MAX_NESTING_DEPTH:
|
|
60
|
+
diags.append(
|
|
61
|
+
Diagnostic(
|
|
62
|
+
file_path=filename,
|
|
63
|
+
rule="no-deep-nesting",
|
|
64
|
+
severity=Severity.WARNING,
|
|
65
|
+
category=Category.ARCHITECTURE,
|
|
66
|
+
message=f"Nesting depth {depth} exceeds max {MAX_NESTING_DEPTH}",
|
|
67
|
+
help="Extract nested logic into helper functions or use early returns",
|
|
68
|
+
line=node.lineno,
|
|
69
|
+
column=node.col_offset,
|
|
70
|
+
cost=1.0,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
for child in ast.iter_child_nodes(node):
|
|
74
|
+
self._walk_nesting(child, depth, filename, diags)
|
|
75
|
+
|
|
76
|
+
def _check_god_functions(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
77
|
+
diags: list[Diagnostic] = []
|
|
78
|
+
for node in ast.walk(tree):
|
|
79
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
80
|
+
end = getattr(node, "end_lineno", None)
|
|
81
|
+
if end is not None:
|
|
82
|
+
length = end - node.lineno + 1
|
|
83
|
+
if length > MAX_FUNCTION_LINES:
|
|
84
|
+
diags.append(
|
|
85
|
+
Diagnostic(
|
|
86
|
+
file_path=filename,
|
|
87
|
+
rule="no-god-function",
|
|
88
|
+
severity=Severity.WARNING,
|
|
89
|
+
category=Category.ARCHITECTURE,
|
|
90
|
+
message=f"Function '{node.name}' is {length} lines (max {MAX_FUNCTION_LINES})",
|
|
91
|
+
help="Break into smaller functions with single responsibilities",
|
|
92
|
+
line=node.lineno,
|
|
93
|
+
column=node.col_offset,
|
|
94
|
+
cost=1.0,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return diags
|
|
98
|
+
|
|
99
|
+
def _check_too_many_args(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
100
|
+
diags: list[Diagnostic] = []
|
|
101
|
+
for node in ast.walk(tree):
|
|
102
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
103
|
+
args = node.args
|
|
104
|
+
total = len(args.posonlyargs) + len(args.args) + len(args.kwonlyargs)
|
|
105
|
+
if total > 0 and args.args and args.args[0].arg in ("self", "cls"):
|
|
106
|
+
total -= 1
|
|
107
|
+
if total > MAX_ARGUMENTS:
|
|
108
|
+
diags.append(
|
|
109
|
+
Diagnostic(
|
|
110
|
+
file_path=filename,
|
|
111
|
+
rule="too-many-arguments",
|
|
112
|
+
severity=Severity.WARNING,
|
|
113
|
+
category=Category.ARCHITECTURE,
|
|
114
|
+
message=f"Function '{node.name}' has {total} arguments (max {MAX_ARGUMENTS})",
|
|
115
|
+
help="Group related arguments into a dataclass or TypedDict",
|
|
116
|
+
line=node.lineno,
|
|
117
|
+
column=node.col_offset,
|
|
118
|
+
cost=1.0,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
return diags
|
pycodegate/rules/base.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Base class for rule sets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
|
|
8
|
+
from pycodegate.types import Diagnostic
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseRules(ABC):
|
|
12
|
+
"""Base class that all rule sets inherit from."""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def check(self, source: str, filename: str) -> list[Diagnostic]:
|
|
16
|
+
"""Analyze source code and return diagnostics."""
|
|
17
|
+
|
|
18
|
+
def _parse(self, source: str) -> ast.Module | None:
|
|
19
|
+
"""Safely parse Python source into AST."""
|
|
20
|
+
try:
|
|
21
|
+
return ast.parse(source)
|
|
22
|
+
except SyntaxError:
|
|
23
|
+
return None
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Complexity rules: cyclomatic complexity analysis using pure AST."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
|
|
7
|
+
from pycodegate.rules.base import BaseRules
|
|
8
|
+
from pycodegate.types import Category, Diagnostic, Severity
|
|
9
|
+
|
|
10
|
+
MAX_COMPLEXITY = 15
|
|
11
|
+
CRITICAL_COMPLEXITY = 25
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _cyclomatic_complexity(func_node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
|
|
15
|
+
"""Calculate cyclomatic complexity for a function node.
|
|
16
|
+
|
|
17
|
+
Starts at 1 and adds 1 for each decision point:
|
|
18
|
+
- if / elif statements
|
|
19
|
+
- for / while loops
|
|
20
|
+
- except handlers
|
|
21
|
+
- and / or boolean operators
|
|
22
|
+
- assert statements
|
|
23
|
+
- ternary expressions (IfExp)
|
|
24
|
+
"""
|
|
25
|
+
complexity = 1
|
|
26
|
+
for node in ast.walk(func_node):
|
|
27
|
+
# Skip nested function/class definitions to avoid double-counting
|
|
28
|
+
if node is not func_node and isinstance(
|
|
29
|
+
node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
30
|
+
):
|
|
31
|
+
continue
|
|
32
|
+
if isinstance(node, (ast.If, ast.For, ast.AsyncFor, ast.While, ast.ExceptHandler)):
|
|
33
|
+
complexity += 1
|
|
34
|
+
elif isinstance(node, ast.BoolOp):
|
|
35
|
+
complexity += len(node.values) - 1
|
|
36
|
+
elif isinstance(node, (ast.Assert, ast.IfExp)):
|
|
37
|
+
complexity += 1
|
|
38
|
+
return complexity
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ComplexityRules(BaseRules):
|
|
42
|
+
"""Cyclomatic complexity checks."""
|
|
43
|
+
|
|
44
|
+
def check(self, source: str, filename: str) -> list[Diagnostic]:
|
|
45
|
+
tree = self._parse(source)
|
|
46
|
+
if tree is None:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
diags: list[Diagnostic] = []
|
|
50
|
+
for node in ast.walk(tree):
|
|
51
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
52
|
+
complexity = _cyclomatic_complexity(node)
|
|
53
|
+
if complexity > CRITICAL_COMPLEXITY:
|
|
54
|
+
diags.append(
|
|
55
|
+
Diagnostic(
|
|
56
|
+
file_path=filename,
|
|
57
|
+
rule="critical-complexity",
|
|
58
|
+
severity=Severity.ERROR,
|
|
59
|
+
category=Category.COMPLEXITY,
|
|
60
|
+
message=(
|
|
61
|
+
f"Function '{node.name}' has cyclomatic complexity"
|
|
62
|
+
f" {complexity} (max {MAX_COMPLEXITY})"
|
|
63
|
+
),
|
|
64
|
+
help="Break into smaller functions or simplify conditional logic",
|
|
65
|
+
line=node.lineno,
|
|
66
|
+
column=node.col_offset,
|
|
67
|
+
cost=3.0,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
elif complexity > MAX_COMPLEXITY:
|
|
71
|
+
diags.append(
|
|
72
|
+
Diagnostic(
|
|
73
|
+
file_path=filename,
|
|
74
|
+
rule="high-complexity",
|
|
75
|
+
severity=Severity.WARNING,
|
|
76
|
+
category=Category.COMPLEXITY,
|
|
77
|
+
message=(
|
|
78
|
+
f"Function '{node.name}' has cyclomatic complexity"
|
|
79
|
+
f" {complexity} (max {MAX_COMPLEXITY})"
|
|
80
|
+
),
|
|
81
|
+
help="Break into smaller functions or simplify conditional logic",
|
|
82
|
+
line=node.lineno,
|
|
83
|
+
column=node.col_offset,
|
|
84
|
+
cost=1.5,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
return diags
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Correctness rules: mutable defaults, bare except, broad exception, assert in prod, return in __init__."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
|
|
7
|
+
from pycodegate.rules.base import BaseRules
|
|
8
|
+
from pycodegate.types import Category, Diagnostic, Severity
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CorrectnessRules(BaseRules):
|
|
12
|
+
"""Correctness-related checks."""
|
|
13
|
+
|
|
14
|
+
def check(self, source: str, filename: str) -> list[Diagnostic]:
|
|
15
|
+
tree = self._parse(source)
|
|
16
|
+
if tree is None:
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
diags: list[Diagnostic] = []
|
|
20
|
+
diags.extend(self._check_mutable_defaults(tree, filename))
|
|
21
|
+
diags.extend(self._check_bare_except(tree, filename))
|
|
22
|
+
diags.extend(self._check_broad_exception(tree, filename))
|
|
23
|
+
diags.extend(self._check_assert_in_production(tree, filename))
|
|
24
|
+
diags.extend(self._check_return_in_init(tree, filename))
|
|
25
|
+
return diags
|
|
26
|
+
|
|
27
|
+
def _check_mutable_defaults(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
28
|
+
diags: list[Diagnostic] = []
|
|
29
|
+
for node in ast.walk(tree):
|
|
30
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
31
|
+
for default in node.args.defaults + node.args.kw_defaults:
|
|
32
|
+
if default is not None and isinstance(default, (ast.List, ast.Dict, ast.Set)):
|
|
33
|
+
diags.append(
|
|
34
|
+
Diagnostic(
|
|
35
|
+
file_path=filename,
|
|
36
|
+
rule="no-mutable-default",
|
|
37
|
+
severity=Severity.ERROR,
|
|
38
|
+
category=Category.CORRECTNESS,
|
|
39
|
+
message="Mutable default argument — shared across all calls",
|
|
40
|
+
help="Use None as default and create the mutable inside the function body",
|
|
41
|
+
line=node.lineno,
|
|
42
|
+
column=node.col_offset,
|
|
43
|
+
cost=2.0,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return diags
|
|
47
|
+
|
|
48
|
+
def _check_bare_except(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
49
|
+
diags: list[Diagnostic] = []
|
|
50
|
+
for node in ast.walk(tree):
|
|
51
|
+
if isinstance(node, ast.ExceptHandler) and node.type is None:
|
|
52
|
+
diags.append(
|
|
53
|
+
Diagnostic(
|
|
54
|
+
file_path=filename,
|
|
55
|
+
rule="no-bare-except",
|
|
56
|
+
severity=Severity.ERROR,
|
|
57
|
+
category=Category.CORRECTNESS,
|
|
58
|
+
message="Bare except catches all exceptions including SystemExit and KeyboardInterrupt",
|
|
59
|
+
help="Catch a specific exception type, or at minimum use 'except Exception'",
|
|
60
|
+
line=node.lineno,
|
|
61
|
+
column=node.col_offset,
|
|
62
|
+
cost=2.0,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
return diags
|
|
66
|
+
|
|
67
|
+
def _check_broad_exception(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
68
|
+
diags: list[Diagnostic] = []
|
|
69
|
+
for node in ast.walk(tree):
|
|
70
|
+
if isinstance(node, ast.ExceptHandler) and node.type is not None:
|
|
71
|
+
if isinstance(node.type, ast.Name) and node.type.id in (
|
|
72
|
+
"Exception",
|
|
73
|
+
"BaseException",
|
|
74
|
+
):
|
|
75
|
+
diags.append(
|
|
76
|
+
Diagnostic(
|
|
77
|
+
file_path=filename,
|
|
78
|
+
rule="no-broad-exception",
|
|
79
|
+
severity=Severity.WARNING,
|
|
80
|
+
category=Category.CORRECTNESS,
|
|
81
|
+
message=f"Catching '{node.type.id}' is too broad — masks real errors",
|
|
82
|
+
help="Catch specific exception types (ValueError, TypeError, etc.)",
|
|
83
|
+
line=node.lineno,
|
|
84
|
+
column=node.col_offset,
|
|
85
|
+
cost=1.0,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return diags
|
|
89
|
+
|
|
90
|
+
def _check_assert_in_production(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
91
|
+
if filename.startswith("test_") or "/test_" in filename or filename.endswith("_test.py"):
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
diags: list[Diagnostic] = []
|
|
95
|
+
for node in ast.walk(tree):
|
|
96
|
+
if isinstance(node, ast.Assert):
|
|
97
|
+
diags.append(
|
|
98
|
+
Diagnostic(
|
|
99
|
+
file_path=filename,
|
|
100
|
+
rule="no-assert-in-production",
|
|
101
|
+
severity=Severity.WARNING,
|
|
102
|
+
category=Category.CORRECTNESS,
|
|
103
|
+
message="assert statements are stripped with python -O flag",
|
|
104
|
+
help="Use explicit if/raise for production validation",
|
|
105
|
+
line=node.lineno,
|
|
106
|
+
column=node.col_offset,
|
|
107
|
+
cost=1.0,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
return diags
|
|
111
|
+
|
|
112
|
+
def _check_return_in_init(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
113
|
+
diags: list[Diagnostic] = []
|
|
114
|
+
for node in ast.walk(tree):
|
|
115
|
+
if isinstance(node, ast.FunctionDef) and node.name == "__init__":
|
|
116
|
+
for child in ast.walk(node):
|
|
117
|
+
if isinstance(child, ast.Return) and child.value is not None:
|
|
118
|
+
diags.append(
|
|
119
|
+
Diagnostic(
|
|
120
|
+
file_path=filename,
|
|
121
|
+
rule="no-return-in-init",
|
|
122
|
+
severity=Severity.ERROR,
|
|
123
|
+
category=Category.CORRECTNESS,
|
|
124
|
+
message="__init__ should not return a value",
|
|
125
|
+
help="Remove the return value — __init__ must return None",
|
|
126
|
+
line=child.lineno,
|
|
127
|
+
column=child.col_offset,
|
|
128
|
+
cost=2.0,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
return diags
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Dead code detection via vulture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import vulture
|
|
9
|
+
except ImportError:
|
|
10
|
+
vulture = None # type: ignore[assignment]
|
|
11
|
+
|
|
12
|
+
from pycodegate.types import Category, Diagnostic, Severity
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DeadCodeRules:
|
|
16
|
+
"""Detect unused code using vulture."""
|
|
17
|
+
|
|
18
|
+
def check_project(self, project_path: str) -> list[Diagnostic]:
|
|
19
|
+
"""Run vulture on the entire project and return diagnostics."""
|
|
20
|
+
if vulture is None:
|
|
21
|
+
return []
|
|
22
|
+
|
|
23
|
+
v = vulture.Vulture()
|
|
24
|
+
|
|
25
|
+
py_files = list(Path(project_path).rglob("*.py"))
|
|
26
|
+
ignore = {".venv", "venv", "node_modules", "__pycache__", ".git", "dist", "build"}
|
|
27
|
+
py_files = [
|
|
28
|
+
f
|
|
29
|
+
for f in py_files
|
|
30
|
+
if not any(part in ignore for part in f.relative_to(project_path).parts)
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
if not py_files:
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
# Include whitelist if present
|
|
37
|
+
whitelist = Path(project_path) / "whitelist.py"
|
|
38
|
+
scan_paths = [str(f) for f in py_files]
|
|
39
|
+
if whitelist.exists():
|
|
40
|
+
scan_paths.append(str(whitelist))
|
|
41
|
+
|
|
42
|
+
v.scavenge(scan_paths)
|
|
43
|
+
|
|
44
|
+
diags: list[Diagnostic] = []
|
|
45
|
+
for item in v.get_unused_code(min_confidence=60):
|
|
46
|
+
diags.append(
|
|
47
|
+
Diagnostic(
|
|
48
|
+
file_path=str(item.filename),
|
|
49
|
+
rule="dead-code",
|
|
50
|
+
severity=Severity.WARNING,
|
|
51
|
+
category=Category.DEAD_CODE,
|
|
52
|
+
message=f"Unused {item.typ}: '{item.name}' ({item.confidence}% confidence)",
|
|
53
|
+
help="Remove this dead code or add it to a vulture whitelist",
|
|
54
|
+
line=item.first_lineno,
|
|
55
|
+
cost=0.5,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return diags
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Dependency rules: vulnerability scanning via pip-audit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pycodegate.types import Category, Diagnostic, Severity
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DependencyRules:
|
|
15
|
+
"""Dependency vulnerability checks using pip-audit (optional)."""
|
|
16
|
+
|
|
17
|
+
def check_project(self, project_path: str) -> list[Diagnostic]:
|
|
18
|
+
if not shutil.which("pip-audit"):
|
|
19
|
+
return []
|
|
20
|
+
|
|
21
|
+
path = Path(project_path)
|
|
22
|
+
req_file = self._find_or_export_requirements(path)
|
|
23
|
+
if not req_file:
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
[
|
|
29
|
+
"pip-audit",
|
|
30
|
+
"--requirement",
|
|
31
|
+
str(req_file),
|
|
32
|
+
"--format",
|
|
33
|
+
"json",
|
|
34
|
+
"--output",
|
|
35
|
+
"-",
|
|
36
|
+
],
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
timeout=60,
|
|
40
|
+
)
|
|
41
|
+
# pip-audit exits non-zero when vulnerabilities found
|
|
42
|
+
if not result.stdout.strip():
|
|
43
|
+
return []
|
|
44
|
+
data = json.loads(result.stdout)
|
|
45
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
|
|
46
|
+
return []
|
|
47
|
+
finally:
|
|
48
|
+
# Clean up temp file if we created one
|
|
49
|
+
if req_file and str(req_file).startswith(tempfile.gettempdir()):
|
|
50
|
+
req_file.unlink(missing_ok=True)
|
|
51
|
+
|
|
52
|
+
diags: list[Diagnostic] = []
|
|
53
|
+
for vuln in data.get("dependencies", []):
|
|
54
|
+
pkg_name = vuln.get("name", "unknown")
|
|
55
|
+
version = vuln.get("version", "?")
|
|
56
|
+
for v in vuln.get("vulns", []):
|
|
57
|
+
vuln_id = v.get("id", "unknown")
|
|
58
|
+
fix_versions = v.get("fix_versions", [])
|
|
59
|
+
fix_str = f" (fix: {', '.join(fix_versions)})" if fix_versions else ""
|
|
60
|
+
diags.append(
|
|
61
|
+
Diagnostic(
|
|
62
|
+
file_path="requirements",
|
|
63
|
+
rule="deps/vulnerability",
|
|
64
|
+
severity=Severity.ERROR,
|
|
65
|
+
category=Category.DEPENDENCIES,
|
|
66
|
+
message=f"{pkg_name}=={version} has known vulnerability {vuln_id}{fix_str}",
|
|
67
|
+
help=f"Upgrade {pkg_name} to a patched version",
|
|
68
|
+
line=0,
|
|
69
|
+
cost=3.0,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
return diags
|
|
73
|
+
|
|
74
|
+
def _find_or_export_requirements(self, path: Path) -> Path | None:
|
|
75
|
+
# Try existing requirements.txt
|
|
76
|
+
for name in ["requirements.txt", "requirements.lock"]:
|
|
77
|
+
req = path / name
|
|
78
|
+
if req.exists():
|
|
79
|
+
return req
|
|
80
|
+
|
|
81
|
+
# Try uv export
|
|
82
|
+
if shutil.which("uv") and (path / "pyproject.toml").exists():
|
|
83
|
+
try:
|
|
84
|
+
tmp = Path(tempfile.gettempdir()) / "py-gate-reqs.txt"
|
|
85
|
+
result = subprocess.run(
|
|
86
|
+
[
|
|
87
|
+
"uv",
|
|
88
|
+
"export",
|
|
89
|
+
"--no-dev",
|
|
90
|
+
"--no-editable",
|
|
91
|
+
"--no-emit-project",
|
|
92
|
+
"--format",
|
|
93
|
+
"requirements-txt",
|
|
94
|
+
"--quiet",
|
|
95
|
+
],
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
timeout=30,
|
|
99
|
+
cwd=str(path),
|
|
100
|
+
)
|
|
101
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
102
|
+
tmp.write_text(result.stdout)
|
|
103
|
+
return tmp
|
|
104
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
return None
|