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 (
+
+ )
+}
+
+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 && (
+
+ )}
+
+
+ {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
+
+
+
+
+ | File |
+ Issues |
+ High |
+ Medium |
+ Low |
+
+
+
+ {report.top_files.map((file) => (
+
+ | {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