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/fixer.py CHANGED
@@ -7,18 +7,38 @@
7
7
  import difflib
8
8
  import pathlib
9
9
  import subprocess
10
- import tempfile
11
- from abc import ABC, abstractmethod
12
- from typing import Any, Dict, List
10
+ from typing import Any, Dict, List, Optional, TypedDict
13
11
 
14
12
  from .transformers import (
15
13
  GDALImportTransformer,
16
14
  I18nTransformer,
17
15
  LegacyImportTransformer,
18
16
  PrintToLogTransformer,
19
- apply_transformation,
17
+ apply_transformation_to_content,
20
18
  )
21
19
 
20
+ # --- Types ---
21
+
22
+
23
+ class FixContext(TypedDict):
24
+ """Context information for a fix handler."""
25
+
26
+ project_path: pathlib.Path
27
+ file_path: pathlib.Path
28
+ issue: Dict[str, Any]
29
+ content: str # Original file content
30
+ dry_run: bool
31
+
32
+
33
+ class FixHandlerResult(TypedDict):
34
+ """Result of a fix execution."""
35
+
36
+ applied: bool
37
+ message: str
38
+ new_content: Optional[str] # Transformed code if applied
39
+ diff: Optional[str]
40
+ error: Optional[str]
41
+
22
42
 
23
43
  def check_git_status(project_path: pathlib.Path) -> bool:
24
44
  """Checks if the Git working directory is clean.
@@ -76,80 +96,123 @@ def show_diff(file_path: pathlib.Path, original_content: str, new_content: str)
76
96
  print(" " + "─" * 60)
77
97
 
78
98
 
79
- class FixStrategy(ABC):
80
- """Abstract base class for all auto-fix strategies."""
81
-
82
- @abstractmethod
83
- def can_fix(self, issue: Dict[str, Any]) -> bool:
84
- """Returns True if this strategy can fix the given issue."""
85
- pass
99
+ # --- Fix Registry ---
86
100
 
87
- @abstractmethod
88
- def apply_fix(self, file_path: pathlib.Path, issue: Dict[str, Any]) -> bool:
89
- """Applies the fix to the file. Returns True if successful."""
90
- pass
91
101
 
92
- @abstractmethod
93
- def get_description(self, issue: Dict[str, Any]) -> str:
94
- """Returns a human-readable description of the fix."""
95
- pass
102
+ class FixRegistry:
103
+ """Registry for managing and discovering fix handlers."""
96
104
 
105
+ def __init__(self) -> None:
106
+ self._handlers: Dict[str, Any] = {}
97
107
 
98
- class GDALImportFixer(FixStrategy):
99
- """Fixes direct GDAL imports."""
108
+ def register(self, issue_type: str):
109
+ """Decorator to register a fix handler for a specific issue type."""
100
110
 
101
- def can_fix(self, issue: Dict[str, Any]) -> bool:
102
- return issue.get("type") == "GDAL_DIRECT_IMPORT"
111
+ def decorator(func):
112
+ self._handlers[issue_type] = func
113
+ return func
103
114
 
104
- def apply_fix(self, file_path: pathlib.Path, issue: Dict[str, Any]) -> bool:
105
- transformer = GDALImportTransformer()
106
- return apply_transformation(file_path, transformer)
115
+ return decorator
107
116
 
108
- def get_description(self, issue: Dict[str, Any]) -> str:
109
- return "Replace 'import gdal' with 'from osgeo import gdal'"
117
+ def get_handler(self, issue_type: str) -> Optional[Any]:
118
+ """Retrieves a handler for a given issue type."""
119
+ return self._handlers.get(issue_type)
110
120
 
121
+ def get_all_handlers(self) -> List[Any]:
122
+ """Returns all registered handlers."""
123
+ return list(self._handlers.values())
111
124
 
112
- class LegacyImportFixer(FixStrategy):
113
- """Fixes PyQt4/PyQt5 imports."""
114
125
 
115
- def can_fix(self, issue: Dict[str, Any]) -> bool:
116
- return issue.get("type") == "QGIS_LEGACY_IMPORT"
126
+ registry = FixRegistry()
117
127
 
118
- def apply_fix(self, file_path: pathlib.Path, issue: Dict[str, Any]) -> bool:
119
- transformer = LegacyImportTransformer()
120
- return apply_transformation(file_path, transformer)
121
128
 
122
- def get_description(self, issue: Dict[str, Any]) -> str:
123
- return "Replace PyQt4/PyQt5 imports with qgis.PyQt"
129
+ def create_ast_handler(issue_type: str, transformer_cls: Any, description_msg: str) -> Any:
130
+ """Factory function to create standard AST-based handlers.
124
131
 
