qgis-plugin-analyzer 1.4.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 (41) hide show
  1. analyzer/__init__.py +2 -1
  2. analyzer/cli/__init__.py +14 -0
  3. analyzer/cli/app.py +147 -0
  4. analyzer/cli/base.py +93 -0
  5. analyzer/cli/commands/__init__.py +19 -0
  6. analyzer/cli/commands/analyze.py +47 -0
  7. analyzer/cli/commands/fix.py +58 -0
  8. analyzer/cli/commands/init.py +41 -0
  9. analyzer/cli/commands/list_rules.py +41 -0
  10. analyzer/cli/commands/security.py +46 -0
  11. analyzer/cli/commands/summary.py +52 -0
  12. analyzer/cli/commands/version.py +41 -0
  13. analyzer/cli.py +4 -281
  14. analyzer/commands.py +163 -0
  15. analyzer/engine.py +491 -245
  16. analyzer/fixer.py +206 -130
  17. analyzer/reporters/markdown_reporter.py +88 -14
  18. analyzer/reporters/summary_reporter.py +226 -49
  19. analyzer/rules/qgis_rules.py +3 -1
  20. analyzer/scanner.py +219 -711
  21. analyzer/secrets.py +84 -0
  22. analyzer/security_checker.py +85 -0
  23. analyzer/security_rules.py +127 -0
  24. analyzer/transformers.py +29 -8
  25. analyzer/utils/__init__.py +2 -0
  26. analyzer/utils/path_utils.py +53 -1
  27. analyzer/validators.py +90 -55
  28. analyzer/visitors/__init__.py +19 -0
  29. analyzer/visitors/base.py +75 -0
  30. analyzer/visitors/composite_visitor.py +73 -0
  31. analyzer/visitors/imports_visitor.py +85 -0
  32. analyzer/visitors/metrics_visitor.py +158 -0
  33. analyzer/visitors/security_visitor.py +52 -0
  34. analyzer/visitors/standards_visitor.py +284 -0
  35. {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/METADATA +32 -10
  36. qgis_plugin_analyzer-1.6.0.dist-info/RECORD +52 -0
  37. {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/WHEEL +1 -1
  38. qgis_plugin_analyzer-1.4.0.dist-info/RECORD +0 -30
  39. {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/entry_points.txt +0 -0
  40. {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/licenses/LICENSE +0 -0
  41. {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/top_level.txt +0 -0
analyzer/secrets.py ADDED
@@ -0,0 +1,84 @@
1
+ """Secret detection logic for QGIS Plugin Analyzer.
2
+
3
+ Handles regex-based matching of API keys and high-entropy string detection.
4
+ """
5
+
6
+ import math
7
+ import re
8
+ from dataclasses import dataclass
9
+ from typing import List
10
+
11
+
12
+ @dataclass
13
+ class SecretFinding:
14
+ """Represents a detected secret or sensitive string."""
15
+
16
+ type: str
17
+ message: str
18
+ line: int
19
+ confidence: str
20
+
21
+
22
+ class SecretScanner:
23
+ """Scanner for hardcoded secrets and high-entropy strings."""
24
+
25
+ # Common patterns for API keys and tokens
26
+ PATTERNS = {
27
+ "AWS_KEY": r"(?i)AKIA[0-9A-Z]{16}",
28
+ "GOOGLE_API_KEY": r"(?i)AIza[0-9A-Za-z\\-_]{35}",
29
+ "TWILIO_KEY": r"(?i)SK[a-z0-9]{32}",
30
+ "GENERIC_SECRET": r"(?i)(password|secret|passwd|api_key|token|access_key)[\"']?\s*[:=]\s*[\"']?([A-Za-z0-9/+=]{16,})[\"']?",
31
+ }
32
+
33
+ def __init__(self):
34
+ self.compiled_patterns = {k: re.compile(v) for k, v in self.PATTERNS.items()}
35
+
36
+ def scan_text(self, text: str) -> List[SecretFinding]:
37
+ """Scans a file's content for secrets line by line."""
38
+ findings = []
39
+ lines = text.splitlines()
40
+
41
+ for i, line in enumerate(lines, 1):
42
+ # 1. Regex Pattern Matching
43
+ for p_name, pattern in self.compiled_patterns.items():
44
+ match = pattern.search(line)
45
+ if match:
46
+ findings.append(
47
+ SecretFinding(
48
+ type=p_name,
49
+ message=f"Possible hardcoded secret detected: {p_name}",
50
+ line=i,
51
+ confidence="HIGH" if p_name != "GENERIC_SECRET" else "MEDIUM",
52
+ )
53
+ )
54
+
55
+ # 2. Entropy Analysis for long strings (heuristic)
56
+ # Find strings in double or single quotes
57
+ str_matches = re.finditer(r"[\"']([A-Za-z0-9/+=]{20,})[\"']", line)
58
+ for m in str_matches:
59
+ candidate = m.group(1)
60
+ entropy = self.calculate_entropy(candidate)
61
+ # Shanon entropy: random strings usually > 3.5 - 4.0
62
+ if entropy > 4.5:
63
+ findings.append(
64
+ SecretFinding(
65
+ type="HIGH_ENTROPY",
66
+ message=f"High-entropy string detected (possible token/key): {candidate[:8]}...",
67
+ line=i,
68
+ confidence="MEDIUM",
69
+ )
70
+ )
71
+
72
+ return findings
73
+
74
+ @staticmethod
75
+ def calculate_entropy(data: str) -> float:
76
+ """Calculates Shannon entropy of a string."""
77
+ if not data:
78
+ return 0.0
79
+ entropy = 0.0
80
+ for x in range(256):
81
+ p_x = float(data.count(chr(x))) / len(data)
82
+ if p_x > 0:
83
+ entropy += -p_x * math.log(p_x, 2)
84
+ return entropy
@@ -0,0 +1,85 @@
1
+ """Core infrastructure for security scanning in QGIS Plugin Analyzer.
2
+
3
+ Inspired by Bandit's architecture, this module provides a decorator-based
4
+ registry for security checks and a context helper for AST analysis.
5
+ """
6
+
7
+ import ast
8
+ from dataclasses import dataclass
9
+ from typing import Any, Callable, Dict, List, Optional, Type
10
+
11
+
12
+ @dataclass
13
+ class SecurityFinding:
14
+ """Represents a detected security vulnerability."""
15
+
16
+ id: str
17
+ severity: str # LOW, MEDIUM, HIGH
18
+ confidence: str # LOW, MEDIUM, HIGH
19
+ message: str
20
+ line: int
21
+ code_snippet: Optional[str] = None
22
+ cwe: Optional[int] = None
23
+
24
+
25
+ class SecurityContext:
26
+ """Helper class providing easy access to AST node information for security checks."""
27
+
28
+ def __init__(self, node: ast.AST, filename: str):
29
+ self.node = node
30
+ self.filename = filename
31
+
32
+ @property
33
+ def call_function_name(self) -> Optional[str]:
34
+ """Returns the name of the function being called if the node is a Call."""
35
+ if isinstance(self.node, ast.Call):
36
+ if isinstance(self.node.func, ast.Name):
37
+ return self.node.func.id
38
+ if isinstance(self.node.func, ast.Attribute):
39
+ return self.node.func.attr
40
+ return None
41
+
42
+ @property
43
+ def call_args_count(self) -> int:
44
+ """Returns the number of positional arguments in a Call node."""
45
+ if isinstance(self.node, ast.Call):
46
+ return len(self.node.args)
47
+ return 0
48
+
49
+ def get_call_keyword_value(self, keyword_name: str) -> Any:
50
+ """Returns the value of a specific keyword argument in a Call node."""
51
+ if isinstance(self.node, ast.Call):
52
+ for kw in self.node.keywords:
53
+ if kw.arg == keyword_name:
54
+ if isinstance(kw.value, ast.Constant):
55
+ return kw.value.value
56
+ return None
57
+
58
+
59
+ class SecurityRegistry:
60
+ """Registry for security checks managed by decorators."""
61
+
62
+ _checks: Dict[Type[ast.AST], List[Callable[[SecurityContext], Optional[SecurityFinding]]]] = {}
63
+
64
+ @classmethod
65
+ def register(cls, node_type: Type[ast.AST]) -> Callable:
66
+ """Decorator to register a function as a security check for a specific node type."""
67
+
68
+ def decorator(func: Callable[[SecurityContext], Optional[SecurityFinding]]):
69
+ if node_type not in cls._checks:
70
+ cls._checks[node_type] = []
71
+ cls._checks[node_type].append(func)
72
+ return func
73
+
74
+ return decorator
75
+
76
+ @classmethod
77
+ def get_checks_for_node(
78
+ cls, node_type: Type[ast.AST]
79
+ ) -> List[Callable[[SecurityContext], Optional[SecurityFinding]]]:
80
+ """Returns all registered checks for a given AST node type."""
81
+ return cls._checks.get(node_type, [])
82
+
83
+
84
+ # Decorator alias
85
+ security_check = SecurityRegistry.register
@@ -0,0 +1,127 @@
1
+ """Security rules for QGIS Plugin Analyzer.
2
+
3
+ Each check is registered for a specific AST node type and performs a focused
4
+ security audit.
5
+ """
6
+
7
+ import ast
8
+ from typing import Optional, cast
9
+
10
+ from .security_checker import SecurityContext, SecurityFinding, security_check
11
+
12
+
13
+ @security_check(node_type=ast.Call)
14
+ def check_exec_eval(context: SecurityContext) -> Optional[SecurityFinding]:
15
+ """B102/B307: Detect use of exec or eval."""
16
+ func_name = context.call_function_name
17
+ if func_name in ("exec", "eval"):
18
+ node = cast(ast.Call, context.node)
19
+ return SecurityFinding(
20
+ id="B102" if func_name == "exec" else "B307",
21
+ severity="HIGH",
22
+ confidence="HIGH",
23
+ message=f"Use of '{func_name}' detected. This can lead to arbitrary code execution.",
24
+ line=node.lineno,
25
+ code_snippet=ast.unparse(node),
26
+ cwe=95 if func_name == "eval" else 78,
27
+ )
28
+ return None
29
+
30
+
31
+ @security_check(node_type=ast.Call)
32
+ def check_insecure_deserialization(context: SecurityContext) -> Optional[SecurityFinding]:
33
+ """B301: Detect unsafe pickle.load()."""
34
+ if context.call_function_name == "load":
35
+ # Check if it's from 'pickle'
36
+ node = cast(ast.Call, context.node)
37
+ if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name):
38
+ if node.func.value.id == "pickle":
39
+ return SecurityFinding(
40
+ id="B301",
41
+ severity="HIGH",
42
+ confidence="HIGH",
43
+ message="Use of 'pickle.load()' detected. Deserializing untrusted data can lead to remote code execution.",
44
+ line=node.lineno,
45
+ code_snippet=ast.unparse(node),
46
+ cwe=502,
47
+ )
48
+ return None
49
+
50
+
51
+ @security_check(node_type=ast.Call)
52
+ def check_subprocess_shell(context: SecurityContext) -> Optional[SecurityFinding]:
53
+ """B602: Subprocess call with shell=True."""
54
+ func_name = context.call_function_name
55
+ subprocess_funcs = {"run", "call", "Popen", "check_call", "check_output"}
56
+
57
+ if func_name in subprocess_funcs:
58
+ shell_val = context.get_call_keyword_value("shell")
59
+ if shell_val is True:
60
+ node = cast(ast.Call, context.node)
61
+ return SecurityFinding(
62
+ id="B602",
63
+ severity="HIGH",
64
+ confidence="HIGH",
65
+ message=f"Subprocess call '{func_name}' with 'shell=True' detected. This is a primary source of shell injection.",
66
+ line=node.lineno,
67
+ code_snippet=ast.unparse(node),
68
+ cwe=78,
69
+ )
70
+ return None
71
+
72
+
73
+ @security_check(node_type=ast.Call)
74
+ def check_sql_injection(context: SecurityContext) -> Optional[SecurityFinding]:
75
+ """B608: Basic detection of SQL injection via string formatting."""
76
+ if context.call_function_name == "execute":
77
+ if context.call_args_count > 0:
78
+ node = cast(ast.Call, context.node)
79
+ sql_arg = node.args[0]
80
+ # Check for f-strings or .format() or % formatting in the first argument
81
+ is_unsafe = False
82
+ if isinstance(sql_arg, ast.JoinedStr):
83
+ is_unsafe = True
84
+ elif isinstance(sql_arg, ast.BinOp) and isinstance(sql_arg.op, ast.Mod):
85
+ is_unsafe = True
86
+ elif isinstance(sql_arg, ast.Call) and isinstance(sql_arg.func, ast.Attribute):
87
+ if sql_arg.func.attr == "format":
88
+ is_unsafe = True
89
+
90
+ if is_unsafe:
91
+ return SecurityFinding(
92
+ id="B608",
93
+ severity="HIGH",
94
+ confidence="MEDIUM",
95
+ message="Possible SQL injection detected. Use parameterized queries instead of string formatting.",
96
+ line=node.lineno,
97
+ code_snippet=ast.unparse(node),
98
+ cwe=89,
99
+ )
100
+ return None
101
+
102
+
103
+ @security_check(node_type=ast.Assign)
104
+ def check_hardcoded_secrets(context: SecurityContext) -> Optional[SecurityFinding]:
105
+ """Detect assignments of sensitive names to constants."""
106
+ node = context.node
107
+ if not isinstance(node, ast.Assign):
108
+ return None
109
+
110
+ sensitive_names = {"password", "token", "api_key", "secret", "access_key"}
111
+
112
+ for target in node.targets:
113
+ if isinstance(target, ast.Name) and any(s in target.id.lower() for s in sensitive_names):
114
+ if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
115
+ # Only flag if it's not empty and looks like a secret
116
+ val = node.value.value
117
+ if len(val) > 8:
118
+ cast_node = cast(ast.Assign, node)
119
+ return SecurityFinding(
120
+ id="HARDCODED_SECRET",
121
+ severity="MEDIUM",
122
+ confidence="MEDIUM",
123
+ message=f"Possible hardcoded secret in assignment to '{target.id}'.",
124
+ line=cast_node.lineno,
125
+ code_snippet=ast.unparse(cast_node),
126
+ )
127
+ return None
analyzer/transformers.py CHANGED
@@ -154,26 +154,24 @@ class I18nTransformer(ast.NodeTransformer):
154
154
  return node
155
155
 
156
156
 
157
- def apply_transformation(file_path: pathlib.Path, transformer: ast.NodeTransformer) -> bool:
158
- """Applies an AST transformation to a file and writes back the modified code.
157
+ def apply_transformation_to_content(
158
+ content: str, transformer: ast.NodeTransformer
159
+ ) -> Optional[str]:
160
+ """Applies an AST transformation to code content string.
159
161
 
160
162
  Args:
161
- file_path: Path to the Python file to transform.
163
+ content: The Python source code.
162
164
  transformer: The AST node transformer to apply.
163
165
 
164
166
  Returns:
165
- True if the file was modified, False otherwise.
167
+ The transformed code string if changes were made, None otherwise.
166
168
  """
167
169
  try:
168
- content = file_path.read_text(encoding="utf-8")
169
170
  tree = ast.parse(content)
170
-
171
- # Apply transformation
172
171
  new_tree = transformer.visit(tree)
173
172
  ast.fix_missing_locations(new_tree)
174
173
 
175
174
  if hasattr(transformer, "changes_made") and transformer.changes_made:
176
- # Unparse back to code
177
175
  new_code = ast.unparse(new_tree)
178
176
 
179
177
  # Add necessary imports if needed
@@ -181,6 +179,29 @@ def apply_transformation(file_path: pathlib.Path, transformer: ast.NodeTransform
181
179
  if "from qgis.core import QgsMessageLog, Qgis" not in new_code:
182
180
  new_code = "from qgis.core import QgsMessageLog, Qgis\n\n" + new_code
183
181
 
182
+ return new_code
183
+
184
+ return None
185
+ except Exception as e:
186
+ print(f"Error transforming content: {e}")
187
+ return None
188
+
189
+
190
+ def apply_transformation(file_path: pathlib.Path, transformer: ast.NodeTransformer) -> bool:
191
+ """Applies an AST transformation to a file and writes back the modified code.
192
+
193
+ Args:
194
+ file_path: Path to the Python file to transform.
195
+ transformer: The AST node transformer to apply.
196
+
197
+ Returns:
198
+ True if the file was modified, False otherwise.
199
+ """
200
+ try:
201
+ content = file_path.read_text(encoding="utf-8")
202
+ new_code = apply_transformation_to_content(content, transformer)
203
+
204
+ if new_code is not None:
184
205
  file_path.write_text(new_code, encoding="utf-8")
185
206
  return True
186
207
 
@@ -13,6 +13,7 @@ from .logging_utils import logger, setup_logger
13
13
  from .path_utils import (
14
14
  DEFAULT_EXCLUDE,
15
15
  IgnoreMatcher,
16
+ discover_project_files,
16
17
  load_ignore_patterns,
17
18
  safe_path_resolve,
18
19
  )
@@ -29,6 +30,7 @@ __all__ = [
29
30
  "logger",
30
31
  "safe_path_resolve",
31
32
  "IgnoreMatcher",
33
+ "discover_project_files",
32
34
  "load_ignore_patterns",
33
35
  "DEFAULT_EXCLUDE",
34
36
  "load_profile_config",
@@ -3,7 +3,7 @@
3
3
  import fnmatch
4
4
  import os
5
5
  import pathlib
6
- from typing import Dict, List
6
+ from typing import Any, Dict, List
7
7
 
8
8
  # Default patterns to ignore if not specified
9
9
  DEFAULT_EXCLUDE = {
@@ -21,6 +21,9 @@ DEFAULT_EXCLUDE = {
21
21
  "analysis_results/",
22
22
  }
23
23
 
24
+ # Prohibited binary extensions per QGIS repository policy
25
+ BINARY_EXTENSIONS = {".exe", ".dll", ".so", ".dylib", ".pyd", ".bin", ".a", ".lib"}
26
+
24
27
 
25
28
  def safe_path_resolve(base_path: pathlib.Path, target_path_str: str) -> pathlib.Path:
26
29
  """Resolves a target path safely relative to a base path.
@@ -133,3 +136,52 @@ def load_ignore_patterns(ignore_file: pathlib.Path) -> List[str]:
133
136
  return []
134
137
  with open(ignore_file) as f:
135
138
  return f.readlines()
139
+
140
+
141
+ def discover_project_files(project_path: pathlib.Path, matcher: IgnoreMatcher) -> Dict[str, Any]:
142
+ """Scans the project directory once to discover all relevant files and metrics.
143
+ This replaces multiple redundant rglob calls, optimizing I/O performance.
144
+
145
+ Args:
146
+ project_path: Root path of the project.
147
+ matcher: IgnoreMatcher instance for filtering.
148
+
149
+ Returns:
150
+ A dictionary with:
151
+ - python_files: List of Paths to .py files.
152
+ - binaries: List of relative paths to binary files.
153
+ - total_size_mb: Total size of non-ignored files in MB.
154
+ - has_metadata: Boolean indicating if metadata.txt exists.
155
+ """
156
+ python_files = []
157
+ binaries = []
158
+ total_bytes = 0
159
+ has_metadata = False
160
+
161
+ for file_path in project_path.rglob("*"):
162
+ if not file_path.is_file():
163
+ continue
164
+
165
+ if matcher.is_ignored(file_path):
166
+ continue
167
+
168
+ # Basics
169
+ total_bytes += file_path.stat().st_size
170
+
171
+ # Check metadata
172
+ if file_path.name == "metadata.txt" and file_path.parent == project_path:
173
+ has_metadata = True
174
+
175
+ # Classify by extension
176
+ ext = file_path.suffix.lower()
177
+ if ext == ".py":
178
+ python_files.append(file_path)
179
+ elif ext in BINARY_EXTENSIONS:
180
+ binaries.append(str(file_path.relative_to(project_path)))
181
+
182
+ return {
183
+ "python_files": python_files,
184
+ "binaries": binaries,
185
+ "total_size_mb": total_bytes / (1024 * 1024),
186
+ "has_metadata": has_metadata,
187
+ }
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
+ ]