qgis-plugin-analyzer 1.5.0__py3-none-any.whl → 1.6.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.
- analyzer/cli/__init__.py +14 -0
- analyzer/cli/app.py +147 -0
- analyzer/cli/base.py +93 -0
- analyzer/cli/commands/__init__.py +19 -0
- analyzer/cli/commands/analyze.py +47 -0
- analyzer/cli/commands/fix.py +58 -0
- analyzer/cli/commands/init.py +41 -0
- analyzer/cli/commands/list_rules.py +41 -0
- analyzer/cli/commands/security.py +46 -0
- analyzer/cli/commands/summary.py +52 -0
- analyzer/cli/commands/version.py +41 -0
- analyzer/cli.py +4 -184
- analyzer/commands.py +7 -7
- analyzer/engine.py +421 -238
- analyzer/fixer.py +206 -130
- analyzer/reporters/markdown_reporter.py +48 -15
- analyzer/reporters/summary_reporter.py +193 -80
- analyzer/scanner.py +218 -138
- analyzer/transformers.py +29 -8
- analyzer/utils/__init__.py +2 -0
- analyzer/utils/path_utils.py +53 -1
- analyzer/validators.py +90 -55
- analyzer/visitors/__init__.py +19 -0
- analyzer/visitors/base.py +75 -0
- analyzer/visitors/composite_visitor.py +73 -0
- analyzer/visitors/imports_visitor.py +85 -0
- analyzer/visitors/metrics_visitor.py +158 -0
- analyzer/visitors/security_visitor.py +52 -0
- analyzer/visitors/standards_visitor.py +284 -0
- {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/METADATA +16 -7
- qgis_plugin_analyzer-1.6.0.dist-info/RECORD +52 -0
- analyzer/visitors.py +0 -455
- qgis_plugin_analyzer-1.5.0.dist-info/RECORD +0 -35
- {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/WHEEL +0 -0
- {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/entry_points.txt +0 -0
- {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/licenses/LICENSE +0 -0
- {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/top_level.txt +0 -0
analyzer/validators.py
CHANGED
|
@@ -6,66 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
import ipaddress
|
|
8
8
|
import pathlib
|
|
9
|
+
import re # Added for folder name validation
|
|
9
10
|
import socket
|
|
10
11
|
import urllib.error
|
|
11
12
|
import urllib.parse
|
|
12
13
|
import urllib.request
|
|
13
14
|
from typing import Any, Dict, List
|
|
14
15
|
|
|
15
|
-
# Prohibited binary extensions per QGIS repository policy
|
|
16
|
-
BINARY_EXTENSIONS = {".exe", ".dll", ".so", ".dylib", ".pyd", ".bin", ".a", ".lib"}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def scan_for_binaries(project_path: pathlib.Path, ignore_matcher: Any = None) -> List[str]:
|
|
20
|
-
"""Scans the project for prohibited binary files per QGIS policies.
|
|
21
|
-
|
|
22
|
-
Args:
|
|
23
|
-
project_path: Root path of the project.
|
|
24
|
-
ignore_matcher: Optional object to determine if a path should be ignored.
|
|
25
|
-
|
|
26
|
-
Returns:
|
|
27
|
-
A list of relative paths to any binary files found.
|
|
28
|
-
"""
|
|
29
|
-
binaries = []
|
|
30
|
-
|
|
31
|
-
for file_path in project_path.rglob("*"):
|
|
32
|
-
if file_path.is_file():
|
|
33
|
-
# Skip if matches ignore pattern
|
|
34
|
-
if ignore_matcher and ignore_matcher.is_ignored(file_path):
|
|
35
|
-
continue
|
|
36
|
-
|
|
37
|
-
if file_path.suffix.lower() in BINARY_EXTENSIONS:
|
|
38
|
-
rel_path = str(file_path.relative_to(project_path))
|
|
39
|
-
binaries.append(rel_path)
|
|
40
|
-
|
|
41
|
-
return binaries
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def calculate_package_size(project_path: pathlib.Path, ignore_matcher: Any = None) -> float:
|
|
45
|
-
"""Calculates the total package size in Megabytes (MB).
|
|
46
|
-
|
|
47
|
-
Args:
|
|
48
|
-
project_path: Root path of the project.
|
|
49
|
-
ignore_matcher: Optional object to determine if a path should be ignored.
|
|
50
|
-
|
|
51
|
-
Returns:
|
|
52
|
-
The total size of the plugin package in MB.
|
|
53
|
-
"""
|
|
54
|
-
total_size = 0
|
|
55
|
-
|
|
56
|
-
for file_path in project_path.rglob("*"):
|
|
57
|
-
if file_path.is_file():
|
|
58
|
-
# Skip if matches ignore pattern
|
|
59
|
-
if ignore_matcher:
|
|
60
|
-
str(file_path.relative_to(project_path))
|
|
61
|
-
if ignore_matcher.is_ignored(file_path):
|
|
62
|
-
continue
|
|
63
|
-
|
|
64
|
-
total_size += file_path.stat().st_size
|
|
65
|
-
|
|
66
|
-
# Convert bytes to MB
|
|
67
|
-
return total_size / (1024 * 1024)
|
|
68
|
-
|
|
69
16
|
|
|
70
17
|
def is_ssrf_safe(url: str) -> bool:
|
|
71
18
|
"""Checks if a URL is safe from Server-Side Request Forgery (SSRF).
|
|
@@ -167,6 +114,39 @@ def validate_metadata_urls(metadata: Dict[str, str]) -> Dict[str, str]:
|
|
|
167
114
|
return results
|
|
168
115
|
|
|
169
116
|
|
|
117
|
+
def validate_package_constraints(total_size_mb: float, binaries: List[str]) -> Dict[str, Any]:
|
|
118
|
+
"""Validates package size and binary constraints against Official Repository rules.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
total_size_mb: Total size of the package in MB.
|
|
122
|
+
binaries: List of detected binary files.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Validation results including error messages.
|
|
126
|
+
"""
|
|
127
|
+
errors = []
|
|
128
|
+
|
|
129
|
+
# Rule: Max 20MB
|
|
130
|
+
if total_size_mb > 20:
|
|
131
|
+
errors.append(f"Package size ({total_size_mb:.2f}MB) exceeds the 20MB limit.")
|
|
132
|
+
|
|
133
|
+
# Rule: No Binaries
|
|
134
|
+
if binaries:
|
|
135
|
+
count = len(binaries)
|
|
136
|
+
shown = ", ".join(binaries[:3])
|
|
137
|
+
suffix = "..." if count > 3 else ""
|
|
138
|
+
errors.append(
|
|
139
|
+
f"Detected {count} binary file(s): {shown}{suffix}. Binaries are not allowed."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
"is_valid": len(errors) == 0,
|
|
144
|
+
"errors": errors,
|
|
145
|
+
"total_size_mb": total_size_mb,
|
|
146
|
+
"binary_count": len(binaries),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
170
150
|
def validate_plugin_structure(project_path: pathlib.Path) -> Dict[str, Any]:
|
|
171
151
|
"""Validates that the plugin following the required QGIS file structure.
|
|
172
152
|
|
|
@@ -193,12 +173,29 @@ def validate_plugin_structure(project_path: pathlib.Path) -> Dict[str, Any]:
|
|
|
193
173
|
py_files = list(project_path.glob("*.py"))
|
|
194
174
|
has_python = len(py_files) > 0
|
|
195
175
|
|
|
176
|
+
# Check Folder Name (Official Repo Rule: ASCII, alphanumeric, no start with digit)
|
|
177
|
+
folder_name = project_path.name
|
|
178
|
+
# Regex explanation:
|
|
179
|
+
# ^[a-zA-Z_] : Must start with letter or underscore (cannot start with digit/hyphen)
|
|
180
|
+
# [a-zA-Z0-9_-]*$ : Followed by alphanumeric, underscore, or hyphen
|
|
181
|
+
# Note: Official repo is strictly ASCII.
|
|
182
|
+
folder_valid = False
|
|
183
|
+
try:
|
|
184
|
+
# Check ASCII encoding implicitly by regex matching on string, or strict encode check
|
|
185
|
+
folder_name.encode("ascii")
|
|
186
|
+
if re.match(r"^[a-zA-Z_][a-zA-Z0-9_-]*$", folder_name):
|
|
187
|
+
folder_valid = True
|
|
188
|
+
except UnicodeEncodeError:
|
|
189
|
+
folder_valid = False
|
|
190
|
+
|
|
196
191
|
return {
|
|
197
192
|
"files": found,
|
|
198
193
|
"missing_files": missing,
|
|
199
194
|
"has_class_factory": has_factory,
|
|
200
195
|
"has_python_files": has_python,
|
|
201
|
-
"
|
|
196
|
+
"folder_name": folder_name,
|
|
197
|
+
"folder_name_valid": folder_valid,
|
|
198
|
+
"is_valid": all(found.values()) and has_factory and has_python and folder_valid,
|
|
202
199
|
}
|
|
203
200
|
|
|
204
201
|
|
|
@@ -218,6 +215,7 @@ def validate_metadata(metadata_path: pathlib.Path) -> Dict[str, Any]:
|
|
|
218
215
|
"qgisMinimumVersion",
|
|
219
216
|
"author",
|
|
220
217
|
"email",
|
|
218
|
+
"about",
|
|
221
219
|
]
|
|
222
220
|
|
|
223
221
|
recommended_fields = [
|
|
@@ -261,3 +259,40 @@ def validate_metadata(metadata_path: pathlib.Path) -> Dict[str, Any]:
|
|
|
261
259
|
"recommended_missing": recommended_missing,
|
|
262
260
|
"metadata": metadata,
|
|
263
261
|
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def calculate_package_size(directory: pathlib.Path) -> float:
|
|
265
|
+
"""Calculates the total size of a directory in megabytes.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
directory: The directory path.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
The total size in MB.
|
|
272
|
+
"""
|
|
273
|
+
total_size = 0
|
|
274
|
+
for file in directory.rglob("*"):
|
|
275
|
+
if file.is_file():
|
|
276
|
+
total_size += file.stat().st_size
|
|
277
|
+
return total_size / (1024 * 1024)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def scan_for_binaries(directory: pathlib.Path) -> List[str]:
|
|
281
|
+
"""Scans for binary files in the directory.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
directory: The directory path.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
A list of relative paths to detected binary files.
|
|
288
|
+
"""
|
|
289
|
+
binary_extensions = {".dll", ".so", ".exe", ".dylib", ".pyd", ".o", ".a"}
|
|
290
|
+
binaries = []
|
|
291
|
+
for file in directory.rglob("*"):
|
|
292
|
+
if file.suffix.lower() in binary_extensions:
|
|
293
|
+
try:
|
|
294
|
+
rel_path = file.relative_to(directory)
|
|
295
|
+
binaries.append(str(rel_path))
|
|
296
|
+
except ValueError:
|
|
297
|
+
binaries.append(str(file))
|
|
298
|
+
return binaries
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""AST Visitors for QGIS Plugin Analysis.
|
|
2
|
+
|
|
3
|
+
This package provides modular AST visitors for analyzing QGIS plugin code.
|
|
4
|
+
Each visitor is specialized for a specific concern (imports, metrics, standards, security).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .composite_visitor import CompositeVisitor
|
|
8
|
+
from .security_visitor import SecurityVisitor
|
|
9
|
+
|
|
10
|
+
# Maintain backward compatibility
|
|
11
|
+
QGISASTVisitor = CompositeVisitor
|
|
12
|
+
QGISSecurityVisitor = SecurityVisitor
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"QGISASTVisitor",
|
|
16
|
+
"QGISSecurityVisitor",
|
|
17
|
+
"CompositeVisitor",
|
|
18
|
+
"SecurityVisitor",
|
|
19
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Base visitor class with shared functionality for all AST visitors."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BaseVisitor(ast.NodeVisitor):
|
|
8
|
+
"""Base class for AST visitors with common reporting and configuration logic.
|
|
9
|
+
|
|
10
|
+
Attributes:
|
|
11
|
+
rel_path: Relative path to the file being analyzed.
|
|
12
|
+
issues: List of detected issues.
|
|
13
|
+
rules_config: Configuration for audit rules and severities.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, rel_path: str, rules_config: Optional[Dict[str, Any]] = None) -> None:
|
|
17
|
+
"""Initializes the base visitor.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
rel_path: Relative path to the file being analyzed.
|
|
21
|
+
rules_config: Optional configuration for audit rules and severities.
|
|
22
|
+
"""
|
|
23
|
+
self.rel_path = rel_path
|
|
24
|
+
self.issues: List[Dict[str, Any]] = []
|
|
25
|
+
self.rules_config = rules_config or {}
|
|
26
|
+
|
|
27
|
+
def _should_report(self, rule_id: str) -> bool:
|
|
28
|
+
"""Check if rule should be reported based on config.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
rule_id: The rule identifier.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
True if the rule should be reported, False otherwise.
|
|
35
|
+
"""
|
|
36
|
+
severity = self.rules_config.get(rule_id, "warning")
|
|
37
|
+
return bool(severity != "ignore")
|
|
38
|
+
|
|
39
|
+
def _get_severity(self, rule_id: str) -> str:
|
|
40
|
+
"""Get configured severity for rule (maps to 'high', 'medium', 'low').
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
rule_id: The rule identifier.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The severity level as a string.
|
|
47
|
+
"""
|
|
48
|
+
config_severity = self.rules_config.get(rule_id, "warning")
|
|
49
|
+
severity_map = {
|
|
50
|
+
"error": "high",
|
|
51
|
+
"warning": "medium",
|
|
52
|
+
"info": "low",
|
|
53
|
+
}
|
|
54
|
+
return severity_map.get(config_severity, "medium")
|
|
55
|
+
|
|
56
|
+
def _report_issue(self, rule_id: str, line: int, message: str, code: str = "") -> None:
|
|
57
|
+
"""Helper to report an issue if enabled.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
rule_id: The rule identifier.
|
|
61
|
+
line: Line number where the issue was detected.
|
|
62
|
+
message: Description of the issue.
|
|
63
|
+
code: Optional code snippet related to the issue.
|
|
64
|
+
"""
|
|
65
|
+
if self._should_report(rule_id):
|
|
66
|
+
self.issues.append(
|
|
67
|
+
{
|
|
68
|
+
"file": self.rel_path,
|
|
69
|
+
"line": line,
|
|
70
|
+
"type": rule_id,
|
|
71
|
+
"severity": self._get_severity(rule_id),
|
|
72
|
+
"message": message,
|
|
73
|
+
"code": code,
|
|
74
|
+
}
|
|
75
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Composite visitor that orchestrates all specialized visitors."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from .imports_visitor import ImportsVisitor
|
|
7
|
+
from .metrics_visitor import MetricsVisitor
|
|
8
|
+
from .standards_visitor import StandardsVisitor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CompositeVisitor(ast.NodeVisitor):
|
|
12
|
+
"""Orchestrator that combines all specialized visitors.
|
|
13
|
+
|
|
14
|
+
This class maintains compatibility with the original QGISASTVisitor API
|
|
15
|
+
while delegating work to specialized visitors.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
rel_path: Relative path to the file being analyzed.
|
|
19
|
+
issues: Aggregated list of all issues from all visitors.
|
|
20
|
+
docstring_styles: Aggregated docstring styles from metrics visitor.
|
|
21
|
+
type_hint_stats: Type hint statistics from metrics visitor.
|
|
22
|
+
docstring_stats: Docstring statistics from metrics visitor.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, rel_path: str, rules_config: Optional[Dict[str, Any]] = None) -> None:
|
|
26
|
+
"""Initializes the composite visitor.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
rel_path: Relative path to the file being analyzed.
|
|
30
|
+
rules_config: Optional configuration for audit rules and severities.
|
|
31
|
+
"""
|
|
32
|
+
self.rel_path = rel_path
|
|
33
|
+
self.rules_config = rules_config or {}
|
|
34
|
+
|
|
35
|
+
# Initialize specialized visitors
|
|
36
|
+
self._imports_visitor = ImportsVisitor(rel_path, rules_config)
|
|
37
|
+
self._metrics_visitor = MetricsVisitor(rel_path, rules_config)
|
|
38
|
+
self._standards_visitor = StandardsVisitor(rel_path, rules_config)
|
|
39
|
+
|
|
40
|
+
# Aggregated results
|
|
41
|
+
self.issues: List[Dict[str, Any]] = []
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def docstring_styles(self) -> List[str]:
|
|
45
|
+
"""Returns docstring styles from metrics visitor."""
|
|
46
|
+
return self._metrics_visitor.docstring_styles
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def type_hint_stats(self) -> Dict[str, int]:
|
|
50
|
+
"""Returns type hint statistics from metrics visitor."""
|
|
51
|
+
return self._metrics_visitor.type_hint_stats
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def docstring_stats(self) -> Dict[str, int]:
|
|
55
|
+
"""Returns docstring statistics from metrics visitor."""
|
|
56
|
+
return self._metrics_visitor.docstring_stats
|
|
57
|
+
|
|
58
|
+
def visit(self, node: ast.AST) -> None:
|
|
59
|
+
"""Visits a node with all specialized visitors.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
node: The AST node to visit.
|
|
63
|
+
"""
|
|
64
|
+
# Dispatch to all specialized visitors
|
|
65
|
+
self._imports_visitor.visit(node)
|
|
66
|
+
self._metrics_visitor.visit(node)
|
|
67
|
+
self._standards_visitor.visit(node)
|
|
68
|
+
|
|
69
|
+
# Aggregate issues
|
|
70
|
+
self.issues = []
|
|
71
|
+
self.issues.extend(self._imports_visitor.issues)
|
|
72
|
+
self.issues.extend(self._metrics_visitor.issues)
|
|
73
|
+
self.issues.extend(self._standards_visitor.issues)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""AST visitor for import validation and analysis."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from .base import BaseVisitor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ImportsVisitor(BaseVisitor):
|
|
10
|
+
"""Visitor focused on import-related checks.
|
|
11
|
+
|
|
12
|
+
Detects issues like:
|
|
13
|
+
- Direct GDAL imports
|
|
14
|
+
- Legacy PyQt4/PyQt5 imports
|
|
15
|
+
- Protected member imports
|
|
16
|
+
- Heavy dependencies in UI files
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
20
|
+
"""Analyzes import nodes.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
node: The import AST node.
|
|
24
|
+
"""
|
|
25
|
+
for alias in node.names:
|
|
26
|
+
self._check_import_name(alias.name, node, ast.unparse(node))
|
|
27
|
+
self.generic_visit(node)
|
|
28
|
+
|
|
29
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
30
|
+
"""Analyzes 'from import' nodes.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
node: The import-from AST node.
|
|
34
|
+
"""
|
|
35
|
+
if node.module:
|
|
36
|
+
self._check_import_name(node.module, node, ast.unparse(node))
|
|
37
|
+
|
|
38
|
+
# HEAVY_LOGIC_UI check
|
|
39
|
+
heavy_libs = {"pandas", "numpy", "scipy", "sklearn", "matplotlib"}
|
|
40
|
+
is_ui_file = "gui" in self.rel_path.lower() or "ui" in self.rel_path.lower()
|
|
41
|
+
if is_ui_file and (
|
|
42
|
+
node.module in heavy_libs or node.module.split(".")[0] in heavy_libs
|
|
43
|
+
):
|
|
44
|
+
self._report_issue(
|
|
45
|
+
"HEAVY_LOGIC_UI",
|
|
46
|
+
node.lineno,
|
|
47
|
+
f"Heavy dependency '{node.module}' detected in UI file. Move logic to core.",
|
|
48
|
+
ast.unparse(node),
|
|
49
|
+
)
|
|
50
|
+
self.generic_visit(node)
|
|
51
|
+
|
|
52
|
+
def _check_import_name(self, name: str, node: ast.AST, code_snippet: str) -> None:
|
|
53
|
+
"""Checks a single import name for violations.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
name: The import name to check.
|
|
57
|
+
node: The AST node containing the import.
|
|
58
|
+
code_snippet: String representation of the import statement.
|
|
59
|
+
"""
|
|
60
|
+
# QGIS_PROTECTED_MEMBER
|
|
61
|
+
if name.startswith("qgis._") and not name.startswith("qgis._3d"):
|
|
62
|
+
self._report_issue(
|
|
63
|
+
"QGIS_PROTECTED_MEMBER",
|
|
64
|
+
cast(Any, node).lineno,
|
|
65
|
+
f"Protected member import detected: '{name}'. Protected members are unstable.",
|
|
66
|
+
code_snippet,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# GDAL_DIRECT_IMPORT
|
|
70
|
+
if name == "gdal":
|
|
71
|
+
self._report_issue(
|
|
72
|
+
"GDAL_DIRECT_IMPORT",
|
|
73
|
+
cast(Any, node).lineno,
|
|
74
|
+
"Direct 'gdal' import detected. Use 'from osgeo import gdal'.",
|
|
75
|
+
code_snippet,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# QGIS_LEGACY_IMPORT
|
|
79
|
+
if name.startswith(("PyQt4", "PyQt5")):
|
|
80
|
+
self._report_issue(
|
|
81
|
+
"QGIS_LEGACY_IMPORT",
|
|
82
|
+
cast(Any, node).lineno,
|
|
83
|
+
f"Legacy import detected: '{name}'. Use 'qgis.PyQt' for compatibility.",
|
|
84
|
+
code_snippet,
|
|
85
|
+
)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""AST visitor for docstring and metrics collection."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from ..utils.ast_utils import calculate_complexity
|
|
8
|
+
from .base import BaseVisitor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MetricsVisitor(BaseVisitor):
|
|
12
|
+
"""Visitor focused on collecting research-based metrics.
|
|
13
|
+
|
|
14
|
+
Collects:
|
|
15
|
+
- Docstring coverage and styles
|
|
16
|
+
- Type hint coverage
|
|
17
|
+
- Complexity metrics
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, rel_path: str, rules_config: Optional[Dict[str, Any]] = None) -> None:
|
|
21
|
+
"""Initializes the metrics visitor.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
rel_path: Relative path to the file being analyzed.
|
|
25
|
+
rules_config: Optional configuration for audit rules and severities.
|
|
26
|
+
"""
|
|
27
|
+
super().__init__(rel_path, rules_config)
|
|
28
|
+
self.docstring_styles: List[str] = []
|
|
29
|
+
self.type_hint_stats = {
|
|
30
|
+
"total_parameters": 0,
|
|
31
|
+
"annotated_parameters": 0,
|
|
32
|
+
"has_return_hint": 0,
|
|
33
|
+
"total_functions": 0,
|
|
34
|
+
}
|
|
35
|
+
self.docstring_stats = {"total_public_items": 0, "has_docstring": 0}
|
|
36
|
+
|
|
37
|
+
def visit_Module(self, node: ast.Module) -> None:
|
|
38
|
+
"""Analyzes a module-level AST node.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
node: The module AST node.
|
|
42
|
+
"""
|
|
43
|
+
doc = ast.get_docstring(node)
|
|
44
|
+
self.docstring_stats["total_public_items"] += 1
|
|
45
|
+
if doc:
|
|
46
|
+
self.docstring_stats["has_docstring"] += 1
|
|
47
|
+
self._check_docstring_style(doc)
|
|
48
|
+
else:
|
|
49
|
+
self._report_issue(
|
|
50
|
+
"MISSING_DOCSTRING",
|
|
51
|
+
1,
|
|
52
|
+
"Module is missing a docstring (PEP 257).",
|
|
53
|
+
f"Module: {self.rel_path}",
|
|
54
|
+
)
|
|
55
|
+
self.generic_visit(node)
|
|
56
|
+
|
|
57
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
58
|
+
"""Analyzes function definitions.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
node: The function definition AST node.
|
|
62
|
+
"""
|
|
63
|
+
# HIGH_COMPLEXITY
|
|
64
|
+
complexity = calculate_complexity(node)
|
|
65
|
+
if complexity > 15:
|
|
66
|
+
self._report_issue(
|
|
67
|
+
"HIGH_COMPLEXITY",
|
|
68
|
+
node.lineno,
|
|
69
|
+
f"Function '{node.name}' is too complex (CC={complexity} > 15). Consider extracting methods.",
|
|
70
|
+
f"def {node.name}...",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
self._check_docstring_and_metrics(node)
|
|
74
|
+
self._check_type_hints(node)
|
|
75
|
+
self.generic_visit(node)
|
|
76
|
+
|
|
77
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
78
|
+
"""Analyzes class definitions.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
node: The class definition AST node.
|
|
82
|
+
"""
|
|
83
|
+
# Missing Docstring
|
|
84
|
+
if not node.name.startswith("_"):
|
|
85
|
+
doc = ast.get_docstring(node)
|
|
86
|
+
self.docstring_stats["total_public_items"] += 1
|
|
87
|
+
if doc:
|
|
88
|
+
self.docstring_stats["has_docstring"] += 1
|
|
89
|
+
self._check_docstring_style(doc)
|
|
90
|
+
else:
|
|
91
|
+
self._report_issue(
|
|
92
|
+
"MISSING_DOCSTRING",
|
|
93
|
+
node.lineno,
|
|
94
|
+
f"Public class '{node.name}' is missing a docstring.",
|
|
95
|
+
f"class {node.name}...",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
self.generic_visit(node)
|
|
99
|
+
|
|
100
|
+
def _check_docstring_style(self, doc: Optional[str]) -> None:
|
|
101
|
+
"""Identifies Google or NumPy docstring styles within a string.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
doc: The docstring to analyze.
|
|
105
|
+
"""
|
|
106
|
+
if not doc:
|
|
107
|
+
return
|
|
108
|
+
# Google: Args: or Returns: or Raises: as headers
|
|
109
|
+
if re.search(r"\n\s*(Args|Returns|Raises|Yields):\s*\n", doc):
|
|
110
|
+
self.docstring_styles.append("Google")
|
|
111
|
+
# NumPy: Underlined headers
|
|
112
|
+
elif re.search(r"\n(Parameters|Returns|Raises|Yields)\n\s*-{3,}", doc):
|
|
113
|
+
self.docstring_styles.append("NumPy")
|
|
114
|
+
|
|
115
|
+
def _check_docstring_and_metrics(self, node: ast.FunctionDef) -> None:
|
|
116
|
+
"""Checks docstrings and collects metrics.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
node: The function definition AST node.
|
|
120
|
+
"""
|
|
121
|
+
if not node.name.startswith("_") and node.name != "__init__":
|
|
122
|
+
doc = ast.get_docstring(node)
|
|
123
|
+
self.docstring_stats["total_public_items"] += 1
|
|
124
|
+
if doc:
|
|
125
|
+
self.docstring_stats["has_docstring"] += 1
|
|
126
|
+
self._check_docstring_style(doc)
|
|
127
|
+
else:
|
|
128
|
+
self._report_issue(
|
|
129
|
+
"MISSING_DOCSTRING",
|
|
130
|
+
node.lineno,
|
|
131
|
+
f"Public function '{node.name}' is missing a docstring.",
|
|
132
|
+
f"def {node.name}...",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _check_type_hints(self, node: ast.FunctionDef) -> None:
|
|
136
|
+
"""Checks for type hints.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
node: The function definition AST node.
|
|
140
|
+
"""
|
|
141
|
+
if node.name == "__init__":
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
self.type_hint_stats["total_functions"] += 1
|
|
145
|
+
params = [a for a in node.args.args if a.arg != "self" and a.arg != "cls"]
|
|
146
|
+
self.type_hint_stats["total_parameters"] += len(params)
|
|
147
|
+
annotated = [a for a in params if a.annotation]
|
|
148
|
+
self.type_hint_stats["annotated_parameters"] += len(annotated)
|
|
149
|
+
if node.returns:
|
|
150
|
+
self.type_hint_stats["has_return_hint"] += 1
|
|
151
|
+
|
|
152
|
+
if params and not annotated and not node.returns:
|
|
153
|
+
self._report_issue(
|
|
154
|
+
"MISSING_TYPE_HINTS",
|
|
155
|
+
node.lineno,
|
|
156
|
+
f"Function '{node.name}' has no type annotations.",
|
|
157
|
+
f"def {node.name}...",
|
|
158
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""AST visitor for security vulnerability detection."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from typing import Any, Dict, List
|
|
5
|
+
|
|
6
|
+
from ..security_checker import SecurityContext, SecurityRegistry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SecurityVisitor(ast.NodeVisitor):
|
|
10
|
+
"""AST visitor focused on security vulnerabilities (Bandit-inspired).
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
rel_path: Relative path to the file being analyzed.
|
|
14
|
+
findings: List of security findings detected.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, rel_path: str):
|
|
18
|
+
"""Initializes the security visitor.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
rel_path: Relative path to the file being analyzed.
|
|
22
|
+
"""
|
|
23
|
+
self.rel_path = rel_path
|
|
24
|
+
self.findings: List[Dict[str, Any]] = []
|
|
25
|
+
|
|
26
|
+
def visit(self, node: ast.AST):
|
|
27
|
+
"""Dispatches security checks for the current node.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
node: The AST node to analyze.
|
|
31
|
+
"""
|
|
32
|
+
checks = SecurityRegistry.get_checks_for_node(type(node))
|
|
33
|
+
context = SecurityContext(node, self.rel_path)
|
|
34
|
+
|
|
35
|
+
for check_func in checks:
|
|
36
|
+
finding = check_func(context)
|
|
37
|
+
if finding:
|
|
38
|
+
self.findings.append(
|
|
39
|
+
{
|
|
40
|
+
"file": self.rel_path,
|
|
41
|
+
"line": finding.line,
|
|
42
|
+
"type": finding.id,
|
|
43
|
+
"severity": finding.severity.lower(),
|
|
44
|
+
"message": finding.message,
|
|
45
|
+
"code": finding.code_snippet,
|
|
46
|
+
"confidence": finding.confidence.lower()
|
|
47
|
+
if hasattr(finding, "confidence")
|
|
48
|
+
else "medium",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
super().generic_visit(node)
|