132
+ Args:
133
+ issue_type: The issue identifier this handler targets.
134
+ transformer_cls: The AST NodeTransformer class to instantiate.
135
+ description_msg: Human-readable description of the fix.
125
136
 
126
- class PrintToLogFixer(FixStrategy):
127
- """Fixes print() statements to use QgsMessageLog."""
128
-
129
- def can_fix(self, issue: Dict[str, Any]) -> bool:
130
- # This would need a new rule type in scanner.py
131
- return issue.get("type") == "PRINT_STATEMENT"
132
-
133
- def apply_fix(self, file_path: pathlib.Path, issue: Dict[str, Any]) -> bool:
134
- transformer = PrintToLogTransformer()
135
- return apply_transformation(file_path, transformer)
136
-
137
- def get_description(self, issue: Dict[str, Any]) -> str:
138
- return "Replace print() with QgsMessageLog.logMessage()"
139
-
137
+ Returns:
138
+ A handler function compatible with FixRegistry.
139
+ """
140
140
 
141
- class I18nFixer(FixStrategy):
142
- """Wraps hardcoded UI strings in self.tr()."""
141
+ def handler(ctx: FixContext) -> FixHandlerResult:
142
+ if ctx["issue"].get("type") != issue_type:
143
+ return {
144
+ "applied": False,
145
+ "message": "",
146
+ "new_content": None,
147
+ "diff": None,
148
+ "error": None,
149
+ }
150
+
151
+ if ctx["dry_run"]:
152
+ return {
153
+ "applied": True,
154
+ "message": description_msg,
155
+ "new_content": None, # Not computed in dry-run
156
+ "diff": None,
157
+ "error": None,
158
+ }
159
+
160
+ transformer = transformer_cls()
161
+ new_code = apply_transformation_to_content(ctx["content"], transformer)
162
+
163
+ if new_code is not None:
164
+ return {
165
+ "applied": True,
166
+ "message": description_msg,
167
+ "new_content": new_code,
168
+ "diff": None,
169
+ "error": None,
170
+ }
171
+
172
+ return {
173
+ "applied": False,
174
+ "message": "",
175
+ "new_content": None,
176
+ "diff": None,
177
+ "error": None,
178
+ }
179
+
180
+ return handler
181
+
182
+
183
+ # --- Fix Handlers Registration ---
184
+
185
+ registry.register("GDAL_DIRECT_IMPORT")(
186
+ create_ast_handler(
187
+ "GDAL_DIRECT_IMPORT",
188
+ GDALImportTransformer,
189
+ "Replace 'import gdal' with 'from osgeo import gdal'",
190
+ )
191
+ )
143
192
 
144
- def can_fix(self, issue: Dict[str, Any]) -> bool:
145
- return issue.get("type") == "MISSING_I18N"
193
+ registry.register("QGIS_LEGACY_IMPORT")(
194
+ create_ast_handler(
195
+ "QGIS_LEGACY_IMPORT",
196
+ LegacyImportTransformer,
197
+ "Replace PyQt4/PyQt5 imports with qgis.PyQt",
198
+ )
199
+ )
146
200
 
147
- def apply_fix(self, file_path: pathlib.Path, issue: Dict[str, Any]) -> bool:
148
- transformer = I18nTransformer()
149
- return apply_transformation(file_path, transformer)
201
+ registry.register("PRINT_STATEMENT")(
202
+ create_ast_handler(
203
+ "PRINT_STATEMENT",
204
+ PrintToLogTransformer,
205
+ "Replace print() with QgsMessageLog.logMessage()",
206
+ )
207
+ )
150
208
 
151
- def get_description(self, issue: Dict[str, Any]) -> str:
152
- return "Wrap hardcoded string in self.tr() for internationalization"
209
+ registry.register("MISSING_I18N")(
210
+ create_ast_handler(
211
+ "MISSING_I18N",
212
+ I18nTransformer,
213
+ "Wrap hardcoded string in self.tr()",
214
+ )
215
+ )
153
216
 
154
217
 
155
218
  class AutoFixer:
@@ -158,7 +221,7 @@ class AutoFixer:
158
221
  Attributes:
159
222
  project_path: Root path of the project.
160
223
  dry_run: If True, changes are proposed but not written.
161
- strategies: List of available fix strategies.
224
+ registry: FixRegistry instance containing handlers.
162
225
  """
163
226
 
