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,471 @@
1
+ """Application architecture and technology profile."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .scanners import is_ignored, load_ignore_patterns
11
+
12
+
13
+ # Directories that only ever contain generated output, dependency caches, VCS
14
+ # metadata, or vendored code. They are pruned by the shared repository walker so
15
+ # architecture detection and test discovery never analyze them.
16
+ EXCLUDED_DIRS = frozenset({
17
+ # Version control
18
+ ".git", ".hg", ".svn",
19
+ # Dependency / vendored trees (large; never first-party source)
20
+ "node_modules", "bower_components", "vendor", "site-packages",
21
+ # Python environments and caches
22
+ ".venv", "venv", "virtualenv", ".tox", ".eggs", "__pycache__",
23
+ ".mypy_cache", ".pytest_cache", ".ruff_cache", ".cache",
24
+ # JS/TS build output and framework caches
25
+ ".next", ".nuxt", ".svelte-kit", ".output", ".parcel-cache",
26
+ "dist", "build", "out", "coverage", ".nyc_output",
27
+ # Infrastructure / deployment caches
28
+ ".terraform", ".serverless",
29
+ })
30
+
31
+
32
+ @dataclass
33
+ class AppProfile:
34
+ path: Path
35
+ languages: dict[str, int] = field(default_factory=dict)
36
+ frameworks: list[str] = field(default_factory=list)
37
+ package_manager: str = "unknown"
38
+ has_tests: bool = False
39
+ test_runners: list[str] = field(default_factory=list)
40
+ endpoints: list[dict[str, Any]] = field(default_factory=list)
41
+ auth_providers: list[str] = field(default_factory=list)
42
+ databases: list[str] = field(default_factory=list)
43
+ env_files: list[str] = field(default_factory=list)
44
+ dependencies: dict[str, str] = field(default_factory=dict)
45
+ lockfiles: list[str] = field(default_factory=list)
46
+ ci_files: list[str] = field(default_factory=list)
47
+ deployment: list[str] = field(default_factory=list)
48
+ logging_hints: list[str] = field(default_factory=list)
49
+ has_gitignore: bool = False
50
+ notes: list[str] = field(default_factory=list)
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ return {
54
+ "path": str(self.path),
55
+ "languages": self.languages,
56
+ "frameworks": self.frameworks,
57
+ "package_manager": self.package_manager,
58
+ "has_tests": self.has_tests,
59
+ "test_runners": self.test_runners,
60
+ "endpoints": self.endpoints,
61
+ "auth_providers": self.auth_providers,
62
+ "databases": self.databases,
63
+ "env_files": self.env_files,
64
+ "dependencies": self.dependencies,
65
+ "lockfiles": self.lockfiles,
66
+ "ci_files": self.ci_files,
67
+ "deployment": self.deployment,
68
+ "logging_hints": self.logging_hints,
69
+ "has_gitignore": self.has_gitignore,
70
+ "notes": self.notes,
71
+ }
72
+
73
+
74
+ def iter_repo_files(
75
+ repo: Path,
76
+ extensions: tuple[str, ...] | None = None,
77
+ *,
78
+ pruned: set[str] | None = None,
79
+ ) -> list[Path]:
80
+ """Walk first-party repository files, skipping generated and vendored trees.
81
+
82
+ This is the single source of truth for which files count as repository
83
+ content across language detection, framework detection, and test discovery.
84
+ It:
85
+
86
+ * prunes :data:`EXCLUDED_DIRS` (``node_modules``, ``.venv``, ``dist``,
87
+ ``coverage``, framework caches, ...) so generated code is never counted
88
+ and huge dependency trees are never scanned;
89
+ * honours ``.vcqaignore``;
90
+ * never follows symlinks (neither directories nor files), so it cannot
91
+ loop or read data outside the repository;
92
+ * accepts only regular files strictly inside ``repo``.
93
+
94
+ ``extensions`` filters by suffix (case-insensitive) when provided. When
95
+ ``pruned`` is passed, the relative names of every skipped directory are
96
+ recorded there so callers can report that completeness was affected.
97
+ """
98
+ repo = Path(repo)
99
+ ignore = load_ignore_patterns(repo)
100
+ results: list[Path] = []
101
+ wanted = tuple(ext.lower() for ext in extensions) if extensions else None
102
+
103
+ for current_root, dirs, files in os.walk(repo, followlinks=False):
104
+ current = Path(current_root)
105
+ kept_dirs: list[str] = []
106
+ for name in dirs:
107
+ child = current / name
108
+ if name in EXCLUDED_DIRS:
109
+ if pruned is not None:
110
+ pruned.add(str(child.relative_to(repo).as_posix()))
111
+ continue
112
+ if child.is_symlink():
113
+ if pruned is not None:
114
+ pruned.add(str(child.relative_to(repo).as_posix()))
115
+ continue
116
+ if is_ignored(child, repo, ignore):
117
+ if pruned is not None:
118
+ pruned.add(str(child.relative_to(repo).as_posix()))
119
+ continue
120
+ kept_dirs.append(name)
121
+ dirs[:] = kept_dirs
122
+
123
+ for name in files:
124
+ path = current / name
125
+ if path.is_symlink():
126
+ continue
127
+ if wanted is not None and path.suffix.lower() not in wanted:
128
+ continue
129
+ results.append(path)
130
+
131
+ return results
132
+
133
+
134
+ def _iter_source_files(repo: Path, extensions: tuple[str, ...]) -> list[Path]:
135
+ return iter_repo_files(repo, extensions)
136
+
137
+
138
+ def _pruned_directories(repo: Path) -> list[str]:
139
+ """Top-level excluded directories that actually exist (for completeness notes)."""
140
+ found: list[str] = []
141
+ for name in EXCLUDED_DIRS:
142
+ if (repo / name).is_dir():
143
+ found.append(name)
144
+ return sorted(found)
145
+
146
+
147
+
148
+ def _detect_languages(repo: Path) -> dict[str, int]:
149
+ extension_language = {
150
+ ".ts": "typescript", ".tsx": "typescript",
151
+ ".js": "javascript", ".jsx": "javascript",
152
+ ".py": "python", ".sql": "sql", ".html": "html",
153
+ ".css": "css", ".md": "markdown", ".json": "json",
154
+ ".yml": "yaml", ".yaml": "yaml", ".prisma": "prisma",
155
+ ".vue": "vue", ".svelte": "svelte",
156
+ }
157
+ counts: dict[str, int] = {}
158
+ # Deployment manifests and lockfiles are first-party content and are counted;
159
+ # generated output and dependency trees are pruned by the walker.
160
+ for path in iter_repo_files(repo, tuple(extension_language)):
161
+ language = extension_language.get(path.suffix.lower())
162
+ if language:
163
+ counts[language] = counts.get(language, 0) + 1
164
+ return counts
165
+
166
+
167
+ def _detect_frameworks(repo: Path) -> list[str]:
168
+ hints: set[str] = set()
169
+ files = {p.name: p for p in iter_repo_files(repo)}
170
+
171
+ if any(name in files for name in ("next.config.js", "next.config.ts", "next.config.mjs")):
172
+ hints.add("nextjs")
173
+ if any(name in files for name in ("vite.config.ts", "vite.config.js", "vite.config.mjs")):
174
+ hints.add("vite")
175
+ if any(name in files for name in ("remix.config.js",)):
176
+ hints.add("remix")
177
+ if "nuxt.config.ts" in files or "nuxt.config.js" in files:
178
+ hints.add("nuxt")
179
+ if "angular.json" in files:
180
+ hints.add("angular")
181
+ if any((repo / name).is_dir() for name in ("supabase",)):
182
+ hints.add("supabase")
183
+ if (repo / "prisma").is_dir() or any(path.suffix.lower() == ".prisma" for path in files.values()):
184
+ hints.add("prisma")
185
+ if any(path.name == "firebase.rules" for path in files.values()):
186
+ hints.add("firebase")
187
+ pkg = repo / "package.json"
188
+ if pkg.is_file():
189
+ try:
190
+ data = json.loads(pkg.read_text(encoding="utf-8", errors="ignore"))
191
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
192
+ for name in deps:
193
+ if name.startswith("react"):
194
+ hints.add("react")
195
+ if name.startswith("vue"):
196
+ hints.add("vue")
197
+ if name.startswith("svelte"):
198
+ hints.add("svelte")
199
+ if name.startswith("express"):
200
+ hints.add("express")
201
+ if name.startswith("fastify"):
202
+ hints.add("fastify")
203
+ if name.startswith("@fastapi") or name == "fastapi":
204
+ hints.add("fastapi")
205
+ if name == "django":
206
+ hints.add("django")
207
+ if name.startswith("flask"):
208
+ hints.add("flask")
209
+ if name in ("@clerk/clerk-react", "@clerk/nextjs"):
210
+ hints.add("clerk")
211
+ if name in ("@auth0/auth0-react",):
212
+ hints.add("auth0")
213
+ if name == "next-auth":
214
+ hints.add("nextauth")
215
+ except Exception:
216
+ pass
217
+ return sorted(hints)
218
+
219
+
220
+ def _detect_package_manager(repo: Path) -> str:
221
+ if (repo / "pnpm-lock.yaml").is_file():
222
+ return "pnpm"
223
+ if (repo / "yarn.lock").is_file():
224
+ return "yarn"
225
+ if (repo / "package-lock.json").is_file():
226
+ return "npm"
227
+ if (repo / "bun.lockb").is_file():
228
+ return "bun"
229
+ if (repo / "poetry.lock").is_file():
230
+ return "poetry"
231
+ if (repo / "Pipfile.lock").is_file():
232
+ return "pipenv"
233
+ if (repo / "requirements.txt").is_file():
234
+ return "pip"
235
+ if (repo / "pyproject.toml").is_file():
236
+ return "pip"
237
+ return "unknown"
238
+
239
+
240
+ def _detect_lockfiles(repo: Path) -> list[str]:
241
+ names = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb",
242
+ "poetry.lock", "Pipfile.lock", "requirements.txt"]
243
+ return sorted([n for n in names if (repo / n).is_file()])
244
+
245
+
246
+ def _detect_ci(repo: Path) -> list[str]:
247
+ ci: list[str] = []
248
+ workflows = repo / ".github" / "workflows"
249
+ if workflows.is_dir():
250
+ ci.extend(str(p.relative_to(repo)) for p in workflows.glob("*.yml"))
251
+ ci.extend(str(p.relative_to(repo)) for p in workflows.glob("*.yaml"))
252
+ for name in (".gitlab-ci.yml", "azure-pipelines.yml", "bitbucket-pipelines.yml", "Jenkinsfile"):
253
+ if (repo / name).is_file():
254
+ ci.append(name)
255
+ return sorted(ci)
256
+
257
+
258
+ def _detect_deployment(repo: Path) -> list[str]:
259
+ deployment: list[str] = []
260
+ names = [p.name for p in iter_repo_files(repo)]
261
+ if any(name.startswith("Dockerfile") for name in names):
262
+ deployment.append("docker")
263
+ if any(name.startswith("docker-compose") and name.endswith((".yml", ".yaml")) for name in names):
264
+ deployment.append("docker-compose")
265
+ if (repo / "vercel.json").is_file() or (repo / ".vercel").is_dir():
266
+ deployment.append("vercel")
267
+ if (repo / "netlify.toml").is_file():
268
+ deployment.append("netlify")
269
+ if any(name.endswith(".tf") for name in names):
270
+ deployment.append("terraform")
271
+ if (repo / ".github" / "workflows").is_dir():
272
+ deployment.append("github-actions")
273
+ return sorted(set(deployment))
274
+
275
+
276
+ def _detect_databases(repo: Path, frameworks: list[str]) -> list[str]:
277
+ dbs: set[str] = set()
278
+ if (repo / "supabase").is_dir():
279
+ dbs.add("supabase-postgres")
280
+ if any(path.suffix.lower() == ".prisma" for path in iter_repo_files(repo)):
281
+ dbs.add("prisma")
282
+ if (repo / "firebase.rules").is_file() or "firebase" in frameworks:
283
+ dbs.add("firebase")
284
+ pkg = repo / "package.json"
285
+ if pkg.is_file():
286
+ try:
287
+ data = json.loads(pkg.read_text(encoding="utf-8", errors="ignore"))
288
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
289
+ for name in deps:
290
+ if name in ("pg", "postgres"):
291
+ dbs.add("postgresql")
292
+ if name in ("mysql2", "mysql"):
293
+ dbs.add("mysql")
294
+ if name in ("mongoose",):
295
+ dbs.add("mongodb")
296
+ if name in ("@supabase/supabase-js",):
297
+ dbs.add("supabase")
298
+ except Exception:
299
+ pass
300
+ pyproject = repo / "pyproject.toml"
301
+ if pyproject.is_file():
302
+ text = pyproject.read_text(encoding="utf-8", errors="ignore").lower()
303
+ if "django" in text:
304
+ dbs.add("django-orm")
305
+ if "sqlalchemy" in text:
306
+ dbs.add("sqlalchemy")
307
+ return sorted(dbs)
308
+
309
+
310
+ def _detect_auth_providers(repo: Path, frameworks: list[str]) -> list[str]:
311
+ providers: set[str] = set()
312
+ pkg = repo / "package.json"
313
+ if pkg.is_file():
314
+ try:
315
+ data = json.loads(pkg.read_text(encoding="utf-8", errors="ignore"))
316
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
317
+ for name in deps:
318
+ if name in ("@clerk/clerk-react", "@clerk/nextjs"):
319
+ providers.add("clerk")
320
+ if name in ("@auth0/auth0-react", "@auth0/nextjs-auth0"):
321
+ providers.add("auth0")
322
+ if name == "next-auth":
323
+ providers.add("nextauth")
324
+ if name in ("@supabase/supabase-js", "@supabase/auth-helpers-nextjs"):
325
+ providers.add("supabase-auth")
326
+ if name == "firebase":
327
+ providers.add("firebase-auth")
328
+ except Exception:
329
+ pass
330
+ for f in _iter_source_files(repo, (".ts", ".tsx", ".js", ".jsx", ".py")):
331
+ text = f.read_text(encoding="utf-8", errors="ignore").lower()
332
+ if "jwt" in text:
333
+ providers.add("jwt")
334
+ if "oauth" in text:
335
+ providers.add("oauth")
336
+ if "passport" in text:
337
+ providers.add("passport")
338
+ return sorted(providers)
339
+
340
+
341
+ def _detect_test_runners(repo: Path) -> list[str]:
342
+ runners: set[str] = set()
343
+ pkg = repo / "package.json"
344
+ if pkg.is_file():
345
+ try:
346
+ data = json.loads(pkg.read_text(encoding="utf-8", errors="ignore"))
347
+ scripts = data.get("scripts", {})
348
+ if "test" in scripts:
349
+ runners.add("npm-test")
350
+ dev = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
351
+ if "jest" in dev:
352
+ runners.add("jest")
353
+ if "vitest" in dev:
354
+ runners.add("vitest")
355
+ if "mocha" in dev:
356
+ runners.add("mocha")
357
+ if "cypress" in dev:
358
+ runners.add("cypress")
359
+ if "playwright" in dev:
360
+ runners.add("playwright")
361
+ except Exception:
362
+ pass
363
+ files = iter_repo_files(repo)
364
+ names = {path.name for path in files}
365
+ if "pytest.ini" in names or "conftest.py" in names or (repo / "pyproject.toml").is_file():
366
+ runners.add("pytest")
367
+ unittest_files = [path for path in files if path.name.startswith("test") and path.suffix == ".py"]
368
+ if any("unittest" in path.read_text(encoding="utf-8", errors="ignore") for path in unittest_files):
369
+ runners.add("unittest")
370
+ if any(path.suffix in (".ts", ".tsx") and (".test." in path.name or ".spec." in path.name) for path in files):
371
+ runners.add("ts-js-tests")
372
+ return sorted(runners)
373
+
374
+
375
+ def _extract_endpoints(repo: Path) -> list[dict[str, Any]]:
376
+ endpoints: list[dict[str, Any]] = []
377
+ patterns = [
378
+ (r"(app|router|route)\.(get|post|put|delete|patch)\s*\(\s*['\"]([^'\"]+)['\"]", "express/fastify"),
379
+ (r"@app\.(route|get|post|put|delete|patch)\s*\(\s*['\"]([^'\"]+)['\"]", "fastapi/flask"),
380
+ (r"(path\s*=\s*['\"]([^'\"]+)['\"])", "django"),
381
+ (r"export\s+(async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH)\s*\(", "nextjs-app-router"),
382
+ (r"api\/(\S+)", "heuristic"),
383
+ ]
384
+ seen: set[tuple[str, str, int]] = set()
385
+ for path in _iter_source_files(repo, (".ts", ".tsx", ".js", ".jsx", ".py")):
386
+ text = path.read_text(encoding="utf-8", errors="ignore")
387
+ for i, raw in enumerate(text.splitlines(), 1):
388
+ for pattern, framework in patterns:
389
+ for match in re.finditer(pattern, raw, re.IGNORECASE):
390
+ key = (framework, match.group(0), i)
391
+ if key in seen:
392
+ continue
393
+ seen.add(key)
394
+ endpoints.append({
395
+ "method": "route",
396
+ "path": match.group(0),
397
+ "framework": framework,
398
+ "file": str(path.relative_to(repo)),
399
+ "line": i,
400
+ })
401
+ return endpoints[:200]
402
+
403
+
404
+ def _detect_env_files(repo: Path) -> list[str]:
405
+ return sorted(
406
+ str(path.relative_to(repo))
407
+ for path in iter_repo_files(repo)
408
+ if path.name.startswith(".env")
409
+ )
410
+
411
+
412
+ def _load_dependencies(repo: Path) -> dict[str, str]:
413
+ deps: dict[str, str] = {}
414
+ pkg = repo / "package.json"
415
+ if pkg.is_file():
416
+ try:
417
+ data = json.loads(pkg.read_text(encoding="utf-8", errors="ignore"))
418
+ for section in ("dependencies", "devDependencies", "peerDependencies"):
419
+ deps.update(data.get(section, {}))
420
+ except Exception:
421
+ pass
422
+ req = repo / "requirements.txt"
423
+ if req.is_file():
424
+ for line in req.read_text(encoding="utf-8", errors="ignore").splitlines():
425
+ line = line.strip()
426
+ if not line or line.startswith("#"):
427
+ continue
428
+ if "==" in line:
429
+ name, _, version = line.partition("==")
430
+ deps[name.strip()] = version.strip()
431
+ else:
432
+ deps[line] = "*"
433
+ return deps
434
+
435
+
436
+ def analyze_repo(repo: Path) -> AppProfile:
437
+ # Single shared walk: every detection helper reads from the same pruned,
438
+ # symlink-safe, .vcqaignore-aware file set so results stay consistent.
439
+ files = iter_repo_files(repo)
440
+ names = [path.name for path in files]
441
+
442
+ profile = AppProfile(path=repo)
443
+ profile.languages = _detect_languages(repo)
444
+ profile.frameworks = _detect_frameworks(repo)
445
+ profile.package_manager = _detect_package_manager(repo)
446
+ profile.lockfiles = _detect_lockfiles(repo)
447
+ profile.ci_files = _detect_ci(repo)
448
+ profile.deployment = _detect_deployment(repo)
449
+ profile.databases = _detect_databases(repo, profile.frameworks)
450
+ profile.auth_providers = _detect_auth_providers(repo, profile.frameworks)
451
+ profile.test_runners = _detect_test_runners(repo)
452
+ profile.has_tests = bool(profile.test_runners)
453
+ profile.env_files = _detect_env_files(repo)
454
+ profile.dependencies = _load_dependencies(repo)
455
+ profile.endpoints = _extract_endpoints(repo)
456
+ profile.has_gitignore = (repo / ".gitignore").is_file()
457
+ profile.logging_hints = []
458
+ if any(name.startswith("sentry.") for name in names) or "@sentry" in " ".join(profile.dependencies):
459
+ profile.logging_hints.append("sentry")
460
+ if "winston" in profile.dependencies:
461
+ profile.logging_hints.append("winston")
462
+ if any(name.endswith(".log") for name in names):
463
+ profile.logging_hints.append("log-files-present")
464
+ profile.notes.append("Architecture profile is heuristic; verify manually against actual deployment.")
465
+ pruned = _pruned_directories(repo)
466
+ if pruned:
467
+ profile.notes.append(
468
+ "Generated and vendored directories were excluded from language, framework, and test "
469
+ f"detection: {', '.join(pruned)}. Detection completeness is reduced accordingly."
470
+ )
471
+ return profile
vibe_coding_qa/repo.py ADDED
@@ -0,0 +1,188 @@
1
+ """Repository loader: local path or GitHub clone."""
2
+
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from . import Finding, Severity, Status
11
+
12
+
13
+ def is_git_url(value: str) -> bool:
14
+ return value.startswith(("http://", "https://", "git@")) or value.endswith(".git")
15
+
16
+
17
+ def _sanitize_url(url: str) -> str:
18
+ """Remove credentials from a URL for safe logging."""
19
+ return re.sub(r"https?://[^:]+:[^@]+@", "https://***:***@", url)
20
+
21
+
22
+ def _create_git_config_with_credential_helper(token: str) -> tuple[str, str]:
23
+ """Create a temporary Git credential store and config with restricted permissions."""
24
+ import shlex
25
+ from urllib.parse import quote
26
+
27
+ credential_file = tempfile.NamedTemporaryFile(
28
+ mode="w", suffix=".credentials", delete=False, prefix="vcqa-git-credentials-", encoding="utf-8"
29
+ )
30
+ credential_path = credential_file.name
31
+ try:
32
+ credential_file.write("https" + "://" + "x-access-token" + ":" + quote(token, safe="") + "@github.com\n")
33
+ finally:
34
+ credential_file.close()
35
+ try:
36
+ os.chmod(credential_path, 0o600)
37
+ except OSError:
38
+ pass
39
+ config_content = "[credential]\n helper = store --file=" + shlex.quote(credential_path) + "\n"
40
+ config_file = tempfile.NamedTemporaryFile(
41
+ mode="w", suffix=".gitconfig", delete=False, prefix="vcqa-git-config-", encoding="utf-8"
42
+ )
43
+ config_path = config_file.name
44
+ try:
45
+ config_file.write(config_content)
46
+ finally:
47
+ config_file.close()
48
+ try:
49
+ os.chmod(config_path, 0o600)
50
+ except OSError:
51
+ pass
52
+ return config_path, credential_path
53
+
54
+
55
+ def clone_repo(github_ref: str, token: str | None = None, workdir: Path | None = None) -> Path:
56
+ """Clone a GitHub repository into a temporary directory.
57
+
58
+ Accepts:
59
+ - owner/repo
60
+ - https://github.com/owner/repo
61
+ - git@github.com:owner/repo.git
62
+ """
63
+ if workdir is None:
64
+ workdir = Path(tempfile.mkdtemp(prefix="vcqa-repo-"))
65
+ else:
66
+ workdir = Path(workdir)
67
+ workdir.mkdir(parents=True, exist_ok=True)
68
+
69
+ if "/" in github_ref and not github_ref.startswith("http") and not github_ref.startswith("git@"):
70
+ url = f"https://github.com/{github_ref}.git"
71
+ else:
72
+ url = github_ref
73
+
74
+ from urllib.parse import urlsplit
75
+ parsed_url = urlsplit(url)
76
+ if parsed_url.username is not None or parsed_url.password is not None:
77
+ raise ValueError("Repository URLs must not contain embedded credentials; pass a GitHub token separately.")
78
+ if token and parsed_url.hostname != "github.com":
79
+ raise ValueError("GitHub tokens can only be used with github.com repositories.")
80
+
81
+ allowed_environment = {
82
+ "PATH", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "TEMP", "TMP",
83
+ "HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "APPDATA", "LOCALAPPDATA",
84
+ "LANG", "LC_ALL", "LC_CTYPE", "SSH_AUTH_SOCK", "SSH_AGENT_PID",
85
+ }
86
+ env = {
87
+ key: value for key, value in os.environ.items()
88
+ if key.upper() in allowed_environment or key.upper().startswith("LC_")
89
+ }
90
+ env["GIT_TERMINAL_PROMPT"] = "0"
91
+ env["GIT_CONFIG_NOSYSTEM"] = "1"
92
+ env["GIT_OPTIONAL_LOCKS"] = "0"
93
+
94
+ temporary_credentials: list[str] = []
95
+ if token and url.startswith("https://"):
96
+ config_path, credential_path = _create_git_config_with_credential_helper(token)
97
+ temporary_credentials.extend((config_path, credential_path))
98
+ env["GIT_CONFIG_GLOBAL"] = config_path
99
+ cmd = ["git", "clone", "--depth", "1", "--single-branch", url, str(workdir)]
100
+ try:
101
+ result = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
102
+ except OSError:
103
+ raise RuntimeError("Unable to invoke Git while cloning the repository.") from None
104
+ finally:
105
+ for credential_path in temporary_credentials:
106
+ try:
107
+ os.unlink(credential_path)
108
+ except OSError:
109
+ pass
110
+
111
+ if result.returncode != 0:
112
+ sanitized_stderr = result.stderr.replace(token, "[REDACTED]") if token else result.stderr
113
+ sanitized_stderr = _sanitize_url(sanitized_stderr)
114
+ raise RuntimeError(f"Failed to clone {_sanitize_url(url)}: {sanitized_stderr}")
115
+ return workdir
116
+
117
+
118
+ def load_repository(source: str, token: str | None = None, workdir: Path | None = None) -> Path:
119
+ if is_git_url(source) or ("/" in source and ".git" not in source and not Path(source).exists()):
120
+ return clone_repo(source, token=token, workdir=workdir)
121
+ path = Path(source).resolve()
122
+ if not path.exists():
123
+ raise FileNotFoundError(f"Repository path not found: {path}")
124
+ return path
125
+
126
+
127
+ def cleanup_repo(path: Path) -> None:
128
+ """Remove a temporary cloned repository."""
129
+ if "vcqa-repo-" in str(path):
130
+ shutil.rmtree(path, ignore_errors=True)
131
+
132
+
133
+ def repo_metadata(repo: Path) -> dict:
134
+ """Gather lightweight metadata about a repo."""
135
+ metadata = {
136
+ "path": str(repo),
137
+ "languages": detect_languages(repo),
138
+ "has_package_json": (repo / "package.json").is_file(),
139
+ "has_requirements_txt": (repo / "requirements.txt").is_file(),
140
+ "has_tests": bool(list(repo.rglob("*test*"))[:1]),
141
+ "framework_hints": detect_frameworks(repo),
142
+ }
143
+ return metadata
144
+
145
+
146
+ def detect_languages(repo: Path) -> dict[str, int]:
147
+ counts: dict[str, int] = {}
148
+ for ext, lang in {
149
+ ".ts": "typescript",
150
+ ".tsx": "typescript",
151
+ ".js": "javascript",
152
+ ".jsx": "javascript",
153
+ ".py": "python",
154
+ ".sql": "sql",
155
+ ".html": "html",
156
+ ".css": "css",
157
+ ".md": "markdown",
158
+ ".json": "json",
159
+ ".yml": "yaml",
160
+ ".yaml": "yaml",
161
+ }.items():
162
+ counts[lang] = counts.get(lang, 0) + len(list(repo.rglob(f"*{ext}")))
163
+ return counts
164
+
165
+
166
+ def detect_frameworks(repo: Path) -> list[str]:
167
+ hints = []
168
+ files = {p.name for p in repo.rglob("*") if p.is_file()}
169
+ if "next.config.js" in files or "next.config.ts" in files or "next.config.mjs" in files:
170
+ hints.append("nextjs")
171
+ if "vite.config.ts" in files or "vite.config.js" in files:
172
+ hints.append("vite")
173
+ if any((repo / name).is_dir() for name in ("supabase", "prisma", "migrations")):
174
+ hints.append("database")
175
+ pkg = repo / "package.json"
176
+ if pkg.is_file():
177
+ text = pkg.read_text(encoding="utf-8", errors="ignore").lower()
178
+ for name in ("react", "vue", "svelte", "angular", "express", "fastapi", "django", "flask"):
179
+ if name in text:
180
+ hints.append(name)
181
+ return sorted(set(hints))
182
+
183
+
184
+ def read_text_lines(path: Path) -> list[str]:
185
+ try:
186
+ return path.read_text(encoding="utf-8", errors="ignore").splitlines()
187
+ except Exception:
188
+ return []