lucidscan 0.5.12__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 (91) hide show
  1. lucidscan/__init__.py +12 -0
  2. lucidscan/bootstrap/__init__.py +26 -0
  3. lucidscan/bootstrap/paths.py +160 -0
  4. lucidscan/bootstrap/platform.py +111 -0
  5. lucidscan/bootstrap/validation.py +76 -0
  6. lucidscan/bootstrap/versions.py +119 -0
  7. lucidscan/cli/__init__.py +50 -0
  8. lucidscan/cli/__main__.py +8 -0
  9. lucidscan/cli/arguments.py +405 -0
  10. lucidscan/cli/commands/__init__.py +64 -0
  11. lucidscan/cli/commands/autoconfigure.py +294 -0
  12. lucidscan/cli/commands/help.py +69 -0
  13. lucidscan/cli/commands/init.py +656 -0
  14. lucidscan/cli/commands/list_scanners.py +59 -0
  15. lucidscan/cli/commands/scan.py +307 -0
  16. lucidscan/cli/commands/serve.py +142 -0
  17. lucidscan/cli/commands/status.py +84 -0
  18. lucidscan/cli/commands/validate.py +105 -0
  19. lucidscan/cli/config_bridge.py +152 -0
  20. lucidscan/cli/exit_codes.py +17 -0
  21. lucidscan/cli/runner.py +284 -0
  22. lucidscan/config/__init__.py +29 -0
  23. lucidscan/config/ignore.py +178 -0
  24. lucidscan/config/loader.py +431 -0
  25. lucidscan/config/models.py +316 -0
  26. lucidscan/config/validation.py +645 -0
  27. lucidscan/core/__init__.py +3 -0
  28. lucidscan/core/domain_runner.py +463 -0
  29. lucidscan/core/git.py +174 -0
  30. lucidscan/core/logging.py +34 -0
  31. lucidscan/core/models.py +207 -0
  32. lucidscan/core/streaming.py +340 -0
  33. lucidscan/core/subprocess_runner.py +164 -0
  34. lucidscan/detection/__init__.py +21 -0
  35. lucidscan/detection/detector.py +154 -0
  36. lucidscan/detection/frameworks.py +270 -0
  37. lucidscan/detection/languages.py +328 -0
  38. lucidscan/detection/tools.py +229 -0
  39. lucidscan/generation/__init__.py +15 -0
  40. lucidscan/generation/config_generator.py +275 -0
  41. lucidscan/generation/package_installer.py +330 -0
  42. lucidscan/mcp/__init__.py +20 -0
  43. lucidscan/mcp/formatter.py +510 -0
  44. lucidscan/mcp/server.py +297 -0
  45. lucidscan/mcp/tools.py +1049 -0
  46. lucidscan/mcp/watcher.py +237 -0
  47. lucidscan/pipeline/__init__.py +17 -0
  48. lucidscan/pipeline/executor.py +187 -0
  49. lucidscan/pipeline/parallel.py +181 -0
  50. lucidscan/plugins/__init__.py +40 -0
  51. lucidscan/plugins/coverage/__init__.py +28 -0
  52. lucidscan/plugins/coverage/base.py +160 -0
  53. lucidscan/plugins/coverage/coverage_py.py +454 -0
  54. lucidscan/plugins/coverage/istanbul.py +411 -0
  55. lucidscan/plugins/discovery.py +107 -0
  56. lucidscan/plugins/enrichers/__init__.py +61 -0
  57. lucidscan/plugins/enrichers/base.py +63 -0
  58. lucidscan/plugins/linters/__init__.py +26 -0
  59. lucidscan/plugins/linters/base.py +125 -0
  60. lucidscan/plugins/linters/biome.py +448 -0
  61. lucidscan/plugins/linters/checkstyle.py +393 -0
  62. lucidscan/plugins/linters/eslint.py +368 -0
  63. lucidscan/plugins/linters/ruff.py +498 -0
  64. lucidscan/plugins/reporters/__init__.py +45 -0
  65. lucidscan/plugins/reporters/base.py +30 -0
  66. lucidscan/plugins/reporters/json_reporter.py +79 -0
  67. lucidscan/plugins/reporters/sarif_reporter.py +303 -0
  68. lucidscan/plugins/reporters/summary_reporter.py +61 -0
  69. lucidscan/plugins/reporters/table_reporter.py +81 -0
  70. lucidscan/plugins/scanners/__init__.py +57 -0
  71. lucidscan/plugins/scanners/base.py +60 -0
  72. lucidscan/plugins/scanners/checkov.py +484 -0
  73. lucidscan/plugins/scanners/opengrep.py +464 -0
  74. lucidscan/plugins/scanners/trivy.py +492 -0
  75. lucidscan/plugins/test_runners/__init__.py +27 -0
  76. lucidscan/plugins/test_runners/base.py +111 -0
  77. lucidscan/plugins/test_runners/jest.py +381 -0
  78. lucidscan/plugins/test_runners/karma.py +481 -0
  79. lucidscan/plugins/test_runners/playwright.py +434 -0
  80. lucidscan/plugins/test_runners/pytest.py +598 -0
  81. lucidscan/plugins/type_checkers/__init__.py +27 -0
  82. lucidscan/plugins/type_checkers/base.py +106 -0
  83. lucidscan/plugins/type_checkers/mypy.py +355 -0
  84. lucidscan/plugins/type_checkers/pyright.py +313 -0
  85. lucidscan/plugins/type_checkers/typescript.py +280 -0
  86. lucidscan-0.5.12.dist-info/METADATA +242 -0
  87. lucidscan-0.5.12.dist-info/RECORD +91 -0
  88. lucidscan-0.5.12.dist-info/WHEEL +5 -0
  89. lucidscan-0.5.12.dist-info/entry_points.txt +34 -0
  90. lucidscan-0.5.12.dist-info/licenses/LICENSE +201 -0
  91. lucidscan-0.5.12.dist-info/top_level.txt +1 -0
