thuban 0.3.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 +21 -0
- package/README.md +150 -0
- package/cli.js +1902 -0
- package/package.json +49 -0
- package/packages/scanner/ai-confidence-scorer.js +260 -0
- package/packages/scanner/alert-manager.js +398 -0
- package/packages/scanner/baseline-manager.js +109 -0
- package/packages/scanner/code-scanner.js +712 -0
- package/packages/scanner/codebase-passport.js +299 -0
- package/packages/scanner/copy-paste-detector.js +250 -0
- package/packages/scanner/dependency-graph.js +539 -0
- package/packages/scanner/drift-detector.js +425 -0
- package/packages/scanner/executive-report.js +774 -0
- package/packages/scanner/file-watcher.js +323 -0
- package/packages/scanner/ghost-code-detector.js +226 -0
- package/packages/scanner/hallucination-detector.js +507 -0
- package/packages/scanner/health-checker.js +586 -0
- package/packages/scanner/html-report.js +634 -0
- package/packages/scanner/index.js +92 -0
- package/packages/scanner/license-manager.js +318 -0
- package/packages/scanner/master-health-checker.js +513 -0
- package/packages/scanner/pre-commit-gate.js +216 -0
- package/packages/scanner/sentinel-core.js +608 -0
- package/packages/scanner/sentinel-knowledge.js +322 -0
- package/packages/scanner/tech-debt-analyzer.js +483 -0
- package/packages/scanner/tech-debt-cost.js +194 -0
- package/packages/scanner/widget-generator.js +415 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thuban Tech Debt Analyzer & Auto-Fixer
|
|
3
|
+
*
|
|
4
|
+
* @purpose Score, visualise, and auto-fix technical debt
|
|
5
|
+
* @module thuban
|
|
6
|
+
* @layer application
|
|
7
|
+
* @imports-from code-scanner, dependency-graph, drift-detector, widget-generator
|
|
8
|
+
* @exports-to cli, dashboard, reports
|
|
9
|
+
* @side-effects File system reads and writes when fixing
|
|
10
|
+
* @critical-for Core Thuban value proposition — the "fix it" button
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const CodeScanner = require('./code-scanner');
|
|
16
|
+
const DependencyGraph = require('./dependency-graph');
|
|
17
|
+
const DriftDetector = require('./drift-detector');
|
|
18
|
+
const WidgetGenerator = require('./widget-generator');
|
|
19
|
+
|
|
20
|
+
class TechDebtAnalyzer {
|
|
21
|
+
constructor(config = {}) {
|
|
22
|
+
this.config = {
|
|
23
|
+
rootPath: config.rootPath || process.cwd(),
|
|
24
|
+
...config
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
this.scanner = new CodeScanner(this.config);
|
|
28
|
+
this.depGraph = new DependencyGraph(this.config);
|
|
29
|
+
this.driftDetector = new DriftDetector(this.config);
|
|
30
|
+
this.widgetGen = new WidgetGenerator({ ...this.config, dryRun: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Full tech debt analysis — the complete health picture
|
|
35
|
+
* Returns a structured report with scores, categories, and fixable items
|
|
36
|
+
*/
|
|
37
|
+
async analyze(files) {
|
|
38
|
+
const startTime = Date.now();
|
|
39
|
+
|
|
40
|
+
// Build dependency graph
|
|
41
|
+
await this.depGraph.build(files);
|
|
42
|
+
this.widgetGen.dependencyGraph = this.depGraph;
|
|
43
|
+
|
|
44
|
+
// Run all analyses
|
|
45
|
+
const scanResults = await this.scanner.scanFiles(files);
|
|
46
|
+
const driftResults = await this.driftDetector.detectAll();
|
|
47
|
+
|
|
48
|
+
// Categorise all issues into tech debt buckets
|
|
49
|
+
const debt = {
|
|
50
|
+
// Category scores (0-100, lower = more debt)
|
|
51
|
+
scores: {
|
|
52
|
+
overall: 100,
|
|
53
|
+
security: 100,
|
|
54
|
+
architecture: 100,
|
|
55
|
+
codeQuality: 100,
|
|
56
|
+
documentation: 100,
|
|
57
|
+
dependencies: 100,
|
|
58
|
+
motherCode: 100,
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
// Fixable items (can be auto-fixed)
|
|
62
|
+
fixable: [],
|
|
63
|
+
// Manual items (need human decision)
|
|
64
|
+
manual: [],
|
|
65
|
+
// Info items (awareness only)
|
|
66
|
+
info: [],
|
|
67
|
+
|
|
68
|
+
// Summary stats
|
|
69
|
+
stats: {
|
|
70
|
+
totalFiles: files.length,
|
|
71
|
+
totalIssues: 0,
|
|
72
|
+
fixableCount: 0,
|
|
73
|
+
manualCount: 0,
|
|
74
|
+
estimatedFixTime: '0 minutes',
|
|
75
|
+
scanTime: 0,
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
// Detailed categories for dashboard
|
|
79
|
+
categories: {},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// ─── 1. Security Debt ───────────────────────────────────
|
|
83
|
+
const securityIssues = [];
|
|
84
|
+
for (const [file, result] of Object.entries(scanResults)) {
|
|
85
|
+
if (result.issues) {
|
|
86
|
+
for (const issue of result.issues) {
|
|
87
|
+
if (issue.id?.startsWith('SEC') || issue.category === 'security') {
|
|
88
|
+
securityIssues.push({
|
|
89
|
+
file: path.relative(this.config.rootPath, file),
|
|
90
|
+
...issue,
|
|
91
|
+
fixable: issue.id === 'SEC001' || issue.id === 'SEC002', // Can extract to env vars
|
|
92
|
+
fixAction: issue.id === 'SEC001' ? 'extract_to_env' : issue.id === 'SEC002' ? 'extract_to_env' : null,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
debt.scores.security = Math.max(0, 100 - (securityIssues.filter(i => i.severity === 'critical').length * 25) - (securityIssues.filter(i => i.severity === 'high').length * 10));
|
|
99
|
+
debt.categories.security = { score: debt.scores.security, issues: securityIssues, label: 'Security', icon: 'shield' };
|
|
100
|
+
|
|
101
|
+
// ─── 2. Architecture Debt ───────────────────────────────
|
|
102
|
+
const archIssues = [];
|
|
103
|
+
const circular = this.depGraph.circularDeps || [];
|
|
104
|
+
const orphans = this.depGraph.getOrphanFiles() || [];
|
|
105
|
+
const critical = this.depGraph.getMostCriticalFiles(5) || [];
|
|
106
|
+
|
|
107
|
+
for (const cycle of circular) {
|
|
108
|
+
archIssues.push({
|
|
109
|
+
type: 'circular_dependency',
|
|
110
|
+
severity: 'high',
|
|
111
|
+
files: Array.isArray(cycle) ? cycle.map(f => path.relative(this.config.rootPath, f)) : [String(cycle)],
|
|
112
|
+
message: 'Circular dependency detected',
|
|
113
|
+
fixable: true,
|
|
114
|
+
fixAction: 'break_circular',
|
|
115
|
+
suggestion: 'Extract shared code into a new module to break the cycle',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const orphan of orphans) {
|
|
120
|
+
const orphanPath = typeof orphan === 'string' ? orphan : (orphan.file || orphan.path || String(orphan));
|
|
121
|
+
archIssues.push({
|
|
122
|
+
type: 'orphan_file',
|
|
123
|
+
severity: 'low',
|
|
124
|
+
file: path.relative(this.config.rootPath, orphanPath),
|
|
125
|
+
message: 'File has no imports and no exports consumed — potential dead code',
|
|
126
|
+
fixable: true,
|
|
127
|
+
fixAction: 'flag_for_removal',
|
|
128
|
+
suggestion: 'Review and remove if truly unused',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const crit of critical) {
|
|
133
|
+
const critPath = typeof crit === 'string' ? crit : (crit.file || crit.path || String(crit));
|
|
134
|
+
const dependents = typeof crit === 'object' ? (crit.dependents || crit.score || crit.count || 0) : 0;
|
|
135
|
+
if (dependents > 10) {
|
|
136
|
+
archIssues.push({
|
|
137
|
+
type: 'god_file',
|
|
138
|
+
severity: 'medium',
|
|
139
|
+
file: path.relative(this.config.rootPath, critPath),
|
|
140
|
+
dependents,
|
|
141
|
+
message: `${dependents} files depend on this — high blast radius if changed`,
|
|
142
|
+
fixable: false,
|
|
143
|
+
suggestion: 'Consider splitting into smaller, focused modules',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
debt.scores.architecture = Math.max(0, 100 - (circular.length * 15) - (orphans.length * 1) - (archIssues.filter(i => i.type === 'god_file').length * 5));
|
|
149
|
+
debt.categories.architecture = { score: debt.scores.architecture, issues: archIssues, label: 'Architecture', icon: 'building' };
|
|
150
|
+
|
|
151
|
+
// ─── 3. Code Quality Debt ───────────────────────────────
|
|
152
|
+
const qualityIssues = [];
|
|
153
|
+
for (const [file, result] of Object.entries(scanResults)) {
|
|
154
|
+
if (result.issues) {
|
|
155
|
+
for (const issue of result.issues) {
|
|
156
|
+
if (issue.id?.startsWith('QUAL') || issue.category === 'quality' || issue.category === 'maintainability') {
|
|
157
|
+
qualityIssues.push({
|
|
158
|
+
file: path.relative(this.config.rootPath, file),
|
|
159
|
+
...issue,
|
|
160
|
+
fixable: ['QUAL001', 'QUAL003', 'QUAL005'].includes(issue.id), // console.log removal, etc.
|
|
161
|
+
fixAction: issue.id === 'QUAL001' ? 'remove_console_log' : null,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
debt.scores.codeQuality = Math.max(0, 100 - (qualityIssues.filter(i => i.severity === 'high').length * 5) - (qualityIssues.filter(i => i.severity === 'medium').length * 2));
|
|
168
|
+
debt.categories.codeQuality = { score: debt.scores.codeQuality, issues: qualityIssues, label: 'Code Quality', icon: 'code' };
|
|
169
|
+
|
|
170
|
+
// ─── 4. Documentation / Mother Code Debt ────────────────
|
|
171
|
+
const docIssues = [];
|
|
172
|
+
let filesWithWidget = 0;
|
|
173
|
+
let filesWithoutWidget = 0;
|
|
174
|
+
let filesWithDrift = 0;
|
|
175
|
+
|
|
176
|
+
for (const file of files) {
|
|
177
|
+
try {
|
|
178
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
179
|
+
const hasWidget = /\/\*\*[\s\S]*?@(purpose|module|layer)[\s\S]*?\*\//.test(content);
|
|
180
|
+
if (hasWidget) {
|
|
181
|
+
filesWithWidget++;
|
|
182
|
+
// Check for drift
|
|
183
|
+
const analysis = await this.driftDetector.analyzeFile(file);
|
|
184
|
+
if (analysis.issues && analysis.issues.length > 0) {
|
|
185
|
+
filesWithDrift++;
|
|
186
|
+
for (const issue of analysis.issues) {
|
|
187
|
+
docIssues.push({
|
|
188
|
+
file: path.relative(this.config.rootPath, file),
|
|
189
|
+
...issue,
|
|
190
|
+
fixable: true,
|
|
191
|
+
fixAction: 'regenerate_widget',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
filesWithoutWidget++;
|
|
197
|
+
docIssues.push({
|
|
198
|
+
file: path.relative(this.config.rootPath, file),
|
|
199
|
+
type: 'missing_mother_code',
|
|
200
|
+
severity: 'info',
|
|
201
|
+
message: 'No Mother Code DNA — file lacks contextual awareness',
|
|
202
|
+
fixable: true,
|
|
203
|
+
fixAction: 'inject_widget',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
} catch (e) { /* skip unreadable */ }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const coverage = files.length > 0 ? Math.round((filesWithWidget / files.length) * 100) : 0;
|
|
210
|
+
debt.scores.motherCode = Math.min(100, coverage + (filesWithDrift > 0 ? -10 : 0));
|
|
211
|
+
debt.scores.documentation = debt.scores.motherCode;
|
|
212
|
+
debt.categories.motherCode = {
|
|
213
|
+
score: debt.scores.motherCode,
|
|
214
|
+
coverage,
|
|
215
|
+
annotated: filesWithWidget,
|
|
216
|
+
missing: filesWithoutWidget,
|
|
217
|
+
drifted: filesWithDrift,
|
|
218
|
+
issues: docIssues,
|
|
219
|
+
label: 'Mother Code',
|
|
220
|
+
icon: 'dna',
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// ─── 5. Dependency Health ───────────────────────────────
|
|
224
|
+
const depIssues = [];
|
|
225
|
+
// Check for very deep dependency chains
|
|
226
|
+
const summary = this.depGraph.getModuleSummary ? this.depGraph.getModuleSummary() : {};
|
|
227
|
+
debt.scores.dependencies = Math.max(0, 100 - (circular.length * 10) - (orphans.length * 0.5));
|
|
228
|
+
debt.categories.dependencies = { score: debt.scores.dependencies, issues: depIssues, label: 'Dependencies', icon: 'link' };
|
|
229
|
+
|
|
230
|
+
// ─── Calculate overall score ────────────────────────────
|
|
231
|
+
const weights = { security: 0.25, architecture: 0.2, codeQuality: 0.2, motherCode: 0.2, dependencies: 0.15 };
|
|
232
|
+
debt.scores.overall = Math.round(
|
|
233
|
+
debt.scores.security * weights.security +
|
|
234
|
+
debt.scores.architecture * weights.architecture +
|
|
235
|
+
debt.scores.codeQuality * weights.codeQuality +
|
|
236
|
+
debt.scores.motherCode * weights.motherCode +
|
|
237
|
+
debt.scores.dependencies * weights.dependencies
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// ─── Sort into fixable / manual / info ──────────────────
|
|
241
|
+
for (const cat of Object.values(debt.categories)) {
|
|
242
|
+
for (const issue of cat.issues || []) {
|
|
243
|
+
debt.stats.totalIssues++;
|
|
244
|
+
if (issue.fixable) {
|
|
245
|
+
debt.fixable.push(issue);
|
|
246
|
+
debt.stats.fixableCount++;
|
|
247
|
+
} else if (issue.severity === 'info') {
|
|
248
|
+
debt.info.push(issue);
|
|
249
|
+
} else {
|
|
250
|
+
debt.manual.push(issue);
|
|
251
|
+
debt.stats.manualCount++;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Estimate fix time
|
|
257
|
+
const fixMinutes = debt.stats.fixableCount * 0.5 + debt.stats.manualCount * 15;
|
|
258
|
+
debt.stats.estimatedFixTime = fixMinutes < 60
|
|
259
|
+
? `${Math.round(fixMinutes)} minutes`
|
|
260
|
+
: `${Math.round(fixMinutes / 60)} hours`;
|
|
261
|
+
|
|
262
|
+
debt.stats.scanTime = Date.now() - startTime;
|
|
263
|
+
|
|
264
|
+
return debt;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* THE FIX BUTTON
|
|
269
|
+
* Auto-fixes all fixable tech debt items
|
|
270
|
+
* Returns a report of what was fixed
|
|
271
|
+
*/
|
|
272
|
+
async fix(files, options = {}) {
|
|
273
|
+
const debt = await this.analyze(files);
|
|
274
|
+
const fixes = [];
|
|
275
|
+
const failures = [];
|
|
276
|
+
|
|
277
|
+
for (const item of debt.fixable) {
|
|
278
|
+
if (options.dryRun) {
|
|
279
|
+
fixes.push({ ...item, status: 'would_fix' });
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
switch (item.fixAction) {
|
|
285
|
+
case 'inject_widget': {
|
|
286
|
+
const fullPath = path.resolve(this.config.rootPath, item.file);
|
|
287
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
288
|
+
const widget = await this.widgetGen.generateWidget(fullPath, content);
|
|
289
|
+
if (widget && widget.widgetString) {
|
|
290
|
+
fs.writeFileSync(fullPath, widget.widgetString + '\n\n' + content, 'utf-8');
|
|
291
|
+
fixes.push({ ...item, status: 'fixed' });
|
|
292
|
+
}
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
case 'regenerate_widget': {
|
|
297
|
+
const fullPath = path.resolve(this.config.rootPath, item.file);
|
|
298
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
299
|
+
// Remove old widget
|
|
300
|
+
const stripped = content.replace(/\/\*\*[\s\S]*?@(purpose|module|layer)[\s\S]*?\*\/\s*\n?\s*\n?/, '');
|
|
301
|
+
// Generate new widget with current state
|
|
302
|
+
const widget = await this.widgetGen.generateWidget(fullPath, stripped);
|
|
303
|
+
if (widget && widget.widgetString) {
|
|
304
|
+
fs.writeFileSync(fullPath, widget.widgetString + '\n\n' + stripped, 'utf-8');
|
|
305
|
+
fixes.push({ ...item, status: 'fixed' });
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
case 'remove_console_log': {
|
|
311
|
+
const fullPath = path.resolve(this.config.rootPath, item.file);
|
|
312
|
+
let content = fs.readFileSync(fullPath, 'utf-8');
|
|
313
|
+
const before = content;
|
|
314
|
+
// Remove console.log lines (but not console.error or console.warn)
|
|
315
|
+
content = content.replace(/^\s*console\.log\(.*?\);\s*$/gm, '');
|
|
316
|
+
if (content !== before) {
|
|
317
|
+
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
318
|
+
fixes.push({ ...item, status: 'fixed' });
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
case 'extract_to_env': {
|
|
324
|
+
// Don't auto-fix secrets — too risky. Flag for manual review.
|
|
325
|
+
fixes.push({ ...item, status: 'flagged_for_review', reason: 'Secrets require manual extraction to .env' });
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
case 'flag_for_removal': {
|
|
330
|
+
// Don't auto-delete files — just confirm they're orphans
|
|
331
|
+
fixes.push({ ...item, status: 'confirmed_orphan', reason: 'Verified no imports. Safe to remove manually.' });
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
default:
|
|
336
|
+
fixes.push({ ...item, status: 'skipped', reason: 'No auto-fix available' });
|
|
337
|
+
}
|
|
338
|
+
} catch (err) {
|
|
339
|
+
failures.push({ ...item, status: 'failed', error: err.message });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
totalFixable: debt.fixable.length,
|
|
345
|
+
fixed: fixes.filter(f => f.status === 'fixed').length,
|
|
346
|
+
flagged: fixes.filter(f => f.status === 'flagged_for_review' || f.status === 'confirmed_orphan').length,
|
|
347
|
+
skipped: fixes.filter(f => f.status === 'skipped').length,
|
|
348
|
+
failed: failures.length,
|
|
349
|
+
fixes,
|
|
350
|
+
failures,
|
|
351
|
+
debtBefore: debt.scores.overall,
|
|
352
|
+
// Re-analyze after fixes to show improvement
|
|
353
|
+
...(options.dryRun ? {} : { note: 'Run thuban report again to see updated scores' }),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Generate a human-readable tech debt report
|
|
359
|
+
* Suitable for dashboard display or terminal output
|
|
360
|
+
*/
|
|
361
|
+
formatReport(debt) {
|
|
362
|
+
const C = {
|
|
363
|
+
reset: '\x1b[0m', bold: '\x1b[1m',
|
|
364
|
+
red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m',
|
|
365
|
+
blue: '\x1b[34m', cyan: '\x1b[36m', gray: '\x1b[90m',
|
|
366
|
+
bgRed: '\x1b[41m', bgGreen: '\x1b[42m', bgYellow: '\x1b[43m',
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const scoreBar = (score) => {
|
|
370
|
+
const filled = Math.round(score / 5);
|
|
371
|
+
const empty = 20 - filled;
|
|
372
|
+
const color = score >= 80 ? C.green : score >= 60 ? C.yellow : C.red;
|
|
373
|
+
return `${color}${'█'.repeat(filled)}${C.gray}${'░'.repeat(empty)}${C.reset} ${color}${score}%${C.reset}`;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const grade = (score) => {
|
|
377
|
+
if (score >= 90) return `${C.green}A${C.reset}`;
|
|
378
|
+
if (score >= 80) return `${C.green}B${C.reset}`;
|
|
379
|
+
if (score >= 70) return `${C.yellow}C${C.reset}`;
|
|
380
|
+
if (score >= 60) return `${C.yellow}D${C.reset}`;
|
|
381
|
+
return `${C.red}F${C.reset}`;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
let output = '';
|
|
385
|
+
output += `\n${C.cyan} ╔══════════════════════════════════════════════╗${C.reset}\n`;
|
|
386
|
+
output += `${C.cyan} ║${C.bold} THUBAN TECH DEBT REPORT ${C.reset}${C.cyan}║${C.reset}\n`;
|
|
387
|
+
output += `${C.cyan} ╚══════════════════════════════════════════════╝${C.reset}\n\n`;
|
|
388
|
+
|
|
389
|
+
output += ` ${C.bold}Overall Health:${C.reset} ${scoreBar(debt.scores.overall)} Grade: ${grade(debt.scores.overall)}\n\n`;
|
|
390
|
+
|
|
391
|
+
// Category breakdown
|
|
392
|
+
output += ` ${C.bold}Category Breakdown:${C.reset}\n\n`;
|
|
393
|
+
const categories = [
|
|
394
|
+
['Security', debt.scores.security, 'shield'],
|
|
395
|
+
['Architecture', debt.scores.architecture, 'building'],
|
|
396
|
+
['Code Quality', debt.scores.codeQuality, 'code'],
|
|
397
|
+
['Mother Code', debt.scores.motherCode, 'dna'],
|
|
398
|
+
['Dependencies', debt.scores.dependencies, 'link'],
|
|
399
|
+
];
|
|
400
|
+
|
|
401
|
+
for (const [name, score] of categories) {
|
|
402
|
+
output += ` ${name.padEnd(18)} ${scoreBar(score)}\n`;
|
|
403
|
+
}
|
|
404
|
+
output += '\n';
|
|
405
|
+
|
|
406
|
+
// Mother Code specific
|
|
407
|
+
if (debt.categories.motherCode) {
|
|
408
|
+
const mc = debt.categories.motherCode;
|
|
409
|
+
output += ` ${C.bold}Mother Code Coverage:${C.reset}\n`;
|
|
410
|
+
output += ` Annotated: ${C.cyan}${mc.annotated}${C.reset} | Missing: ${C.yellow}${mc.missing}${C.reset} | Drifted: ${mc.drifted > 0 ? C.red : C.green}${mc.drifted}${C.reset} | Coverage: ${mc.coverage >= 80 ? C.green : mc.coverage >= 40 ? C.yellow : C.red}${mc.coverage}%${C.reset}\n\n`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Summary
|
|
414
|
+
output += ` ${C.bold}Tech Debt Summary:${C.reset}\n`;
|
|
415
|
+
output += ` Total issues: ${C.bold}${debt.stats.totalIssues}${C.reset}\n`;
|
|
416
|
+
output += ` Auto-fixable: ${C.green}${debt.stats.fixableCount}${C.reset} ${C.gray}← run 'thuban fix' to resolve${C.reset}\n`;
|
|
417
|
+
output += ` Manual review: ${C.yellow}${debt.stats.manualCount}${C.reset}\n`;
|
|
418
|
+
output += ` Est. manual time: ${C.gray}${debt.stats.estimatedFixTime}${C.reset}\n`;
|
|
419
|
+
output += ` Scan time: ${C.gray}${debt.stats.scanTime}ms${C.reset}\n\n`;
|
|
420
|
+
|
|
421
|
+
// Top fixable items
|
|
422
|
+
if (debt.fixable.length > 0) {
|
|
423
|
+
output += ` ${C.bold}Top Auto-Fixable Items:${C.reset}\n`;
|
|
424
|
+
const grouped = {};
|
|
425
|
+
for (const item of debt.fixable) {
|
|
426
|
+
const action = item.fixAction || 'unknown';
|
|
427
|
+
if (!grouped[action]) grouped[action] = { count: 0, label: action };
|
|
428
|
+
grouped[action].count++;
|
|
429
|
+
}
|
|
430
|
+
for (const [action, data] of Object.entries(grouped).sort((a, b) => b[1].count - a[1].count)) {
|
|
431
|
+
const label = {
|
|
432
|
+
'inject_widget': 'Inject Mother Code DNA',
|
|
433
|
+
'regenerate_widget': 'Update stale annotations',
|
|
434
|
+
'remove_console_log': 'Remove debug console.logs',
|
|
435
|
+
'extract_to_env': 'Extract hardcoded secrets to .env',
|
|
436
|
+
'flag_for_removal': 'Remove orphan files',
|
|
437
|
+
'break_circular': 'Break circular dependencies',
|
|
438
|
+
}[action] || action;
|
|
439
|
+
output += ` ${C.green}→${C.reset} ${label}: ${C.bold}${data.count}${C.reset} items\n`;
|
|
440
|
+
}
|
|
441
|
+
output += `\n ${C.cyan}Run 'thuban fix [path]' to auto-fix all ${debt.stats.fixableCount} items${C.reset}\n\n`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Manual items (top 5)
|
|
445
|
+
if (debt.manual.length > 0) {
|
|
446
|
+
output += ` ${C.bold}Manual Review Required:${C.reset}\n`;
|
|
447
|
+
for (const item of debt.manual.slice(0, 5)) {
|
|
448
|
+
const sevColor = item.severity === 'critical' ? C.red : item.severity === 'high' ? C.red : C.yellow;
|
|
449
|
+
output += ` ${sevColor}${(item.severity || '').toUpperCase().padEnd(8)}${C.reset} ${C.cyan}${item.file || ''}${C.reset}\n`;
|
|
450
|
+
output += ` ${C.gray} ${item.message || item.suggestion || ''}${C.reset}\n`;
|
|
451
|
+
}
|
|
452
|
+
if (debt.manual.length > 5) {
|
|
453
|
+
output += ` ${C.gray} ... and ${debt.manual.length - 5} more${C.reset}\n`;
|
|
454
|
+
}
|
|
455
|
+
output += '\n';
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return output;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Generate JSON report for dashboard / API consumption
|
|
463
|
+
*/
|
|
464
|
+
toJSON(debt) {
|
|
465
|
+
return {
|
|
466
|
+
timestamp: new Date().toISOString(),
|
|
467
|
+
scores: debt.scores,
|
|
468
|
+
categories: Object.fromEntries(
|
|
469
|
+
Object.entries(debt.categories).map(([key, cat]) => [key, {
|
|
470
|
+
score: cat.score,
|
|
471
|
+
label: cat.label,
|
|
472
|
+
issueCount: cat.issues?.length || 0,
|
|
473
|
+
coverage: cat.coverage,
|
|
474
|
+
}])
|
|
475
|
+
),
|
|
476
|
+
stats: debt.stats,
|
|
477
|
+
fixable: debt.fixable.map(i => ({ file: i.file, action: i.fixAction, severity: i.severity })),
|
|
478
|
+
manual: debt.manual.map(i => ({ file: i.file, message: i.message, severity: i.severity })),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
module.exports = TechDebtAnalyzer;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thuban Tech Debt Cost Calculator
|
|
3
|
+
*
|
|
4
|
+
* @purpose Translate tech debt into £/hours so CTOs can see the business cost
|
|
5
|
+
* @module thuban
|
|
6
|
+
* @layer scanner
|
|
7
|
+
* @exports-to cli
|
|
8
|
+
* @critical-for Sales — CTOs buy when they see the number
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
// Cost model — conservative estimates
|
|
15
|
+
const COST_MODEL = {
|
|
16
|
+
hourlyRate: 80, // £/hr average UK developer
|
|
17
|
+
weeklyDriftHours: 3, // Hours of new debt accumulated per week per 1000 files
|
|
18
|
+
|
|
19
|
+
// Minutes to fix each issue type manually (without Thuban)
|
|
20
|
+
manualFixMinutes: {
|
|
21
|
+
hallucination: 30, // Finding + understanding + replacing a phantom API
|
|
22
|
+
deprecated: 15, // Researching replacement + updating
|
|
23
|
+
security: 45, // Security issues need careful review
|
|
24
|
+
circular_dep: 60, // Untangling circular dependencies
|
|
25
|
+
orphan_file: 5, // Reviewing and deleting
|
|
26
|
+
missing_annotation: 2, // Adding Mother Code DNA
|
|
27
|
+
copy_paste: 20, // Extracting to shared utility
|
|
28
|
+
ghost_code: 10, // Reviewing and removing dead functions
|
|
29
|
+
code_smell: 10, // General refactoring
|
|
30
|
+
drift: 15, // Re-aligning annotation with reality
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// Minutes with Thuban auto-fix
|
|
34
|
+
thubanFixMinutes: {
|
|
35
|
+
hallucination: 0.5, // Auto-suggests replacement
|
|
36
|
+
deprecated: 0.5, // Auto-replaces
|
|
37
|
+
security: 5, // Flags + suggests but needs human review
|
|
38
|
+
circular_dep: 5, // Shows the cycle, suggests break point
|
|
39
|
+
orphan_file: 0.5, // Auto-flags for deletion
|
|
40
|
+
missing_annotation: 0.1, // Auto-injects
|
|
41
|
+
copy_paste: 2, // Shows cluster, suggests extraction
|
|
42
|
+
ghost_code: 0.5, // Auto-flags for removal
|
|
43
|
+
code_smell: 1, // Auto-fixes common patterns
|
|
44
|
+
drift: 0.5, // Auto-updates annotation
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
class TechDebtCostCalculator {
|
|
49
|
+
constructor(opts = {}) {
|
|
50
|
+
this.hourlyRate = opts.hourlyRate || COST_MODEL.hourlyRate;
|
|
51
|
+
this.currency = opts.currency || '£';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Calculate cost from a list of issues
|
|
56
|
+
*/
|
|
57
|
+
calculate(issues, fileCount = 0) {
|
|
58
|
+
const breakdown = {};
|
|
59
|
+
let totalManualMinutes = 0;
|
|
60
|
+
let totalThubanMinutes = 0;
|
|
61
|
+
|
|
62
|
+
for (const issue of issues) {
|
|
63
|
+
const category = this._categorize(issue);
|
|
64
|
+
if (!breakdown[category]) {
|
|
65
|
+
breakdown[category] = { count: 0, manualMinutes: 0, thubanMinutes: 0 };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const manualMin = COST_MODEL.manualFixMinutes[category] || 10;
|
|
69
|
+
const thubanMin = COST_MODEL.thubanFixMinutes[category] || 1;
|
|
70
|
+
|
|
71
|
+
breakdown[category].count++;
|
|
72
|
+
breakdown[category].manualMinutes += manualMin;
|
|
73
|
+
breakdown[category].thubanMinutes += thubanMin;
|
|
74
|
+
totalManualMinutes += manualMin;
|
|
75
|
+
totalThubanMinutes += thubanMin;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const manualHours = totalManualMinutes / 60;
|
|
79
|
+
const thubanHours = totalThubanMinutes / 60;
|
|
80
|
+
const manualCost = manualHours * this.hourlyRate;
|
|
81
|
+
const thubanCost = thubanHours * this.hourlyRate;
|
|
82
|
+
const savings = manualCost - thubanCost;
|
|
83
|
+
const savingsPercent = manualCost > 0 ? Math.round((savings / manualCost) * 100) : 0;
|
|
84
|
+
|
|
85
|
+
// Weekly debt accumulation estimate
|
|
86
|
+
const weeklyNewDebt = (fileCount / 1000) * COST_MODEL.weeklyDriftHours;
|
|
87
|
+
const monthlyNewDebtCost = weeklyNewDebt * 4 * this.hourlyRate;
|
|
88
|
+
|
|
89
|
+
// ROI calculation
|
|
90
|
+
const thubanMonthlyCost = 19; // Pro tier
|
|
91
|
+
const monthlyROI = savings > 0 ? Math.round(savings / thubanMonthlyCost) : 0;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
summary: {
|
|
95
|
+
totalIssues: issues.length,
|
|
96
|
+
manualHours: Math.round(manualHours * 10) / 10,
|
|
97
|
+
manualCost: Math.round(manualCost),
|
|
98
|
+
thubanHours: Math.round(thubanHours * 10) / 10,
|
|
99
|
+
thubanCost: Math.round(thubanCost),
|
|
100
|
+
savings: Math.round(savings),
|
|
101
|
+
savingsPercent,
|
|
102
|
+
timeSaved: this._formatTime(totalManualMinutes - totalThubanMinutes),
|
|
103
|
+
},
|
|
104
|
+
breakdown: Object.entries(breakdown).map(([category, data]) => ({
|
|
105
|
+
category,
|
|
106
|
+
count: data.count,
|
|
107
|
+
manualTime: this._formatTime(data.manualMinutes),
|
|
108
|
+
thubanTime: this._formatTime(data.thubanMinutes),
|
|
109
|
+
manualCost: Math.round((data.manualMinutes / 60) * this.hourlyRate),
|
|
110
|
+
thubanCost: Math.round((data.thubanMinutes / 60) * this.hourlyRate),
|
|
111
|
+
})).sort((a, b) => b.manualCost - a.manualCost),
|
|
112
|
+
projection: {
|
|
113
|
+
weeklyNewDebtHours: Math.round(weeklyNewDebt * 10) / 10,
|
|
114
|
+
monthlyNewDebtCost: Math.round(monthlyNewDebtCost),
|
|
115
|
+
monthlyThubanCost: thubanMonthlyCost,
|
|
116
|
+
monthlyROI: `${monthlyROI}x`,
|
|
117
|
+
annualSavings: Math.round(savings + (monthlyNewDebtCost * 12)),
|
|
118
|
+
verdict: monthlyROI >= 5
|
|
119
|
+
? 'Thuban pays for itself within the first scan.'
|
|
120
|
+
: monthlyROI >= 2
|
|
121
|
+
? 'Thuban pays for itself within the first week.'
|
|
122
|
+
: 'Thuban will save you money within the first month.',
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Format for CLI output
|
|
129
|
+
*/
|
|
130
|
+
formatCLI(result) {
|
|
131
|
+
const lines = [];
|
|
132
|
+
const s = result.summary;
|
|
133
|
+
const p = result.projection;
|
|
134
|
+
const c = this.currency;
|
|
135
|
+
|
|
136
|
+
lines.push('');
|
|
137
|
+
lines.push(' TECH DEBT COST ESTIMATE');
|
|
138
|
+
lines.push('');
|
|
139
|
+
lines.push(` Current debt: ${s.manualHours} developer-hours to fix manually`);
|
|
140
|
+
lines.push(` At ${c}${this.hourlyRate}/hr: ${c}${s.manualCost.toLocaleString()} if you fix it yourself`);
|
|
141
|
+
lines.push(` With Thuban: ${s.thubanHours} hours (${s.timeSaved} saved)`);
|
|
142
|
+
lines.push(` You save: ${c}${s.savings.toLocaleString()} (${s.savingsPercent}%)`);
|
|
143
|
+
lines.push('');
|
|
144
|
+
|
|
145
|
+
// Breakdown table
|
|
146
|
+
lines.push(' BREAKDOWN');
|
|
147
|
+
lines.push(` ${'Category'.padEnd(20)} ${'Count'.padEnd(8)} ${'Manual'.padEnd(12)} ${'Thuban'.padEnd(12)} ${'Saving'.padEnd(10)}`);
|
|
148
|
+
lines.push(' ' + '-'.repeat(62));
|
|
149
|
+
for (const b of result.breakdown) {
|
|
150
|
+
const saving = `${c}${b.manualCost - b.thubanCost}`;
|
|
151
|
+
lines.push(` ${b.category.padEnd(20)} ${String(b.count).padEnd(8)} ${(c + b.manualCost).padEnd(12)} ${(c + b.thubanCost).padEnd(12)} ${saving.padEnd(10)}`);
|
|
152
|
+
}
|
|
153
|
+
lines.push('');
|
|
154
|
+
|
|
155
|
+
// Projection
|
|
156
|
+
lines.push(' PROJECTION');
|
|
157
|
+
lines.push(` Every week you wait, ${p.weeklyNewDebtHours} hours of new debt accumulates.`);
|
|
158
|
+
lines.push(` That's ${c}${p.monthlyNewDebtCost}/month in growing technical debt.`);
|
|
159
|
+
lines.push(` Thuban Pro costs ${c}${p.monthlyThubanCost}/month. ROI: ${p.monthlyROI}.`);
|
|
160
|
+
lines.push('');
|
|
161
|
+
lines.push(` ${p.verdict}`);
|
|
162
|
+
lines.push('');
|
|
163
|
+
|
|
164
|
+
return lines.join('\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Private ─────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
_categorize(issue) {
|
|
170
|
+
const cat = (issue.category || '').toLowerCase();
|
|
171
|
+
const id = (issue.id || '').toLowerCase();
|
|
172
|
+
|
|
173
|
+
if (cat === 'hallucination' || id.startsWith('hall')) return 'hallucination';
|
|
174
|
+
if (cat === 'deprecated' || id.startsWith('depr')) return 'deprecated';
|
|
175
|
+
if (cat === 'security') return 'security';
|
|
176
|
+
if (cat === 'circular' || id.includes('circular')) return 'circular_dep';
|
|
177
|
+
if (cat === 'orphan' || id.includes('orphan')) return 'orphan_file';
|
|
178
|
+
if (cat === 'drift') return 'drift';
|
|
179
|
+
if (cat === 'ghost' || id.includes('ghost')) return 'ghost_code';
|
|
180
|
+
if (cat === 'copy_paste' || id.includes('duplicate')) return 'copy_paste';
|
|
181
|
+
if (cat === 'annotation' || id.includes('mother')) return 'missing_annotation';
|
|
182
|
+
return 'code_smell';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
_formatTime(minutes) {
|
|
186
|
+
if (minutes < 60) return `${Math.round(minutes)} mins`;
|
|
187
|
+
const hours = Math.floor(minutes / 60);
|
|
188
|
+
const mins = Math.round(minutes % 60);
|
|
189
|
+
if (mins === 0) return `${hours}h`;
|
|
190
|
+
return `${hours}h ${mins}m`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = TechDebtCostCalculator;
|