vibe-coding-qa 0.5.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 (58) hide show
  1. dashboard/__init__.py +10 -0
  2. dashboard/backend/__init__.py +11 -0
  3. dashboard/backend/__main__.py +6 -0
  4. dashboard/backend/ai/__init__.py +6 -0
  5. dashboard/backend/ai/provider.py +102 -0
  6. dashboard/backend/ai/service.py +757 -0
  7. dashboard/backend/api/__init__.py +5 -0
  8. dashboard/backend/api/ai.py +142 -0
  9. dashboard/backend/api/dashboard.py +34 -0
  10. dashboard/backend/api/deps.py +51 -0
  11. dashboard/backend/api/findings.py +102 -0
  12. dashboard/backend/api/intel.py +41 -0
  13. dashboard/backend/api/repositories.py +62 -0
  14. dashboard/backend/api/scans.py +168 -0
  15. dashboard/backend/api/system.py +69 -0
  16. dashboard/backend/config.py +180 -0
  17. dashboard/backend/main.py +183 -0
  18. dashboard/backend/persistence/__init__.py +5 -0
  19. dashboard/backend/persistence/database.py +278 -0
  20. dashboard/backend/persistence/store.py +600 -0
  21. dashboard/backend/profiles.py +141 -0
  22. dashboard/backend/schemas.py +452 -0
  23. dashboard/backend/security.py +158 -0
  24. dashboard/backend/services/__init__.py +7 -0
  25. dashboard/backend/services/dashboard.py +172 -0
  26. dashboard/backend/services/intel.py +341 -0
  27. dashboard/backend/services/paths.py +38 -0
  28. dashboard/backend/services/repositories.py +123 -0
  29. dashboard/backend/services/scans.py +328 -0
  30. dashboard/backend/services/settings.py +49 -0
  31. dashboard/frontend/dist/assets/index-COC-CdqD.js +492 -0
  32. dashboard/frontend/dist/assets/index-hWDLRaLr.css +1 -0
  33. dashboard/frontend/dist/index.html +29 -0
  34. dashboard/frontend/dist/phone-mascots.gif +0 -0
  35. dashboard/launcher.py +48 -0
  36. vibe_coding_qa/__init__.py +140 -0
  37. vibe_coding_qa/__main__.py +5 -0
  38. vibe_coding_qa/ai_review.py +165 -0
  39. vibe_coding_qa/cli.py +157 -0
  40. vibe_coding_qa/engine.py +860 -0
  41. vibe_coding_qa/fixes.py +222 -0
  42. vibe_coding_qa/gates.py +300 -0
  43. vibe_coding_qa/profile.py +471 -0
  44. vibe_coding_qa/repo.py +188 -0
  45. vibe_coding_qa/reporters/__init__.py +253 -0
  46. vibe_coding_qa/scanners/__init__.py +195 -0
  47. vibe_coding_qa/scanners/compliance.py +167 -0
  48. vibe_coding_qa/scanners/database.py +316 -0
  49. vibe_coding_qa/scanners/dependencies.py +595 -0
  50. vibe_coding_qa/scanners/infrastructure.py +125 -0
  51. vibe_coding_qa/scanners/performance.py +84 -0
  52. vibe_coding_qa/scanners/quality.py +129 -0
  53. vibe_coding_qa/scanners/security.py +521 -0
  54. vibe_coding_qa-0.5.0.dist-info/METADATA +323 -0
  55. vibe_coding_qa-0.5.0.dist-info/RECORD +58 -0
  56. vibe_coding_qa-0.5.0.dist-info/WHEEL +5 -0
  57. vibe_coding_qa-0.5.0.dist-info/entry_points.txt +3 -0
  58. vibe_coding_qa-0.5.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,222 @@
