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,180 @@
1
+ """Runtime configuration for the dashboard backend.
2
+
3
+ All settings are environment-driven so that credentials never live in the
4
+ repository or reach the browser. ``get_settings`` is cached; tests call
5
+ ``reset_settings`` after mutating the environment.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ from dataclasses import dataclass, field
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+
14
+
15
+ BACKEND_DIR = Path(__file__).resolve().parent
16
+ DASHBOARD_DIR = BACKEND_DIR.parent
17
+ PROJECT_ROOT = DASHBOARD_DIR.parent
18
+
19
+ # When the dashboard is installed as a package it lives in site-packages, so user
20
+ # data (the SQLite database) must live in a writable, per-user location on the
21
+ # user's own machine - never inside the installed package. Override with the
22
+ # ``VCQA_DASHBOARD_DB`` environment variable.
23
+ USER_DATA_DIR = Path.home() / ".vibe-coding-qa"
24
+ DEFAULT_DB_PATH = USER_DATA_DIR / "vcqa.sqlite3"
25
+
26
+ # The dashboard reuses the existing analysis engine in place; it is not a copy.
27
+ # Put the project root on ``sys.path`` so ``import vibe_coding_qa`` works no
28
+ # matter how the app is launched (``uvicorn``, ``python -m``, tests).
29
+ if str(PROJECT_ROOT) not in sys.path:
30
+ sys.path.insert(0, str(PROJECT_ROOT))
31
+
32
+
33
+ def _flag(name: str, default: bool = False) -> bool:
34
+ raw = os.environ.get(name)
35
+ if raw is None:
36
+ return default
37
+ return raw.strip().lower() in ("1", "true", "yes", "on")
38
+
39
+
40
+ def _int(name: str, default: int) -> int:
41
+ raw = os.environ.get(name)
42
+ if raw is None or not raw.strip():
43
+ return default
44
+ try:
45
+ return int(raw)
46
+ except ValueError:
47
+ return default
48
+
49
+
50
+ def _csv(name: str, default: list[str]) -> list[str]:
51
+ raw = os.environ.get(name)
52
+ if raw is None or not raw.strip():
53
+ return list(default)
54
+ return [part.strip() for part in raw.split(",") if part.strip()]
55
+
56
+
57
+ def _roots(name: str, default: list[Path]) -> list[Path]:
58
+ raw = os.environ.get(name)
59
+ if raw is None or not raw.strip():
60
+ return list(default)
61
+ roots: list[Path] = []
62
+ for part in raw.split(os.pathsep):
63
+ part = part.strip()
64
+ if part:
65
+ roots.append(Path(part).expanduser())
66
+ return roots or list(default)
67
+
68
+
69
+ @dataclass
70
+ class Settings:
71
+ """Immutable-ish runtime settings resolved from the environment."""
72
+
73
+ host: str = "127.0.0.1"
74
+ port: int = 8756
75
+ db_path: Path = field(default_factory=lambda: DEFAULT_DB_PATH)
76
+
77
+ # Only localhost origins are allowed by default. The frontend dev server and
78
+ # the packaged static build are both served from localhost.
79
+ cors_origins: list[str] = field(
80
+ default_factory=lambda: [
81
+ "http://localhost:5173",
82
+ "http://127.0.0.1:5173",
83
+ "http://localhost:8756",
84
+ "http://127.0.0.1:8756",
85
+ ]
86
+ )
87
+
88
+ # Registering a local repository is restricted to these roots to prevent
89
+ # arbitrary filesystem access. Paths are resolved and checked against them.
90
+ allowed_repo_roots: list[Path] = field(
91
+ default_factory=lambda: [Path.home().resolve(), PROJECT_ROOT.resolve()]
92
+ )
93
+
94
+ max_concurrent_scans: int = 2
95
+ finding_page_size: int = 50
96
+ max_page_size: int = 200
97
+
98
+ # AI is entirely optional; the scanner and gate work without it.
99
+ ai_provider: str = ""
100
+ ai_api_key: str = ""
101
+ ai_base_url: str = "https://openrouter.ai/api/v1"
102
+ ai_model: str = "openai/gpt-3.5-turbo"
103
+ ai_timeout: int = 30
104
+ ai_max_output_tokens: int = 3000
105
+ ai_enable_source_context: bool = False
106
+ ai_max_requests_per_minute: int = 20
107
+ ai_max_input_chars: int = 40_000
108
+
109
+ github_token: str = ""
110
+
111
+ # Defaults surfaced to the scan UI (mirrors the CLI defaults).
112
+ default_scan_profile: str = "standard"
113
+ allow_test_execution: bool = False
114
+ default_test_image: str = "python:3.12-slim"
115
+ default_test_timeout: int = 300
116
+ default_max_memory_mb: int = 512
117
+ default_max_cpu_seconds: int = 60
118
+ default_max_snapshot_mb: int = 256
119
+ default_max_write_mb: int = 64
120
+ default_osv_enabled: bool = True
121
+
122
+
123
+ def _build_settings() -> Settings:
124
+ return Settings(
125
+ host=os.environ.get("VCQA_DASHBOARD_HOST", "127.0.0.1"),
126
+ port=_int("VCQA_DASHBOARD_PORT", 8756),
127
+ db_path=Path(os.environ.get("VCQA_DASHBOARD_DB", str(DEFAULT_DB_PATH))).expanduser(),
128
+ cors_origins=_csv(
129
+ "VCQA_DASHBOARD_CORS",
130
+ [
131
+ "http://localhost:5173",
132
+ "http://127.0.0.1:5173",
133
+ "http://localhost:8756",
134
+ "http://127.0.0.1:8756",
135
+ ],
136
+ ),
137
+ allowed_repo_roots=_roots(
138
+ "VCQA_ALLOWED_REPO_ROOTS",
139
+ [Path.home().resolve(), PROJECT_ROOT.resolve()],
140
+ ),
141
+ max_concurrent_scans=max(1, _int("VCQA_MAX_CONCURRENT_SCANS", 2)),
142
+ finding_page_size=_int("VCQA_FINDING_PAGE_SIZE", 50),
143
+ max_page_size=_int("VCQA_MAX_PAGE_SIZE", 200),
144
+ ai_provider=os.environ.get("VCQA_AI_PROVIDER", ""),
145
+ ai_api_key=os.environ.get("VCQA_AI_API_KEY", ""),
146
+ ai_base_url=os.environ.get("VCQA_AI_BASE_URL", "https://openrouter.ai/api/v1"),
147
+ ai_model=os.environ.get("VCQA_AI_MODEL", "openai/gpt-3.5-turbo"),
148
+ ai_timeout=_int("VCQA_AI_TIMEOUT", 30),
149
+ ai_max_output_tokens=_int("VCQA_AI_MAX_OUTPUT_TOKENS", 3000),
150
+ ai_enable_source_context=_flag("VCQA_AI_ENABLE_SOURCE_CONTEXT", False),
151
+ ai_max_requests_per_minute=_int("VCQA_AI_MAX_REQUESTS_PER_MINUTE", 20),
152
+ ai_max_input_chars=_int("VCQA_AI_MAX_INPUT_CHARS", 40_000),
153
+ github_token=os.environ.get("VCQA_GITHUB_TOKEN", "") or os.environ.get("GITHUB_TOKEN", ""),
154
+ default_scan_profile=os.environ.get("VCQA_DEFAULT_SCAN_PROFILE", "standard"),
155
+ allow_test_execution=_flag("VCQA_ALLOW_TEST_EXECUTION", False),
156
+ default_test_image=os.environ.get("VCQA_TEST_CONTAINER_IMAGE", "python:3.12-slim"),
157
+ default_test_timeout=_int("VCQA_TEST_TIMEOUT", 300),
158
+ default_max_memory_mb=_int("VCQA_MAX_MEMORY_MB", 512),
159
+ default_max_cpu_seconds=_int("VCQA_MAX_CPU_SECONDS", 60),
160
+ default_max_snapshot_mb=_int("VCQA_MAX_SNAPSHOT_MB", 256),
161
+ default_max_write_mb=_int("VCQA_MAX_WRITE_MB", 64),
162
+ default_osv_enabled=_flag("VCQA_OSV_ENABLED", True),
163
+ )
164
+
165
+
166
+ @lru_cache(maxsize=1)
167
+ def get_settings() -> Settings:
168
+ return _build_settings()
169
+
170
+
171
+ def reset_settings() -> Settings:
172
+ """Clear the cache (used by tests and on SIGHUP-style reloads)."""
173
+ get_settings.cache_clear()
174
+ return get_settings()
175
+
176
+
177
+ def ai_configured(settings: Settings | None = None) -> bool:
178
+ settings = settings or get_settings()
179
+ provider = settings.ai_provider.strip().lower()
180
+ return bool(settings.ai_api_key) and provider not in ("", "none", "false", "0")
@@ -0,0 +1,183 @@
1
+ """FastAPI application factory for the Vibe Coding QA dashboard.
2
+
3
+ Run it with ``python -m dashboard.backend`` (or
4
+ ``uvicorn dashboard.backend.main:create_app --factory``).
5
+
6
+ Security posture:
7
+ * bound to localhost by default;
8
+ * CORS restricted to the configured local origins;
9
+ * a repository must live inside an allow-listed root to be registered;
10
+ * the AI provider key stays on the server and is never returned;
11
+ * the release gate is computed by the engine, never by the API or the AI layer.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import binascii
18
+ import hmac
19
+ import ipaddress
20
+ import os
21
+ from contextlib import asynccontextmanager
22
+ from pathlib import Path
23
+ from urllib.parse import urlsplit
24
+
25
+ from fastapi import FastAPI, Request
26
+ from fastapi.middleware.cors import CORSMiddleware
27
+ from fastapi.responses import JSONResponse
28
+ from fastapi.staticfiles import StaticFiles
29
+ from starlette.exceptions import HTTPException as StarletteHTTPException
30
+
31
+ from . import __version__
32
+ from .ai.provider import AIError
33
+ from .api import ai, dashboard, findings, intel, repositories, scans, system
34
+ from .api.deps import build_services
35
+ from .config import Settings, get_settings
36
+ from .security import ValidationError, safe_error
37
+
38
+
39
+ class BasicAuthMiddleware:
40
+ """Optional HTTP Basic Auth gate for a tunnelled/public deployment.
41
+
42
+ It is inert unless ``VCQA_BASIC_AUTH`` is set (``user:password``), so the
43
+ default localhost-only posture is unchanged. Implemented as pure ASGI
44
+ middleware rather than ``BaseHTTPMiddleware`` so streaming responses (the
45
+ SSE scan-progress feed) are never buffered.
46
+ """
47
+
48
+ def __init__(self, app, *, credential: str, realm: str = "Vibe Coding QA Dashboard"):
49
+ self.app = app
50
+ self.credential = credential.encode("utf-8")
51
+ self.realm = realm
52
+
53
+ async def __call__(self, scope, receive, send):
54
+ if scope["type"] != "http":
55
+ await self.app(scope, receive, send)
56
+ return
57
+ raw = dict(scope.get("headers") or []).get(b"authorization", b"")
58
+ if raw[:6].lower() == b"basic ":
59
+ token = raw[6:].strip()
60
+ try:
61
+ supplied = base64.b64decode(token + b"=" * (-len(token) % 4))
62
+ except (binascii.Error, ValueError):
63
+ supplied = b""
64
+ if hmac.compare_digest(supplied, self.credential):
65
+ await self.app(scope, receive, send)
66
+ return
67
+ body = b"Authentication required."
68
+ await send({
69
+ "type": "http.response.start",
70
+ "status": 401,
71
+ "headers": [
72
+ (b"content-type", b"text/plain; charset=utf-8"),
73
+ (b"www-authenticate", f'Basic realm="{self.realm}", charset="UTF-8"'.encode("ascii")),
74
+ (b"content-length", str(len(body)).encode("ascii")),
75
+ ],
76
+ })
77
+ await send({"type": "http.response.body", "body": body})
78
+
79
+
80
+ class _SpaStaticFiles(StaticFiles):
81
+ """Serve the built dashboard and fall back to index.html for client routes."""
82
+
83
+ async def get_response(self, path: str, scope):
84
+ try:
85
+ return await super().get_response(path, scope)
86
+ except StarletteHTTPException as exc:
87
+ if exc.status_code == 404:
88
+ return await super().get_response("index.html", scope)
89
+ raise
90
+
91
+
92
+ def create_app(settings: Settings | None = None) -> FastAPI:
93
+ resolved = settings or get_settings()
94
+ try:
95
+ host_is_loopback = resolved.host.lower() == "localhost" or ipaddress.ip_address(resolved.host).is_loopback
96
+ except ValueError:
97
+ host_is_loopback = False
98
+ if not host_is_loopback:
99
+ raise ValueError("The dashboard API must bind to a loopback host; remote access requires authentication.")
100
+ for origin in resolved.cors_origins:
101
+ parsed_origin = urlsplit(origin)
102
+ try:
103
+ origin_is_loopback = parsed_origin.hostname == "localhost" or ipaddress.ip_address(
104
+ parsed_origin.hostname or ""
105
+ ).is_loopback
106
+ except ValueError:
107
+ origin_is_loopback = False
108
+ if parsed_origin.scheme not in ("http", "https") or not origin_is_loopback:
109
+ raise ValueError("Dashboard CORS origins must be localhost origins.")
110
+ services = build_services(resolved)
111
+
112
+ @asynccontextmanager
113
+ async def lifespan(_: FastAPI):
114
+ # A scan that was running when the previous process died can never
115
+ # finish; mark it failed so the UI never shows a phantom "running" job.
116
+ services.scans.recover_interrupted()
117
+ try:
118
+ yield
119
+ finally:
120
+ services.scans.shutdown()
121
+ services.database.close()
122
+
123
+ app = FastAPI(
124
+ title="Vibe Coding QA Dashboard API",
125
+ version=__version__,
126
+ docs_url="/api/docs",
127
+ redoc_url=None,
128
+ openapi_url="/api/openapi.json",
129
+ lifespan=lifespan,
130
+ )
131
+ app.state.services = services
132
+
133
+ app.add_middleware(
134
+ CORSMiddleware,
135
+ allow_origins=resolved.cors_origins,
136
+ allow_credentials=False,
137
+ allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
138
+ allow_headers=["Content-Type", "Accept"],
139
+ )
140
+
141
+ # Added last on purpose: Starlette applies the most recently added middleware
142
+ # outermost, so the auth gate protects every route (and CORS preflight).
143
+ basic_auth = os.environ.get("VCQA_BASIC_AUTH", "").strip()
144
+ if basic_auth:
145
+ app.add_middleware(BasicAuthMiddleware, credential=basic_auth)
146
+
147
+ @app.exception_handler(ValidationError)
148
+ async def _validation_error(_: Request, exc: ValidationError) -> JSONResponse:
149
+ return JSONResponse(status_code=400, content={"detail": str(exc), "code": "validation_error"})
150
+
151
+ @app.exception_handler(AIError)
152
+ async def _ai_error(_: Request, exc: AIError) -> JSONResponse:
153
+ return JSONResponse(status_code=400, content={"detail": str(exc), "code": "ai_error"})
154
+
155
+ @app.exception_handler(KeyError)
156
+ async def _not_found(_: Request, exc: KeyError) -> JSONResponse:
157
+ return JSONResponse(
158
+ status_code=404,
159
+ content={"detail": safe_error(exc, "The requested item was not found."), "code": "not_found"},
160
+ )
161
+
162
+ for module in (system, dashboard, repositories, scans, findings, ai, intel):
163
+ app.include_router(module.router)
164
+
165
+ # Serve the built single-page dashboard when present, so one process (and one
166
+ # tunnelled port) exposes both the UI and the API. Mounted after the routers so
167
+ # /api still wins; unknown paths fall back to index.html for client routing.
168
+ dist = Path(__file__).resolve().parents[1] / "frontend" / "dist"
169
+ if dist.is_dir():
170
+ app.mount("/", _SpaStaticFiles(directory=dist, html=True), name="ui")
171
+
172
+ return app
173
+
174
+
175
+ def main() -> None:
176
+ import uvicorn
177
+
178
+ settings = get_settings()
179
+ uvicorn.run(create_app(settings), host=settings.host, port=settings.port, log_level="info")
180
+
181
+
182
+ if __name__ == "__main__":
183
+ main()
@@ -0,0 +1,5 @@
1
+ """Persistence package: SQLite database and data-access store."""
2
+
3
+ from .database import Database, utcnow
4
+
5
+ __all__ = ["Database", "utcnow"]
@@ -0,0 +1,278 @@
1
+ """SQLite persistence for the dashboard.
2
+
3
+ A tiny, dependency-free data layer: no ORM, just ``sqlite3`` with explicit
4
+ migrations. Scan history must survive restarts and interrupted scans must be
5
+ identifiable, so everything the engine produces is persisted here.
6
+ """
7
+
8
+ import sqlite3
9
+ import threading
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+
13
+
14
+ def utcnow() -> str:
15
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
16
+
17
+
18
+ # Each entry is (version, [statements]). Migrations are append-only; never edit a
19
+ # shipped migration, add a new one.
20
+ MIGRATIONS: list[tuple[int, list[str]]] = [
21
+ (
22
+ 1,
23
+ [
24
+ """
25
+ CREATE TABLE repositories (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ name TEXT NOT NULL,
28
+ source TEXT NOT NULL UNIQUE,
29
+ source_type TEXT NOT NULL DEFAULT 'local',
30
+ created_at TEXT NOT NULL,
31
+ updated_at TEXT NOT NULL,
32
+ main_language TEXT,
33
+ frameworks TEXT,
34
+ package_manager TEXT,
35
+ database_tech TEXT,
36
+ auth_tech TEXT,
37
+ deployment TEXT,
38
+ default_branch TEXT,
39
+ last_commit_sha TEXT,
40
+ last_scan_id INTEGER
41
+ )
42
+ """,
43
+ """
44
+ CREATE TABLE scans (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
47
+ status TEXT NOT NULL,
48
+ profile TEXT NOT NULL,
49
+ created_at TEXT NOT NULL,
50
+ started_at TEXT,
51
+ completed_at TEXT,
52
+ commit_sha TEXT,
53
+ overall_score REAL,
54
+ gate TEXT,
55
+ critical_count INTEGER DEFAULT 0,
56
+ high_count INTEGER DEFAULT 0,
57
+ total_findings INTEGER DEFAULT 0,
58
+ test_status TEXT,
59
+ tests_executed INTEGER,
60
+ tests_passed INTEGER,
61
+ tests_failed INTEGER,
62
+ tests_skipped INTEGER,
63
+ duration_ms INTEGER,
64
+ scanner_versions TEXT,
65
+ coverage_gaps TEXT,
66
+ ai_review_status TEXT,
67
+ error TEXT,
68
+ options TEXT,
69
+ trigger TEXT
70
+ )
71
+ """,
72
+ "CREATE INDEX idx_scans_repo ON scans(repository_id, id DESC)",
73
+ """
74
+ CREATE TABLE findings (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ scan_id INTEGER NOT NULL REFERENCES scans(id) ON DELETE CASCADE,
77
+ repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
78
+ fingerprint TEXT NOT NULL,
79
+ rule TEXT NOT NULL,
80
+ module TEXT NOT NULL,
81
+ severity TEXT NOT NULL,
82
+ file TEXT NOT NULL,
83
+ line INTEGER NOT NULL,
84
+ column INTEGER DEFAULT 0,
85
+ message TEXT,
86
+ suggestion TEXT,
87
+ confidence TEXT,
88
+ fixable INTEGER DEFAULT 0,
89
+ status TEXT NOT NULL DEFAULT 'open',
90
+ first_seen_scan_id INTEGER,
91
+ created_at TEXT NOT NULL
92
+ )
93
+ """,
94
+ "CREATE INDEX idx_findings_scan ON findings(scan_id)",
95
+ "CREATE INDEX idx_findings_repo ON findings(repository_id, status)",
96
+ "CREATE INDEX idx_findings_fingerprint ON findings(fingerprint)",
97
+ """
98
+ CREATE TABLE scan_events (
99
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
100
+ scan_id INTEGER NOT NULL REFERENCES scans(id) ON DELETE CASCADE,
101
+ ts TEXT NOT NULL,
102
+ stage TEXT NOT NULL,
103
+ status TEXT,
104
+ message TEXT,
105
+ data TEXT
106
+ )
107
+ """,
108
+ "CREATE INDEX idx_events_scan ON scan_events(scan_id, id)",
109
+ """
110
+ CREATE TABLE ai_reviews (
111
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
112
+ finding_id INTEGER REFERENCES findings(id) ON DELETE CASCADE,
113
+ scan_id INTEGER,
114
+ provider TEXT,
115
+ model TEXT,
116
+ kind TEXT,
117
+ created_at TEXT NOT NULL,
118
+ content TEXT,
119
+ error TEXT
120
+ )
121
+ """,
122
+ """
123
+ CREATE TABLE ai_conversations (
124
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
125
+ repository_id INTEGER REFERENCES repositories(id) ON DELETE SET NULL,
126
+ title TEXT,
127
+ created_at TEXT NOT NULL,
128
+ updated_at TEXT NOT NULL
129
+ )
130
+ """,
131
+ """
132
+ CREATE TABLE ai_messages (
133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ conversation_id INTEGER NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
135
+ role TEXT NOT NULL,
136
+ content TEXT NOT NULL,
137
+ created_at TEXT NOT NULL,
138
+ metadata TEXT
139
+ )
140
+ """,
141
+ "CREATE INDEX idx_messages_conversation ON ai_messages(conversation_id, id)",
142
+ """
143
+ CREATE TABLE remediation_proposals (
144
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
145
+ finding_id INTEGER REFERENCES findings(id) ON DELETE CASCADE,
146
+ repository_id INTEGER REFERENCES repositories(id) ON DELETE CASCADE,
147
+ status TEXT NOT NULL DEFAULT 'proposed',
148
+ created_at TEXT NOT NULL,
149
+ updated_at TEXT NOT NULL,
150
+ provider TEXT,
151
+ model TEXT,
152
+ file TEXT,
153
+ original TEXT,
154
+ proposed TEXT,
155
+ diff TEXT,
156
+ rationale TEXT,
157
+ error TEXT
158
+ )
159
+ """,
160
+ """
161
+ CREATE TABLE remediation_history (
162
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
163
+ proposal_id INTEGER NOT NULL REFERENCES remediation_proposals(id) ON DELETE CASCADE,
164
+ action TEXT NOT NULL,
165
+ ts TEXT NOT NULL,
166
+ actor TEXT,
167
+ detail TEXT
168
+ )
169
+ """,
170
+ """
171
+ CREATE TABLE app_settings (
172
+ key TEXT PRIMARY KEY,
173
+ value TEXT NOT NULL,
174
+ updated_at TEXT NOT NULL
175
+ )
176
+ """,
177
+ ],
178
+ ),
179
+ ]
180
+
181
+
182
+ class Database:
183
+ """Thread-safe SQLite wrapper with WAL and per-thread connections."""
184
+
185
+ def __init__(self, path: Path | str):
186
+ self.path = Path(path)
187
+ if str(self.path) != ":memory:":
188
+ self.path.parent.mkdir(parents=True, exist_ok=True)
189
+ self._local = threading.local()
190
+ self._write_lock = threading.Lock()
191
+ self._conn_lock = threading.Lock()
192
+ # Every connection is tracked so shutdown can release the file handles
193
+ # (worker threads open their own connection and Windows keeps the file
194
+ # locked until all of them are closed).
195
+ self._open_connections: list[sqlite3.Connection] = []
196
+ self._memory_conn: sqlite3.Connection | None = None
197
+ if str(self.path) == ":memory:":
198
+ # A shared in-memory database must keep one connection alive, otherwise
199
+ # each connect() would see an empty schema.
200
+ self._memory_conn = self._connect_raw()
201
+ self.migrate()
202
+
203
+ def _connect_raw(self) -> sqlite3.Connection:
204
+ conn = sqlite3.connect(str(self.path), check_same_thread=False)
205
+ conn.row_factory = sqlite3.Row
206
+ conn.execute("PRAGMA foreign_keys = ON")
207
+ conn.execute("PRAGMA journal_mode = WAL")
208
+ conn.execute("PRAGMA synchronous = NORMAL")
209
+ with self._conn_lock:
210
+ self._open_connections.append(conn)
211
+ return conn
212
+
213
+ def connection(self) -> sqlite3.Connection:
214
+ if self._memory_conn is not None:
215
+ return self._memory_conn
216
+ conn = getattr(self._local, "conn", None)
217
+ if conn is None:
218
+ conn = self._connect_raw()
219
+ self._local.conn = conn
220
+ return conn
221
+
222
+ def close(self) -> None:
223
+ """Close every connection opened by this database."""
224
+ with self._conn_lock:
225
+ connections = list(self._open_connections)
226
+ self._open_connections.clear()
227
+ for conn in connections:
228
+ try:
229
+ conn.close()
230
+ except sqlite3.Error:
231
+ pass
232
+ self._local = threading.local()
233
+ self._memory_conn = None
234
+
235
+ def applied_versions(self) -> set[int]:
236
+ conn = self.connection()
237
+ conn.execute(
238
+ "CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)"
239
+ )
240
+ rows = conn.execute("SELECT version FROM schema_migrations").fetchall()
241
+ return {row["version"] for row in rows}
242
+
243
+ def migrate(self) -> list[int]:
244
+ applied = self.applied_versions()
245
+ newly: list[int] = []
246
+ conn = self.connection()
247
+ with self._write_lock:
248
+ for version, statements in MIGRATIONS:
249
+ if version in applied:
250
+ continue
251
+ for statement in statements:
252
+ conn.execute(statement)
253
+ conn.execute(
254
+ "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
255
+ (version, utcnow()),
256
+ )
257
+ conn.commit()
258
+ newly.append(version)
259
+ return newly
260
+
261
+ def execute(self, sql: str, params: tuple = ()) -> sqlite3.Cursor:
262
+ conn = self.connection()
263
+ with self._write_lock:
264
+ cursor = conn.execute(sql, params)
265
+ conn.commit()
266
+ return cursor
267
+
268
+ def executemany(self, sql: str, seq_params) -> None:
269
+ conn = self.connection()
270
+ with self._write_lock:
271
+ conn.executemany(sql, seq_params)
272
+ conn.commit()
273
+
274
+ def query(self, sql: str, params: tuple = ()) -> list[sqlite3.Row]:
275
+ return self.connection().execute(sql, params).fetchall()
276
+
277
+ def query_one(self, sql: str, params: tuple = ()) -> sqlite3.Row | None:
278
+ return self.connection().execute(sql, params).fetchone()