Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitbcc4020unknown_key

feat: AI Technical Debt Map — visual debt graph with Claude analysis

feat: AI Technical Debt Map — visual debt graph with Claude analysis

Adds /:owner/:repo/debt-map — an interactive Canvas-based force-directed
graph that sizes each file node by line count and colours it by debt score
(green→yellow→red). The top 20 files by size are rated by Claude Sonnet in
a single batch call; remaining files get heuristic scores.

New files:
- src/lib/debt-analyzer.ts  — git ls-tree scan, static metrics, Claude batch analysis
- src/lib/debt-cache.ts     — in-memory TTL cache + job status tracking
- src/routes/debt-map.tsx   — GET page / POST analyze / GET data endpoints

Modified:
- src/views/components.tsx  — adds "debt-map" to RepoNavActive union + nav link
- src/app.tsx               — registers debtMapRoutes

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 7, 2026Parent: 5f245bf
5 files changed+16111bcc40203c68674dcb8fced77e273c5736357befb
5 changed files+1611−1
Modifiedsrc/app.tsx+3−0View fileUnifiedSplit
183183import pulseRoutes from "./routes/pulse";
184184import healthScoreRoutes from "./routes/health-score";
185185import hotFilesRoutes from "./routes/hot-files";
186import debtMapRoutes from "./routes/debt-map";
186187import developerProgramRoutes from "./routes/developer-program";
187188import shareRoutes from "./routes/share";
188189import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
714715app.route("/", healthScoreRoutes);
715716// Hot Files Heatmap — BLOCK M16 — /:owner/:repo/insights/hotfiles
716717app.route("/", hotFilesRoutes);
718// AI Technical Debt Map — /:owner/:repo/debt-map (visual debt graph + Claude analysis)
719app.route("/", debtMapRoutes);
717720// Hosted Claude tool-use loops — paste loop, get endpoint, billing meter.
718721// See src/routes/claude-deploy.tsx + src/lib/hosted-claude-loop.ts.
719722app.route("/", claudeDeployRoutes);
Addedsrc/lib/debt-analyzer.ts+302−0View fileUnifiedSplit
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
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}
Addedsrc/lib/debt-cache.ts+56−0View fileUnifiedSplit
1/**
2 * In-memory cache for DebtReport objects.
3 * Reports are invalidated after 1 hour.
4 */
5
6import type { DebtReport } from "./debt-analyzer";
7
8interface CacheEntry {
9 report: DebtReport;
10 cachedAt: number;
11}
12
13const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
14
15const cache = new Map<string, CacheEntry>();
16
17/** Returns the cached DebtReport if it's less than 1 hour old, else null. */
18export function getDebtReport(repoId: string): DebtReport | null {
19 const entry = cache.get(repoId);
20 if (!entry) return null;
21 if (Date.now() - entry.cachedAt > CACHE_TTL_MS) {
22 cache.delete(repoId);
23 return null;
24 }
25 return entry.report;
26}
27
28/** Store a DebtReport in the cache. */
29export function setDebtReport(repoId: string, report: DebtReport): void {
30 cache.set(repoId, { report, cachedAt: Date.now() });
31}
32
33/** Evict the cached report for a repository. */
34export function invalidateDebtReport(repoId: string): void {
35 cache.delete(repoId);
36}
37
38// ─── In-memory job status ─────────────────────────────────────────────────────
39
40export type JobStatus = "pending" | "running" | "done" | "error";
41
42interface JobEntry {
43 status: JobStatus;
44 error?: string;
45 startedAt: number;
46}
47
48const jobs = new Map<string, JobEntry>();
49
50export function getJobStatus(repoId: string): JobEntry | null {
51 return jobs.get(repoId) ?? null;
52}
53
54export function setJobStatus(repoId: string, status: JobStatus, error?: string): void {
55 jobs.set(repoId, { status, error, startedAt: Date.now() });
56}
Addedsrc/routes/debt-map.tsx+1241−0View fileUnifiedSplit
Large file (1,241 lines). Load full file
Modifiedsrc/views/components.tsx+9−1View fileUnifiedSplit
150150 | "agents"
151151 | "discussions"
152152 | "security"
153 | "settings";
153 | "settings"
154 | "debt-map";
154155}> = ({ owner, repo, active }) => (
155156 <div class="repo-nav">
156157 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
252253 >
253254 {"\u2728"} Tests
254255 </a>
256 <a
257 href={`/${owner}/${repo}/debt-map`}
258 class={`repo-nav-ai${active === "debt-map" ? " active" : ""}`}
259 title="AI Debt Map \u2014 visual technical debt graph with Claude analysis"
260 >
261 {"\u2593"} Debt Map
262 </a>
255263 </div>
256264);
257265
258266