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.
@@ -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;