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.
- dashboard/__init__.py +10 -0
- dashboard/backend/__init__.py +11 -0
- dashboard/backend/__main__.py +6 -0
- dashboard/backend/ai/__init__.py +6 -0
- dashboard/backend/ai/provider.py +102 -0
- dashboard/backend/ai/service.py +757 -0
- dashboard/backend/api/__init__.py +5 -0
- dashboard/backend/api/ai.py +142 -0
- dashboard/backend/api/dashboard.py +34 -0
- dashboard/backend/api/deps.py +51 -0
- dashboard/backend/api/findings.py +102 -0
- dashboard/backend/api/intel.py +41 -0
- dashboard/backend/api/repositories.py +62 -0
- dashboard/backend/api/scans.py +168 -0
- dashboard/backend/api/system.py +69 -0
- dashboard/backend/config.py +180 -0
- dashboard/backend/main.py +183 -0
- dashboard/backend/persistence/__init__.py +5 -0
- dashboard/backend/persistence/database.py +278 -0
- dashboard/backend/persistence/store.py +600 -0
- dashboard/backend/profiles.py +141 -0
- dashboard/backend/schemas.py +452 -0
- dashboard/backend/security.py +158 -0
- dashboard/backend/services/__init__.py +7 -0
- dashboard/backend/services/dashboard.py +172 -0
- dashboard/backend/services/intel.py +341 -0
- dashboard/backend/services/paths.py +38 -0
- dashboard/backend/services/repositories.py +123 -0
- dashboard/backend/services/scans.py +328 -0
- dashboard/backend/services/settings.py +49 -0
- dashboard/frontend/dist/assets/index-COC-CdqD.js +492 -0
- dashboard/frontend/dist/assets/index-hWDLRaLr.css +1 -0
- dashboard/frontend/dist/index.html +29 -0
- dashboard/frontend/dist/phone-mascots.gif +0 -0
- dashboard/launcher.py +48 -0
- vibe_coding_qa/__init__.py +140 -0
- vibe_coding_qa/__main__.py +5 -0
- vibe_coding_qa/ai_review.py +165 -0
- vibe_coding_qa/cli.py +157 -0
- vibe_coding_qa/engine.py +860 -0
- vibe_coding_qa/fixes.py +222 -0
- vibe_coding_qa/gates.py +300 -0
- vibe_coding_qa/profile.py +471 -0
- vibe_coding_qa/repo.py +188 -0
- vibe_coding_qa/reporters/__init__.py +253 -0
- vibe_coding_qa/scanners/__init__.py +195 -0
- vibe_coding_qa/scanners/compliance.py +167 -0
- vibe_coding_qa/scanners/database.py +316 -0
- vibe_coding_qa/scanners/dependencies.py +595 -0
- vibe_coding_qa/scanners/infrastructure.py +125 -0
- vibe_coding_qa/scanners/performance.py +84 -0
- vibe_coding_qa/scanners/quality.py +129 -0
- vibe_coding_qa/scanners/security.py +521 -0
- vibe_coding_qa-0.5.0.dist-info/METADATA +323 -0
- vibe_coding_qa-0.5.0.dist-info/RECORD +58 -0
- vibe_coding_qa-0.5.0.dist-info/WHEEL +5 -0
- vibe_coding_qa-0.5.0.dist-info/entry_points.txt +3 -0
- 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
|