1
+ """Automated fix engine: apply safe, reviewable fixes."""
2
+
3
+ import difflib
4
+ import os
5
+ import re
6
+ import shutil
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ from . import Finding, Severity
11
+
12
+
13
+ class FixClassification:
14
+ SAFE = "safe-automatic"
15
+ APPROVAL = "approval-required"
16
+ MANUAL = "manual-remediation"
17
+
18
+
19
+ class FixEngine:
20
+ def __init__(self, repo: Path, dry_run: bool = True):
21
+ self.repo = repo
22
+ self.dry_run = dry_run
23
+ self.applied: list[dict] = []
24
+ self.skipped: list[dict] = []
25
+ self.backups: list[Path] = []
26
+ self.backup_dir = repo / ".vcqa-backups" / datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
27
+
28
+ def apply(self, findings: list[Finding]) -> dict:
29
+ for finding in findings:
30
+ if not finding.fixable:
31
+ self.skipped.append({"finding": finding.to_dict(), "reason": "not marked fixable", "classification": FixClassification.MANUAL})
32
+ continue
33
+ classification = self.classify(finding)
34
+ if classification == FixClassification.MANUAL:
35
+ self.skipped.append({"finding": finding.to_dict(), "reason": "manual remediation required", "classification": classification})
36
+ continue
37
+ if classification == FixClassification.APPROVAL:
38
+ self.skipped.append({"finding": finding.to_dict(), "reason": "requires explicit approval", "classification": classification})
39
+ continue
40
+ method_name = f"fix_{finding.rule.replace(':', '_').replace('-', '_')}"
41
+ method = getattr(self, method_name, None)
42
+ if method is None:
43
+ self.skipped.append({"finding": finding.to_dict(), "reason": "no automated fix available", "classification": classification})
44
+ continue
45
+ try:
46
+ method(finding)
47
+ except Exception as exc:
48
+ self.skipped.append({"finding": finding.to_dict(), "reason": str(exc), "classification": classification})
49
+ return {"applied": self.applied, "skipped": self.skipped, "dry_run": self.dry_run}
50
+
51
+ def classify(self, finding: Finding) -> str:
52
+ if finding.severity in (Severity.CRITICAL,):
53
+ return FixClassification.APPROVAL
54
+ if finding.rule in ("security:client-side-secret", "security:committed-env-file"):
55
+ return FixClassification.MANUAL
56
+ if finding.rule in ("quality:console-log-in-production", "quality:debugger-statement"):
57
+ return FixClassification.SAFE
58
+ return FixClassification.APPROVAL
59
+
60
+ def _resolve_path(self, file: Path) -> Path:
61
+ target = self.repo / file
62
+ resolved = target.resolve()
63
+ repo_resolved = self.repo.resolve()
64
+ # Prevent symlink/path traversal escape.
65
+ if not str(resolved).startswith(str(repo_resolved) + os.sep) and resolved != repo_resolved:
66
+ raise ValueError(f"Target path escapes repository: {file}")
67
+ return resolved
68
+
69
+ def _read(self, file: Path) -> list[str]:
70
+ path = self._resolve_path(file)
71
+ return path.read_text(encoding="utf-8", errors="ignore").splitlines()
72
+
73
+ def _backup(self, path: Path) -> Path:
74
+ if self.dry_run:
75
+ return path
76
+ rel = path.relative_to(self.repo.resolve())
77
+ backup = self.backup_dir / rel
78
+ backup.parent.mkdir(parents=True, exist_ok=True)
79
+ shutil.copy2(path, backup)
80
+ self.backups.append(backup)
81
+ return backup
82
+
83
+ def _write(self, file: Path, lines: list[str]) -> None:
84
+ path = self._resolve_path(file)
85
+ self._backup(path)
86
+ if self.dry_run:
87
+ return
88
+ path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
89
+
90
+ def _make_diff(self, original_lines: list[str], new_lines: list[str], file: Path) -> str:
91
+ return "\n".join(difflib.unified_diff(
92
+ original_lines,
93
+ new_lines,
94
+ fromfile=str(file),
95
+ tofile=str(file),
96
+ lineterm="",
97
+ ))
98
+
99
+ def fix_quality_console_log_in_production(self, finding: Finding) -> None:
100
+ lines = self._read(finding.file)
101
+ original = list(lines)
102
+ i = finding.line - 1
103
+ if i < 0 or i >= len(lines):
104
+ raise IndexError("line out of range")
105
+ line = lines[i]
106
+ cleaned = re.sub(r"\s*console\.(log|debug|warn)\s*\([^;]*\);?", "", line)
107
+ if cleaned == line or not cleaned.strip():
108
+ cleaned = re.sub(r"console\.(log|debug|warn)\s*\([^;]*\);?", "", line)
109
+ lines[i] = cleaned
110
+ diff = self._make_diff(original, lines, finding.file)
111
+ self._write(finding.file, lines)
112
+ self.applied.append({"finding": finding.to_dict(), "replacement": cleaned, "diff": diff, "classification": FixClassification.SAFE})
113
+
114
+ def fix_quality_debugger_statement(self, finding: Finding) -> None:
115
+ lines = self._read(finding.file)
116
+ original = list(lines)
117
+ i = finding.line - 1
118
+ if i < 0 or i >= len(lines):
119
+ raise IndexError("line out of range")
120
+ line = lines[i]
121
+ cleaned = re.sub(r"\s*debugger;?", "", line)
122
+ lines[i] = cleaned
123
+ diff = self._make_diff(original, lines, finding.file)
124
+ self._write(finding.file, lines)
125
+ self.applied.append({"finding": finding.to_dict(), "replacement": cleaned, "diff": diff, "classification": FixClassification.SAFE})
126
+
127
+ def fix_security_client_side_secret(self, finding: Finding) -> None:
128
+ raise RuntimeError("client-side secrets require moving the value to a server route; cannot auto-fix")
129
+
130
+ def fix_security_committed_env_file(self, finding: Finding) -> None:
131
+ raise RuntimeError("remove the file from git and rotate secrets; cannot auto-fix")
132
+
133
+ def rollback(self, backup: Path | None = None) -> list[Path]:
134
+ """Restore the most recent backup, or a specific backup path."""
135
+ if backup is None:
136
+ if not self.backups:
137
+ return []
138
+ backup = self.backups[-1]
139
+ rel = backup.relative_to(self.backup_dir)
140
+ target = self.repo / rel
141
+ shutil.copy2(backup, target)
142
+ return [target]
143
+
144
+ @staticmethod
145
+ def _finding_key(finding: "Finding | dict") -> tuple[str, str]:
146
+ if isinstance(finding, Finding):
147
+ return finding.rule, str(finding.file)
148
+ return str(finding["rule"]), str(finding["file"])
149
+
150
+ def verify(self, rescan_findings: list[Finding], tests: dict | None = None) -> dict:
151
+ """Confirm which applied fixes actually removed the detected issue.
152
+
153
+ Verification is evidence-based: a fix is only marked ``resolved`` when a
154
+ fresh scan of the modified repository no longer reports the rule for that
155
+ file. It is never inferred from the fact that a file was edited or from a
156
+ shifted line number. When the same rule appears more than once in a file,
157
+ resolutions are counted by multiplicity so a partial fix is reported as
158
+ ``partially_resolved`` rather than ``resolved``.
159
+
160
+ If tests were run and failed, no fix can be confirmed and every attempt is
161
+ reported as ``unverifiable`` so a regression is never masked.
162
+ """
163
+ before: dict[tuple[str, str], int] = {}
164
+ changes_by_key: dict[tuple[str, str], list[dict]] = {}
165
+ for change in self.applied:
166
+ key = self._finding_key(change["finding"])
167
+ before[key] = before.get(key, 0) + 1
168
+ changes_by_key.setdefault(key, []).append(change)
169
+
170
+ after: dict[tuple[str, str], int] = {}
171
+ for finding in rescan_findings:
172
+ key = self._finding_key(finding)
173
+ after[key] = after.get(key, 0) + 1
174
+
175
+ tests_failed = bool(tests and tests.get("status") == "fail")
176
+ report: dict = {
177
+ "resolved": [],
178
+ "partially_resolved": [],
179
+ "unresolved": [],
180
+ "unverifiable": [],
181
+ "tests_failed": tests_failed,
182
+ "verified": False,
183
+ }
184
+ for key, attempted in before.items():
185
+ remaining = after.get(key, 0)
186
+ entry = {"rule": key[0], "file": key[1], "attempted": attempted, "remaining": remaining}
187
+ if tests_failed:
188
+ status = "unverifiable"
189
+ elif remaining == 0:
190
+ status = "resolved"
191
+ elif remaining < attempted:
192
+ status = "partially_resolved"
193
+ else:
194
+ status = "unresolved"
195
+ entry["status"] = status
196
+ report[status].append(entry)
197
+ for change in changes_by_key[key]:
198
+ change["resolution"] = status
199
+
200
+ report["verified"] = (
201
+ not tests_failed
202
+ and bool(report["resolved"])
203
+ and not report["partially_resolved"]
204
+ and not report["unresolved"]
205
+ )
206
+ return report
207
+
208
+
209
+ def suggest_fixes(findings: list[Finding]) -> list[dict]:
210
+ """Generate human-readable suggestions without modifying files."""
211
+ suggestions = []
212
+ for f in findings:
213
+ suggestions.append({
214
+ "file": str(f.file),
215
+ "line": f.line,
216
+ "rule": f.rule,
217
+ "severity": f.severity.value,
218
+ "message": f.message,
219
+ "suggestion": f.suggestion,
220
+ "auto_fixable": f.fixable,
221
+ })
222
+ return suggestions
@@ -0,0 +1,300 @@
1
+ """Production-readiness scoring and release gate."""
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ from . import Finding, ModuleResult, ScanResult, Severity, Status
7
+
8
+
9
+ MODULE_WEIGHTS = {
10
+ "security": 1.5,
11
+ "database": 1.4,
12
+ "dependencies": 1.2,
13
+ "infrastructure": 1.2,
14
+ "quality": 1.0,
15
+ "compliance": 1.1,
16
+ "performance": 0.8,
17
+ }
18
+
19
+ SEVERITY_PENALTY = {
20
+ Severity.CRITICAL: 40,
21
+ Severity.HIGH: 20,
22
+ Severity.MEDIUM: 10,
23
+ Severity.LOW: 3,
24
+ Severity.INFO: 0,
25
+ }
26
+
27
+ CONFIDENCE_MULTIPLIER = {
28
+ "high": 1.0,
29
+ "medium": 0.8,
30
+ "low": 0.5,
31
+ }
32
+
33
+ EXPLOITABILITY_MULTIPLIER = {
34
+ "security": 1.5,
35
+ "database": 1.4,
36
+ "dependencies": 1.2,
37
+ "infrastructure": 1.2,
38
+ "compliance": 1.0,
39
+ "quality": 0.9,
40
+ "performance": 0.8,
41
+ }
42
+
43
+ EXPLOITABLE_RULES = {
44
+ "security:client-side-secret",
45
+ "security:generic-api-key",
46
+ "security:sql-injection-risk",
47
+ "security:unsafe-shell-execution",
48
+ "security:path-traversal-risk",
49
+ "security:ssrf-risk",
50
+ "security:private-key",
51
+ "security:aws-access-key",
52
+ "security:openai-key",
53
+ "security:stripe-key",
54
+ "security:supabase-service-key",
55
+ "security:firebase-insecure-rules",
56
+ "database:table-without-rls",
57
+ "database:editable-role-field",
58
+ "database:client-role-update",
59
+ }
60
+
61
+
62
+ def _finding_key(f: Finding) -> tuple:
63
+ return (f.rule, str(f.file), f.line)
64
+
65
+
66
+ def deduplicate_findings(findings: list[Finding]) -> list[Finding]:
67
+ seen: set[tuple] = set()
68
+ unique: list[Finding] = []
69
+ for f in findings:
70
+ key = _finding_key(f)
71
+ if key in seen:
72
+ continue
73
+ seen.add(key)
74
+ unique.append(f)
75
+ return unique
76
+
77
+
78
+ def _penalty_for(f: Finding) -> float:
79
+ base = float(SEVERITY_PENALTY.get(f.severity, 0))
80
+ confidence = CONFIDENCE_MULTIPLIER.get(f.confidence, 0.8)
81
+ exploitability = EXPLOITABILITY_MULTIPLIER.get(f.module, 1.0)
82
+ if f.rule in EXPLOITABLE_RULES:
83
+ exploitability *= 1.2
84
+ return round(base * confidence * exploitability, 2)
85
+
86
+
87
+ def score_module(findings: list[Finding], max_score: float = 100.0) -> tuple[float, Status, list[dict[str, Any]]]:
88
+ unique = deduplicate_findings(findings)
89
+ if not unique:
90
+ return max_score, Status.PASS, []
91
+ deductions: list[dict[str, Any]] = []
92
+ penalty = 0.0
93
+ for f in unique:
94
+ p = _penalty_for(f)
95
+ penalty += p
96
+ deductions.append({
97
+ "rule": f.rule,
98
+ "file": str(f.file),
99
+ "line": f.line,
100
+ "severity": f.severity.value,
101
+ "confidence": f.confidence,
102
+ "penalty": p,
103
+ "reason": f"{f.severity.value} {f.rule} in {f.module}",
104
+ })
105
+ score = max(0.0, max_score - penalty)
106
+ if any(f.severity == Severity.CRITICAL for f in unique):
107
+ return score, Status.BLOCK, deductions
108
+ if any(f.severity == Severity.HIGH for f in unique):
109
+ return score, Status.FAIL, deductions
110
+ return score, Status.PASS, deductions
111
+
112
+
113
+ def _determine_gate(
114
+ overall: float,
115
+ minimum_score: int,
116
+ critical_count: int,
117
+ high_count: int,
118
+ test_status: str | Status | None,
119
+ test_execution_mode: str | None,
120
+ ) -> str:
121
+ """
122
+ Determine the release gate decision.
123
+
124
+ Returns one of: "block", "fail", "pass", "not_verified"
125
+ """
126
+ # Critical findings always block
127
+ if critical_count > 0:
128
+ return "block"
129
+
130
+ # High findings fail
131
+ if high_count > 0:
132
+ return "fail"
133
+
134
+ # Below minimum score fails
135
+ if overall < minimum_score:
136
+ return "fail"
137
+
138
+ # Normalize test_status to string
139
+ if isinstance(test_status, Status):
140
+ test_status_str = test_status.value
141
+ else:
142
+ test_status_str = test_status
143
+
144
+ if test_status_str == "fail":
145
+ return "fail"
146
+ if test_status_str == "pass":
147
+ return "pass"
148
+ return "not_verified"
149
+
150
+
151
+ def assess(result: ScanResult, minimum_score: int | None = None) -> dict:
152
+ if minimum_score is None:
153
+ minimum_score = int(os.environ.get("VCQA_MINIMUM_SCORE", "60"))
154
+ score_explanation: dict[str, Any] = {"deductions": [], "modules": []}
155
+ for module in result.modules:
156
+ module.findings = deduplicate_findings(module.findings)
157
+ module.score, module.status, deductions = score_module(module.findings)
158
+ score_explanation["modules"].append({
159
+ "module": module.module,
160
+ "score": module.score,
161
+ "status": module.status.value,
162
+ "deductions": deductions,
163
+ })
164
+ score_explanation["deductions"].extend(deductions)
165
+
166
+ weighted_sum = 0.0
167
+ total_weight = 0.0
168
+ for module in result.modules:
169
+ weight = MODULE_WEIGHTS.get(module.module, 1.0)
170
+ weighted_sum += module.score * weight
171
+ total_weight += weight
172
+ overall = round(weighted_sum / total_weight, 2) if total_weight else 0.0
173
+
174
+ # Get test information
175
+ test_status = result.tests.get("status")
176
+ test_execution_mode = result.tests.get("execution_mode")
177
+
178
+ gate = _determine_gate(
179
+ overall=overall,
180
+ minimum_score=minimum_score,
181
+ critical_count=result.critical_count,
182
+ high_count=result.high_count,
183
+ test_status=test_status,
184
+ test_execution_mode=test_execution_mode,
185
+ )
186
+
187
+ score_explanation["overall_score"] = overall
188
+ score_explanation["minimum_score"] = minimum_score
189
+ score_explanation["weights"] = MODULE_WEIGHTS
190
+ score_explanation["gate"] = gate
191
+ score_explanation["test_status"] = test_status
192
+ score_explanation["test_execution_mode"] = test_execution_mode
193
+
194
+ assessment = {
195
+ "overall_score": overall,
196
+ "minimum_score": minimum_score,
197
+ "gate": gate,
198
+ "weights": MODULE_WEIGHTS,
199
+ "score_explanation": score_explanation,
200
+ "criteria": {
201
+ "critical_blocks_release": True,
202
+ "high_fails_release": True,
203
+ "minimum_score": minimum_score,
204
+ },
205
+ # Also store at top level for easy access in release_decision
206
+ "test_status": test_status,
207
+ "test_execution_mode": test_execution_mode,
208
+ }
209
+
210
+ # Store authoritative assessment on the result for consistent access
211
+ result._assessment = assessment
212
+
213
+ return assessment
214
+
215
+
216
+ def release_gate_label(result: ScanResult) -> str:
217
+ if not result._assessment:
218
+ assess(result)
219
+ return {
220
+ "pass": "PASS",
221
+ "fail": "FAIL",
222
+ "block": "BLOCKED",
223
+ "not_verified": "NOT VERIFIED",
224
+ }.get(result.gate, "NOT VERIFIED")
225
+
226
+
227
+ def release_decision(result: ScanResult) -> str:
228
+ # Ensure every consumer reads the same authoritative assessment.
229
+ if not result._assessment:
230
+ assess(result)
231
+ # Use authoritative assessment if available
232
+ if result._assessment:
233
+ gate = result._assessment.get("gate", "unknown")
234
+ overall = result._assessment.get("overall_score", 0)
235
+ minimum = result._assessment.get("minimum_score", 60)
236
+ test_status = result._assessment.get("test_status")
237
+ test_mode = result._assessment.get("test_execution_mode")
238
+
239
+ # Normalize test_status to string
240
+ if isinstance(test_status, Status):
241
+ test_status_str = test_status.value
242
+ else:
243
+ test_status_str = test_status
244
+
245
+ if gate == "block":
246
+ return "BLOCKED: critical issues or failing tests must be resolved before release."
247
+ if gate == "fail":
248
+ if result.critical_count > 0:
249
+ return "BLOCKED: critical issues or failing tests must be resolved before release."
250
+ if result.high_count > 0:
251
+ return f"FAIL: high-severity findings remain; fix before release."
252
+ if overall < minimum:
253
+ return f"FAIL: overall readiness score ({overall}) is below the minimum threshold ({minimum})."
254
+ if test_status_str == "fail":
255
+ return "FAIL: test suite failed."
256
+ if gate == "not_verified":
257
+ return f"NOT VERIFIED: overall score ({overall}) meets threshold but tests were not executed ({test_mode}). Run with --explicit-permission to verify."
258
+ if gate == "pass":
259
+ return f"PASS: no critical or high issues, score ({overall}) above threshold ({minimum}), tests passed."
260
+ # Fallback to legacy logic
261
+ if result.release_blocked:
262
+ return "BLOCKED: critical issues or failing tests must be resolved before release."
263
+ if result.high_count > 0:
264
+ return "FAIL: high-severity findings remain; fix before release."
265
+ if result.overall_score < 60:
266
+ return "FAIL: overall readiness score is below 60."
267
+ return "PASS: no critical or high issues and score is above the release threshold."
268
+
269
+
270
+ def coverage_gaps(result: ScanResult) -> list[str]:
271
+ gaps: list[str] = []
272
+ architecture = result.tests.get("architecture", {})
273
+ if not architecture.get("has_tests"):
274
+ gaps.append("No automated test runner detected; runtime behavior is unverified.")
275
+ if not architecture.get("lockfiles"):
276
+ gaps.append("No dependency lockfile found; supply-chain reproducibility is unverified.")
277
+ if not architecture.get("auth_providers"):
278
+ gaps.append("No authentication provider detected; review access-control assumptions.")
279
+ if not architecture.get("ci_files"):
280
+ gaps.append("No CI workflow detected; build and deployment processes are unaudited.")
281
+ if not architecture.get("has_gitignore"):
282
+ gaps.append("No .gitignore detected; secret leaks are more likely.")
283
+ if not result.modules:
284
+ gaps.append("No scanner modules produced results.")
285
+
286
+ # Add test execution status to gaps
287
+ test_status = result.tests.get("status")
288
+ test_mode = result.tests.get("execution_mode")
289
+ test_reason = result.tests.get("test_reason")
290
+ if test_status == "skip":
291
+ if test_reason in ("all_tests_skipped", "zero_tests_discovered"):
292
+ gaps.append(f"Tests not verified: no test executed ({test_reason}).")
293
+ elif test_reason == "no_recognizable_results":
294
+ gaps.append("Tests not verified: the runner output could not be recognized.")
295
+ elif test_mode == "test_infrastructure_unavailable":
296
+ gaps.append("Tests not verified: required dependencies are unavailable in the sandbox image.")
297
+ else:
298
+ gaps.append(f"Tests not executed: {test_mode}. Use --explicit-permission to run tests.")
299
+
300
+ return gaps