tech-debt-visualizer 0.1.6 → 0.2.1
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 +91 -69
- 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 +170 -91
- 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/cli.js
CHANGED
|
@@ -10,11 +10,13 @@ import { Command } from "commander";
|
|
|
10
10
|
import chalk from "chalk";
|
|
11
11
|
import cliProgress from "cli-progress";
|
|
12
12
|
import { getCleanlinessTier } from "./cleanliness-score.js";
|
|
13
|
+
import { getDebtScore } from "./debt-score.js";
|
|
13
14
|
import { runAnalysis } from "./engine.js";
|
|
14
|
-
import { assessFileCleanliness, assessOverallCleanliness,
|
|
15
|
+
import { assessFileCleanliness, assessOverallCleanliness, resolveLLMConfig, } from "./llm.js";
|
|
15
16
|
import { generateHtmlReport } from "./reports/html.js";
|
|
16
17
|
import { generateJsonReport } from "./reports/json.js";
|
|
17
18
|
import { generateMarkdownReport } from "./reports/markdown.js";
|
|
19
|
+
import { SEVERITY_ORDER } from "./types.js";
|
|
18
20
|
const program = new Command();
|
|
19
21
|
program
|
|
20
22
|
.name("tech-debt")
|
|
@@ -28,31 +30,36 @@ program
|
|
|
28
30
|
.option("-f, --format <type>", "Output format: cli | html | json | markdown", "cli")
|
|
29
31
|
.option("--no-llm", "Skip LLM-powered insights")
|
|
30
32
|
.option("--llm", "Enable LLM (default). Use with --llm-key and/or --llm-model")
|
|
31
|
-
.option("--llm-key <key>", "API key
|
|
32
|
-
.option("--llm-
|
|
33
|
+
.option("--llm-key <key>", "API key (overrides env: GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY)")
|
|
34
|
+
.option("--llm-endpoint <url>", "OpenAI-compatible API base URL (e.g. https://api.openai.com/v1 or proxy)")
|
|
35
|
+
.option("--llm-model <model>", "Model name (e.g. gpt-4o-mini, gemini-2.5-flash)")
|
|
36
|
+
.option("--llm-max-tokens <n>", "Max tokens per response", (v) => parseInt(v, 10))
|
|
33
37
|
.option("--ci", "CI mode: minimal output, exit with non-zero if debt score is high")
|
|
34
38
|
.action(async (path, opts) => {
|
|
35
39
|
const repoPath = join(process.cwd(), path);
|
|
36
40
|
const format = (opts.format ?? "cli");
|
|
37
41
|
const useLlm = opts.llm !== false;
|
|
38
42
|
const outputPath = opts.output ?? (format === "html" ? "tech-debt-report.html" : undefined);
|
|
39
|
-
const
|
|
43
|
+
const llmConfigOverrides = {
|
|
44
|
+
apiKey: opts.llmKey,
|
|
45
|
+
baseURL: opts.llmEndpoint,
|
|
46
|
+
model: opts.llmModel,
|
|
47
|
+
...(opts.llmMaxTokens != null && opts.llmMaxTokens > 0 ? { maxTokens: opts.llmMaxTokens } : {}),
|
|
48
|
+
};
|
|
40
49
|
const progress = new cliProgress.SingleBar({
|
|
41
|
-
format: chalk.cyan(" {bar} ") + "| {
|
|
50
|
+
format: chalk.cyan(" {bar} ") + "| {percentage}% | {value}/{total} | {task}",
|
|
42
51
|
barCompleteChar: "█",
|
|
43
52
|
barIncompleteChar: "░",
|
|
44
53
|
}, cliProgress.Presets.shades_classic);
|
|
54
|
+
let run;
|
|
55
|
+
const fileContents = new Map();
|
|
45
56
|
try {
|
|
46
57
|
process.stderr.write(chalk.bold.blue("\n Technical Debt Visualizer\n\n"));
|
|
47
|
-
|
|
58
|
+
const discoverySteps = useLlm ? 2 : 4;
|
|
59
|
+
progress.start(discoverySteps, 0, { task: "Discovering files..." });
|
|
60
|
+
run = await runAnalysis({ repoPath, maxFiles: 1500, gitDays: 90 });
|
|
48
61
|
progress.update(1, { task: "Discovering files..." });
|
|
49
|
-
const run = await runAnalysis({
|
|
50
|
-
repoPath,
|
|
51
|
-
maxFiles: 1500,
|
|
52
|
-
gitDays: 90,
|
|
53
|
-
});
|
|
54
62
|
progress.update(2, { task: "Analyzing..." });
|
|
55
|
-
const fileContents = new Map();
|
|
56
63
|
for (const f of run.fileMetrics.map((m) => m.file)) {
|
|
57
64
|
try {
|
|
58
65
|
fileContents.set(f, await readFile(join(repoPath, f), "utf-8"));
|
|
@@ -61,54 +68,77 @@ program
|
|
|
61
68
|
// ignore
|
|
62
69
|
}
|
|
63
70
|
}
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
if (!useLlm) {
|
|
72
|
+
progress.update(4, { task: "Done" });
|
|
73
|
+
progress.stop();
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
progress.stop();
|
|
77
|
+
const maxFiles = 80;
|
|
78
|
+
const filesToAssess = run.fileMetrics.slice(0, maxFiles);
|
|
79
|
+
const totalSteps = 2 + filesToAssess.length + 1;
|
|
80
|
+
progress.start(totalSteps, 2, {
|
|
81
|
+
task: filesToAssess.length > 0 ? `LLM: file 0/${filesToAssess.length}` : "LLM: overall...",
|
|
82
|
+
});
|
|
66
83
|
const llmConfig = resolveLLMConfig(llmConfigOverrides);
|
|
67
84
|
if (!llmConfig) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
85
|
+
progress.update(totalSteps, { task: "Skipping LLM (no key)" });
|
|
86
|
+
progress.stop();
|
|
87
|
+
process.stderr.write(chalk.yellow(" No LLM API key found. Use --llm-key <key> or set one of:\n" +
|
|
88
|
+
" GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY (or .env).\n" +
|
|
89
|
+
" For a custom endpoint: --llm-endpoint <url> --llm-key <key>\n" +
|
|
71
90
|
" Skipping AI insights for this run.\n\n"));
|
|
72
91
|
}
|
|
73
92
|
else {
|
|
74
|
-
|
|
75
|
-
const allFilePaths = run.fileMetrics.map((m) => m.file);
|
|
76
|
-
const maxFiles = 80;
|
|
77
|
-
const filesToAssess = run.fileMetrics.slice(0, maxFiles);
|
|
93
|
+
run.llmAttempted = true;
|
|
78
94
|
const config = { ...llmConfigOverrides };
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
const allFilePaths = run.fileMetrics.map((m) => m.file);
|
|
96
|
+
const FILE_BATCH_SIZE = 10;
|
|
97
|
+
for (let i = 0; i < filesToAssess.length; i += FILE_BATCH_SIZE) {
|
|
98
|
+
const batch = filesToAssess.slice(i, i + FILE_BATCH_SIZE);
|
|
99
|
+
const completedBefore = i;
|
|
100
|
+
const results = await Promise.allSettled(batch.map((m) => {
|
|
101
|
+
const content = fileContents.get(m.file);
|
|
102
|
+
if (!content)
|
|
103
|
+
return Promise.resolve(null);
|
|
104
|
+
return assessFileCleanliness(m.file, content, m, config, { filePaths: allFilePaths });
|
|
105
|
+
}));
|
|
106
|
+
for (let j = 0; j < batch.length; j++) {
|
|
107
|
+
const result = results[j];
|
|
108
|
+
if (result?.status === "fulfilled" && result.value) {
|
|
109
|
+
const m = batch[j];
|
|
110
|
+
const idx = run.fileMetrics.findIndex((x) => x.file === m.file);
|
|
111
|
+
if (idx >= 0)
|
|
112
|
+
run.fileMetrics[idx] = {
|
|
113
|
+
...run.fileMetrics[idx],
|
|
114
|
+
llmAssessment: result.value.assessment,
|
|
115
|
+
llmSuggestedCode: result.value.suggestedCode,
|
|
116
|
+
llmFileScore: result.value.fileScore,
|
|
117
|
+
llmSeverity: result.value.severity,
|
|
118
|
+
llmRawAssessment: result.value.raw,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
92
121
|
}
|
|
122
|
+
const completedFiles = Math.min(completedBefore + batch.length, filesToAssess.length);
|
|
123
|
+
progress.update(2 + completedFiles, {
|
|
124
|
+
task: `LLM: file ${completedFiles}/${filesToAssess.length}`,
|
|
125
|
+
});
|
|
93
126
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (debtItems.length > 0) {
|
|
97
|
-
debtItems = await enrichDebtWithInsights(debtItems.slice(0, 25), fileContents, config);
|
|
98
|
-
const byId = new Map(debtItems.map((d) => [d.id, d]));
|
|
99
|
-
run.debtItems = run.debtItems.map((d) => byId.get(d.id) ?? d);
|
|
100
|
-
}
|
|
101
|
-
progress.update(5, { task: "LLM: overall assessment..." });
|
|
127
|
+
const overallStep = 2 + filesToAssess.length;
|
|
128
|
+
progress.update(overallStep, { task: "LLM: overall assessment..." });
|
|
102
129
|
const overall = await assessOverallCleanliness(run, config);
|
|
103
|
-
if (overall)
|
|
104
|
-
run.llmOverallAssessment = overall;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
130
|
+
if (overall) {
|
|
131
|
+
run.llmOverallAssessment = overall.assessment;
|
|
132
|
+
if (overall.score != null)
|
|
133
|
+
run.llmOverallScore = overall.score;
|
|
134
|
+
if (overall.severity)
|
|
135
|
+
run.llmOverallSeverity = overall.severity;
|
|
136
|
+
run.llmOverallRaw = overall.raw;
|
|
137
|
+
}
|
|
138
|
+
progress.update(totalSteps, { task: "Done" });
|
|
139
|
+
progress.stop();
|
|
108
140
|
}
|
|
109
141
|
}
|
|
110
|
-
progress.update(totalSteps, { task: "Done" });
|
|
111
|
-
progress.stop();
|
|
112
142
|
if (format === "html" && outputPath) {
|
|
113
143
|
await generateHtmlReport(run, { outputPath, title: "Technical Debt Report", darkMode: true });
|
|
114
144
|
process.stdout.write(chalk.green(`\n Report written to ${outputPath}\n\n`));
|
|
@@ -140,7 +170,12 @@ program
|
|
|
140
170
|
else {
|
|
141
171
|
printCliReport(run, opts.ci ?? false);
|
|
142
172
|
if (!run.llmOverallAssessment) {
|
|
143
|
-
|
|
173
|
+
if (run.llmAttempted) {
|
|
174
|
+
process.stdout.write(chalk.dim(" LLM was used but returned no insights. Check [LLM] errors above or verify your API key.\n\n"));
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
process.stdout.write(chalk.dim(" To get AI insights: set GEMINI_API_KEY (or OPENAI_API_KEY) or use --llm-key <key>. Run without --no-llm.\n\n"));
|
|
178
|
+
}
|
|
144
179
|
}
|
|
145
180
|
if (opts.ci && getDebtScore(run) > 60)
|
|
146
181
|
process.exit(1);
|
|
@@ -152,14 +187,6 @@ program
|
|
|
152
187
|
process.exit(1);
|
|
153
188
|
}
|
|
154
189
|
});
|
|
155
|
-
function getDebtScore(run) {
|
|
156
|
-
const items = run.debtItems;
|
|
157
|
-
if (items.length === 0)
|
|
158
|
-
return 0;
|
|
159
|
-
const severityWeight = { low: 1, medium: 2, high: 3, critical: 4 };
|
|
160
|
-
const sum = items.reduce((a, b) => a + (severityWeight[b.severity] ?? 0) * b.confidence, 0);
|
|
161
|
-
return Math.min(100, Math.round((sum / items.length) * 25));
|
|
162
|
-
}
|
|
163
190
|
function printCliReport(run, ci) {
|
|
164
191
|
const { debtItems, fileMetrics, errors } = run;
|
|
165
192
|
const score = getDebtScore(run);
|
|
@@ -198,7 +225,10 @@ function printCliReport(run, ci) {
|
|
|
198
225
|
if (hotspots.length > 0) {
|
|
199
226
|
process.stdout.write(chalk.bold(" Hotspot files (high churn + complexity)\n"));
|
|
200
227
|
for (const h of hotspots) {
|
|
201
|
-
|
|
228
|
+
const hotspotInfo = `(score ${(h.hotspotScore ?? 0).toFixed(2)})`;
|
|
229
|
+
const llmInfo = h.llmFileScore != null ? ` LLM debt ${h.llmFileScore}/100` : "";
|
|
230
|
+
const llmSev = h.llmSeverity ? ` LLM severity ${h.llmSeverity}` : "";
|
|
231
|
+
process.stdout.write(` ${chalk.red("●")} ${h.file} ${chalk.dim(hotspotInfo + llmInfo + llmSev)}\n`);
|
|
202
232
|
if (h.llmAssessment)
|
|
203
233
|
process.stdout.write(chalk.dim(` ${h.llmAssessment.replace(/\n/g, "\n ")}\n`));
|
|
204
234
|
if (h.llmSuggestedCode) {
|
|
@@ -217,7 +247,7 @@ function printCliReport(run, ci) {
|
|
|
217
247
|
process.stdout.write(` ${sev} ${d.title}\n`);
|
|
218
248
|
process.stdout.write(chalk.dim(` ${d.file}${d.line ? `:${d.line}` : ""}\n`));
|
|
219
249
|
if (d.insight)
|
|
220
|
-
process.stdout.write(chalk.dim(` ${d.insight.
|
|
250
|
+
process.stdout.write(chalk.dim(` ${d.insight.replace(/\n/g, "\n ")}\n`));
|
|
221
251
|
if (d.suggestedCode) {
|
|
222
252
|
process.stdout.write(chalk.cyan(" Suggested refactor:\n"));
|
|
223
253
|
process.stdout.write(chalk.dim(d.suggestedCode.split("\n").map((l) => " " + l).join("\n") + "\n"));
|
|
@@ -245,18 +275,10 @@ function printCliReport(run, ci) {
|
|
|
245
275
|
process.stdout.write(chalk.dim(" No debt items. Keep it up.\n"));
|
|
246
276
|
}
|
|
247
277
|
process.stdout.write("\n");
|
|
248
|
-
if (run.llmNextSteps && run.llmNextSteps.length > 0) {
|
|
249
|
-
process.stdout.write(chalk.bold.cyan(" Recommended next steps (AI)\n"));
|
|
250
|
-
process.stdout.write(chalk.dim(" " + "—".repeat(50) + "\n"));
|
|
251
|
-
for (const step of run.llmNextSteps) {
|
|
252
|
-
process.stdout.write(chalk.cyan(" • ") + step + "\n");
|
|
253
|
-
}
|
|
254
|
-
process.stdout.write("\n");
|
|
255
|
-
}
|
|
256
278
|
process.stdout.write(chalk.dim(" Run with --format html -o report.html for the interactive dashboard.\n\n"));
|
|
257
279
|
}
|
|
258
280
|
function severityOrder(s) {
|
|
259
|
-
return
|
|
281
|
+
return SEVERITY_ORDER[s] ?? 0;
|
|
260
282
|
}
|
|
261
283
|
function chalkSeverity(s) {
|
|
262
284
|
const map = {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Technical debt score 0–100 (higher = more debt).
|
|
3
|
+
* When LLM is used, the overall score is taken from LLM only so it matches per-file and overall LLM scores.
|
|
4
|
+
*/
|
|
5
|
+
import type { AnalysisRun } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Debt score 0–100. Uses a single consistent source when LLM is available so overall and file scores match:
|
|
8
|
+
* - If LLM overall score is set: use it as-is.
|
|
9
|
+
* - Else if any file has LLM file score: use average of those.
|
|
10
|
+
* - Else: static score from debt items.
|
|
11
|
+
*/
|
|
12
|
+
export declare function getDebtScore(run: AnalysisRun): number;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Technical debt score 0–100 (higher = more debt).
|
|
3
|
+
* When LLM is used, the overall score is taken from LLM only so it matches per-file and overall LLM scores.
|
|
4
|
+
*/
|
|
5
|
+
/** Compute static score from debt items (severity × confidence). Used when no LLM scores exist. */
|
|
6
|
+
function getStaticDebtScore(run) {
|
|
7
|
+
const items = run.debtItems;
|
|
8
|
+
if (items.length === 0)
|
|
9
|
+
return 0;
|
|
10
|
+
const severityWeight = { low: 1, medium: 2, high: 3, critical: 4 };
|
|
11
|
+
const sum = items.reduce((a, b) => a + (severityWeight[b.severity] ?? 0) * b.confidence, 0);
|
|
12
|
+
return Math.min(100, Math.round((sum / items.length) * 25));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Debt score 0–100. Uses a single consistent source when LLM is available so overall and file scores match:
|
|
16
|
+
* - If LLM overall score is set: use it as-is.
|
|
17
|
+
* - Else if any file has LLM file score: use average of those.
|
|
18
|
+
* - Else: static score from debt items.
|
|
19
|
+
*/
|
|
20
|
+
export function getDebtScore(run) {
|
|
21
|
+
if (run.llmOverallScore != null) {
|
|
22
|
+
return Math.min(100, Math.max(0, Math.round(run.llmOverallScore)));
|
|
23
|
+
}
|
|
24
|
+
const fileScores = run.fileMetrics
|
|
25
|
+
.map((m) => m.llmFileScore)
|
|
26
|
+
.filter((s) => typeof s === "number");
|
|
27
|
+
if (fileScores.length > 0) {
|
|
28
|
+
const avg = fileScores.reduce((a, b) => a + b, 0) / fileScores.length;
|
|
29
|
+
return Math.min(100, Math.max(0, Math.round(avg)));
|
|
30
|
+
}
|
|
31
|
+
return getStaticDebtScore(run);
|
|
32
|
+
}
|
package/dist/llm.d.ts
CHANGED
|
@@ -1,35 +1,49 @@
|
|
|
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
|
-
import type { DebtItem, FileMetrics } from "./types.js";
|
|
6
|
-
import type { AnalysisRun } from "./types.js";
|
|
12
|
+
import type { AnalysisRun, DebtItem, FileMetrics, LlmFileSeverity } from "./types.js";
|
|
7
13
|
export interface LLMConfig {
|
|
8
14
|
apiKey?: string;
|
|
9
15
|
baseURL?: string;
|
|
10
16
|
model?: string;
|
|
17
|
+
/** Overrides default token limits for LLM responses (used where applicable). */
|
|
11
18
|
maxTokens?: number;
|
|
12
19
|
}
|
|
13
20
|
export type LLMProvider = "openai" | "openrouter" | "gemini";
|
|
14
|
-
/** Resolve provider and auth from config + env.
|
|
21
|
+
/** Resolve provider and auth from config + env. Explicit baseURL = OpenAI-compatible; else key format or env picks provider. */
|
|
15
22
|
export declare function resolveLLMConfig(config?: LLMConfig): {
|
|
16
23
|
provider: LLMProvider;
|
|
17
24
|
apiKey: string;
|
|
18
25
|
baseURL: string;
|
|
19
26
|
model: string;
|
|
20
27
|
} | null;
|
|
21
|
-
|
|
28
|
+
/** Optional progress callback: (completedBatches, totalBatches) after each batch. */
|
|
29
|
+
export declare function enrichDebtWithInsights(items: DebtItem[], fileContents: Map<string, string>, config?: LLMConfig, onProgress?: (completed: number, total: number) => void): Promise<DebtItem[]>;
|
|
22
30
|
/** Context about the rest of the repo for cross-file optimization suggestions. */
|
|
23
31
|
export interface RepoContext {
|
|
24
32
|
/** All analyzed file paths in this run (including the current file). */
|
|
25
33
|
filePaths: string[];
|
|
26
34
|
}
|
|
27
|
-
/** Per-file: LLM
|
|
35
|
+
/** 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. */
|
|
28
36
|
export declare function assessFileCleanliness(filePath: string, content: string, metrics: FileMetrics, config?: LLMConfig, repoContext?: RepoContext): Promise<{
|
|
29
37
|
assessment: string;
|
|
30
38
|
suggestedCode?: string;
|
|
39
|
+
fileScore?: number;
|
|
40
|
+
severity?: LlmFileSeverity;
|
|
41
|
+
raw: string;
|
|
42
|
+
} | null>;
|
|
43
|
+
/** Overall: LLM assesses the whole codebase and optionally a 0–100 debt score. */
|
|
44
|
+
export declare function assessOverallCleanliness(run: AnalysisRun, config?: LLMConfig): Promise<{
|
|
45
|
+
assessment: string;
|
|
46
|
+
score?: number;
|
|
47
|
+
severity?: LlmFileSeverity;
|
|
48
|
+
raw: string;
|
|
31
49
|
} | null>;
|
|
32
|
-
/** Overall: LLM assesses the whole codebase cleanliness in a short paragraph. */
|
|
33
|
-
export declare function assessOverallCleanliness(run: AnalysisRun, config?: LLMConfig): Promise<string | null>;
|
|
34
|
-
/** LLM suggests 3–5 prioritized next steps (actionable bullets). */
|
|
35
|
-
export declare function suggestNextSteps(run: AnalysisRun, config?: LLMConfig): Promise<string[] | null>;
|