tech-debt-visualizer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +26 -0
- package/README.md +166 -0
- package/dist/analyzers/base.d.ts +18 -0
- package/dist/analyzers/base.js +68 -0
- package/dist/analyzers/index.d.ts +4 -0
- package/dist/analyzers/index.js +5 -0
- package/dist/analyzers/javascript.d.ts +13 -0
- package/dist/analyzers/javascript.js +89 -0
- package/dist/analyzers/python.d.ts +13 -0
- package/dist/analyzers/python.js +77 -0
- package/dist/cleanliness-score.d.ts +11 -0
- package/dist/cleanliness-score.js +24 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +241 -0
- package/dist/discover.d.ts +7 -0
- package/dist/discover.js +70 -0
- package/dist/engine.d.ts +12 -0
- package/dist/engine.js +76 -0
- package/dist/git-analyzer.d.ts +27 -0
- package/dist/git-analyzer.js +133 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/llm.d.ts +28 -0
- package/dist/llm.js +227 -0
- package/dist/reports/html.d.ts +7 -0
- package/dist/reports/html.js +434 -0
- package/dist/reports/json.d.ts +2 -0
- package/dist/reports/json.js +19 -0
- package/dist/reports/markdown.d.ts +2 -0
- package/dist/reports/markdown.js +64 -0
- package/dist/types.d.ts +116 -0
- package/dist/types.js +5 -0
- package/package.json +47 -0
package/dist/llm.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM integration: debt explanations, per-file cleanliness, and overall assessment.
|
|
3
|
+
* Supports OpenAI, OpenRouter (OpenAI-compatible), and Google Gemini.
|
|
4
|
+
*/
|
|
5
|
+
const OPENAI_DEFAULT_MODEL = "gpt-4o-mini";
|
|
6
|
+
const OPENROUTER_DEFAULT_MODEL = "google/gemini-2.0-flash-001";
|
|
7
|
+
const GEMINI_DEFAULT_MODEL = "gemini-1.5-flash";
|
|
8
|
+
const DEFAULT_MAX_TOKENS = 300;
|
|
9
|
+
const DEFAULT_MAX_TOKENS_OVERALL = 500;
|
|
10
|
+
const OPENROUTER_BASE = "https://openrouter.ai/api/v1";
|
|
11
|
+
const GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
|
12
|
+
/** Extract first markdown code block and the prose before it. */
|
|
13
|
+
function parseCodeBlockAndProse(response) {
|
|
14
|
+
const match = response.match(/^(.*?)(?:```(?:\w*)\n?([\s\S]*?)```|$)/);
|
|
15
|
+
if (!match)
|
|
16
|
+
return { prose: response.trim() };
|
|
17
|
+
const prose = match[1].trim();
|
|
18
|
+
const code = match[2]?.trim();
|
|
19
|
+
return { prose, code: code || undefined };
|
|
20
|
+
}
|
|
21
|
+
/** Resolve provider and auth from config + env. OpenRouter and Gemini take precedence when their keys are set. */
|
|
22
|
+
export function resolveLLMConfig(config = {}) {
|
|
23
|
+
const openRouterKey = config.apiKey ?? process.env.OPENROUTER_API_KEY;
|
|
24
|
+
const geminiKey = config.apiKey ?? process.env.GEMINI_API_KEY ?? process.env.GOOGLE_GENAI_API_KEY;
|
|
25
|
+
const openaiKey = config.apiKey ?? process.env.OPENAI_API_KEY ?? process.env.ANTHROPIC_API_KEY;
|
|
26
|
+
if (openRouterKey) {
|
|
27
|
+
return {
|
|
28
|
+
provider: "openrouter",
|
|
29
|
+
apiKey: openRouterKey,
|
|
30
|
+
baseURL: config.baseURL ?? process.env.OPENROUTER_BASE_URL ?? OPENROUTER_BASE,
|
|
31
|
+
model: config.model ?? process.env.OPENROUTER_MODEL ?? OPENROUTER_DEFAULT_MODEL,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (geminiKey) {
|
|
35
|
+
return {
|
|
36
|
+
provider: "gemini",
|
|
37
|
+
apiKey: geminiKey,
|
|
38
|
+
baseURL: GEMINI_BASE,
|
|
39
|
+
model: config.model ?? process.env.GEMINI_MODEL ?? GEMINI_DEFAULT_MODEL,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (openaiKey) {
|
|
43
|
+
return {
|
|
44
|
+
provider: "openai",
|
|
45
|
+
apiKey: openaiKey,
|
|
46
|
+
baseURL: config.baseURL ?? process.env.OPENAI_BASE_URL ?? "",
|
|
47
|
+
model: config.model ?? process.env.OPENAI_MODEL ?? OPENAI_DEFAULT_MODEL,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
async function chat(prompt, opts) {
|
|
53
|
+
const provider = opts.provider ?? (opts.baseURL?.includes("openrouter") ? "openrouter" : "openai");
|
|
54
|
+
if (provider === "gemini") {
|
|
55
|
+
return geminiCompletion(prompt, { apiKey: opts.apiKey, model: opts.model, maxTokens: opts.maxTokens });
|
|
56
|
+
}
|
|
57
|
+
return openAICompatibleCompletion(prompt, opts);
|
|
58
|
+
}
|
|
59
|
+
async function openAICompatibleCompletion(prompt, opts) {
|
|
60
|
+
try {
|
|
61
|
+
const url = opts.baseURL
|
|
62
|
+
? `${opts.baseURL.replace(/\/$/, "")}/chat/completions`
|
|
63
|
+
: "https://api.openai.com/v1/chat/completions";
|
|
64
|
+
const res = await fetch(url, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: {
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
model: opts.model,
|
|
72
|
+
messages: [{ role: "user", content: prompt }],
|
|
73
|
+
max_tokens: opts.maxTokens,
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok)
|
|
77
|
+
return null;
|
|
78
|
+
const data = (await res.json());
|
|
79
|
+
const text = data.choices?.[0]?.message?.content?.trim();
|
|
80
|
+
return text || null;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function geminiCompletion(prompt, opts) {
|
|
87
|
+
try {
|
|
88
|
+
const modelId = opts.model.replace(/^models\//, "");
|
|
89
|
+
const url = `${GEMINI_BASE}/models/${modelId}:generateContent?key=${encodeURIComponent(opts.apiKey)}`;
|
|
90
|
+
const res = await fetch(url, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
95
|
+
generationConfig: {
|
|
96
|
+
maxOutputTokens: opts.maxTokens,
|
|
97
|
+
temperature: 0.2,
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
if (!res.ok)
|
|
102
|
+
return null;
|
|
103
|
+
const data = (await res.json());
|
|
104
|
+
const text = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
|
|
105
|
+
return text || null;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export async function enrichDebtWithInsights(items, fileContents, config = {}) {
|
|
112
|
+
const resolved = resolveLLMConfig(config);
|
|
113
|
+
if (!resolved)
|
|
114
|
+
return items;
|
|
115
|
+
const { provider, apiKey, baseURL, model } = resolved;
|
|
116
|
+
const maxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
117
|
+
const enriched = [];
|
|
118
|
+
const batchSize = 5;
|
|
119
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
120
|
+
const batch = items.slice(i, i + batchSize);
|
|
121
|
+
const results = await Promise.allSettled(batch.map((item) => explainDebtItem(item, fileContents.get(item.file) ?? "", {
|
|
122
|
+
apiKey,
|
|
123
|
+
baseURL,
|
|
124
|
+
model,
|
|
125
|
+
maxTokens,
|
|
126
|
+
provider,
|
|
127
|
+
})));
|
|
128
|
+
for (let j = 0; j < batch.length; j++) {
|
|
129
|
+
const item = batch[j];
|
|
130
|
+
const result = results[j];
|
|
131
|
+
if (result?.status === "fulfilled" && result.value) {
|
|
132
|
+
const v = result.value;
|
|
133
|
+
enriched.push({
|
|
134
|
+
...item,
|
|
135
|
+
insight: v.insight,
|
|
136
|
+
suggestedCode: v.suggestedCode,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
enriched.push(item);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return enriched;
|
|
145
|
+
}
|
|
146
|
+
async function explainDebtItem(item, fileContent, opts) {
|
|
147
|
+
const snippet = item.line
|
|
148
|
+
? fileContent.split("\n").slice(Math.max(0, item.line - 3), (item.endLine ?? item.line) + 2).join("\n")
|
|
149
|
+
: fileContent.slice(0, 1500);
|
|
150
|
+
const prompt = `You are a senior engineer reviewing technical debt. For this item:
|
|
151
|
+
|
|
152
|
+
1) In 1-2 sentences: why it matters for maintainability or risk, and what to do.
|
|
153
|
+
2) If a concrete code simplification or refactor is possible (e.g. reduce branching, extract function, simplify condition), provide ONLY the refactored/simplified code in a markdown code block. Use the same language as the snippet. If no code change is needed or the fix is trivial (e.g. "add a comment"), omit the code block.
|
|
154
|
+
|
|
155
|
+
Debt: ${item.title}
|
|
156
|
+
Category: ${item.category}
|
|
157
|
+
Description: ${item.description}
|
|
158
|
+
${item.metrics ? `Metrics: ${JSON.stringify(item.metrics)}` : ""}
|
|
159
|
+
|
|
160
|
+
Relevant code:
|
|
161
|
+
\`\`\`
|
|
162
|
+
${snippet}
|
|
163
|
+
\`\`\`
|
|
164
|
+
|
|
165
|
+
Reply format: Short explanation first, then optionally a code block with the suggested refactor. No other preamble.`;
|
|
166
|
+
const raw = await chat(prompt, { ...opts, maxTokens: 500 });
|
|
167
|
+
if (!raw)
|
|
168
|
+
return null;
|
|
169
|
+
const { prose, code } = parseCodeBlockAndProse(raw);
|
|
170
|
+
return { insight: prose || item.description, suggestedCode: code };
|
|
171
|
+
}
|
|
172
|
+
/** Per-file: LLM assesses cleanliness and optionally suggests a concrete code simplification. */
|
|
173
|
+
export async function assessFileCleanliness(filePath, content, metrics, config = {}) {
|
|
174
|
+
const resolved = resolveLLMConfig(config);
|
|
175
|
+
if (!resolved)
|
|
176
|
+
return null;
|
|
177
|
+
const snippet = content.length > 4000 ? content.slice(0, 4000) + "\n\n[... truncated]" : content;
|
|
178
|
+
const prompt = `You are a senior engineer assessing code cleanliness. For this file:
|
|
179
|
+
|
|
180
|
+
1) In 1-2 sentences: how clean and maintainable is it, and one concrete improvement (or "Looks good" if fine).
|
|
181
|
+
2) If one specific code simplification or streamlining is possible (e.g. simplify a function, reduce nesting, extract a helper), provide ONLY that refactored snippet in a markdown code block. Same language as the file. If no clear code change applies, omit the code block.
|
|
182
|
+
|
|
183
|
+
File: ${filePath}
|
|
184
|
+
Metrics: complexity ${metrics.cyclomaticComplexity ?? "?"}, lines ${metrics.lineCount}, ${metrics.hasDocumentation ? "has docs" : "no module docs"}${metrics.hotspotScore != null ? `, hotspot ${metrics.hotspotScore.toFixed(2)}` : ""}.
|
|
185
|
+
|
|
186
|
+
Code:
|
|
187
|
+
\`\`\`
|
|
188
|
+
${snippet}
|
|
189
|
+
\`\`\`
|
|
190
|
+
|
|
191
|
+
Reply: short assessment first, then optionally a code block with the suggested refactor. No preamble.`;
|
|
192
|
+
const raw = await chat(prompt, {
|
|
193
|
+
...resolved,
|
|
194
|
+
maxTokens: config.maxTokens ?? 400,
|
|
195
|
+
});
|
|
196
|
+
if (!raw)
|
|
197
|
+
return null;
|
|
198
|
+
const { prose, code } = parseCodeBlockAndProse(raw);
|
|
199
|
+
return { assessment: prose, suggestedCode: code };
|
|
200
|
+
}
|
|
201
|
+
/** Overall: LLM assesses the whole codebase cleanliness in a short paragraph. */
|
|
202
|
+
export async function assessOverallCleanliness(run, config = {}) {
|
|
203
|
+
const resolved = resolveLLMConfig(config);
|
|
204
|
+
if (!resolved)
|
|
205
|
+
return null;
|
|
206
|
+
const fileCount = run.fileMetrics.length;
|
|
207
|
+
const debtCount = run.debtItems.length;
|
|
208
|
+
const criticalHigh = run.debtItems.filter((d) => d.severity === "critical" || d.severity === "high").length;
|
|
209
|
+
const hotspots = run.fileMetrics.filter((m) => (m.hotspotScore ?? 0) > 0.3).length;
|
|
210
|
+
const topFiles = run.fileMetrics
|
|
211
|
+
.sort((a, b) => (b.hotspotScore ?? 0) - (a.hotspotScore ?? 0))
|
|
212
|
+
.slice(0, 12)
|
|
213
|
+
.map((m) => m.file);
|
|
214
|
+
const prompt = `You are a senior engineer giving a brief overall assessment of a codebase's technical debt and cleanliness.
|
|
215
|
+
|
|
216
|
+
Summary:
|
|
217
|
+
- ${fileCount} files analyzed
|
|
218
|
+
- ${debtCount} debt items (${criticalHigh} critical/high)
|
|
219
|
+
- ${hotspots} hotspot files (high churn + complexity)
|
|
220
|
+
- Top files by risk: ${topFiles.join(", ")}
|
|
221
|
+
|
|
222
|
+
In one short paragraph (3-5 sentences), assess overall cleanliness: main strengths or concerns, and the single most important thing to improve. Be direct and actionable. No preamble.`;
|
|
223
|
+
return chat(prompt, {
|
|
224
|
+
...resolved,
|
|
225
|
+
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS_OVERALL,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { getCleanlinessTier } from "../cleanliness-score.js";
|
|
3
|
+
function getDebtScore(run) {
|
|
4
|
+
const items = run.debtItems;
|
|
5
|
+
if (items.length === 0)
|
|
6
|
+
return 0;
|
|
7
|
+
const severityWeight = { low: 1, medium: 2, high: 3, critical: 4 };
|
|
8
|
+
const sum = items.reduce((a, b) => a + (severityWeight[b.severity] ?? 0) * b.confidence, 0);
|
|
9
|
+
return Math.min(100, Math.round((sum / items.length) * 25));
|
|
10
|
+
}
|
|
11
|
+
export async function generateHtmlReport(run, options) {
|
|
12
|
+
const { outputPath, title = "Technical Debt Report", darkMode = true } = options;
|
|
13
|
+
const html = buildHtml(run, title, darkMode);
|
|
14
|
+
await writeFile(outputPath, html, "utf-8");
|
|
15
|
+
}
|
|
16
|
+
function buildHtml(run, title, darkMode) {
|
|
17
|
+
const theme = darkMode ? "dark" : "light";
|
|
18
|
+
const debtScore = getDebtScore(run);
|
|
19
|
+
const cleanliness = getCleanlinessTier(debtScore);
|
|
20
|
+
const dataJson = JSON.stringify({
|
|
21
|
+
fileMetrics: run.fileMetrics,
|
|
22
|
+
debtItems: run.debtItems,
|
|
23
|
+
debtTrend: run.debtTrend ?? [],
|
|
24
|
+
llmOverallAssessment: run.llmOverallAssessment ?? null,
|
|
25
|
+
summary: {
|
|
26
|
+
filesAnalyzed: run.fileMetrics.length,
|
|
27
|
+
debtCount: run.debtItems.length,
|
|
28
|
+
debtScore,
|
|
29
|
+
cleanlinessTier: cleanliness.tier,
|
|
30
|
+
cleanlinessLabel: cleanliness.label,
|
|
31
|
+
cleanlinessDescription: cleanliness.description,
|
|
32
|
+
repoPath: run.repoPath,
|
|
33
|
+
completedAt: run.completedAt ?? run.startedAt,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
return `<!DOCTYPE html>
|
|
37
|
+
<html lang="en" data-theme="${theme}">
|
|
38
|
+
<head>
|
|
39
|
+
<meta charset="UTF-8" />
|
|
40
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
41
|
+
<title>${escapeHtml(title)}</title>
|
|
42
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
|
43
|
+
<style>
|
|
44
|
+
:root {
|
|
45
|
+
--bg: #0f0f12;
|
|
46
|
+
--surface: #1a1a1f;
|
|
47
|
+
--border: #2a2a32;
|
|
48
|
+
--text: #e4e4e7;
|
|
49
|
+
--text-muted: #71717a;
|
|
50
|
+
--accent: #6366f1;
|
|
51
|
+
--accent-dim: #4f46e5;
|
|
52
|
+
--green: #22c55e;
|
|
53
|
+
--yellow: #eab308;
|
|
54
|
+
--red: #ef4444;
|
|
55
|
+
--gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%);
|
|
56
|
+
}
|
|
57
|
+
[data-theme="light"] {
|
|
58
|
+
--bg: #fafafa;
|
|
59
|
+
--surface: #fff;
|
|
60
|
+
--border: #e4e4e7;
|
|
61
|
+
--text: #18181b;
|
|
62
|
+
--text-muted: #71717a;
|
|
63
|
+
}
|
|
64
|
+
* { box-sizing: border-box; }
|
|
65
|
+
body {
|
|
66
|
+
margin: 0;
|
|
67
|
+
font-family: 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
68
|
+
background: var(--bg);
|
|
69
|
+
color: var(--text);
|
|
70
|
+
min-height: 100vh;
|
|
71
|
+
letter-spacing: -0.01em;
|
|
72
|
+
}
|
|
73
|
+
.container { max-width: 1100px; margin: 0 auto; padding: 2.5rem 2rem; }
|
|
74
|
+
.hero-caption { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-muted); margin: 0 0 0.75rem; font-weight: 600; }
|
|
75
|
+
.hero {
|
|
76
|
+
display: flex;
|
|
77
|
+
align-items: stretch;
|
|
78
|
+
gap: 0;
|
|
79
|
+
margin-bottom: 2.5rem;
|
|
80
|
+
border-radius: 16px;
|
|
81
|
+
overflow: hidden;
|
|
82
|
+
background: var(--surface);
|
|
83
|
+
border: 1px solid var(--border);
|
|
84
|
+
}
|
|
85
|
+
.hero-grade {
|
|
86
|
+
width: 140px;
|
|
87
|
+
flex-shrink: 0;
|
|
88
|
+
display: flex;
|
|
89
|
+
flex-direction: column;
|
|
90
|
+
align-items: center;
|
|
91
|
+
justify-content: center;
|
|
92
|
+
padding: 2rem 1.5rem;
|
|
93
|
+
background: var(--tier-bg);
|
|
94
|
+
border-right: 1px solid var(--border);
|
|
95
|
+
}
|
|
96
|
+
.hero-grade .num { font-size: 3rem; font-weight: 800; line-height: 1; color: var(--tier-fg); letter-spacing: -0.03em; }
|
|
97
|
+
.hero-grade .of { font-size: 0.75rem; color: var(--tier-fg); opacity: 0.7; margin-top: 0.25rem; text-transform: uppercase; letter-spacing: 0.1em; }
|
|
98
|
+
.hero-body { flex: 1; padding: 2rem 2.25rem; min-width: 0; }
|
|
99
|
+
.hero-body .score-label { font-size: 1.35rem; font-weight: 700; color: var(--text); margin: 0 0 0.35rem; letter-spacing: -0.02em; }
|
|
100
|
+
.hero-body .score-desc { font-size: 0.9rem; color: var(--text-muted); line-height: 1.45; margin: 0; }
|
|
101
|
+
.hero-body .report-meta { font-size: 0.8rem; color: var(--text-muted); margin-top: 1.25rem; }
|
|
102
|
+
.hero.tier-1 { --tier-bg: #1c1917; --tier-fg: #f87171; }
|
|
103
|
+
.hero.tier-2 { --tier-bg: #1c1917; --tier-fg: #fb923c; }
|
|
104
|
+
.hero.tier-3 { --tier-bg: #1c1917; --tier-fg: #facc15; }
|
|
105
|
+
.hero.tier-4 { --tier-bg: #0f172a; --tier-fg: #38bdf8; }
|
|
106
|
+
.hero.tier-5 { --tier-bg: #052e16; --tier-fg: #4ade80; }
|
|
107
|
+
[data-theme="light"] .hero.tier-1 { --tier-bg: #fef2f2; --tier-fg: #dc2626; }
|
|
108
|
+
[data-theme="light"] .hero.tier-2 { --tier-bg: #fff7ed; --tier-fg: #ea580c; }
|
|
109
|
+
[data-theme="light"] .hero.tier-3 { --tier-bg: #fefce8; --tier-fg: #ca8a04; }
|
|
110
|
+
[data-theme="light"] .hero.tier-4 { --tier-bg: #f0f9ff; --tier-fg: #0284c7; }
|
|
111
|
+
[data-theme="light"] .hero.tier-5 { --tier-bg: #f0fdf4; --tier-fg: #16a34a; }
|
|
112
|
+
.summary-cards {
|
|
113
|
+
display: grid;
|
|
114
|
+
grid-template-columns: repeat(4, 1fr);
|
|
115
|
+
gap: 1rem;
|
|
116
|
+
margin-bottom: 2.5rem;
|
|
117
|
+
}
|
|
118
|
+
@media (max-width: 640px) { .summary-cards { grid-template-columns: repeat(2, 1fr); } }
|
|
119
|
+
.card {
|
|
120
|
+
background: var(--surface);
|
|
121
|
+
border: 1px solid var(--border);
|
|
122
|
+
border-radius: 12px;
|
|
123
|
+
padding: 1.25rem 1.35rem;
|
|
124
|
+
transition: border-color 0.2s;
|
|
125
|
+
}
|
|
126
|
+
.card:hover { border-color: var(--text-muted); }
|
|
127
|
+
.card .value { font-size: 1.65rem; font-weight: 700; color: var(--text); letter-spacing: -0.02em; }
|
|
128
|
+
.card .label { font-size: 0.72rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 0.2rem; }
|
|
129
|
+
.section {
|
|
130
|
+
background: var(--surface);
|
|
131
|
+
border: 1px solid var(--border);
|
|
132
|
+
border-radius: 12px;
|
|
133
|
+
padding: 1.5rem;
|
|
134
|
+
margin-bottom: 1.5rem;
|
|
135
|
+
}
|
|
136
|
+
.section h2 { font-size: 1.1rem; margin: 0 0 1rem; color: var(--text-muted); font-weight: 600; }
|
|
137
|
+
#treemap {
|
|
138
|
+
display: flex;
|
|
139
|
+
flex-wrap: wrap;
|
|
140
|
+
gap: 4px;
|
|
141
|
+
min-height: 280px;
|
|
142
|
+
}
|
|
143
|
+
.treemap-cell {
|
|
144
|
+
border-radius: 6px;
|
|
145
|
+
display: flex;
|
|
146
|
+
align-items: flex-end;
|
|
147
|
+
padding: 6px 8px;
|
|
148
|
+
font-size: 0.7rem;
|
|
149
|
+
cursor: pointer;
|
|
150
|
+
transition: transform 0.15s, filter 0.15s;
|
|
151
|
+
overflow: hidden;
|
|
152
|
+
text-overflow: ellipsis;
|
|
153
|
+
white-space: nowrap;
|
|
154
|
+
}
|
|
155
|
+
.treemap-cell:hover { transform: scale(1.03); filter: brightness(1.1); }
|
|
156
|
+
.treemap-cell[data-severity="critical"] { background: linear-gradient(180deg, #ef4444 0%, #b91c1c 100%); color: #fff; }
|
|
157
|
+
.treemap-cell[data-severity="high"] { background: linear-gradient(180deg, #f59e0b 0%, #d97706 100%); color: #fff; }
|
|
158
|
+
.treemap-cell[data-severity="medium"] { background: linear-gradient(180deg, #eab308 0%, #ca8a04 100%); color: #1a1a1f; }
|
|
159
|
+
.treemap-cell[data-severity="low"] { background: linear-gradient(180deg, #22c55e 0%, #16a34a 100%); color: #fff; }
|
|
160
|
+
.treemap-cell[data-severity="none"] { background: var(--border); color: var(--text-muted); }
|
|
161
|
+
#trendChart { max-height: 220px; }
|
|
162
|
+
.debt-list { list-style: none; padding: 0; margin: 0; }
|
|
163
|
+
.debt-list li {
|
|
164
|
+
border-bottom: 1px solid var(--border);
|
|
165
|
+
padding: 0.85rem 0;
|
|
166
|
+
cursor: pointer;
|
|
167
|
+
transition: background 0.15s;
|
|
168
|
+
}
|
|
169
|
+
.debt-list li:last-child { border-bottom: none; }
|
|
170
|
+
.debt-list li:hover { background: rgba(99, 102, 241, 0.08); }
|
|
171
|
+
.debt-list .title { font-weight: 600; margin-bottom: 0.25rem; }
|
|
172
|
+
.debt-list .meta { font-size: 0.8rem; color: var(--text-muted); }
|
|
173
|
+
.debt-list .insight { font-size: 0.85rem; color: var(--text-muted); margin-top: 0.35rem; line-height: 1.4; }
|
|
174
|
+
.badge { display: inline-block; padding: 0.2em 0.5em; border-radius: 4px; font-size: 0.7rem; font-weight: 600; }
|
|
175
|
+
.badge-critical { background: #ef4444; color: #fff; }
|
|
176
|
+
.badge-high { background: #f59e0b; color: #fff; }
|
|
177
|
+
.badge-medium { background: #eab308; color: #1a1a1f; }
|
|
178
|
+
.badge-low { background: #22c55e; color: #fff; }
|
|
179
|
+
#detail {
|
|
180
|
+
position: fixed;
|
|
181
|
+
inset: 0;
|
|
182
|
+
background: rgba(0,0,0,0.6);
|
|
183
|
+
display: none;
|
|
184
|
+
align-items: center;
|
|
185
|
+
justify-content: center;
|
|
186
|
+
z-index: 100;
|
|
187
|
+
padding: 2rem;
|
|
188
|
+
}
|
|
189
|
+
#detail.show { display: flex; }
|
|
190
|
+
#detail .panel {
|
|
191
|
+
background: var(--surface);
|
|
192
|
+
border: 1px solid var(--border);
|
|
193
|
+
border-radius: 16px;
|
|
194
|
+
max-width: 520px;
|
|
195
|
+
width: 100%;
|
|
196
|
+
max-height: 85vh;
|
|
197
|
+
overflow: auto;
|
|
198
|
+
padding: 1.5rem;
|
|
199
|
+
}
|
|
200
|
+
#detail .panel h3 { margin: 0 0 0.5rem; }
|
|
201
|
+
#detail .panel .file { font-family: monospace; font-size: 0.85rem; color: var(--accent); }
|
|
202
|
+
#detail .panel .explanation { margin-top: 1rem; line-height: 1.5; color: var(--text-muted); }
|
|
203
|
+
#detail .panel .close-hint { margin-top: 1rem; font-size: 0.75rem; color: var(--text-muted); opacity: 0.8; }
|
|
204
|
+
#detail .panel .file-assessment { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); font-size: 0.9rem; color: var(--text-muted); line-height: 1.5; }
|
|
205
|
+
#detail .panel .file-assessment strong { color: var(--text); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
206
|
+
#detail .panel .suggested-code { margin-top: 1rem; padding: 0.75rem; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; font-size: 0.8rem; overflow-x: auto; }
|
|
207
|
+
#detail .panel .suggested-code pre { margin: 0; white-space: pre-wrap; }
|
|
208
|
+
.llm-overall .llm-overall-text { margin: 0; color: var(--text); line-height: 1.6; }
|
|
209
|
+
.priority-matrix { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; }
|
|
210
|
+
.priority-matrix .quadrant { padding: 1rem; border-radius: 8px; border: 1px solid var(--border); }
|
|
211
|
+
.priority-matrix .quadrant h4 { margin: 0 0 0.5rem; font-size: 0.9rem; }
|
|
212
|
+
.priority-matrix .quadrant p { margin: 0; font-size: 0.8rem; color: var(--text-muted); }
|
|
213
|
+
.glossary {
|
|
214
|
+
background: var(--surface);
|
|
215
|
+
border: 1px solid var(--border);
|
|
216
|
+
border-radius: 12px;
|
|
217
|
+
padding: 1.25rem 1.5rem;
|
|
218
|
+
margin-bottom: 1.5rem;
|
|
219
|
+
}
|
|
220
|
+
.glossary h2 { font-size: 0.95rem; margin: 0 0 0.75rem; color: var(--text-muted); font-weight: 600; }
|
|
221
|
+
.glossary dl { margin: 0; font-size: 0.875rem; line-height: 1.6; }
|
|
222
|
+
.glossary dt { font-weight: 600; color: var(--text); margin-top: 0.5rem; }
|
|
223
|
+
.glossary dt:first-child { margin-top: 0; }
|
|
224
|
+
.glossary dd { margin: 0.2rem 0 0 1rem; color: var(--text-muted); }
|
|
225
|
+
.section-desc { font-size: 0.875rem; color: var(--text-muted); margin: -0.5rem 0 1rem; line-height: 1.45; }
|
|
226
|
+
.legend { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; margin-bottom: 1rem; font-size: 0.8rem; color: var(--text-muted); }
|
|
227
|
+
.legend span { display: inline-flex; align-items: center; gap: 0.35rem; }
|
|
228
|
+
.legend .swatch { width: 12px; height: 12px; border-radius: 3px; }
|
|
229
|
+
.legend .swatch-crit { background: linear-gradient(135deg, #ef4444, #b91c1c); }
|
|
230
|
+
.legend .swatch-high { background: linear-gradient(135deg, #f59e0b, #d97706); }
|
|
231
|
+
.legend .swatch-med { background: linear-gradient(135deg, #eab308, #ca8a04); }
|
|
232
|
+
.legend .swatch-low { background: linear-gradient(135deg, #22c55e, #16a34a); }
|
|
233
|
+
.legend .swatch-none { background: var(--border); }
|
|
234
|
+
</style>
|
|
235
|
+
</head>
|
|
236
|
+
<body>
|
|
237
|
+
<div class="container">
|
|
238
|
+
<p class="hero-caption">Technical Debt Cleanliness Score</p>
|
|
239
|
+
<div class="hero tier-${cleanliness.tier}">
|
|
240
|
+
<div class="hero-grade">
|
|
241
|
+
<span class="num">${cleanliness.tier}</span>
|
|
242
|
+
<span class="of">of 5</span>
|
|
243
|
+
</div>
|
|
244
|
+
<div class="hero-body">
|
|
245
|
+
<div class="score-label">${escapeHtml(cleanliness.label)}</div>
|
|
246
|
+
<p class="score-desc">${escapeHtml(cleanliness.description)}</p>
|
|
247
|
+
<p class="report-meta">${escapeHtml(run.repoPath)} · ${run.completedAt ?? run.startedAt}</p>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<div class="summary-cards">
|
|
252
|
+
<div class="card"><div class="value">${run.fileMetrics.length}</div><div class="label">Files</div></div>
|
|
253
|
+
<div class="card"><div class="value">${run.debtItems.length}</div><div class="label">Debt items</div></div>
|
|
254
|
+
<div class="card"><div class="value">${run.debtItems.filter(d => d.severity === 'high' || d.severity === 'critical').length}</div><div class="label">High / Critical</div></div>
|
|
255
|
+
<div class="card"><div class="value">${run.fileMetrics.filter(m => (m.hotspotScore ?? 0) > 0.3).length}</div><div class="label">Hotspots</div></div>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
${run.llmOverallAssessment ? `
|
|
259
|
+
<div class="section llm-overall">
|
|
260
|
+
<h2>LLM overall assessment</h2>
|
|
261
|
+
<p class="llm-overall-text">${escapeHtml(run.llmOverallAssessment)}</p>
|
|
262
|
+
</div>
|
|
263
|
+
` : ""}
|
|
264
|
+
|
|
265
|
+
<div class="glossary">
|
|
266
|
+
<h2>Understanding this report</h2>
|
|
267
|
+
<dl>
|
|
268
|
+
<dt>Debt score (0–100)</dt>
|
|
269
|
+
<dd>Combined severity and confidence of all issues. Lower is better. <40 = healthy, 40–70 = address soon, 70+ = high priority.</dd>
|
|
270
|
+
<dt>Severity</dt>
|
|
271
|
+
<dd><strong>Critical</strong> = fix first. <strong>High</strong> = plan soon. <strong>Medium/Low</strong> = backlog.</dd>
|
|
272
|
+
<dt>Cyclomatic complexity</dt>
|
|
273
|
+
<dd>Decision paths in code (if/else, loops). >10 high, >20 critical.</dd>
|
|
274
|
+
<dt>Hotspot</dt>
|
|
275
|
+
<dd>Files that change often and have high complexity—highest refactor risk.</dd>
|
|
276
|
+
<dt>Trend chart</dt>
|
|
277
|
+
<dd>Heuristic from recent commits; shows churn pattern, not full history.</dd>
|
|
278
|
+
</dl>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<div class="section">
|
|
282
|
+
<h2>Debt distribution by file</h2>
|
|
283
|
+
<p class="section-desc">Each block is a file. <strong>Size</strong> = complexity + churn (larger = heavier). <strong>Color</strong> = worst severity in that file. Click a block to see details.</p>
|
|
284
|
+
<div class="legend">
|
|
285
|
+
<span><span class="swatch swatch-crit"></span> Critical</span>
|
|
286
|
+
<span><span class="swatch swatch-high"></span> High</span>
|
|
287
|
+
<span><span class="swatch swatch-med"></span> Medium</span>
|
|
288
|
+
<span><span class="swatch swatch-low"></span> Low</span>
|
|
289
|
+
<span><span class="swatch swatch-none"></span> No debt</span>
|
|
290
|
+
</div>
|
|
291
|
+
<div id="treemap"></div>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<div class="section">
|
|
295
|
+
<h2>Debt trend (recent commits)</h2>
|
|
296
|
+
<p class="section-desc">Estimated activity per commit (files changed). Rising pattern may indicate growing churn; not a full historical debt metric.</p>
|
|
297
|
+
<canvas id="trendChart"></canvas>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
<div class="section">
|
|
301
|
+
<h2>Prioritized recommendations</h2>
|
|
302
|
+
<p class="section-desc">Focus on high-impact items first. Easy wins = high severity but smaller files; harder = critical or hotspot files that need planning.</p>
|
|
303
|
+
<div class="priority-matrix">
|
|
304
|
+
<div class="quadrant"><h4>High impact, easier</h4><p>High severity in smaller or less complex files. Good first targets.</p><ul id="q1"></ul></div>
|
|
305
|
+
<div class="quadrant"><h4>High impact, harder</h4><p>Critical or hotspot files. Plan refactors and tests.</p><ul id="q2"></ul></div>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<div class="section">
|
|
310
|
+
<h2>All debt items</h2>
|
|
311
|
+
<p class="section-desc">Full list by severity. Click a row to see the detail panel.</p>
|
|
312
|
+
<ul class="debt-list" id="debtList"></ul>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<div id="detail"><div class="panel"><h3 id="detailTitle"></h3><div class="file" id="detailFile"></div><div class="explanation" id="detailExplanation"></div><div class="suggested-code" id="detailSuggestedCode"></div><div class="file-assessment" id="detailFileAssessment"></div><p class="close-hint">Click outside to close</p></div></div>
|
|
317
|
+
|
|
318
|
+
<script>
|
|
319
|
+
const DATA = ${dataJson};
|
|
320
|
+
|
|
321
|
+
function escapeHtml(s) {
|
|
322
|
+
const div = document.createElement('div');
|
|
323
|
+
div.textContent = s;
|
|
324
|
+
return div.innerHTML;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Treemap: file blocks by debt weight
|
|
328
|
+
const fileScores = DATA.fileMetrics.map(m => ({
|
|
329
|
+
file: m.file,
|
|
330
|
+
score: (m.cyclomaticComplexity ?? 0) * 2 + (m.hotspotScore ?? 0) * 50 + (m.lineCount ?? 0) / 10,
|
|
331
|
+
complexity: m.cyclomaticComplexity ?? 0,
|
|
332
|
+
hotspot: m.hotspotScore ?? 0,
|
|
333
|
+
})).filter(x => x.score > 0).sort((a,b) => b.score - a.score).slice(0, 60);
|
|
334
|
+
const maxScore = Math.max(...fileScores.map(x => x.score), 1);
|
|
335
|
+
const debtByFile = new Map();
|
|
336
|
+
DATA.debtItems.forEach(d => {
|
|
337
|
+
const arr = debtByFile.get(d.file) || [];
|
|
338
|
+
arr.push(d);
|
|
339
|
+
debtByFile.set(d.file, arr);
|
|
340
|
+
});
|
|
341
|
+
const treemap = document.getElementById('treemap');
|
|
342
|
+
fileScores.forEach(({ file, score }) => {
|
|
343
|
+
const items = debtByFile.get(file) || [];
|
|
344
|
+
const severity = items.length ? items.reduce((a,b) => (a === 'critical' || b.severity === 'critical' ? 'critical' : b.severity === 'high' ? 'high' : b.severity), 'low') : 'none';
|
|
345
|
+
const cell = document.createElement('div');
|
|
346
|
+
cell.className = 'treemap-cell';
|
|
347
|
+
cell.dataset.severity = severity;
|
|
348
|
+
cell.style.flex = String(score / maxScore * 100) + ' 1 80px';
|
|
349
|
+
cell.style.minWidth = '80px';
|
|
350
|
+
cell.title = file;
|
|
351
|
+
cell.textContent = file.split('/').pop() || file;
|
|
352
|
+
cell.addEventListener('click', () => showDetail(file, items));
|
|
353
|
+
treemap.appendChild(cell);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Trend chart
|
|
357
|
+
const ctx = document.getElementById('trendChart').getContext('2d');
|
|
358
|
+
new Chart(ctx, {
|
|
359
|
+
type: 'line',
|
|
360
|
+
data: {
|
|
361
|
+
labels: (DATA.debtTrend || []).map(t => t.commit),
|
|
362
|
+
datasets: [{
|
|
363
|
+
label: 'Debt score',
|
|
364
|
+
data: (DATA.debtTrend || []).map(t => t.score),
|
|
365
|
+
borderColor: '#6366f1',
|
|
366
|
+
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
|
367
|
+
fill: true,
|
|
368
|
+
tension: 0.3,
|
|
369
|
+
}]
|
|
370
|
+
},
|
|
371
|
+
options: {
|
|
372
|
+
responsive: true,
|
|
373
|
+
plugins: { legend: { display: false } },
|
|
374
|
+
scales: {
|
|
375
|
+
y: { beginAtZero: true, grid: { color: 'var(--border)' }, ticks: { color: 'var(--text-muted)' } },
|
|
376
|
+
x: { grid: { color: 'var(--border)' }, ticks: { color: 'var(--text-muted)' } }
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Priority quadrants
|
|
382
|
+
const highImpact = DATA.debtItems.filter(d => d.severity === 'high' || d.severity === 'critical').slice(0, 5);
|
|
383
|
+
document.getElementById('q1').innerHTML = highImpact.slice(0, 3).map(d => '<li style="font-size:0.8rem">' + escapeHtml(d.file) + '</li>').join('');
|
|
384
|
+
document.getElementById('q2').innerHTML = highImpact.slice(3, 6).map(d => '<li style="font-size:0.8rem">' + escapeHtml(d.file) + '</li>').join('');
|
|
385
|
+
|
|
386
|
+
// Debt list
|
|
387
|
+
const list = document.getElementById('debtList');
|
|
388
|
+
const sev = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
389
|
+
DATA.debtItems.sort((a,b) => (sev[b.severity] || 0) - (sev[a.severity] || 0)).forEach(d => {
|
|
390
|
+
const li = document.createElement('li');
|
|
391
|
+
li.innerHTML = '<span class="title">' + escapeHtml(d.title) + '</span> <span class="badge badge-' + d.severity + '">' + d.severity + '</span>' + (d.suggestedCode ? ' <span class="badge" style="background:var(--accent);color:#fff">refactor</span>' : '') + '<br><span class="meta">' + escapeHtml(d.file) + (d.line ? ':' + d.line : '') + '</span>' + (d.insight ? '<div class="insight">' + escapeHtml(d.insight) + '</div>' : '');
|
|
392
|
+
li.addEventListener('click', () => showDetail(d.file, [d]));
|
|
393
|
+
list.appendChild(li);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
function showDetail(file, items) {
|
|
397
|
+
const panel = document.getElementById('detail');
|
|
398
|
+
const item = items.length ? items[0] : null;
|
|
399
|
+
document.getElementById('detailTitle').textContent = item ? item.title : 'No debt items';
|
|
400
|
+
document.getElementById('detailFile').textContent = file;
|
|
401
|
+
document.getElementById('detailExplanation').textContent = item && item.insight ? item.insight : (item ? item.description : '');
|
|
402
|
+
const codeEl = document.getElementById('detailSuggestedCode');
|
|
403
|
+
if (item && item.suggestedCode) {
|
|
404
|
+
codeEl.style.display = 'block';
|
|
405
|
+
codeEl.innerHTML = '<strong>Suggested refactor</strong><pre>' + escapeHtml(item.suggestedCode) + '</pre>';
|
|
406
|
+
} else {
|
|
407
|
+
codeEl.style.display = 'none';
|
|
408
|
+
codeEl.textContent = '';
|
|
409
|
+
}
|
|
410
|
+
const fileMetric = DATA.fileMetrics.find(m => m.file === file);
|
|
411
|
+
const fileAssessEl = document.getElementById('detailFileAssessment');
|
|
412
|
+
if (fileMetric && (fileMetric.llmAssessment || fileMetric.llmSuggestedCode)) {
|
|
413
|
+
fileAssessEl.style.display = 'block';
|
|
414
|
+
let html = '<strong>LLM file assessment</strong><br>' + (fileMetric.llmAssessment ? escapeHtml(fileMetric.llmAssessment) : '');
|
|
415
|
+
if (fileMetric.llmSuggestedCode) html += '<br><br><strong>Suggested refactor</strong><pre style="margin:0.5rem 0 0;padding:0.5rem;background:var(--bg);border-radius:6px;font-size:0.8rem;overflow-x:auto">' + escapeHtml(fileMetric.llmSuggestedCode) + '</pre>';
|
|
416
|
+
fileAssessEl.innerHTML = html;
|
|
417
|
+
} else {
|
|
418
|
+
fileAssessEl.style.display = 'none';
|
|
419
|
+
fileAssessEl.textContent = '';
|
|
420
|
+
}
|
|
421
|
+
panel.classList.add('show');
|
|
422
|
+
panel.onclick = e => { if (e.target === panel) panel.classList.remove('show'); };
|
|
423
|
+
}
|
|
424
|
+
</script>
|
|
425
|
+
</body>
|
|
426
|
+
</html>`;
|
|
427
|
+
}
|
|
428
|
+
function escapeHtml(s) {
|
|
429
|
+
return s
|
|
430
|
+
.replace(/&/g, "&")
|
|
431
|
+
.replace(/</g, "<")
|
|
432
|
+
.replace(/>/g, ">")
|
|
433
|
+
.replace(/"/g, """);
|
|
434
|
+
}
|