@@ -0,0 +1,510 @@
1
+ """AI instruction formatter for MCP tools.
2
+
3
+ Transforms UnifiedIssue objects into rich, AI-friendly fix instructions.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, asdict, field
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from lucidscan.core.models import ScanDomain, Severity, ToolDomain, UnifiedIssue
12
+
13
+
14
+ @dataclass
15
+ class FixInstruction:
16
+ """Rich fix instruction for AI agents."""
17
+
18
+ priority: int # 1 (highest) to 5 (lowest)
19
+ action: str # FIX_SECURITY_VULNERABILITY, FIX_TYPE_ERROR, etc.
20
+ summary: str # One-line summary
21
+ file: str
22
+ line: int
23
+ column: Optional[int] = None
24
+ problem: str = "" # Detailed problem description
25
+ fix_steps: List[str] = field(default_factory=list) # Ordered steps to fix
26
+ suggested_fix: Optional[str] = None # Suggested code replacement
27
+ current_code: Optional[str] = None # Current code snippet
28
+ documentation_url: Optional[str] = None
29
+ related_issues: List[str] = field(default_factory=list) # Related issue IDs
30
+ issue_id: str = "" # Original issue ID for reference
31
+
32
+
33
+ class InstructionFormatter:
34
+ """Transforms UnifiedIssue to AI-friendly instructions."""
35
+
36
+ SEVERITY_PRIORITY = {
37
+ Severity.CRITICAL: 1,
38
+ Severity.HIGH: 2,
39
+ Severity.MEDIUM: 3,
40
+ Severity.LOW: 4,
41
+ Severity.INFO: 5,
42
+ }
43
+
44
+ # Map both ScanDomain and ToolDomain to action prefixes
45
+ DOMAIN_ACTION_PREFIX = {
46
+ # ScanDomain values
47
+ ScanDomain.SCA: "FIX_DEPENDENCY_",
48
+ ScanDomain.SAST: "FIX_SECURITY_",
49
+ ScanDomain.IAC: "FIX_INFRASTRUCTURE_",
50
+ ScanDomain.CONTAINER: "FIX_CONTAINER_",
51
+ # ToolDomain values
52
+ ToolDomain.LINTING: "FIX_LINTING_",
53
+ ToolDomain.TYPE_CHECKING: "FIX_TYPE_",
54
+ ToolDomain.SECURITY: "FIX_SECURITY_",
55
+ ToolDomain.TESTING: "FIX_TEST_",
56
+ ToolDomain.COVERAGE: "IMPROVE_COVERAGE_",
57
+ }
58
+
59
+ def format_scan_result(
60
+ self,
61
+ issues: List[UnifiedIssue],
62
+ checked_domains: Optional[List[str]] = None,
63
+ ) -> Dict[str, Any]:
64
+ """Format scan result as AI instructions.
65
+
66
+ Args:
67
+ issues: List of unified issues from scan.
68
+ checked_domains: List of domain names that were checked.
69
+
70
+ Returns:
71
+ Dictionary with structured AI instructions including:
72
+ - total_issues: Count of all issues
73
+ - blocking: Whether there are high-priority issues
74
+ - summary: Human-readable summary
75
+ - severity_counts: Issues by severity level
76
+ - domain_status: Pass/fail status for each checked domain
77
+ - issues_by_domain: Issues grouped by domain
78
+ - instructions: Sorted list of fix instructions
79
+ - recommended_action: Suggested next step
80
+ """
81
+ instructions = [self._issue_to_instruction(issue) for issue in issues]
82
+
83
+ # Sort by priority
84
+ instructions.sort(key=lambda x: x.priority)
85
+
86
+ # Count by severity
87
+ severity_counts: dict[str, int] = {}
88
+ for issue in issues:
89
+ sev_name = issue.severity.value if issue.severity else "unknown"
90
+ severity_counts[sev_name] = severity_counts.get(sev_name, 0) + 1
91
+
92
+ # Group issues by domain
93
+ issues_by_domain: Dict[str, List[Dict[str, Any]]] = {}
94
+ for issue in issues:
95
+ domain_name = issue.domain.value if issue.domain else "unknown"
96
+ if domain_name not in issues_by_domain:
97
+ issues_by_domain[domain_name] = []
98
+ issues_by_domain[domain_name].append(
99
+ self._issue_to_brief(issue)
100
+ )
101
+
102
+ # Build domain status (pass/fail for each checked domain)
103
+ domain_status: Dict[str, Dict[str, Any]] = {}
104
+ if checked_domains:
105
+ for domain in checked_domains:
106
+ domain_issues = issues_by_domain.get(domain, [])
107
+ issue_count = len(domain_issues)
108
+ fixable_count = sum(1 for i in domain_issues if i.get("fixable", False))
109
+
110
+ if issue_count == 0:
111
+ status = "pass"
112
+ status_display = "Pass"
113
+ else:
114
+ status = "fail"
115
+ if fixable_count > 0:
116
+ status_display = f"{issue_count} issues ({fixable_count} auto-fixable)"
117
+ else:
118
+ status_display = f"{issue_count} issues"
119
+
120
+ domain_status[domain] = {
121
+ "status": status,
122
+ "display": status_display,
123
+ "issue_count": issue_count,
124
+ "fixable_count": fixable_count,
125
+ }
126
+
127
+ # Generate recommended action
128
+ recommended_action = self._generate_recommended_action(
129
+ issues, severity_counts, domain_status
130
+ )
131
+
132
+ return {
133
+ "total_issues": len(issues),
134
+ "blocking": any(i.priority <= 2 for i in instructions),
135
+ "summary": self._generate_summary(issues, severity_counts),
136
+ "severity_counts": severity_counts,
137
+ "domain_status": domain_status,
138
+ "issues_by_domain": issues_by_domain,
139
+ "instructions": [asdict(i) for i in instructions],
140
+ "recommended_action": recommended_action,
141
+ }
142
+
143
+ def format_single_issue(
144
+ self,
145
+ issue: UnifiedIssue,
146
+ detailed: bool = False,
147
+ ) -> Dict[str, Any]:
148
+ """Format a single issue for AI consumption.
149
+
150
+ Args:
151
+ issue: The issue to format.
152
+ detailed: Whether to include extra detail.
153
+
154
+ Returns:
155
+ Dictionary with issue details and fix instructions.
156
+ """
157
+ instruction = self._issue_to_instruction(issue, detailed=detailed)
158
+ return asdict(instruction)
159
+
160
+ def _issue_to_instruction(
161
+ self,
162
+ issue: UnifiedIssue,
163
+ detailed: bool = False,
164
+ ) -> FixInstruction:
165
+ """Convert UnifiedIssue to FixInstruction.
166
+
167
+ Args:
168
+ issue: The unified issue.
169
+ detailed: Whether to include extra detail.
170
+
171
+ Returns:
172
+ FixInstruction instance.
173
+ """
174
+ file_path = str(issue.file_path) if issue.file_path else ""
175
+
176
+ return FixInstruction(
177
+ priority=self.SEVERITY_PRIORITY.get(issue.severity, 3),
178
+ action=self._generate_action(issue),
179
+ summary=self._generate_summary_line(issue),
180
+ file=file_path,
181
+ line=issue.line_start or 0,
182
+ column=issue.column_start,
183
+ problem=issue.description or "",
184
+ fix_steps=self._generate_fix_steps(issue, detailed),
185
+ suggested_fix=self._generate_suggested_fix(issue),
186
+ current_code=issue.code_snippet,
187
+ documentation_url=issue.documentation_url,
188
+ related_issues=[],
189
+ issue_id=issue.id,
190
+ )
191
+
192
+ def _generate_action(self, issue: UnifiedIssue) -> str:
193
+ """Generate action type from issue.
194
+
195
+ Args:
196
+ issue: The unified issue.
197
+
198
+ Returns:
199
+ Action string like FIX_SECURITY_VULNERABILITY.
200
+ """
201
+ prefix = self.DOMAIN_ACTION_PREFIX.get(issue.domain, "FIX_")
202
+ title_lower = issue.title.lower() if issue.title else ""
203
+ domain = issue.domain
204
+
205
+ # Specific action types based on issue characteristics
206
+ # Handle both ScanDomain and ToolDomain
207
+ if domain in (ScanDomain.SAST, ToolDomain.SECURITY):
208
+ if "hardcoded" in title_lower or "secret" in title_lower:
209
+ return f"{prefix}HARDCODED_SECRET"
210
+ elif "injection" in title_lower:
211
+ return f"{prefix}INJECTION"
212
+ elif "xss" in title_lower:
213
+ return f"{prefix}XSS"
214
+ return f"{prefix}VULNERABILITY"
215
+
216
+ if domain == ScanDomain.SCA:
217
+ return f"{prefix}VULNERABILITY"
218
+
219
+ if domain == ScanDomain.IAC:
220
+ if "exposed" in title_lower or "public" in title_lower:
221
+ return f"{prefix}EXPOSURE"
222
+ return f"{prefix}MISCONFIGURATION"
223
+
224
+ if domain == ScanDomain.CONTAINER:
225
+ return f"{prefix}VULNERABILITY"
226
+
227
+ if domain == ToolDomain.LINTING:
228
+ return f"{prefix}ERROR"
229
+
230
+ if domain == ToolDomain.TYPE_CHECKING:
231
+ return f"{prefix}ERROR"
232
+
233
+ if domain == ToolDomain.TESTING:
234
+ return f"{prefix}FAILURE"
235
+
236
+ if domain == ToolDomain.COVERAGE:
237
+ return f"{prefix}GAP"
238
+
239
+ return "FIX_ISSUE"
240
+
241
+ def _generate_summary_line(self, issue: UnifiedIssue) -> str:
242
+ """Generate one-line summary for issue.
243
+
244
+ Args:
245
+ issue: The unified issue.
246
+
247
+ Returns:
248
+ Summary string.
249
+ """
250
+ file_part = ""
251
+ if issue.file_path:
252
+ file_name = issue.file_path.name if hasattr(issue.file_path, "name") else str(issue.file_path).split("/")[-1]
253
+ if issue.line_start:
254
+ file_part = f" in {file_name}:{issue.line_start}"
255
+ else:
256
+ file_part = f" in {file_name}"
257
+
258
+ return f"{issue.title}{file_part}"
259
+
260
+ def _generate_summary(
261
+ self,
262
+ issues: List[UnifiedIssue],
263
+ severity_counts: Dict[str, int],
264
+ ) -> str:
265
+ """Generate overall summary string.
266
+
267
+ Args:
268
+ issues: List of issues.
269
+ severity_counts: Count by severity.
270
+
271
+ Returns:
272
+ Summary string.
273
+ """
274
+ if not issues:
275
+ return "No issues found"
276
+
277
+ parts = []
278
+ for sev in ["critical", "high", "medium", "low", "info"]:
279
+ count = severity_counts.get(sev, 0)
280
+ if count > 0:
281
+ parts.append(f"{count} {sev}")
282
+
283
+ return f"{len(issues)} issues found: {', '.join(parts)}"
284
+
285
+ def _generate_fix_steps(
286
+ self,
287
+ issue: UnifiedIssue,
288
+ detailed: bool = False,
289
+ ) -> List[str]:
290
+ """Generate fix steps from issue context.
291
+
292
+ Args:
293
+ issue: The unified issue.
294
+ detailed: Whether to include extra detail.
295
+
296
+ Returns:
297
+ List of fix steps.
298
+ """
299
+ steps = []
300
+
301
+ # Use recommendation if available
302
+ if issue.recommendation:
303
+ steps.append(issue.recommendation)
304
+
305
+ # Add AI explanation if enriched
306
+ ai_explanation = issue.metadata.get("ai_explanation")
307
+ if ai_explanation:
308
+ steps.extend(self._parse_ai_explanation(ai_explanation))
309
+
310
+ # Generate generic steps based on domain if no specific steps
311
+ if not steps:
312
+ steps = self._generate_generic_steps(issue)
313
+
314
+ return steps
315
+
316
+ def _parse_ai_explanation(self, explanation: str) -> List[str]:
317
+ """Parse AI explanation into steps.
318
+
319
+ Args:
320
+ explanation: AI-generated explanation text.
321
+
322
+ Returns:
323
+ List of steps extracted from explanation.
324
+ """
325
+ if not explanation:
326
+ return []
327
+
328
+ # Split on numbered items or bullet points
329
+ lines = explanation.strip().split("\n")
330
+ steps = []
331
+
332
+ for line in lines:
333
+ line = line.strip()
334
+ # Skip empty lines
335
+ if not line:
336
+ continue
337
+ # Remove leading numbers/bullets
338
+ if line[0].isdigit() and "." in line[:3]:
339
+ line = line.split(".", 1)[1].strip()
340
+ elif line.startswith("-") or line.startswith("*"):
341
+ line = line[1:].strip()
342
+
343
+ if line and len(line) > 5:
344
+ steps.append(line)
345
+
346
+ return steps[:5] # Limit to 5 steps
347
+
348
+ def _generate_generic_steps(self, issue: UnifiedIssue) -> List[str]:
349
+ """Generate generic fix steps based on domain.
350
+
351
+ Args:
352
+ issue: The unified issue.
353
+
354
+ Returns:
355
+ List of generic fix steps.
356
+ """
357
+ file_ref = f"{issue.file_path}:{issue.line_start}" if issue.file_path and issue.line_start else str(issue.file_path or "the file")
358
+ domain = issue.domain
359
+
360
+ # Handle both ScanDomain and ToolDomain
361
+ if domain in (ScanDomain.SAST, ToolDomain.SECURITY):
362
+ return [
363
+ f"Review the security issue at {file_ref}",
364
+ "Apply the recommended fix from the scanner",
365
+ "Verify the fix doesn't break functionality",
366
+ "Consider adding tests to prevent regression",
367
+ ]
368
+
369
+ if domain == ScanDomain.SCA:
370
+ return [
371
+ f"Update the vulnerable dependency mentioned in {issue.title}",
372
+ "Run tests to ensure compatibility with new version",
373
+ "Check for breaking changes in the changelog",
374
+ ]
375
+
376
+ if domain == ScanDomain.IAC:
377
+ return [
378
+ f"Review the infrastructure issue at {file_ref}",
379
+ "Apply security best practices for the resource",
380
+ "Test the changes in a non-production environment",
381
+ ]
382
+
383
+ if domain == ScanDomain.CONTAINER:
384
+ return [
385
+ f"Review the container vulnerability at {file_ref}",
386
+ "Update the base image or vulnerable packages",
387
+ "Rebuild and test the container",
388
+ ]
389
+
390
+ if domain == ToolDomain.LINTING:
391
+ return [
392
+ f"Fix the linting issue at {file_ref}",
393
+ "Consider running 'lucidscan scan --linting --fix' for auto-fix",
394
+ ]
395
+
396
+ if domain == ToolDomain.TYPE_CHECKING:
397
+ return [
398
+ f"Fix the type error at {file_ref}",
399
+ "Ensure type annotations are correct and complete",
400
+ "Check for None values that need handling",
401
+ ]
402
+
403
+ if domain == ToolDomain.TESTING:
404
+ return [
405
+ f"Review the failing test at {file_ref}",
406
+ "Determine if the test or the code needs to be fixed",
407
+ "Run the test in isolation to verify the fix",
408
+ ]
409
+
410
+ if domain == ToolDomain.COVERAGE:
411
+ return [
412
+ f"Add tests to cover the uncovered code at {file_ref}",
413
+ "Focus on critical paths and edge cases",
414
+ "Verify coverage threshold is met after adding tests",
415
+ ]
416
+
417
+ return [f"Address the issue at {file_ref}"]
418
+
419
+ def _generate_suggested_fix(self, issue: UnifiedIssue) -> Optional[str]:
420
+ """Generate suggested fix code if available.
421
+
422
+ Args:
423
+ issue: The unified issue.
424
+
425
+ Returns:
426
+ Suggested fix code or None.
427
+ """
428
+ # Use the issue's suggested_fix field directly
429
+ if issue.suggested_fix:
430
+ return issue.suggested_fix
431
+
432
+ # For linting issues, check metadata for auto_fix
433
+ if issue.domain == ToolDomain.LINTING:
434
+ auto_fix = issue.metadata.get("auto_fix")
435
+ if auto_fix:
436
+ return auto_fix
437
+
438
+ return None
439
+
440
+ def _issue_to_brief(self, issue: UnifiedIssue) -> Dict[str, Any]:
441
+ """Convert issue to brief format for domain grouping.
442
+
443
+ Args:
444
+ issue: The unified issue.
445
+
446
+ Returns:
447
+ Brief issue dictionary.
448
+ """
449
+ file_path = str(issue.file_path) if issue.file_path else ""
450
+ location = file_path
451
+ if issue.line_start:
452
+ location = f"{file_path}:{issue.line_start}"
453
+
454
+ return {
455
+ "id": issue.id,
456
+ "location": location,
457
+ "severity": issue.severity.value if issue.severity else "unknown",
458
+ "title": issue.title or "",
459
+ "fixable": issue.fixable,
460
+ }
461
+
462
+ def _generate_recommended_action(
463
+ self,
464
+ issues: List[UnifiedIssue],
465
+ severity_counts: Dict[str, int],
466
+ domain_status: Dict[str, Dict[str, Any]],
467
+ ) -> str:
468
+ """Generate recommended next action based on scan results.
469
+
470
+ Args:
471
+ issues: List of issues found.
472
+ severity_counts: Count of issues by severity.
473
+ domain_status: Status of each checked domain.
474
+
475
+ Returns:
476
+ Recommended action string.
477
+ """
478
+ if not issues:
479
+ return "All checks passed. Ready to proceed."
480
+
481
+ # Count fixable issues
482
+ fixable_count = sum(1 for i in issues if i.fixable)
483
+ linting_fixable = sum(
484
+ 1 for i in issues if i.domain == ToolDomain.LINTING and i.fixable
485
+ )
486
+
487
+ # Check for critical/high severity issues
488
+ critical_count = severity_counts.get("critical", 0)
489
+ high_count = severity_counts.get("high", 0)
490
+
491
+ if critical_count > 0:
492
+ return f"Fix {critical_count} critical issue(s) immediately before proceeding."
493
+
494
+ if high_count > 0:
495
+ return f"Address {high_count} high-severity issue(s) before committing."
496
+
497
+ if linting_fixable > 0:
498
+ return f"Run `scan(fix=true)` to auto-fix {linting_fixable} linting issue(s), then address remaining issues manually."
499
+
500
+ if fixable_count > 0:
501
+ return f"Run `scan(fix=true)` to auto-fix {fixable_count} issue(s)."
502
+
503
+ # Type errors or other issues
504
+ type_issues = sum(
505
+ 1 for i in issues if i.domain == ToolDomain.TYPE_CHECKING
506
+ )
507
+ if type_issues > 0:
508
+ return f"Fix {type_issues} type error(s) by updating type annotations or handling None values."
509
+
510
+ return f"Review and fix {len(issues)} issue(s), then re-scan to verify."