164
227
  def __init__(self, project_path: pathlib.Path, dry_run: bool = True) -> None:
@@ -170,12 +233,7 @@ class AutoFixer:
170
233
  """
171
234
  self.project_path = project_path
172
235
  self.dry_run = dry_run
173
- self.strategies: List[FixStrategy] = [
174
- GDALImportFixer(),
175
- LegacyImportFixer(),
176
- PrintToLogFixer(),
177
- I18nFixer(),
178
- ]
236
+ self.registry = registry
179
237
 
180
238
  def get_fixable_issues(self, issues: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
181
239
  """Filters a list of issues to identify those that can be auto-fixed.
@@ -184,17 +242,34 @@ class AutoFixer:
184
242
  issues: A list of issue dictionaries.
185
243
 
186
244
  Returns:
187
- A list of fixable issues, each enriched with a 'fixer' strategy.
245
+ A list of fixable issues, each enriched with a 'handler' function.
188
246
  """
189
247
  fixable = []
190
248
  for issue in issues:
191
- for strategy in self.strategies:
192
- if strategy.can_fix(issue):
193
- issue["fixer"] = strategy
249
+ handler = self.registry.get_handler(issue.get("type", ""))
250
+ if handler:
251
+ # We use a temporary context in dry_run mode to check if handler can fix it
252
+ ctx = self._create_context(pathlib.Path(), issue, "")
253
+ result = handler(ctx)
254
+ if result["applied"]:
255
+ # Enriched issue with its handler and description
256
+ issue["handler"] = handler
257
+ issue["fix_description"] = result["message"]
194
258
  fixable.append(issue)
195
- break
196
259
  return fixable
197
260
 
261
+ def _create_context(
262
+ self, file_path: pathlib.Path, issue: Dict[str, Any], content: str
263
+ ) -> FixContext:
264
+ """Creates a standardized FixContext."""
265
+ return {
266
+ "project_path": self.project_path,
267
+ "file_path": file_path,
268
+ "issue": issue,
269
+ "content": content,
270
+ "dry_run": self.dry_run,
271
+ }
272
+
198
273
  def _check_git_status_with_prompt(self, interactive: bool) -> bool:
199
274
  """Checks git status and prompts user if needed. Returns True to continue."""
200
275
  if self.dry_run:
@@ -224,56 +299,6 @@ class AutoFixer:
224
299
  by_file[file_path].append(issue)
225
300
  return by_file
226
301
 
227
- def _apply_single_fix(
228
- self,
229
- file_path: pathlib.Path,
230
- issue: Dict[str, Any],
231
- original_content: str,
232
- interactive: bool,
233
- stats: Dict[str, int],
234
- ) -> bool:
235
- """Applies a single fix and updates stats. Returns True to continue, False to abort."""
236
- fixer: FixStrategy = issue["fixer"]
237
- description = fixer.get_description(issue)
238
-
239
- print(f" Line {issue.get('line', '?')}: {description}")
240
-
241
- if interactive and not self.dry_run:
242
- # Show diff preview
243
- with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as tmp:
244
- tmp.write(original_content)
245
- tmp_path = pathlib.Path(tmp.name)
246
-
247
- try:
248
- fixer.apply_fix(tmp_path, issue)
249
- new_content = tmp_path.read_text(encoding="utf-8")
250
-
251
- if new_content != original_content:
252
- show_diff(file_path, original_content, new_content)
253
- finally:
254
- tmp_path.unlink()
255
-
256
- response = input(" Apply fix? [y/n/q]: ").lower()
257
- if response == "q":
258
- print("Aborted by user.")
259
- return False
260
- if response != "y":
261
- stats["skipped"] += 1
262
- return True
263
-
264
- if not self.dry_run:
265
- success = fixer.apply_fix(file_path, issue)
266
- if success:
267
- stats["applied"] += 1
268
- print(" ✅ Applied")
269
- else:
270
- stats["failed"] += 1
271
- print(" ❌ Failed")
272
- else:
273
- stats["applied"] += 1 # Count as "would apply"
274
-
275
- return True
276
-
277
302
  def apply_fixes(self, issues: List[Dict[str, Any]], interactive: bool = True) -> Dict[str, int]:
278
303
  """Applies fixes to identified issues, grouping by file.
279
304
 
