Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

debt-analyzer.tsBlame302 lines · 1 contributor
fa97fd1Claude1/**
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
9import { join } from "path";
10import { config } from "./config";
11import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client";
12
13// ─── Types ────────────────────────────────────────────────────────────────────
14
15export 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
24export interface DebtReport {
25 repoId: string;
26 commitSha: string;
27 nodes: DebtNode[];
28 totalDebtHours: number;
29 analyzedAt: string;
30}
31
32// ─── Constants ────────────────────────────────────────────────────────────────
33
34const CODE_EXTENSIONS = new Set([
35 ".ts", ".tsx", ".js", ".jsx",
36 ".py", ".go", ".rs", ".java", ".rb", ".php",
37]);
38
39const SKIP_PATTERNS = ["node_modules/", "dist/", ".min.", "vendor/"];
40
41const MAX_FILES = 150;
42const MAX_AI_FILES = 20;
43
44// ─── Helpers ─────────────────────────────────────────────────────────────────
45
46/** Run a git command inside the bare repo directory for `owner/repo`. */
47async 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. */
62function 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. */
70function 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. */
77function countLines(content: string): number {
78 if (!content) return 0;
79 return content.split("\n").length;
80}
81
82/** Count TODO/FIXME/HACK/XXX occurrences. */
83function 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. */
88function 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 */
107function 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. */
148function 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
154export 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.
203Return a JSON array only, no prose: [{"path":"...","debtScore":N,"issues":["..."],"estimatedHours":N}, ...]
204
205Files:
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
293function 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}