tech-debt-visualizer 0.1.6 → 0.2.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/dist/cli.js +84 -64
- package/dist/debt-score.d.ts +12 -0
- package/dist/debt-score.js +32 -0
- package/dist/llm.d.ts +23 -9
- package/dist/llm.js +157 -86
- package/dist/reports/assets/report.css +322 -0
- package/dist/reports/assets/report.js +251 -0
- package/dist/reports/html.js +163 -294
- package/dist/reports/json.js +3 -1
- package/dist/reports/markdown.js +2 -9
- package/dist/types.d.ts +22 -0
- package/dist/types.js +7 -1
- package/package.json +2 -2
package/dist/llm.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LLM integration: debt explanations, per-file cleanliness, and overall assessment.
|
|
3
3
|
* Supports OpenAI, OpenRouter (OpenAI-compatible), and Google Gemini.
|
|
4
|
+
*
|
|
5
|
+
* No time limits: requests run until the API returns. Truncation is only from token limits.
|
|
6
|
+
* Override with LLMConfig.maxTokens or --llm-max-tokens. Defaults are generous to avoid cut-off:
|
|
7
|
+
* - Debt item insights (explainDebtItem): config.maxTokens ?? DEFAULT_MAX_TOKENS (2048)
|
|
8
|
+
* - Per-file assessment (assessFileCleanliness): config.maxTokens ?? DEFAULT_MAX_TOKENS_FILE (8192)
|
|
9
|
+
* - Overall assessment (assessOverallCleanliness): config.maxTokens ?? DEFAULT_MAX_TOKENS_OVERALL (8192)
|
|
10
|
+
* - enrichDebtWithInsights: passes config.maxTokens ?? DEFAULT_MAX_TOKENS to each item
|
|
4
11
|
*/
|
|
5
12
|
const OPENAI_DEFAULT_MODEL = "gpt-4o-mini";
|
|
6
|
-
const OPENROUTER_DEFAULT_MODEL = "google/gemini-2.
|
|
7
|
-
const GEMINI_DEFAULT_MODEL = "gemini-
|
|
8
|
-
|
|
9
|
-
const
|
|
13
|
+
const OPENROUTER_DEFAULT_MODEL = "google/gemini-2.5-flash";
|
|
14
|
+
const GEMINI_DEFAULT_MODEL = "gemini-2.5-flash";
|
|
15
|
+
/** Default for enrichDebtWithInsights (debt item insights). Override with config.maxTokens. */
|
|
16
|
+
const DEFAULT_MAX_TOKENS = 2048;
|
|
17
|
+
/** Default for assessFileCleanliness. */
|
|
18
|
+
const DEFAULT_MAX_TOKENS_FILE = 8192;
|
|
19
|
+
/** Default for assessOverallCleanliness. Override with config.maxTokens. */
|
|
20
|
+
const DEFAULT_MAX_TOKENS_OVERALL = 8192;
|
|
10
21
|
const OPENROUTER_BASE = "https://openrouter.ai/api/v1";
|
|
11
22
|
const GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
|
12
23
|
/** Extract first markdown code block and the prose before it. */
|
|
@@ -18,11 +29,70 @@ function parseCodeBlockAndProse(response) {
|
|
|
18
29
|
const code = match[2]?.trim();
|
|
19
30
|
return { prose, code: code || undefined };
|
|
20
31
|
}
|
|
21
|
-
/**
|
|
32
|
+
/** Parse trailing "severity" line and "score" line from LLM text; return assessment + optional score/severity. */
|
|
33
|
+
function parseSeverityAndScore(lines) {
|
|
34
|
+
let assessment = lines.join("\n").trim();
|
|
35
|
+
let score;
|
|
36
|
+
let severity;
|
|
37
|
+
if (lines.length > 0) {
|
|
38
|
+
const lastLine = lines[lines.length - 1];
|
|
39
|
+
const scoreMatch = lastLine.match(/^\s*(\d{1,3})\s*$/);
|
|
40
|
+
if (scoreMatch) {
|
|
41
|
+
score = Math.min(100, Math.max(0, parseInt(scoreMatch[1], 10)));
|
|
42
|
+
const rest = lines.length > 1 ? lines.slice(0, -1) : [];
|
|
43
|
+
if (rest.length > 0) {
|
|
44
|
+
const severityLine = rest[rest.length - 1];
|
|
45
|
+
const sevMatch = severityLine.match(/^\s*(critical|high|medium|low|none)\s*$/i);
|
|
46
|
+
if (sevMatch) {
|
|
47
|
+
severity = sevMatch[1].toLowerCase();
|
|
48
|
+
rest.pop();
|
|
49
|
+
}
|
|
50
|
+
assessment = rest.join("\n").trim();
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
assessment = "";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { assessment: assessment || lines.join("\n").trim(), score, severity };
|
|
58
|
+
}
|
|
59
|
+
/** Parse per-file assessment: prose, fileScore 0–100, severity (critical|high|medium|low|none), optional code block. */
|
|
60
|
+
function parseFileAssessmentResponse(raw) {
|
|
61
|
+
const { prose, code } = parseCodeBlockAndProse(raw);
|
|
62
|
+
const lines = prose.split(/\n/).map((l) => l.trim()).filter(Boolean);
|
|
63
|
+
const parsed = parseSeverityAndScore(lines);
|
|
64
|
+
return {
|
|
65
|
+
assessment: parsed.assessment || prose,
|
|
66
|
+
fileScore: parsed.score,
|
|
67
|
+
severity: parsed.severity,
|
|
68
|
+
code: code || undefined,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Resolve provider and auth from config + env. When --llm-key is used, provider is inferred from key format so a Gemini key is not sent to OpenRouter. */
|
|
22
72
|
export function resolveLLMConfig(config = {}) {
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
73
|
+
const cliKey = config.apiKey;
|
|
74
|
+
const openRouterKey = cliKey ?? process.env.OPENROUTER_API_KEY;
|
|
75
|
+
const geminiKey = cliKey ?? process.env.GEMINI_API_KEY ?? process.env.GOOGLE_GENAI_API_KEY;
|
|
76
|
+
const openaiKey = cliKey ?? process.env.OPENAI_API_KEY ?? process.env.ANTHROPIC_API_KEY;
|
|
77
|
+
// When a single key is passed (e.g. --llm-key), pick provider by key format so we don't send a Gemini key to OpenRouter (401 "No cookie auth").
|
|
78
|
+
if (cliKey) {
|
|
79
|
+
if (cliKey.startsWith("AIza")) {
|
|
80
|
+
return {
|
|
81
|
+
provider: "gemini",
|
|
82
|
+
apiKey: cliKey,
|
|
83
|
+
baseURL: GEMINI_BASE,
|
|
84
|
+
model: config.model ?? process.env.GEMINI_MODEL ?? GEMINI_DEFAULT_MODEL,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (cliKey.startsWith("sk-")) {
|
|
88
|
+
return {
|
|
89
|
+
provider: "openai",
|
|
90
|
+
apiKey: cliKey,
|
|
91
|
+
baseURL: config.baseURL ?? process.env.OPENAI_BASE_URL ?? "",
|
|
92
|
+
model: config.model ?? process.env.OPENAI_MODEL ?? OPENAI_DEFAULT_MODEL,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
26
96
|
if (openRouterKey) {
|
|
27
97
|
return {
|
|
28
98
|
provider: "openrouter",
|
|
@@ -73,13 +143,17 @@ async function openAICompatibleCompletion(prompt, opts) {
|
|
|
73
143
|
max_tokens: opts.maxTokens,
|
|
74
144
|
}),
|
|
75
145
|
});
|
|
76
|
-
|
|
146
|
+
const bodyText = await res.text();
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
process.stderr.write(`[LLM] OpenAI-compatible API error ${res.status}: ${bodyText.slice(0, 200)}${bodyText.length > 200 ? "..." : ""}\n`);
|
|
77
149
|
return null;
|
|
78
|
-
|
|
150
|
+
}
|
|
151
|
+
const data = JSON.parse(bodyText);
|
|
79
152
|
const text = data.choices?.[0]?.message?.content?.trim();
|
|
80
153
|
return text || null;
|
|
81
154
|
}
|
|
82
|
-
catch {
|
|
155
|
+
catch (e) {
|
|
156
|
+
process.stderr.write(`[LLM] Request failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
83
157
|
return null;
|
|
84
158
|
}
|
|
85
159
|
}
|
|
@@ -98,17 +172,22 @@ async function geminiCompletion(prompt, opts) {
|
|
|
98
172
|
},
|
|
99
173
|
}),
|
|
100
174
|
});
|
|
101
|
-
|
|
175
|
+
const bodyText = await res.text();
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
process.stderr.write(`[LLM] Gemini API error ${res.status}: ${bodyText.slice(0, 300)}${bodyText.length > 300 ? "..." : ""}\n`);
|
|
102
178
|
return null;
|
|
103
|
-
|
|
179
|
+
}
|
|
180
|
+
const data = JSON.parse(bodyText);
|
|
104
181
|
const text = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
|
|
105
182
|
return text || null;
|
|
106
183
|
}
|
|
107
|
-
catch {
|
|
184
|
+
catch (e) {
|
|
185
|
+
process.stderr.write(`[LLM] Gemini request failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
108
186
|
return null;
|
|
109
187
|
}
|
|
110
188
|
}
|
|
111
|
-
|
|
189
|
+
/** Optional progress callback: (completedBatches, totalBatches) after each batch. */
|
|
190
|
+
export async function enrichDebtWithInsights(items, fileContents, config = {}, onProgress) {
|
|
112
191
|
const resolved = resolveLLMConfig(config);
|
|
113
192
|
if (!resolved)
|
|
114
193
|
return items;
|
|
@@ -116,6 +195,7 @@ export async function enrichDebtWithInsights(items, fileContents, config = {}) {
|
|
|
116
195
|
const maxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
117
196
|
const enriched = [];
|
|
118
197
|
const batchSize = 5;
|
|
198
|
+
const totalBatches = Math.ceil(items.length / batchSize);
|
|
119
199
|
for (let i = 0; i < items.length; i += batchSize) {
|
|
120
200
|
const batch = items.slice(i, i + batchSize);
|
|
121
201
|
const results = await Promise.allSettled(batch.map((item) => explainDebtItem(item, fileContents.get(item.file) ?? "", {
|
|
@@ -134,12 +214,15 @@ export async function enrichDebtWithInsights(items, fileContents, config = {}) {
|
|
|
134
214
|
...item,
|
|
135
215
|
insight: v.insight,
|
|
136
216
|
suggestedCode: v.suggestedCode,
|
|
217
|
+
llmSeverity: v.severity,
|
|
218
|
+
llmRawResponse: v.raw,
|
|
137
219
|
});
|
|
138
220
|
}
|
|
139
221
|
else {
|
|
140
222
|
enriched.push(item);
|
|
141
223
|
}
|
|
142
224
|
}
|
|
225
|
+
onProgress?.(Math.floor((i + batchSize) / batchSize), totalBatches);
|
|
143
226
|
}
|
|
144
227
|
return enriched;
|
|
145
228
|
}
|
|
@@ -147,14 +230,8 @@ async function explainDebtItem(item, fileContent, opts) {
|
|
|
147
230
|
const snippet = item.line
|
|
148
231
|
? fileContent.split("\n").slice(Math.max(0, item.line - 3), (item.endLine ?? item.line) + 2).join("\n")
|
|
149
232
|
: fileContent.slice(0, 1500);
|
|
150
|
-
const prompt = `
|
|
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}
|
|
233
|
+
const prompt = `Technical debt item: ${item.title} (${item.category})
|
|
234
|
+
${item.description}
|
|
158
235
|
${item.metrics ? `Metrics: ${JSON.stringify(item.metrics)}` : ""}
|
|
159
236
|
|
|
160
237
|
Relevant code:
|
|
@@ -162,47 +239,68 @@ Relevant code:
|
|
|
162
239
|
${snippet}
|
|
163
240
|
\`\`\`
|
|
164
241
|
|
|
165
|
-
|
|
166
|
-
|
|
242
|
+
Output only:
|
|
243
|
+
1. A two- to four-sentence summary of the issues: why it matters and what to do. No code block unless absolutely necessary to demonstrate.
|
|
244
|
+
2. On the next line write only one word: critical, high, medium, low, or none (how severe this debt item is; none = not significant).
|
|
245
|
+
No preamble.`;
|
|
246
|
+
const raw = await chat(prompt, opts);
|
|
167
247
|
if (!raw)
|
|
168
248
|
return null;
|
|
169
249
|
const { prose, code } = parseCodeBlockAndProse(raw);
|
|
170
|
-
|
|
250
|
+
const lines = prose.split(/\n/).map((l) => l.trim()).filter(Boolean);
|
|
251
|
+
let severity;
|
|
252
|
+
let assessment = prose;
|
|
253
|
+
if (lines.length > 0) {
|
|
254
|
+
const lastLine = lines[lines.length - 1];
|
|
255
|
+
const sevMatch = lastLine.match(/^\s*(critical|high|medium|low|none)\s*$/i);
|
|
256
|
+
if (sevMatch) {
|
|
257
|
+
severity = sevMatch[1].toLowerCase();
|
|
258
|
+
const rest = lines.slice(0, -1).join("\n").trim();
|
|
259
|
+
assessment = rest || prose;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
insight: assessment || item.description,
|
|
264
|
+
suggestedCode: code,
|
|
265
|
+
severity,
|
|
266
|
+
raw,
|
|
267
|
+
};
|
|
171
268
|
}
|
|
172
|
-
/** Per-file: LLM
|
|
269
|
+
/** Per-file: LLM gives a short summary, a 0–100 debt score, and optionally one refactor. One request per file; call in parallel from CLI. */
|
|
173
270
|
export async function assessFileCleanliness(filePath, content, metrics, config = {}, repoContext) {
|
|
174
271
|
const resolved = resolveLLMConfig(config);
|
|
175
272
|
if (!resolved)
|
|
176
273
|
return null;
|
|
177
|
-
const snippet = content.length >
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
? `\nRepository context (other files in this run): ${otherFiles.join(", ")}.\nWhen suggesting optimizations, you may reference other files (e.g. extract to a shared module, reuse from another file, or move code between files). Explain why each suggestion helps.\n`
|
|
181
|
-
: "";
|
|
182
|
-
const prompt = `You are a senior engineer assessing code cleanliness and possible optimizations for this file.
|
|
183
|
-
|
|
184
|
-
1) In 1-3 sentences: how clean and maintainable is it, and one or two concrete improvements (or "Looks good" if fine). Explain why each improvement matters.
|
|
185
|
-
2) If one specific optimization is possible (e.g. simplify a function, reduce nesting, extract a helper, or a cross-file refactor like moving code to a shared module), provide ONLY that refactored snippet in a markdown code block. Same language as the file. Briefly say why it helps. If no clear code change applies, omit the code block.
|
|
186
|
-
${repoContextBlock}
|
|
187
|
-
File: ${filePath}
|
|
188
|
-
Metrics: complexity ${metrics.cyclomaticComplexity ?? "?"}, lines ${metrics.lineCount}, ${metrics.hasDocumentation ? "has docs" : "no module docs"}${metrics.hotspotScore != null ? `, hotspot ${metrics.hotspotScore.toFixed(2)}` : ""}.
|
|
274
|
+
const snippet = content.length > 3500 ? content.slice(0, 3500) + "\n\n[... truncated]" : content;
|
|
275
|
+
const prompt = `File: ${filePath}
|
|
276
|
+
Metrics: complexity ${metrics.cyclomaticComplexity ?? "?"}, lines ${metrics.lineCount}, ${metrics.hasDocumentation ? "has docs" : "no module docs"}${metrics.hotspotScore != null ? `, hotspot ${metrics.hotspotScore.toFixed(2)}` : ""}
|
|
189
277
|
|
|
190
278
|
Code:
|
|
191
279
|
\`\`\`
|
|
192
280
|
${snippet}
|
|
193
281
|
\`\`\`
|
|
194
282
|
|
|
195
|
-
|
|
283
|
+
Output only:
|
|
284
|
+
1. A two- to four-sentence summary of the issues (how clean/maintainable, main concerns). No code block unless absolutely necessary to demonstrate.
|
|
285
|
+
2. On the next line write only one word: critical, high, medium, low, or none (this file's technical debt severity; none = no significant debt).
|
|
286
|
+
3. On the line after that write only a number 0-100 (100 = most technical debt).
|
|
287
|
+
No preamble.`;
|
|
196
288
|
const raw = await chat(prompt, {
|
|
197
289
|
...resolved,
|
|
198
|
-
maxTokens: config.maxTokens ??
|
|
290
|
+
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS_FILE,
|
|
199
291
|
});
|
|
200
292
|
if (!raw)
|
|
201
293
|
return null;
|
|
202
|
-
const
|
|
203
|
-
return {
|
|
294
|
+
const parsed = parseFileAssessmentResponse(raw);
|
|
295
|
+
return {
|
|
296
|
+
assessment: parsed.assessment,
|
|
297
|
+
suggestedCode: parsed.code,
|
|
298
|
+
fileScore: parsed.fileScore,
|
|
299
|
+
severity: parsed.severity,
|
|
300
|
+
raw,
|
|
301
|
+
};
|
|
204
302
|
}
|
|
205
|
-
/** Overall: LLM assesses the whole codebase
|
|
303
|
+
/** Overall: LLM assesses the whole codebase and optionally a 0–100 debt score. */
|
|
206
304
|
export async function assessOverallCleanliness(run, config = {}) {
|
|
207
305
|
const resolved = resolveLLMConfig(config);
|
|
208
306
|
if (!resolved)
|
|
@@ -215,52 +313,25 @@ export async function assessOverallCleanliness(run, config = {}) {
|
|
|
215
313
|
.sort((a, b) => (b.hotspotScore ?? 0) - (a.hotspotScore ?? 0))
|
|
216
314
|
.slice(0, 12)
|
|
217
315
|
.map((m) => m.file);
|
|
218
|
-
const prompt = `
|
|
219
|
-
|
|
220
|
-
Summary:
|
|
221
|
-
- ${fileCount} files analyzed
|
|
222
|
-
- ${debtCount} debt items (${criticalHigh} critical/high)
|
|
223
|
-
- ${hotspots} hotspot files (high churn + complexity)
|
|
224
|
-
- Top files by risk: ${topFiles.join(", ")}
|
|
225
|
-
|
|
226
|
-
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.`;
|
|
227
|
-
return chat(prompt, {
|
|
228
|
-
...resolved,
|
|
229
|
-
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS_OVERALL,
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
/** LLM suggests 3–5 prioritized next steps (actionable bullets). */
|
|
233
|
-
export async function suggestNextSteps(run, config = {}) {
|
|
234
|
-
const resolved = resolveLLMConfig(config);
|
|
235
|
-
if (!resolved)
|
|
236
|
-
return null;
|
|
237
|
-
const fileCount = run.fileMetrics.length;
|
|
238
|
-
const debtCount = run.debtItems.length;
|
|
239
|
-
const bySeverity = run.debtItems.reduce((acc, d) => {
|
|
240
|
-
acc[d.severity] = (acc[d.severity] ?? 0) + 1;
|
|
241
|
-
return acc;
|
|
242
|
-
}, {});
|
|
243
|
-
const severityOrd = (s) => ({ critical: 4, high: 3, medium: 2, low: 1 }[s] ?? 0);
|
|
244
|
-
const topItems = run.debtItems
|
|
245
|
-
.sort((a, b) => severityOrd(b.severity) - severityOrd(a.severity))
|
|
246
|
-
.slice(0, 15)
|
|
247
|
-
.map((d) => `${d.severity}: ${d.title} (${d.file}${d.line ? `:${d.line}` : ""})`);
|
|
248
|
-
const prompt = `You are a senior engineer. Given this technical debt summary, suggest 3–5 concrete, prioritized next steps the team should take to reduce debt. Be specific (files, types of fixes). Output ONLY a short bullet list: one action per line, starting each line with "- " or "• ". No preamble or explanation.
|
|
316
|
+
const prompt = `Codebase summary: ${fileCount} files, ${debtCount} debt items (${criticalHigh} critical/high), ${hotspots} hotspots. Top risk files: ${topFiles.join(", ")}
|
|
249
317
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
318
|
+
Output only:
|
|
319
|
+
1. A two- to four-sentence summary of the main issues (strengths, concerns, single most important improvement). No code block unless absolutely necessary to demonstrate.
|
|
320
|
+
2. On the next line write only one word: critical, high, medium, low, or none (overall codebase technical debt severity).
|
|
321
|
+
3. On the line after that write only a number 0-100 (100 = most debt).
|
|
322
|
+
No preamble.`;
|
|
254
323
|
const raw = await chat(prompt, {
|
|
255
324
|
...resolved,
|
|
256
|
-
maxTokens:
|
|
325
|
+
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS_OVERALL,
|
|
257
326
|
});
|
|
258
327
|
if (!raw)
|
|
259
328
|
return null;
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
.
|
|
264
|
-
.
|
|
265
|
-
|
|
329
|
+
const lines = raw.trim().split(/\n/).map((l) => l.trim()).filter(Boolean);
|
|
330
|
+
const parsed = parseSeverityAndScore(lines);
|
|
331
|
+
return {
|
|
332
|
+
assessment: parsed.assessment || raw.trim(),
|
|
333
|
+
score: parsed.score,
|
|
334
|
+
severity: parsed.severity,
|
|
335
|
+
raw,
|
|
336
|
+
};
|
|
266
337
|
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/* Technical Debt Report - theme and layout */
|
|
2
|
+
:root {
|
|
3
|
+
--bg: #fff;
|
|
4
|
+
--surface: #fff;
|
|
5
|
+
--border: #ccc;
|
|
6
|
+
--text: #222;
|
|
7
|
+
--text-muted: #666;
|
|
8
|
+
--link: #0568c2;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
[data-theme="dark"] {
|
|
12
|
+
--bg: #1a1a1a;
|
|
13
|
+
--surface: #252525;
|
|
14
|
+
--border: #404040;
|
|
15
|
+
--text: #e0e0e0;
|
|
16
|
+
--text-muted: #999;
|
|
17
|
+
--link: #6eb3f7;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
* { box-sizing: border-box; }
|
|
21
|
+
|
|
22
|
+
body {
|
|
23
|
+
margin: 0;
|
|
24
|
+
font-family: system-ui, sans-serif;
|
|
25
|
+
background: var(--bg);
|
|
26
|
+
color: var(--text);
|
|
27
|
+
min-height: 100vh;
|
|
28
|
+
font-size: 14px;
|
|
29
|
+
line-height: 1.5;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.container {
|
|
33
|
+
max-width: 900px;
|
|
34
|
+
margin: 0 auto;
|
|
35
|
+
padding: 1.5rem 1.25rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Banner */
|
|
39
|
+
.no-llm-banner {
|
|
40
|
+
width: 100%;
|
|
41
|
+
background: var(--surface);
|
|
42
|
+
border-bottom: 1px solid var(--border);
|
|
43
|
+
padding: 0.5rem 1rem;
|
|
44
|
+
text-align: center;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.no-llm-banner .no-llm-cta {
|
|
48
|
+
font-size: 14px;
|
|
49
|
+
font-weight: normal;
|
|
50
|
+
color: var(--text);
|
|
51
|
+
margin: 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Hero / score */
|
|
55
|
+
.hero-caption {
|
|
56
|
+
font-size: 12px;
|
|
57
|
+
color: var(--text-muted);
|
|
58
|
+
margin: 0 0 0.5rem;
|
|
59
|
+
text-align: center;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.hero {
|
|
63
|
+
text-align: center;
|
|
64
|
+
margin-bottom: 1.5rem;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.score-badge {
|
|
68
|
+
display: inline-block;
|
|
69
|
+
margin-bottom: 0.75rem;
|
|
70
|
+
line-height: 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.score-badge-svg {
|
|
74
|
+
display: block;
|
|
75
|
+
width: 160px;
|
|
76
|
+
height: auto;
|
|
77
|
+
max-width: min(160px, 40vw);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.score-badge-svg .score-badge-num {
|
|
81
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
82
|
+
font-size: 56px;
|
|
83
|
+
font-weight: 800;
|
|
84
|
+
letter-spacing: -0.02em;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.score-badge-svg .score-badge-of {
|
|
88
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
89
|
+
font-size: 14px;
|
|
90
|
+
font-weight: 700;
|
|
91
|
+
letter-spacing: 0.08em;
|
|
92
|
+
text-transform: uppercase;
|
|
93
|
+
opacity: 0.95;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.hero .score-label { font-size: 1.1rem; font-weight: bold; color: var(--text); margin: 0 0 0.2rem; }
|
|
97
|
+
.hero .score-desc { font-size: 13px; color: var(--text-muted); margin: 0 0 0.35rem; max-width: 360px; margin-left: auto; margin-right: auto; }
|
|
98
|
+
.hero .report-meta { font-size: 12px; color: var(--text-muted); }
|
|
99
|
+
|
|
100
|
+
.hero.tier-1 { --tier-bg: #c00; --tier-num: #fff; }
|
|
101
|
+
.hero.tier-2 { --tier-bg: #e85d00; --tier-num: #fff; }
|
|
102
|
+
.hero.tier-3 { --tier-bg: #b8860b; --tier-num: #fff; }
|
|
103
|
+
.hero.tier-4 { --tier-bg: #069; --tier-num: #fff; }
|
|
104
|
+
.hero.tier-5 { --tier-bg: #0a6b0a; --tier-num: #fff; }
|
|
105
|
+
|
|
106
|
+
/* Summary cards */
|
|
107
|
+
.summary-cards {
|
|
108
|
+
display: grid;
|
|
109
|
+
grid-template-columns: repeat(4, 1fr);
|
|
110
|
+
gap: 0.75rem;
|
|
111
|
+
margin-bottom: 1.5rem;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@media (max-width: 640px) {
|
|
115
|
+
.summary-cards { grid-template-columns: repeat(2, 1fr); }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.card {
|
|
119
|
+
background: var(--surface);
|
|
120
|
+
border: 1px solid var(--border);
|
|
121
|
+
padding: 0.75rem 1rem;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.card .value { font-size: 1.35rem; font-weight: bold; color: var(--text); }
|
|
125
|
+
.card .label { font-size: 12px; color: var(--text-muted); margin-top: 0.15rem; }
|
|
126
|
+
|
|
127
|
+
/* Sections */
|
|
128
|
+
.section {
|
|
129
|
+
background: var(--surface);
|
|
130
|
+
border: 1px solid var(--border);
|
|
131
|
+
padding: 1rem 1.25rem;
|
|
132
|
+
margin-bottom: 1rem;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.section h2 { font-size: 1rem; margin: 0 0 0.75rem; color: var(--text); font-weight: bold; }
|
|
136
|
+
.section-desc { font-size: 13px; color: var(--text-muted); margin: -0.35rem 0 0.75rem; line-height: 1.4; }
|
|
137
|
+
|
|
138
|
+
/* Treemap */
|
|
139
|
+
#treemap {
|
|
140
|
+
display: flex;
|
|
141
|
+
flex-wrap: wrap;
|
|
142
|
+
gap: 4px;
|
|
143
|
+
min-height: 160px;
|
|
144
|
+
padding: 0.25rem 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.treemap-cell {
|
|
148
|
+
display: flex;
|
|
149
|
+
align-items: flex-end;
|
|
150
|
+
min-width: 64px;
|
|
151
|
+
padding: 6px 8px;
|
|
152
|
+
font-size: 11px;
|
|
153
|
+
cursor: pointer;
|
|
154
|
+
overflow: hidden;
|
|
155
|
+
text-overflow: ellipsis;
|
|
156
|
+
white-space: nowrap;
|
|
157
|
+
border: 1px solid rgba(0,0,0,0.1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.treemap-cell:hover { outline: 2px solid var(--text); outline-offset: 1px; }
|
|
161
|
+
|
|
162
|
+
.treemap-cell[data-severity="critical"] { background: #c00; color: #fff; border-color: #900; }
|
|
163
|
+
.treemap-cell[data-severity="high"] { background: #e85d00; color: #fff; border-color: #b84a00; }
|
|
164
|
+
.treemap-cell[data-severity="medium"] { background: #b8860b; color: #fff; border-color: #8b6914; }
|
|
165
|
+
.treemap-cell[data-severity="low"] { background: #0a6b0a; color: #fff; border-color: #064906; }
|
|
166
|
+
.treemap-cell[data-severity="none"] { background: var(--border); color: var(--text-muted); border-color: var(--border); }
|
|
167
|
+
|
|
168
|
+
/* Legend */
|
|
169
|
+
.legend { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; margin-bottom: 0.75rem; font-size: 12px; color: var(--text-muted); }
|
|
170
|
+
.legend span { display: inline-flex; align-items: center; gap: 0.25rem; }
|
|
171
|
+
.legend .swatch { width: 10px; height: 10px; }
|
|
172
|
+
.legend .swatch-crit { background: #c00; }
|
|
173
|
+
.legend .swatch-high { background: #e85d00; }
|
|
174
|
+
.legend .swatch-med { background: #b8860b; }
|
|
175
|
+
.legend .swatch-low { background: #0a6b0a; }
|
|
176
|
+
.legend .swatch-none { background: var(--border); }
|
|
177
|
+
|
|
178
|
+
/* Debt list */
|
|
179
|
+
.debt-list { list-style: none; padding: 0; margin: 0; }
|
|
180
|
+
|
|
181
|
+
.debt-list li {
|
|
182
|
+
border-bottom: 1px solid var(--border);
|
|
183
|
+
padding: 0.6rem 0;
|
|
184
|
+
cursor: pointer;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.debt-list li:last-child { border-bottom: none; }
|
|
188
|
+
.debt-list li:hover { background: var(--bg); }
|
|
189
|
+
.debt-list .title { font-weight: bold; margin-bottom: 0.2rem; }
|
|
190
|
+
.debt-list .meta { font-size: 12px; color: var(--text-muted); display: block; margin-top: 0.2rem; }
|
|
191
|
+
.debt-list .insight { font-size: 13px; color: var(--text-muted); margin-top: 0.25rem; line-height: 1.4; white-space: pre-wrap; word-break: break-word; }
|
|
192
|
+
.debt-list-ratings { display: flex; align-items: center; gap: 1rem; margin: 0.25rem 0; flex-wrap: wrap; }
|
|
193
|
+
.debt-list-rating { display: inline-flex; align-items: center; gap: 0.35rem; }
|
|
194
|
+
.debt-list-rating-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-muted); }
|
|
195
|
+
.debt-list-llm-none { color: var(--text-muted); font-size: 13px; }
|
|
196
|
+
|
|
197
|
+
/* Badges */
|
|
198
|
+
.badge { display: inline-block; padding: 0.15em 0.4em; font-size: 11px; font-weight: bold; }
|
|
199
|
+
.badge-critical { background: #c00; color: #fff; }
|
|
200
|
+
.badge-high { background: #e85d00; color: #fff; }
|
|
201
|
+
.badge-medium { background: #b8860b; color: #fff; }
|
|
202
|
+
.badge-low { background: #0a6b0a; color: #fff; }
|
|
203
|
+
.badge-none { background: var(--border); color: var(--text-muted); }
|
|
204
|
+
.badge-llm { background: var(--link); color: #fff; }
|
|
205
|
+
|
|
206
|
+
/* Detail modal */
|
|
207
|
+
#detail {
|
|
208
|
+
position: fixed;
|
|
209
|
+
inset: 0;
|
|
210
|
+
background: rgba(0,0,0,0.5);
|
|
211
|
+
display: none;
|
|
212
|
+
align-items: center;
|
|
213
|
+
justify-content: center;
|
|
214
|
+
z-index: 100;
|
|
215
|
+
padding: 1rem;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#detail.show { display: flex; }
|
|
219
|
+
|
|
220
|
+
#detail .panel {
|
|
221
|
+
background: var(--surface);
|
|
222
|
+
border: 1px solid var(--border);
|
|
223
|
+
max-width: 520px;
|
|
224
|
+
width: 100%;
|
|
225
|
+
max-height: 90vh;
|
|
226
|
+
overflow-y: auto;
|
|
227
|
+
overflow-x: hidden;
|
|
228
|
+
padding: 1.25rem;
|
|
229
|
+
-webkit-overflow-scrolling: touch;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#detail .panel h3 { margin: 0 0 0.35rem; font-size: 1rem; }
|
|
233
|
+
#detail .panel .file { font-family: ui-monospace, monospace; font-size: 13px; color: var(--link); }
|
|
234
|
+
#detail .panel .close-hint { margin-top: 0.75rem; font-size: 12px; color: var(--text-muted); }
|
|
235
|
+
|
|
236
|
+
#detail .panel .explanation,
|
|
237
|
+
#detail .panel .detail-explanation {
|
|
238
|
+
margin-top: 0.75rem;
|
|
239
|
+
line-height: 1.5;
|
|
240
|
+
color: var(--text-muted);
|
|
241
|
+
font-size: 13px;
|
|
242
|
+
white-space: pre-wrap;
|
|
243
|
+
word-break: break-word;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#detail .panel .detail-explanation.has-llm-output { white-space: normal; }
|
|
247
|
+
#detail .panel .detail-explanation .llm-output { margin-top: 0.25rem; }
|
|
248
|
+
#detail .panel .detail-explanation .detail-severities { margin-bottom: 0.5rem; }
|
|
249
|
+
#detail .panel .detail-explanation .detail-sev { margin-right: 1rem; }
|
|
250
|
+
#detail .panel .detail-explanation .detail-llm-label { margin-top: 0.5rem; margin-bottom: 0.25rem; }
|
|
251
|
+
#detail .panel .detail-explanation .detail-no-llm { font-style: italic; color: var(--text-muted); }
|
|
252
|
+
#detail .panel .detail-explanation .detail-static-desc { margin-top: 0.5rem; font-size: 12px; color: var(--text-muted); }
|
|
253
|
+
|
|
254
|
+
#detail .panel .file-assessment {
|
|
255
|
+
margin-top: 0.75rem;
|
|
256
|
+
padding-top: 0.75rem;
|
|
257
|
+
border-top: 1px solid var(--border);
|
|
258
|
+
font-size: 13px;
|
|
259
|
+
color: var(--text-muted);
|
|
260
|
+
line-height: 1.5;
|
|
261
|
+
white-space: pre-wrap;
|
|
262
|
+
word-break: break-word;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#detail .panel .file-assessment strong { color: var(--text); font-size: 12px; }
|
|
266
|
+
#detail .panel .suggested-code { margin-top: 0.75rem; }
|
|
267
|
+
|
|
268
|
+
/* Code blocks - shared */
|
|
269
|
+
.code-block {
|
|
270
|
+
margin: 0.5rem 0;
|
|
271
|
+
padding: 0.75rem 1rem;
|
|
272
|
+
background: var(--bg);
|
|
273
|
+
border: 1px solid var(--border);
|
|
274
|
+
border-radius: 6px;
|
|
275
|
+
font-size: 13px;
|
|
276
|
+
overflow-x: auto;
|
|
277
|
+
overflow-y: auto;
|
|
278
|
+
max-height: 20em;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.code-block .lang-label {
|
|
282
|
+
display: block;
|
|
283
|
+
font-size: 11px;
|
|
284
|
+
font-family: system-ui, sans-serif;
|
|
285
|
+
color: var(--text-muted);
|
|
286
|
+
margin-bottom: 0.5rem;
|
|
287
|
+
text-transform: uppercase;
|
|
288
|
+
letter-spacing: 0.03em;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.code-block pre { margin: 0; white-space: pre; overflow-x: auto; min-width: min-content; }
|
|
292
|
+
.code-block code {
|
|
293
|
+
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
|
294
|
+
font-size: 12px;
|
|
295
|
+
line-height: 1.5;
|
|
296
|
+
color: var(--text);
|
|
297
|
+
display: block;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#detail .panel .code-block {
|
|
301
|
+
max-height: 16em;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
#detail .panel .suggested-code .code-block { margin: 0.35rem 0 0; background: var(--surface); }
|
|
305
|
+
#detail .panel .file-assessment .code-block { margin: 0.5rem 0 0; background: var(--surface); }
|
|
306
|
+
|
|
307
|
+
/* LLM sections */
|
|
308
|
+
.llm-overall { border-left: 3px solid var(--link); padding-left: 0.5rem; margin-left: -0.5rem; }
|
|
309
|
+
.llm-overall h2 { color: var(--text); font-size: 1rem; }
|
|
310
|
+
.llm-overall .llm-overall-text { margin: 0; color: var(--text); line-height: 1.5; font-size: 14px; white-space: pre-wrap; word-break: break-word; }
|
|
311
|
+
|
|
312
|
+
.llm-output .llm-prose { margin: 0 0 0.75rem; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
|
|
313
|
+
.llm-output .llm-prose:last-child { margin-bottom: 0; }
|
|
314
|
+
|
|
315
|
+
.llm-next-steps h2 { color: var(--text); font-size: 1rem; }
|
|
316
|
+
.llm-next-steps ul { margin: 0; padding-left: 1.25rem; color: var(--text); line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
|
|
317
|
+
|
|
318
|
+
/* Priority matrix */
|
|
319
|
+
.priority-matrix { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-top: 0.75rem; }
|
|
320
|
+
.priority-matrix .quadrant { padding: 0.75rem; border: 1px solid var(--border); }
|
|
321
|
+
.priority-matrix .quadrant h4 { margin: 0 0 0.35rem; font-size: 0.9rem; font-weight: bold; }
|
|
322
|
+
.priority-matrix .quadrant p { margin: 0; font-size: 13px; color: var(--text-muted); }
|