@@ -286,29 +311,80 @@ class AutoFixer:
286
311
  """
287
312
  stats = {"applied": 0, "skipped": 0, "failed": 0}
288
313
 
289
- # Git status check
290
314
  if not self._check_git_status_with_prompt(interactive):
291
315
  return stats
292
316
 
293
- # Group by file
294
317
  by_file = self._group_issues_by_file(issues)
295
318
 
296
319
  for file_rel, file_issues in by_file.items():
297
320
  file_path = self.project_path / file_rel
298
321
  print(f"\n📄 {file_rel}")
299
322
 
300
- # Read original content for diff
301
323
  try:
302
- original_content = file_path.read_text(encoding="utf-8")
324
+ current_content = file_path.read_text(encoding="utf-8")
303
325
  except Exception as e:
304
326
  print(f" ❌ Error reading file: {e}")
305
327
  stats["failed"] += len(file_issues)
306
328
  continue
307
329
 
330
+ file_modified = False
308
331
  for issue in file_issues:
309
- if not self._apply_single_fix(
310
- file_path, issue, original_content, interactive, stats
311
- ):
312
- return stats
332
+ handler = issue.get("handler")
333
+ if not handler:
334
+ continue
335
+
336
+ description = issue.get("fix_description", "Automatic fix")
337
+ print(f" Line {issue.get('line', '?')}: {description}")
338
+
339
+ # Context with current memory buffer
340
+ ctx = self._create_context(file_path, issue, current_content)
341
+
342
+ if interactive and not self.dry_run:
343
+ # In-memory transformation for preview
344
+ # For interactive mode, we show diff of the single fix
345
+ work_ctx = ctx.copy()
346
+ work_ctx["dry_run"] = False
347
+ result = handler(work_ctx)
348
+
349
+ if result["applied"] and result["new_content"]:
350
+ show_diff(file_path, current_content, result["new_content"])
351
+ else:
352
+ print(" (No changes suggested by handler)")
353
+
354
+ response = input(" Apply fix? [y/n/q]: ").lower()
355
+ if response == "q":
356
+ print("Aborted by user.")
357
+ return stats
358
+ if response != "y":
359
+ stats["skipped"] += 1
360
+ continue
361
+
362
+ # Actual application on memory buffer
363
+ if not self.dry_run:
364
+ work_ctx = ctx.copy()
365
+ work_ctx["dry_run"] = False
366
+ result = handler(work_ctx)
367
+
368
+ if result["applied"] and result["new_content"]:
369
+ current_content = result["new_content"]
370
+ stats["applied"] += 1
371
+ file_modified = True
372
+ print(f" ✅ Applied: {result['message']}")
373
+ else:
374
+ stats["failed"] += 1
375
+ error = result.get("error", "Transformation returned no changes")
376
+ print(f" ❌ Failed: {error}")
377
+ else:
378
+ # Simulation
379
+ stats["applied"] += 1
380
+
381
+ # Write back the modified content once per file
382
+ if file_modified and not self.dry_run:
383
+ try:
384
+ file_path.write_text(current_content, encoding="utf-8")
385
+ except Exception as e:
386
+ print(f" ❌ Error writing back to file: {e}")
387
+ # We already counted them as applied, but technically it failed
388
+ pass
313
389
 
314
390
  return stats
@@ -43,6 +43,10 @@ def _build_markdown_header(
43
43
  qgis_score = compliance.get("compliance_score", 0)
44
44
  lines.append(f"- **QGIS Compliance**: `{qgis_score}/100`")
45
45
 
46
+ # Add Security Score
47
+ sec_score = analyses.get("security", {}).get("score", 0)
48
+ lines.append(f"- **Security Score**: `{sec_score}/100` (Bandit-inspired)")
49
+
46
50
  lines.append("")
47
51
  return lines
48
52
 
@@ -142,6 +146,76 @@ def _build_markdown_repo_standards(analyses: Dict[str, Any]) -> List[str]:
142
146
  return lines
143
147
 
144
148
 
149
+ def _build_markdown_research_metrics(research: Dict[str, Any]) -> List[str]:
150
+ """Builds the research-based metrics section.
151
+
152
+ Args:
153
+ research: The research summary dictionary.
154
+
155
+ Returns:
156
+ A list of Markdown lines for the research metrics section.
157
+ """
158
+ if not research:
159
+ return []
160
+
161
+ lines = [
162
+ "\n## 🔬 Research-based Metrics",
163
+ f"- **Type Hint Coverage (Params)**: {research.get('type_hint_coverage')}% (Microsoft/Dropbox Std)",
164
+ f"- **Type Hint Coverage (Returns)**: {research.get('return_hint_coverage')}%",
165
+ f"- **Docstring Coverage**: {research.get('docstring_coverage')}% (PEP 257)",
166
+ ]
167
+ styles = ", ".join(research.get("detected_docstring_styles", [])) or "PEP 257 (Default)"
168
+ lines.append(f"- **Detected Documentation Style**: {styles}")
169
+ return lines
170
+
171
+
172
+ def _build_markdown_general_metrics(metrics: Dict[str, Any]) -> List[str]:
173
+ """Builds the general metrics section.
174
+
175
+ Args:
176
+ metrics: The metrics dictionary.
177
+
178
+ Returns:
179
+ A list of Markdown lines for the general metrics section.
180
+ """
181
+ lines = ["\n## 📊 General Metrics"]
182
+ for k, v in metrics.items():
183
+ if k not in ["quality_score", "maintainability_score", "overall_score"]:
184
+ lines.append(f"- **{k.replace('_', ' ').title()}**: {v}")
185
+ return lines
186
+
187
+
188
+ def _build_markdown_security_section(security: Dict[str, Any]) -> List[str]:
189
+ """Builds the security analysis section with findings.
190
+
191
+ Args:
192
+ security: The security analysis dictionary.
193
+
194
+ Returns:
195
+ A list of Markdown lines for the security section.
196
+ """
197
+ lines = ["\n## 🛡️ Security Analysis"]
198
+ findings = security.get("findings", [])
199
+ score = security.get("score", 0)
200
+
201
+ lines.append(f"Security score: `{score}/100` (Based on AST and secret scanning)")
202
+ lines.append(f"Detected **{len(findings)}** potential security risks.")
203
+
204
+ if not findings:
205
+ lines.append("- ✅ No security vulnerabilities detected.")
206
+ else:
207
+ for finding in findings:
208
+ severity = finding.get("severity", "medium").upper()
209
+ icon = "🛑" if severity == "HIGH" else "⚠️"
210
+ lines.append(
211
+ f"- {icon} **[{severity}]** `{finding.get('file')}:{finding.get('line')}`: {finding.get('message')}"
212
+ )
213
+ if finding.get("code"):
214
+ lines.append(f" - Code: `{finding.get('code')}`")
215
+
216
+ return lines
217
+
218
+
145
219
  def generate_markdown_summary(analyses: Dict[str, Any], output_path: pathlib.Path) -> None:
146
220
  """Generates a professional PROJECT_SUMMARY.md report.
