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.
Files changed (37) hide show
  1. analyzer/cli/__init__.py +14 -0
  2. analyzer/cli/app.py +147 -0
  3. analyzer/cli/base.py +93 -0
  4. analyzer/cli/commands/__init__.py +19 -0
  5. analyzer/cli/commands/analyze.py +47 -0
  6. analyzer/cli/commands/fix.py +58 -0
  7. analyzer/cli/commands/init.py +41 -0
  8. analyzer/cli/commands/list_rules.py +41 -0
  9. analyzer/cli/commands/security.py +46 -0
  10. analyzer/cli/commands/summary.py +52 -0
  11. analyzer/cli/commands/version.py +41 -0
  12. analyzer/cli.py +4 -184
  13. analyzer/commands.py +7 -7
  14. analyzer/engine.py +421 -238
  15. analyzer/fixer.py +206 -130
  16. analyzer/reporters/markdown_reporter.py +48 -15
  17. analyzer/reporters/summary_reporter.py +193 -80
  18. analyzer/scanner.py +218 -138
  19. analyzer/transformers.py +29 -8
  20. analyzer/utils/__init__.py +2 -0
  21. analyzer/utils/path_utils.py +53 -1
  22. analyzer/validators.py +90 -55
  23. analyzer/visitors/__init__.py +19 -0
  24. analyzer/visitors/base.py +75 -0
  25. analyzer/visitors/composite_visitor.py +73 -0
  26. analyzer/visitors/imports_visitor.py +85 -0
  27. analyzer/visitors/metrics_visitor.py +158 -0
  28. analyzer/visitors/security_visitor.py +52 -0
  29. analyzer/visitors/standards_visitor.py +284 -0
  30. {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/METADATA +16 -7
  31. qgis_plugin_analyzer-1.6.0.dist-info/RECORD +52 -0
  32. analyzer/visitors.py +0 -455
  33. qgis_plugin_analyzer-1.5.0.dist-info/RECORD +0 -35
  34. {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/WHEEL +0 -0
  35. {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/entry_points.txt +0 -0
  36. {qgis_plugin_analyzer-1.5.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/licenses/LICENSE +0 -0
  37. {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
- "is_valid": all(found.values()) and has_factory and has_python,
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)