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,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 []
|