147
221
 
@@ -167,22 +241,16 @@ def generate_markdown_summary(analyses: Dict[str, Any], output_path: pathlib.Pat
167
241
  )
168
242
  f.write("\n")
169
243
 
244
+ # Research-based metrics
170
245
  if research:
171
- f.write("\n## 🔬 Research-based Metrics\n")
172
- f.write(
173
- f"- **Type Hint Coverage (Params)**: {research.get('type_hint_coverage')}% (Microsoft/Dropbox Std)\n"
174
- )
175
- f.write(
176
- f"- **Type Hint Coverage (Returns)**: {research.get('return_hint_coverage')}% \n"
177
- )
178
- f.write(f"- **Docstring Coverage**: {research.get('docstring_coverage')}% (PEP 257)\n")
179
- styles = ", ".join(research.get("detected_docstring_styles", [])) or "PEP 257 (Default)"
180
- f.write(f"- **Detected Documentation Style**: {styles}\n")
246
+ research_lines = _build_markdown_research_metrics(research)
247
+ f.write("\n".join(research_lines))
248
+ f.write("\n")
181
249
 
182
- f.write("\n## 📊 General Metrics\n")
183
- for k, v in metrics.items():
184
- if k not in ["quality_score", "maintainability_score", "overall_score"]:
185
- f.write(f"- **{k.replace('_', ' ').title()}**: {v}\n")
250
+ # General metrics
251
+ general_metrics_lines = _build_markdown_general_metrics(metrics)
252
+ f.write("\n".join(general_metrics_lines))
253
+ f.write("\n")
186
254
 
187
255
  # QGIS-specific findings
188
256
  if project_type == "qgis":
@@ -195,6 +263,12 @@ def generate_markdown_summary(analyses: Dict[str, Any], output_path: pathlib.Pat
195
263
  semantic_lines = _build_markdown_semantic_section(semantic)
196
264
  f.write("\n".join(semantic_lines))
197
265
 
266
+ # Security analysis
267
+ security = analyses.get("security", {})
268
+ if security:
269
+ security_lines = _build_markdown_security_section(security)
270
+ f.write("\n".join(security_lines))
271
+
198
272
  # Repository standards (QGIS only)
199
273
  if project_type == "qgis":
200
274
  repo_standards_lines = _build_markdown_repo_standards(analyses)