diff --git a/app/api.py b/app/api.py index 49288a1..0a087f7 100644 --- a/app/api.py +++ b/app/api.py @@ -75,6 +75,7 @@ _readme_summary_cache: dict[str, tuple[str, float]] = {} README_SUMMARY_CACHE_TTL_SEC = 86400.0 from core.orchestrator import ScoutOrchestrator, MAX_QA_RETRIES +from core.audit import audit_repository from core.agents.pathfinder import PathfinderAgent from core.terminal_manager import ( SessionNotFoundError, @@ -238,6 +239,12 @@ class ExportPdfRequest(BaseModel): content: str +class AuditRepoRequest(BaseModel): + """Request body for a repository health audit.""" + + repo_url: str + + class ReAnalyzeRequest(BaseModel): """Request body for re-running phases 2+3 for a specific issue.""" @@ -1269,6 +1276,44 @@ def admin_memory_graph( return payload +@app.post("/api/audit-repo") +def audit_repo( + body: AuditRepoRequest, + request: Request, + user_ctx: UserContext = Depends(get_current_user), +): + """ + Run a deterministic health audit over an entire repository. + + Clones the repo, scans for technical-debt markers and debug artifacts, and + returns a readiness score, pass/fail gate, severity breakdown, and findings. + No LLM calls are made. + """ + repo_url = (body.repo_url or "").strip() + if not repo_url: + raise HTTPException(status_code=400, detail="repo_url is required") + + try: + github_client = GitHubClient(token=_get_github_token_for_user(request)) + owner, repo = github_client.parse_repo_url(repo_url) + repo_path = github_client.clone_repo(repo_url) + file_tree = github_client.get_file_tree(repo_path) + report = audit_repository( + repo_url=repo_url, + repo_full_name=f"{owner}/{repo}", + repo_path=repo_path, + file_tree=file_tree, + ) + return _to_jsonable(report) + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid repository URL: {e}") + except RuntimeError as e: + raise HTTPException(status_code=502, detail=str(e)) + except Exception as e: + logger.exception("Repository audit failed") + raise HTTPException(status_code=500, detail=str(e)) + + @app.post("/api/export/pdf") def export_pdf(body: ExportPdfRequest): """Generate PDF from markdown content.""" diff --git a/core/audit/__init__.py b/core/audit/__init__.py new file mode 100644 index 0000000..d35fd4e --- /dev/null +++ b/core/audit/__init__.py @@ -0,0 +1,4 @@ +"""Repository health audit package.""" +from core.audit.repo_auditor import READINESS_THRESHOLD, audit_repository + +__all__ = ["audit_repository", "READINESS_THRESHOLD"] diff --git a/core/audit/repo_auditor.py b/core/audit/repo_auditor.py new file mode 100644 index 0000000..bd58c0f --- /dev/null +++ b/core/audit/repo_auditor.py @@ -0,0 +1,257 @@ +""" +Deterministic repository health audit. + +Scans a cloned repository for technical-debt markers and debug artifacts, +then derives a readiness score (0-100) and a pass/fail gate. No LLM calls are +made, so the audit is fast, reproducible, and free of API cost. +""" +from __future__ import annotations + +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import List, Optional, Tuple + +from core.schemas import ( + AuditFinding, + AuditFileSummary, + AuditSeverityCounts, + RepoAuditReport, +) + +READINESS_THRESHOLD = 70 + +MAX_FILES = 4000 +MAX_FILE_BYTES = 1_500_000 +MAX_LINE_SCAN_LENGTH = 500 +MAX_FINDINGS = 400 +MAX_TOP_FILES = 10 +SNIPPET_LENGTH = 200 + +SEVERITY_HIGH = "high" +SEVERITY_MEDIUM = "medium" +SEVERITY_LOW = "low" + +_SEVERITY_WEIGHTS = {SEVERITY_HIGH: 3.0, SEVERITY_MEDIUM: 1.0, SEVERITY_LOW: 0.4} +_SCORE_SENSITIVITY = 6.0 + +_HIGH_PATTERNS: List[Tuple[re.Pattern, str]] = [ + (re.compile(r"\bFIXME\b", re.IGNORECASE), "FIXME"), + (re.compile(r"\bHACK\b", re.IGNORECASE), "HACK"), + (re.compile(r"\bXXX\b"), "XXX"), + (re.compile(r"\bBUG\b"), "BUG"), + (re.compile(r"\bSECURITY\b", re.IGNORECASE), "SECURITY"), +] + +_MEDIUM_PATTERNS: List[Tuple[re.Pattern, str]] = [ + (re.compile(r"\bTODO\b", re.IGNORECASE), "TODO"), + (re.compile(r"\bTBD\b", re.IGNORECASE), "TBD"), + (re.compile(r"\bWIP\b"), "WIP"), + (re.compile(r"\bDEPRECATED\b", re.IGNORECASE), "DEPRECATED"), +] + +_LOW_PATTERNS: List[Tuple[re.Pattern, str]] = [ + (re.compile(r"console\.(?:log|debug|trace)\s*\("), "console-log"), + (re.compile(r"\bdebugger\b\s*;?"), "debugger"), + (re.compile(r"\bpdb\.set_trace\s*\("), "pdb-set-trace"), + (re.compile(r"\bbreakpoint\s*\(\s*\)"), "breakpoint"), + (re.compile(r"\bbinding\.pry\b"), "binding-pry"), + (re.compile(r"\bvar_dump\s*\("), "var-dump"), +] + + +def _classify_line(line: str) -> Optional[Tuple[str, str, str]]: + """Return (severity, category, marker) for the highest-severity match, or None.""" + for pattern, marker in _HIGH_PATTERNS: + if pattern.search(line): + return SEVERITY_HIGH, marker, marker + for pattern, marker in _MEDIUM_PATTERNS: + if pattern.search(line): + return SEVERITY_MEDIUM, marker, marker + for pattern, marker in _LOW_PATTERNS: + if pattern.search(line): + return SEVERITY_LOW, "debug-artifact", marker + return None + + +def _compute_readiness(counts: AuditSeverityCounts, lines_scanned: int) -> int: + """Derive a 0-100 readiness score from weighted finding density per 1000 lines.""" + weighted = ( + counts.high * _SEVERITY_WEIGHTS[SEVERITY_HIGH] + + counts.medium * _SEVERITY_WEIGHTS[SEVERITY_MEDIUM] + + counts.low * _SEVERITY_WEIGHTS[SEVERITY_LOW] + ) + per_kloc = (weighted / max(lines_scanned, 1)) * 1000 + score = round(100 - per_kloc * _SCORE_SENSITIVITY) + return max(0, min(100, score)) + + +def _build_summary(report: RepoAuditReport) -> str: + gate = "PASSED" if report.gate_passed else "FAILED" + counts = report.severity_counts + return ( + f"Gate {gate} — readiness {report.readiness_score}/100. " + f"Scanned {report.files_scanned} files ({report.lines_scanned} lines) and found " + f"{report.technical_debt} issues " + f"({counts.high} high, {counts.medium} medium, {counts.low} low)." + ) + + +def _build_markdown(report: RepoAuditReport) -> str: + counts = report.severity_counts + gate = "PASSED" if report.gate_passed else "FAILED" + lines: List[str] = [ + f"# Repository Health Audit: {report.repo_full_name}", + "", + f"- **Readiness score:** {report.readiness_score}/100 (threshold {report.readiness_threshold})", + f"- **Gate:** {gate}", + f"- **Technical debt (total findings):** {report.technical_debt}", + f"- **Files scanned:** {report.files_scanned}", + f"- **Lines scanned:** {report.lines_scanned}", + f"- **Scanned at:** {report.scanned_at}", + "", + "## Severity breakdown", + "", + f"- High: {counts.high}", + f"- Medium: {counts.medium}", + f"- Low: {counts.low}", + "", + ] + + if report.top_files: + lines.append("## Files with the most findings") + lines.append("") + lines.append("| File | Issues | High | Medium | Low |") + lines.append("| --- | ---: | ---: | ---: | ---: |") + for f in report.top_files: + lines.append( + f"| {f.file_path} | {f.issue_count} | {f.high} | {f.medium} | {f.low} |" + ) + lines.append("") + + if report.findings: + shown = report.findings[:50] + lines.append("## Sample findings") + lines.append("") + lines.append("| Severity | Category | Location | Snippet |") + lines.append("| --- | --- | --- | --- |") + for finding in shown: + snippet = finding.snippet.replace("|", "\\|") + location = f"{finding.file_path}:{finding.line_number}" + lines.append( + f"| {finding.severity} | {finding.category} | {location} | `{snippet}` |" + ) + if report.findings_truncated or len(report.findings) > len(shown): + lines.append("") + lines.append( + f"_Showing {len(shown)} of {report.technical_debt} findings._" + ) + lines.append("") + + return "\n".join(lines) + + +def audit_repository( + repo_url: str, + repo_full_name: str, + repo_path: Path, + file_tree: List[str], +) -> RepoAuditReport: + """ + Scan a cloned repository and produce a health audit report. + + Args: + repo_url: Original repository URL. + repo_full_name: owner/repo identifier. + repo_path: Path to the locally cloned repository. + file_tree: Repo-relative file paths to scan (already filtered of binaries). + + Returns: + A populated RepoAuditReport. + """ + repo_path = Path(repo_path) + counts = AuditSeverityCounts() + findings: List[AuditFinding] = [] + file_stats: dict[str, dict[str, int]] = {} + files_scanned = 0 + lines_scanned = 0 + findings_truncated = False + + for rel_path in file_tree[:MAX_FILES]: + full_path = repo_path / rel_path + try: + if not full_path.is_file() or full_path.stat().st_size > MAX_FILE_BYTES: + continue + except OSError: + continue + + try: + with open(full_path, "r", encoding="utf-8", errors="ignore") as handle: + files_scanned += 1 + for line_number, raw_line in enumerate(handle, 1): + lines_scanned += 1 + line = raw_line[:MAX_LINE_SCAN_LENGTH] + classified = _classify_line(line) + if classified is None: + continue + + severity, category, marker = classified + setattr(counts, severity, getattr(counts, severity) + 1) + + stats = file_stats.setdefault( + rel_path, {SEVERITY_HIGH: 0, SEVERITY_MEDIUM: 0, SEVERITY_LOW: 0} + ) + stats[severity] += 1 + + if len(findings) < MAX_FINDINGS: + findings.append( + AuditFinding( + file_path=rel_path, + line_number=line_number, + severity=severity, + category=category, + marker=marker, + snippet=raw_line.strip()[:SNIPPET_LENGTH], + ) + ) + else: + findings_truncated = True + except OSError: + continue + + technical_debt = counts.high + counts.medium + counts.low + readiness_score = _compute_readiness(counts, lines_scanned) + + top_files = [ + AuditFileSummary( + file_path=path, + issue_count=stats[SEVERITY_HIGH] + stats[SEVERITY_MEDIUM] + stats[SEVERITY_LOW], + high=stats[SEVERITY_HIGH], + medium=stats[SEVERITY_MEDIUM], + low=stats[SEVERITY_LOW], + ) + for path, stats in file_stats.items() + ] + top_files.sort(key=lambda f: (f.issue_count, f.high, f.medium), reverse=True) + top_files = top_files[:MAX_TOP_FILES] + + report = RepoAuditReport( + repo_url=repo_url, + repo_full_name=repo_full_name, + readiness_score=readiness_score, + gate_passed=readiness_score >= READINESS_THRESHOLD, + readiness_threshold=READINESS_THRESHOLD, + technical_debt=technical_debt, + files_scanned=files_scanned, + lines_scanned=lines_scanned, + severity_counts=counts, + top_files=top_files, + findings=findings, + findings_truncated=findings_truncated, + summary="", + report_markdown="", + scanned_at=datetime.now(timezone.utc).isoformat(), + ) + report.summary = _build_summary(report) + report.report_markdown = _build_markdown(report) + return report diff --git a/core/schemas.py b/core/schemas.py index 3e3f3fa..3fa9b27 100644 --- a/core/schemas.py +++ b/core/schemas.py @@ -192,6 +192,53 @@ class TestingAgentOutput(BaseModel): iterations_used: int = Field(default=1, description="Number of QA iterations completed") +# ==================== Repository Health Audit ==================== + +class AuditFinding(BaseModel): + """A single technical-debt or debug-artifact finding in the codebase.""" + file_path: str = Field(description="File path relative to repo root") + line_number: int = Field(ge=1, description="1-indexed line number of the finding") + severity: str = Field(description="Severity level: high, medium, or low") + category: str = Field(description="Finding category, e.g. FIXME, TODO, debug-artifact") + marker: str = Field(description="The matched marker keyword") + snippet: str = Field(description="Trimmed line content where the finding occurred") + + +class AuditSeverityCounts(BaseModel): + """Aggregate finding counts grouped by severity.""" + high: int = Field(default=0, ge=0, description="High severity count") + medium: int = Field(default=0, ge=0, description="Medium severity count") + low: int = Field(default=0, ge=0, description="Low severity count") + + +class AuditFileSummary(BaseModel): + """Per-file rollup of findings, used to surface the noisiest files.""" + file_path: str = Field(description="File path relative to repo root") + issue_count: int = Field(ge=0, description="Total findings in the file") + high: int = Field(default=0, ge=0, description="High severity count in file") + medium: int = Field(default=0, ge=0, description="Medium severity count in file") + low: int = Field(default=0, ge=0, description="Low severity count in file") + + +class RepoAuditReport(BaseModel): + """Deterministic repository health audit report.""" + repo_url: str = Field(description="Audited repository URL") + repo_full_name: str = Field(description="Repository full name (owner/repo)") + readiness_score: int = Field(ge=0, le=100, description="Overall readiness score (0-100)") + gate_passed: bool = Field(description="Whether the repo passes the readiness gate") + readiness_threshold: int = Field(ge=0, le=100, description="Score required to pass the gate") + technical_debt: int = Field(ge=0, description="Total number of findings across all severities") + files_scanned: int = Field(ge=0, description="Number of files scanned") + lines_scanned: int = Field(ge=0, description="Number of lines scanned") + severity_counts: AuditSeverityCounts = Field(description="Findings grouped by severity") + top_files: List[AuditFileSummary] = Field(default_factory=list, description="Files with the most findings") + findings: List[AuditFinding] = Field(default_factory=list, description="Individual findings (capped)") + findings_truncated: bool = Field(default=False, description="True when findings list was capped") + summary: str = Field(description="Human-readable summary of the audit") + report_markdown: str = Field(description="Full audit report rendered as Markdown") + scanned_at: str = Field(description="ISO-8601 UTC timestamp when the audit completed") + + # ==================== GitHub API Models ==================== class GitHubIssue(BaseModel): diff --git a/frontend/src/api.js b/frontend/src/api.js index 360fbf8..d0d90f3 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -256,6 +256,17 @@ export async function reviewAndPushCode(owner, repo, payload) { return res.json(); } +export async function auditRepo(repoUrl) { + const res = await apiFetch(`/audit-repo`, { + method: 'POST', + body: JSON.stringify({ repo_url: repoUrl }), + }, TIMEOUTS.analysis) + if (!res.ok) { + throw new Error((await responseErrorDetail(res)) || 'Repository audit failed') + } + return res.json() +} + export async function exportPdf(content) { const res = await apiFetch(`/export/pdf`, { method: 'POST', diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index 573f850..efc2c6b 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { useSearchParams, useNavigate, Link } from 'react-router-dom' -import { Search, Rocket, User, Github, ExternalLink, FolderKanban, Gauge, Brain } from 'lucide-react' +import { Search, Rocket, User, Github, ExternalLink, FolderKanban, Gauge, Brain, ShieldCheck } from 'lucide-react' import { searchReposByTechStack, runAnalyze, createProject, getMe, feedbackRepoSelection } from '../api' import ScoutLogo from './ScoutLogo' import PathfinderSearchLoader from './PathfinderSearchLoader' @@ -558,6 +558,17 @@ export default function Dashboard() { )} +
+

Tools

+ + + Repository Health Audit + +
+ {/* Profile Sidebar Footer */}
} /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/pages/RepoAudit.jsx b/frontend/src/pages/RepoAudit.jsx new file mode 100644 index 0000000..78c1f88 --- /dev/null +++ b/frontend/src/pages/RepoAudit.jsx @@ -0,0 +1,288 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { + ShieldCheck, + ShieldAlert, + ArrowLeft, + Search, + Download, + FileWarning, + FileCode2, + AlertTriangle, +} from 'lucide-react' +import { auditRepo, exportPdf } from '../api' +import ScoutLogo from '../components/ScoutLogo' + +const SEVERITY_STYLES = { + high: 'bg-red-500/15 text-red-400 border-red-500/30', + medium: 'bg-amber-500/15 text-amber-400 border-amber-500/30', + low: 'bg-sky-500/15 text-sky-400 border-sky-500/30', +} + +function scoreColor(score) { + if (score >= 70) return 'text-emerald-400' + if (score >= 40) return 'text-amber-400' + return 'text-red-400' +} + +function StatCard({ label, value }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +export default function RepoAudit() { + const [repoUrl, setRepoUrl] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [report, setReport] = useState(null) + const [downloading, setDownloading] = useState(false) + + const handleScan = async () => { + const url = repoUrl.trim() + if (!url) return + setLoading(true) + setError(null) + setReport(null) + try { + const result = await auditRepo(url) + setReport(result) + } catch (err) { + setError(err.message || 'Audit failed. Please try again.') + } finally { + setLoading(false) + } + } + + const handleDownloadPdf = async () => { + if (!report?.report_markdown) return + setDownloading(true) + try { + const blob = await exportPdf(report.report_markdown) + const objectUrl = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = objectUrl + const safeName = report.repo_full_name.replace(/[^a-z0-9]+/gi, '_') + link.download = `audit_${safeName}.pdf` + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(objectUrl) + } catch (err) { + setError(err.message || 'PDF export failed.') + } finally { + setDownloading(false) + } + } + + const counts = report?.severity_counts || { high: 0, medium: 0, low: 0 } + + return ( +
+
+
+
+ +
+

Repository Health Audit

+

Scan an entire codebase for technical debt and debug artifacts

+
+
+ + + Dashboard + +
+
+ +
+
+ +
+ setRepoUrl(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleScan()} + placeholder="https://github.com/owner/repo" + className="flex-1 rounded-lg border border-app-border bg-app-bg px-4 py-3 text-sm text-app-text outline-none focus:border-primary-500/50" + /> + +
+ {error && ( +
+ + {error} +
+ )} +
+ + {report && ( +
+
+
+ {report.gate_passed ? ( + + ) : ( + + )} +
+
{report.repo_full_name}
+

+ Gate {report.gate_passed ? 'PASSED' : 'FAILED'} +

+

{report.summary}

+
+
+
+
+ {report.readiness_score} +
+
/ 100 readiness
+
+
+ +
+ + + + +
+ +
+
+
+ + High severity +
+
{counts.high}
+
+
+
+ + Medium severity +
+
{counts.medium}
+
+
+
+ + Low severity +
+
{counts.low}
+
+
+ +
+ +
+ + {report.top_files?.length > 0 && ( +
+

Files with the most findings

+
+ + + + + + + + + + + + {report.top_files.map((file) => ( + + + + + + + + ))} + +
FileIssuesHighMediumLow
{file.file_path}{file.issue_count}{file.high}{file.medium}{file.low}
+
+
+ )} + + {report.findings?.length > 0 && ( +
+

+ Findings + {report.findings_truncated && ( + + (showing first {report.findings.length} of {report.technical_debt}) + + )} +

+
+ {report.findings.map((finding, idx) => ( +
+ + {finding.severity} · {finding.category} + + + {finding.file_path}:{finding.line_number} + + {finding.snippet} +
+ ))} +
+
+ )} +
+ )} +
+
+ ) +} diff --git a/tests/test_repo_auditor.py b/tests/test_repo_auditor.py new file mode 100644 index 0000000..a00dd68 --- /dev/null +++ b/tests/test_repo_auditor.py @@ -0,0 +1,101 @@ +"""Tests for the deterministic repository health audit.""" +from pathlib import Path + +from core.audit.repo_auditor import READINESS_THRESHOLD, audit_repository + + +def _write(repo: Path, rel: str, content: str) -> None: + path = repo / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def test_clean_repo_passes_gate(tmp_path: Path): + _write(tmp_path, "main.py", "def add(a, b):\n return a + b\n") + _write(tmp_path, "util.js", "export const ok = () => 42\n") + file_tree = ["main.py", "util.js"] + + report = audit_repository( + repo_url="https://github.com/o/clean", + repo_full_name="o/clean", + repo_path=tmp_path, + file_tree=file_tree, + ) + + assert report.technical_debt == 0 + assert report.readiness_score == 100 + assert report.gate_passed is True + assert report.files_scanned == 2 + + +def test_markers_are_classified_by_severity(tmp_path: Path): + _write( + tmp_path, + "app.py", + "# TODO: refactor\n" + "x = 1 # FIXME broken\n" + "console.log('debug')\n", + ) + file_tree = ["app.py"] + + report = audit_repository( + repo_url="https://github.com/o/debt", + repo_full_name="o/debt", + repo_path=tmp_path, + file_tree=file_tree, + ) + + assert report.severity_counts.high == 1 + assert report.severity_counts.medium == 1 + assert report.severity_counts.low == 1 + assert report.technical_debt == 3 + assert report.top_files[0].file_path == "app.py" + assert report.top_files[0].issue_count == 3 + + +def test_highest_severity_wins_per_line(tmp_path: Path): + _write(tmp_path, "mix.py", "y = 2 # TODO and FIXME on one line\n") + + report = audit_repository( + repo_url="https://github.com/o/mix", + repo_full_name="o/mix", + repo_path=tmp_path, + file_tree=["mix.py"], + ) + + assert report.severity_counts.high == 1 + assert report.severity_counts.medium == 0 + assert report.technical_debt == 1 + + +def test_high_debt_density_fails_gate_and_builds_report(tmp_path: Path): + body = "".join(f"# FIXME issue {i}\n" for i in range(40)) + _write(tmp_path, "legacy.py", body) + + report = audit_repository( + repo_url="https://github.com/o/legacy", + repo_full_name="o/legacy", + repo_path=tmp_path, + file_tree=["legacy.py"], + ) + + assert report.severity_counts.high == 40 + assert report.readiness_score < READINESS_THRESHOLD + assert report.gate_passed is False + assert "Repository Health Audit" in report.report_markdown + assert report.summary + + +def test_missing_files_are_skipped(tmp_path: Path): + _write(tmp_path, "present.py", "# TODO only here\n") + file_tree = ["present.py", "does_not_exist.py"] + + report = audit_repository( + repo_url="https://github.com/o/partial", + repo_full_name="o/partial", + repo_path=tmp_path, + file_tree=file_tree, + ) + + assert report.files_scanned == 1 + assert report.severity_counts.medium == 1