CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
debt-analyzer.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| fa97fd1 | 1 | /** |
| 2 | * AI Technical Debt Analyzer. | |
| 3 | * | |
| 4 | * Scans a repository's files, extracts static metrics, then uses Claude | |
| 5 | * Sonnet to score the top 20 files by technical debt. Returns a DebtReport | |
| 6 | * with per-file scores, issue lists, and estimated cleanup hours. | |
| 7 | */ | |
| 8 | ||
| 9 | import { join } from "path"; | |
| 10 | import { config } from "./config"; | |
| 11 | import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client"; | |
| 12 | ||
| 13 | // ─── Types ──────────────────────────────────────────────────────────────────── | |
| 14 | ||
| 15 | export interface DebtNode { | |
| 16 | path: string; | |
| 17 | lines: number; | |
| 18 | debtScore: number; // 0-100, higher = more debt | |
| 19 | issues: string[]; // e.g. ["Long functions (avg 87 lines)", "Deep nesting"] | |
| 20 | estimatedHours: number; // Claude's estimate to clean up | |
| 21 | imports: string[]; // files this file imports (resolved relative paths) | |
| 22 | } | |
| 23 | ||
| 24 | export interface DebtReport { | |
| 25 | repoId: string; | |
| 26 | commitSha: string; | |
| 27 | nodes: DebtNode[]; | |
| 28 | totalDebtHours: number; | |
| 29 | analyzedAt: string; | |
| 30 | } | |
| 31 | ||
| 32 | // ─── Constants ──────────────────────────────────────────────────────────────── | |
| 33 | ||
| 34 | const CODE_EXTENSIONS = new Set([ | |
| 35 | ".ts", ".tsx", ".js", ".jsx", | |
| 36 | ".py", ".go", ".rs", ".java", ".rb", ".php", | |
| 37 | ]); | |
| 38 | ||
| 39 | const SKIP_PATTERNS = ["node_modules/", "dist/", ".min.", "vendor/"]; | |
| 40 | ||
| 41 | const MAX_FILES = 150; | |
| 42 | const MAX_AI_FILES = 20; | |
| 43 | ||
| 44 | // ─── Helpers ───────────────────────────────────────────────────────────────── | |
| 45 | ||
| 46 | /** Run a git command inside the bare repo directory for `owner/repo`. */ | |
| 47 | async function gitExec( | |
| 48 | repoPath: string, | |
| 49 | args: string[] | |
| 50 | ): Promise<string> { | |
| 51 | const proc = Bun.spawn(["git", ...args], { | |
| 52 | cwd: repoPath, | |
| 53 | stdout: "pipe", | |
| 54 | stderr: "pipe", | |
| 55 | }); | |
| 56 | const text = await new Response(proc.stdout).text(); | |
| 57 | await proc.exited; | |
| 58 | return text; | |
| 59 | } | |
| 60 | ||
| 61 | /** Return true if a file path should be skipped. */ | |
| 62 | function shouldSkip(path: string): boolean { | |
| 63 | for (const pat of SKIP_PATTERNS) { | |
| 64 | if (path.includes(pat)) return true; | |
| 65 | } | |
| 66 | return false; | |
| 67 | } | |
| 68 | ||
| 69 | /** Return true if the file has a code extension we want to analyze. */ | |
| 70 | function isCodeFile(path: string): boolean { | |
| 71 | const dot = path.lastIndexOf("."); | |
| 72 | if (dot === -1) return false; | |
| 73 | return CODE_EXTENSIONS.has(path.slice(dot)); | |
| 74 | } | |
| 75 | ||
| 76 | /** Count lines in a string. */ | |
| 77 | function countLines(content: string): number { | |
| 78 | if (!content) return 0; | |
| 79 | return content.split("\n").length; | |
| 80 | } | |
| 81 | ||
| 82 | /** Count TODO/FIXME/HACK/XXX occurrences. */ | |
| 83 | function countTodos(content: string): number { | |
| 84 | return (content.match(/\bTODO\b|\bFIXME\b|\bHACK\b|\bXXX\b/g) || []).length; | |
| 85 | } | |
| 86 | ||
| 87 | /** Rough complexity hint: count function/def/fn keywords vs line count. */ | |
| 88 | function complexityHint(content: string, lines: number): string { | |
| 89 | const fnCount = ( | |
| 90 | content.match(/\b(function|def |fn |func |async function|const \w+ = \(|=> \{)/g) || [] | |
| 91 | ).length; | |
| 92 | if (lines === 0) return "empty"; | |
| 93 | if (lines > 800) return "very large file"; | |
| 94 | if (lines > 400) return "large file"; | |
| 95 | if (fnCount > 0) { | |
| 96 | const avgFnLen = Math.round(lines / fnCount); | |
| 97 | if (avgFnLen > 60) return `avg function ${avgFnLen} lines`; | |
| 98 | } | |
| 99 | return "normal"; | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Extract import paths from a file's content. | |
| 104 | * Handles: import/from (ES modules), require() calls. | |
| 105 | * Returns only local relative imports (starting with . or /). | |
| 106 | */ | |
| 107 | function extractImports(content: string, filePath: string): string[] { | |
| 108 | const imports: string[] = []; | |
| 109 | const dir = filePath.includes("/") | |
| 110 | ? filePath.slice(0, filePath.lastIndexOf("/")) | |
| 111 | : ""; | |
| 112 | ||
| 113 | // ES import: import ... from '...' or import '...' | |
| 114 | const esImports = content.matchAll(/(?:import|from)\s+['"]([^'"]+)['"]/g); | |
| 115 | for (const m of esImports) { | |
| 116 | imports.push(m[1]); | |
| 117 | } | |
| 118 | // require(): require('...') | |
| 119 | const requireImports = content.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g); | |
| 120 | for (const m of requireImports) { | |
| 121 | imports.push(m[1]); | |
| 122 | } | |
| 123 | ||
| 124 | // Filter to relative/local only, resolve to plausible path | |
| 125 | const resolved: string[] = []; | |
| 126 | for (const imp of imports) { | |
| 127 | if (!imp.startsWith(".") && !imp.startsWith("/")) continue; | |
| 128 | // Simple resolution | |
| 129 | let resolved_path = imp.startsWith("/") | |
| 130 | ? imp.slice(1) | |
| 131 | : dir | |
| 132 | ? `${dir}/${imp}` | |
| 133 | : imp; | |
| 134 | // Normalize .. segments (very roughly) | |
| 135 | const parts = resolved_path.split("/").filter(Boolean); | |
| 136 | const stack: string[] = []; | |
| 137 | for (const p of parts) { | |
| 138 | if (p === "..") stack.pop(); | |
| 139 | else if (p !== ".") stack.push(p); | |
| 140 | } | |
| 141 | resolved_path = stack.join("/"); | |
| 142 | if (resolved_path) resolved.push(resolved_path); | |
| 143 | } | |
| 144 | return [...new Set(resolved)]; | |
| 145 | } | |
| 146 | ||
| 147 | /** Heuristic debt score for files not sent to Claude. */ | |
| 148 | function heuristicScore(todos: number, lines: number): number { | |
| 149 | return Math.min(100, todos * 5 + Math.min(50, Math.round(lines / 20))); | |
| 150 | } | |
| 151 | ||
| 152 | // ─── Main analyzer ──────────────────────────────────────────────────────────── | |
| 153 | ||
| 154 | export async function analyzeRepo( | |
| 155 | repoId: string, | |
| 156 | owner: string, | |
| 157 | repo: string | |
| 158 | ): Promise<DebtReport> { | |
| 159 | const repoPath = join(config.gitReposPath, owner, `${repo}.git`); | |
| 160 | ||
| 161 | // 1. List all files at HEAD | |
| 162 | const lsOutput = await gitExec(repoPath, ["ls-tree", "-r", "--name-only", "HEAD"]); | |
| 163 | const allFiles = lsOutput | |
| 164 | .split("\n") | |
| 165 | .map((f) => f.trim()) | |
| 166 | .filter((f) => f.length > 0 && isCodeFile(f) && !shouldSkip(f)) | |
| 167 | .slice(0, MAX_FILES); | |
| 168 | ||
| 169 | // 2. Get HEAD commit SHA | |
| 170 | const sha = (await gitExec(repoPath, ["rev-parse", "HEAD"])).trim(); | |
| 171 | ||
| 172 | // 3. Read each file, gather static metrics | |
| 173 | interface FileInfo { | |
| 174 | path: string; | |
| 175 | content: string; | |
| 176 | lines: number; | |
| 177 | todos: number; | |
| 178 | hint: string; | |
| 179 | imports: string[]; | |
| 180 | } | |
| 181 | ||
| 182 | const fileInfos: FileInfo[] = []; | |
| 183 | for (const path of allFiles) { | |
| 184 | const content = await gitExec(repoPath, ["show", `HEAD:${path}`]).catch(() => ""); | |
| 185 | const lines = countLines(content); | |
| 186 | const todos = countTodos(content); | |
| 187 | const hint = complexityHint(content, lines); | |
| 188 | const imports = extractImports(content, path); | |
| 189 | fileInfos.push({ path, content, lines, todos, hint, imports }); | |
| 190 | } | |
| 191 | ||
| 192 | // 4. Sort by line count desc; pick top 20 for Claude | |
| 193 | fileInfos.sort((a, b) => b.lines - a.lines); | |
| 194 | const topFiles = fileInfos.slice(0, MAX_AI_FILES); | |
| 195 | const restFiles = fileInfos.slice(MAX_AI_FILES); | |
| 196 | ||
| 197 | // 5. Call Claude for the top 20 | |
| 198 | let aiResults: Map<string, { debtScore: number; issues: string[]; estimatedHours: number }> = | |
| 199 | new Map(); | |
| 200 | ||
| 201 | if (topFiles.length > 0) { | |
| 202 | const prompt = `You are a senior engineer assessing technical debt. Rate each file's debt 0-100 and estimate cleanup hours. | |
| 203 | Return a JSON array only, no prose: [{"path":"...","debtScore":N,"issues":["..."],"estimatedHours":N}, ...] | |
| 204 | ||
| 205 | Files: | |
| 206 | ${JSON.stringify( | |
| 207 | topFiles.map((f) => ({ | |
| 208 | path: f.path, | |
| 209 | lines: f.lines, | |
| 210 | todos: f.todos, | |
| 211 | complexityHint: f.hint, | |
| 212 | })) | |
| 213 | )}`; | |
| 214 | ||
| 215 | try { | |
| 216 | const client = getAnthropic(); | |
| 217 | const msg = await client.messages.create({ | |
| 218 | model: MODEL_SONNET, | |
| 219 | max_tokens: 4096, | |
| 220 | messages: [{ role: "user", content: prompt }], | |
| 221 | }); | |
| 222 | const text = extractText(msg); | |
| 223 | type AiRow = { path: string; debtScore: number; issues: string[]; estimatedHours: number }; | |
| 224 | const parsed = parseJsonResponse<AiRow[]>(text); | |
| 225 | if (Array.isArray(parsed)) { | |
| 226 | for (const row of parsed) { | |
| 227 | if (row && typeof row.path === "string") { | |
| 228 | aiResults.set(row.path, { | |
| 229 | debtScore: Math.min(100, Math.max(0, Number(row.debtScore) || 0)), | |
| 230 | issues: Array.isArray(row.issues) | |
| 231 | ? row.issues.map(String) | |
| 232 | : [], | |
| 233 | estimatedHours: Math.max(0, Number(row.estimatedHours) || 0), | |
| 234 | }); | |
| 235 | } | |
| 236 | } | |
| 237 | } | |
| 238 | } catch { | |
| 239 | // Claude unavailable — fall through to heuristics for all | |
| 240 | } | |
| 241 | } | |
| 242 | ||
| 243 | // 6. Build nodes | |
| 244 | const nodes: DebtNode[] = []; | |
| 245 | ||
| 246 | for (const f of topFiles) { | |
| 247 | const ai = aiResults.get(f.path); | |
| 248 | if (ai) { | |
| 249 | nodes.push({ | |
| 250 | path: f.path, | |
| 251 | lines: f.lines, | |
| 252 | debtScore: ai.debtScore, | |
| 253 | issues: ai.issues, | |
| 254 | estimatedHours: ai.estimatedHours, | |
| 255 | imports: f.imports, | |
| 256 | }); | |
| 257 | } else { | |
| 258 | const score = heuristicScore(f.todos, f.lines); | |
| 259 | nodes.push({ | |
| 260 | path: f.path, | |
| 261 | lines: f.lines, | |
| 262 | debtScore: score, | |
| 263 | issues: buildHeuristicIssues(f.todos, f.lines, f.hint), | |
| 264 | estimatedHours: Math.round(score / 10), | |
| 265 | imports: f.imports, | |
| 266 | }); | |
| 267 | } | |
| 268 | } | |
| 269 | ||
| 270 | for (const f of restFiles) { | |
| 271 | const score = heuristicScore(f.todos, f.lines); | |
| 272 | nodes.push({ | |
| 273 | path: f.path, | |
| 274 | lines: f.lines, | |
| 275 | debtScore: score, | |
| 276 | issues: buildHeuristicIssues(f.todos, f.lines, f.hint), | |
| 277 | estimatedHours: Math.round(score / 10), | |
| 278 | imports: f.imports, | |
| 279 | }); | |
| 280 | } | |
| 281 | ||
| 282 | const totalDebtHours = nodes.reduce((sum, n) => sum + n.estimatedHours, 0); | |
| 283 | ||
| 284 | return { | |
| 285 | repoId, | |
| 286 | commitSha: sha, | |
| 287 | nodes, | |
| 288 | totalDebtHours, | |
| 289 | analyzedAt: new Date().toISOString(), | |
| 290 | }; | |
| 291 | } | |
| 292 | ||
| 293 | function buildHeuristicIssues(todos: number, lines: number, hint: string): string[] { | |
| 294 | const issues: string[] = []; | |
| 295 | if (todos > 0) issues.push(`${todos} TODO/FIXME comment${todos > 1 ? "s" : ""}`); | |
| 296 | if (lines > 800) issues.push("Very large file (>800 lines)"); | |
| 297 | else if (lines > 400) issues.push("Large file (>400 lines)"); | |
| 298 | if (hint !== "normal" && hint !== "empty" && hint !== "large file" && hint !== "very large file") { | |
| 299 | issues.push(hint.charAt(0).toUpperCase() + hint.slice(1)); | |
| 300 | } | |
| 301 | return issues; | |
| 302 | } |