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,38 @@
1
+ """Shared helpers for locating a repository's working tree on disk."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from ..config import DASHBOARD_DIR
7
+
8
+ _SLUG = re.compile(r"[^A-Za-z0-9_.-]+")
9
+
10
+
11
+ def slug(source: str) -> str:
12
+ cleaned = source.rstrip("/")
13
+ if cleaned.endswith(".git"):
14
+ cleaned = cleaned[:-4]
15
+ cleaned = cleaned.split("://", 1)[-1]
16
+ if cleaned.startswith("git@"):
17
+ cleaned = cleaned.split(":", 1)[-1]
18
+ return _SLUG.sub("__", cleaned).strip("_")[:120] or "repository"
19
+
20
+
21
+ def cached_clone_path(source: str) -> Path:
22
+ """Deterministic cache location for a remote repository clone."""
23
+ return DASHBOARD_DIR / "data" / "repos" / slug(source)
24
+
25
+
26
+ def repository_dir(repository: dict) -> Path | None:
27
+ """Return the on-disk working tree for a repository, if one is available.
28
+
29
+ Local repositories map to their own path; remote repositories map to the
30
+ cached clone created on first scan. Returns ``None`` when nothing is on disk
31
+ (a remote repository that has never been scanned).
32
+ """
33
+ source = repository.get("source") or ""
34
+ if repository.get("source_type") == "local":
35
+ path = Path(source)
36
+ return path if path.is_dir() else None
37
+ path = cached_clone_path(source)
38
+ return path if path.is_dir() else None
@@ -0,0 +1,123 @@
1
+ """Repository registration and metadata refresh.
2
+
3
+ A repository is either a validated local directory or a validated git source.
4
+ Nothing is cloned at registration time: a remote repository is fetched with the
5
+ existing engine loader when it is first scanned, so registration stays cheap and
6
+ never performs a surprise network operation.
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ from vibe_coding_qa.profile import analyze_repo
12
+
13
+ from ..config import Settings
14
+ from ..persistence.store import Store
15
+ from ..security import ValidationError, resolve_repo_path, validate_git_source
16
+
17
+
18
+ def _derive_name(source: str, source_type: str) -> str:
19
+ if source_type == "local":
20
+ return Path(source).name or source
21
+ cleaned = source.rstrip("/")
22
+ if cleaned.endswith(".git"):
23
+ cleaned = cleaned[:-4]
24
+ if "://" in cleaned:
25
+ cleaned = cleaned.split("://", 1)[1]
26
+ if cleaned.startswith("git@"):
27
+ cleaned = cleaned.split(":", 1)[-1]
28
+ parts = [p for p in cleaned.split("/") if p]
29
+ return "/".join(parts[-2:]) if len(parts) >= 2 else (parts[-1] if parts else source)
30
+
31
+
32
+ class RepositoryService:
33
+ def __init__(self, store: Store, settings: Settings):
34
+ self.store = store
35
+ self.settings = settings
36
+
37
+ # ------------------------------------------------------------------ reads
38
+ def list(self) -> list[dict]:
39
+ return self.store.list_repositories()
40
+
41
+ def get(self, repository_id: int) -> dict | None:
42
+ return self.store.get_repository(repository_id)
43
+
44
+ def require(self, repository_id: int) -> dict:
45
+ repo = self.store.get_repository(repository_id)
46
+ if repo is None:
47
+ raise KeyError(f"Repository {repository_id} was not found.")
48
+ return repo
49
+
50
+ # ------------------------------------------------------------------ writes
51
+ def register(self, *, source: str, source_type: str, name: str | None = None) -> dict:
52
+ source_type = (source_type or "local").lower()
53
+ if source_type == "local":
54
+ resolved = resolve_repo_path(source, self.settings.allowed_repo_roots)
55
+ normalized = str(resolved)
56
+ metadata = self._detect(resolved)
57
+ elif source_type == "github":
58
+ normalized = validate_git_source(source)
59
+ metadata = {}
60
+ else:
61
+ raise ValidationError(f"Unsupported repository source type: {source_type}")
62
+
63
+ existing = self.store.get_repository_by_source(normalized)
64
+ if existing:
65
+ raise ValidationError(f"Repository is already registered: {normalized}")
66
+
67
+ return self.store.create_repository(
68
+ name=(name or "").strip() or _derive_name(normalized, source_type),
69
+ source=normalized,
70
+ source_type=source_type,
71
+ **metadata,
72
+ )
73
+
74
+ def refresh(self, repository_id: int) -> dict:
75
+ repo = self.require(repository_id)
76
+ if repo["source_type"] != "local":
77
+ raise ValidationError(
78
+ "Metadata for remote repositories is captured when they are scanned."
79
+ )
80
+ resolved = resolve_repo_path(repo["source"], self.settings.allowed_repo_roots)
81
+ metadata = self._detect(resolved)
82
+ return self.store.update_repository(repository_id, **metadata) or repo
83
+
84
+ def delete(self, repository_id: int) -> bool:
85
+ return self.store.delete_repository(repository_id)
86
+
87
+ # ------------------------------------------------------------- internals
88
+ def _detect(self, path: Path) -> dict:
89
+ """Detect the technology profile of a *local* repository."""
90
+ try:
91
+ profile = analyze_repo(path)
92
+ except Exception:
93
+ return {}
94
+ languages = {k: v for k, v in profile.languages.items() if v}
95
+ main_language = max(languages, key=languages.get) if languages else None
96
+ return {
97
+ "main_language": main_language,
98
+ "frameworks": profile.frameworks,
99
+ "package_manager": profile.package_manager,
100
+ "database_tech": profile.databases,
101
+ "auth_tech": profile.auth_providers,
102
+ "deployment": profile.deployment,
103
+ }
104
+
105
+ def metadata_from_scan(self, repository_id: int, architecture: dict, share_source_context: bool) -> None:
106
+ """Persist the richer metadata captured during a real scan.
107
+
108
+ Repository paths are only stored/echoed when the operator opted in to
109
+ sharing source context; otherwise the path stays on the server.
110
+ """
111
+ if not architecture:
112
+ return
113
+ languages = {k: v for k, v in (architecture.get("languages") or {}).items() if v}
114
+ main_language = max(languages, key=languages.get) if languages else None
115
+ self.store.update_repository(
116
+ repository_id,
117
+ main_language=main_language,
118
+ frameworks=list(architecture.get("frameworks") or []),
119
+ package_manager=architecture.get("package_manager"),
120
+ database_tech=list(architecture.get("databases") or []),
121
+ auth_tech=list(architecture.get("auth_providers") or []),
122
+ deployment=list(architecture.get("deployment") or []),
123
+ )
@@ -0,0 +1,328 @@
1
+ """Bounded background scan runner.
2
+
3
+ Scans are long-running and must not block the request thread, so they run in a
4
+ bounded thread pool. Behaviour that the spec requires explicitly:
5
+
6
+ * **Bounded concurrency** — a fixed pool of ``max_concurrent_scans`` workers;
7
+ extra scans queue instead of fanning out, and the queue itself is capped.
8
+ * **Persistent job state** — every transition is written to SQLite, so a scan
9
+ interrupted by a restart is identifiable (see ``mark_interrupted_scans``).
10
+ * **Real events, never simulated progress** — the engine reports each stage
11
+ through a callback that is persisted as a ``scan_events`` row and streamed
12
+ over SSE. The dashboard never invents percentages.
13
+ * **No test execution without permission** — tests run only when the request
14
+ opts in *and* the operator enabled ``VCQA_ALLOW_TEST_EXECUTION``.
15
+ * **The AI stage never gates a release** — AI review is recorded but the release
16
+ gate is computed by the engine alone.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import subprocess
22
+ import threading
23
+ import time
24
+ from concurrent.futures import ThreadPoolExecutor
25
+ from pathlib import Path
26
+
27
+ from vibe_coding_qa.engine import run_pipeline
28
+ from vibe_coding_qa.gates import coverage_gaps
29
+ from vibe_coding_qa.scanners.dependencies import reset_osv_enabled, set_osv_enabled
30
+
31
+ from .. import __version__
32
+ from ..config import Settings
33
+ from ..persistence.store import Store
34
+ from ..profiles import get_profile
35
+ from ..security import safe_error
36
+ from .paths import cached_clone_path
37
+ from .repositories import RepositoryService
38
+
39
+ _TERMINAL = ("completed", "failed", "cancelled")
40
+
41
+
42
+ def _git_head(path: Path) -> str | None:
43
+ try:
44
+ result = subprocess.run(
45
+ ["git", "-C", str(path), "rev-parse", "HEAD"],
46
+ capture_output=True,
47
+ text=True,
48
+ timeout=10,
49
+ check=False,
50
+ )
51
+ except (OSError, subprocess.SubprocessError):
52
+ return None
53
+ if result.returncode != 0:
54
+ return None
55
+ sha = result.stdout.strip()
56
+ return sha or None
57
+
58
+
59
+ class ScanRunner:
60
+ def __init__(self, store: Store, settings: Settings, repositories: RepositoryService):
61
+ self.store = store
62
+ self.settings = settings
63
+ self.repositories = repositories
64
+ self._pool = ThreadPoolExecutor(
65
+ max_workers=max(1, settings.max_concurrent_scans), thread_name_prefix="vcqa-scan"
66
+ )
67
+ self._lock = threading.Lock()
68
+ self._submitted: set[int] = set()
69
+
70
+ # -------------------------------------------------------------- lifecycle
71
+ @property
72
+ def max_queue(self) -> int:
73
+ return max(1, self.settings.max_concurrent_scans) * 4
74
+
75
+ def active_scan_ids(self) -> set[int]:
76
+ with self._lock:
77
+ return set(self._submitted)
78
+
79
+ def pending_count(self) -> int:
80
+ _, total = self.store.list_scans(status="queued", limit=1)
81
+ _, running = self.store.list_scans(status="running", limit=1)
82
+ return total + running
83
+
84
+ def scan_settings(self) -> dict:
85
+ """Effective scan defaults (persisted operator settings, else env)."""
86
+ from .settings import scan_settings
87
+
88
+ return scan_settings(self.store, self.settings)
89
+
90
+ def allow_test_execution(self) -> bool:
91
+ """Tests never run unless the operator enabled the capability.
92
+
93
+ Either the environment flag or the persisted setting enables it; both are
94
+ deliberate operator actions.
95
+ """
96
+ return bool(self.scan_settings()["allow_test_execution"])
97
+
98
+ def shutdown(self) -> None:
99
+ self._pool.shutdown(wait=False, cancel_futures=True)
100
+
101
+ def recover_interrupted(self) -> list[int]:
102
+ """Mark scans a previous process left queued/running as failed."""
103
+ return self.store.mark_interrupted_scans()
104
+
105
+ # ----------------------------------------------------------------- create
106
+ def create(self, *, repository: dict, profile_key: str, options: dict) -> dict:
107
+ profile = get_profile(profile_key)
108
+ if self.pending_count() >= self.max_queue:
109
+ raise RuntimeError(
110
+ "The scan queue is full. Wait for running scans to finish before starting more."
111
+ )
112
+ stored_options = dict(options)
113
+ stored_options["profile"] = profile.key
114
+ scan = self.store.create_scan(
115
+ repository_id=repository["id"], profile=profile.key, options=stored_options
116
+ )
117
+ self.store.add_event(scan["id"], "queued", status="queued", message="Scan queued")
118
+ self._submit(scan["id"])
119
+ return scan
120
+
121
+ def cancel(self, scan_id: int) -> dict | None:
122
+ scan = self.store.get_scan(scan_id)
123
+ if scan is None:
124
+ return None
125
+ if scan["status"] == "queued":
126
+ updated = self.store.update_scan(
127
+ scan_id,
128
+ status="cancelled",
129
+ completed_at=scan["created_at"],
130
+ error="Cancelled before it started.",
131
+ )
132
+ self.store.add_event(scan_id, "cancelled", status="cancelled", message="Scan cancelled")
133
+ return updated
134
+ return scan
135
+
136
+ def _submit(self, scan_id: int) -> None:
137
+ with self._lock:
138
+ if scan_id in self._submitted:
139
+ return
140
+ self._submitted.add(scan_id)
141
+ try:
142
+ self._pool.submit(self._run, scan_id)
143
+ except RuntimeError:
144
+ with self._lock:
145
+ self._submitted.discard(scan_id)
146
+ self._fail(scan_id, "The scan runner is shutting down.")
147
+
148
+ # ---------------------------------------------------------------- execute
149
+ def _run(self, scan_id: int) -> None:
150
+ started = time.time()
151
+ try:
152
+ scan = self.store.get_scan(scan_id)
153
+ if scan is None or scan["status"] not in ("queued", "running"):
154
+ return
155
+ repository = self.store.get_repository(scan["repository_id"])
156
+ if repository is None:
157
+ self._fail(scan_id, "The repository no longer exists.")
158
+ return
159
+
160
+ profile = get_profile(scan.get("profile"))
161
+ options = scan.get("options") or {}
162
+ defaults = self.scan_settings()
163
+
164
+ self.store.update_scan(scan_id, status="running", started_at=_now())
165
+ self.store.add_event(scan_id, "started", status="running", message="Scan started")
166
+
167
+ source_path = self._prepare_source(repository, scan_id)
168
+ if source_path is None:
169
+ return
170
+
171
+ def progress(stage: str, status: str, message: str = "", data: dict | None = None) -> None:
172
+ self.store.add_event(scan_id, stage, status=status, message=message, data=data or {})
173
+
174
+ static_only = bool(options.get("static_only"))
175
+ run_tests_flag = bool(profile.run_tests) and not static_only
176
+ explicit_permission = bool(options.get("explicit_permission")) and self.allow_test_execution()
177
+
178
+ # ``None`` means "use the profile / operator default"; an explicit
179
+ # value always wins.
180
+ osv_option = options.get("osv")
181
+ osv_enabled = bool(profile.osv) and (
182
+ bool(defaults["osv_enabled"]) if osv_option is None else bool(osv_option)
183
+ )
184
+ ai_option = options.get("ai_review")
185
+ ai_requested = bool(profile.ai_review) if ai_option is None else bool(ai_option)
186
+
187
+ token = set_osv_enabled(osv_enabled)
188
+ if osv_enabled:
189
+ self.store.add_event(scan_id, "dependencies", status="running", message="OSV advisory lookups enabled")
190
+ else:
191
+ self.store.add_event(scan_id, "dependencies", status="skipped", message="OSV advisory lookups disabled")
192
+ try:
193
+ result = run_pipeline(
194
+ str(source_path),
195
+ run_tests_flag=run_tests_flag,
196
+ no_execution=static_only or not run_tests_flag,
197
+ explicit_permission=explicit_permission,
198
+ apply_fixes=False,
199
+ dry_run_fixes=False,
200
+ github_token=self.settings.github_token or None,
201
+ test_command=options.get("test_command") or None,
202
+ test_timeout=int(options.get("test_timeout") or defaults["test_timeout"]),
203
+ max_memory_mb=int(options.get("max_memory_mb") or defaults["max_memory_mb"]),
204
+ max_cpu_seconds=int(options.get("max_cpu_seconds") or defaults["max_cpu_seconds"]),
205
+ test_image=options.get("test_image") or defaults["test_image"],
206
+ max_snapshot_mb=int(options.get("max_snapshot_mb") or defaults["max_snapshot_mb"]),
207
+ max_write_mb=int(options.get("max_write_mb") or defaults["max_write_mb"]),
208
+ keep_clone=True,
209
+ progress=progress,
210
+ modules=list(profile.modules),
211
+ minimum_score=profile.minimum_score,
212
+ ai_review_enabled=ai_requested,
213
+ )
214
+ finally:
215
+ reset_osv_enabled(token)
216
+
217
+ if not run_tests_flag:
218
+ reason = "static-only profile" if not profile.run_tests else "static-only requested"
219
+ self.store.add_event(scan_id, "tests", status="skipped", message=f"Test execution skipped ({reason})")
220
+ elif not explicit_permission:
221
+ self.store.add_event(
222
+ scan_id, "tests", status="skipped",
223
+ message="Test execution skipped: explicit permission was not granted.",
224
+ )
225
+
226
+ self._persist_result(scan_id, repository, result, profile, ai_requested)
227
+
228
+ duration_ms = int((time.time() - started) * 1000)
229
+ self.store.update_scan(scan_id, duration_ms=duration_ms)
230
+ self.store.add_event(
231
+ scan_id, "completed", status="completed",
232
+ message=f"Scan complete: {result.gate}",
233
+ data={"gate": result.gate, "score": result.overall_score},
234
+ )
235
+ except Exception as exc: # noqa: BLE001 - never let a worker die silently
236
+ self._fail(scan_id, safe_error(exc, "The scan failed while running."))
237
+ finally:
238
+ with self._lock:
239
+ self._submitted.discard(scan_id)
240
+
241
+ # -------------------------------------------------------------- internals
242
+ def _prepare_source(self, repository: dict, scan_id: int) -> Path | None:
243
+ """Return a local directory to analyse, cloning remote repos on first use."""
244
+ if repository["source_type"] == "local":
245
+ path = Path(repository["source"])
246
+ if not path.is_dir():
247
+ self._fail(scan_id, "The local repository path is no longer available.")
248
+ return None
249
+ return path
250
+
251
+ cache_dir = cached_clone_path(repository["source"])
252
+ if cache_dir.is_dir() and (cache_dir / ".git").exists():
253
+ self.store.add_event(scan_id, "preparation", status="running", message="Reusing the cached clone")
254
+ return cache_dir
255
+ self.store.add_event(scan_id, "preparation", status="running", message="Cloning the remote repository")
256
+ try:
257
+ from vibe_coding_qa.repo import clone_repo
258
+
259
+ clone_repo(repository["source"], token=self.settings.github_token or None, workdir=cache_dir)
260
+ except Exception as exc: # noqa: BLE001
261
+ self._fail(scan_id, safe_error(exc, "The remote repository could not be cloned."))
262
+ return None
263
+ return cache_dir
264
+
265
+ def _persist_result(self, scan_id: int, repository: dict, result, profile, ai_requested: bool) -> None:
266
+ tests = result.tests or {}
267
+ findings = [f.to_dict() for f in result.findings]
268
+ inserted = self.store.insert_findings(scan_id, repository["id"], findings)
269
+
270
+ test_status = tests.get("status")
271
+ test_status_value = getattr(test_status, "value", test_status)
272
+
273
+ architecture = tests.get("architecture") or {}
274
+ self.repositories.metadata_from_scan(repository["id"], architecture, True)
275
+ # Persist the full profile so the Architecture/Dependencies pages render
276
+ # instantly instead of re-walking the whole working tree on every request.
277
+ if architecture:
278
+ try:
279
+ self.store.set_setting(f"profile:{repository['id']}", architecture)
280
+ except Exception: # noqa: BLE001
281
+ pass
282
+
283
+ # The release gate is decided by the engine alone. AI review is recorded
284
+ # as advisory status and never influences the gate.
285
+ if tests.get("ai_reviews"):
286
+ ai_status = "completed"
287
+ elif ai_requested:
288
+ ai_status = "unavailable"
289
+ else:
290
+ ai_status = "not_requested"
291
+
292
+ commit = _git_head(result.repo)
293
+ self.store.update_scan(
294
+ scan_id,
295
+ status="completed",
296
+ completed_at=_now(),
297
+ commit_sha=commit,
298
+ overall_score=result.overall_score,
299
+ gate=result.gate,
300
+ critical_count=result.critical_count,
301
+ high_count=result.high_count,
302
+ total_findings=inserted,
303
+ test_status=test_status_value,
304
+ tests_executed=tests.get("tests_executed"),
305
+ tests_passed=tests.get("tests_passed"),
306
+ tests_failed=tests.get("tests_failed"),
307
+ tests_skipped=tests.get("tests_skipped"),
308
+ scanner_versions={
309
+ "dashboard_version": __version__,
310
+ "profile": profile.key,
311
+ "modules": [module.module for module in result.modules],
312
+ },
313
+ coverage_gaps=coverage_gaps(result),
314
+ ai_review_status=ai_status,
315
+ )
316
+ self.store.update_repository(
317
+ repository["id"], last_scan_id=scan_id, last_commit_sha=commit
318
+ )
319
+
320
+ def _fail(self, scan_id: int, message: str) -> None:
321
+ self.store.update_scan(scan_id, status="failed", completed_at=_now(), error=message)
322
+ self.store.add_event(scan_id, "failed", status="failed", message=message)
323
+
324
+
325
+ def _now() -> str:
326
+ from ..persistence.database import utcnow
327
+
328
+ return utcnow()
@@ -0,0 +1,49 @@
1
+ """Operator scan defaults.
2
+
3
+ These values are persisted in SQLite so the New Scan screen and the scan runner
4
+ share a single source of truth. Environment variables remain the fallback, so a
5
+ fresh install behaves exactly like the CLI until the operator changes something.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ..config import Settings
11
+ from ..persistence.store import Store
12
+ from ..profiles import PROFILES
13
+ from ..security import ValidationError
14
+
15
+ FIELDS = (
16
+ "default_scan_profile",
17
+ "allow_test_execution",
18
+ "test_image",
19
+ "test_timeout",
20
+ "max_memory_mb",
21
+ "max_cpu_seconds",
22
+ "max_snapshot_mb",
23
+ "max_write_mb",
24
+ "osv_enabled",
25
+ )
26
+
27
+
28
+ def scan_settings(store: Store, settings: Settings) -> dict:
29
+ """The effective scan defaults: persisted values, else environment values."""
30
+ stored = store.all_settings().get("scan") or {}
31
+ return {
32
+ "default_scan_profile": str(stored.get("default_scan_profile") or settings.default_scan_profile),
33
+ "allow_test_execution": bool(stored.get("allow_test_execution", settings.allow_test_execution)),
34
+ "test_image": str(stored.get("test_image") or settings.default_test_image),
35
+ "test_timeout": int(stored.get("test_timeout") or settings.default_test_timeout),
36
+ "max_memory_mb": int(stored.get("max_memory_mb") or settings.default_max_memory_mb),
37
+ "max_cpu_seconds": int(stored.get("max_cpu_seconds") or settings.default_max_cpu_seconds),
38
+ "max_snapshot_mb": int(stored.get("max_snapshot_mb") or settings.default_max_snapshot_mb),
39
+ "max_write_mb": int(stored.get("max_write_mb") or settings.default_max_write_mb),
40
+ "osv_enabled": bool(stored.get("osv_enabled", settings.default_osv_enabled)),
41
+ }
42
+
43
+
44
+ def update_scan_settings(store: Store, payload) -> dict:
45
+ if payload.default_scan_profile not in PROFILES:
46
+ raise ValidationError(f"Unknown scan profile: {payload.default_scan_profile}")
47
+ values = {field: getattr(payload, field) for field in FIELDS}
48
+ store.set_setting("scan", values)
49
+ return values