thuban 0.3.1 → 0.3.3

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/cli.js CHANGED
@@ -14,12 +14,16 @@
14
14
  * npx thuban report [path] Full scan + deps + drift combined report
15
15
  * npx thuban hallucinate [path] Detect AI hallucinations and phantom imports
16
16
  * npx thuban watch [path] Live sentinel — continuous file monitoring
17
+ * npx thuban monitor [path] Recurring scans with saved history and alerts
18
+ * npx thuban history [path] View recurring scan history for a repo
19
+ * npx thuban trend [path] Show issue trends across recurring scans
17
20
  * npx thuban dashboard [path] Generate visual HTML dashboard report
18
21
  * npx thuban diff [path] Scan only git-changed files
19
22
  */
20
23
 
21
24
  const path = require('path');
22
25
  const fs = require('fs');
26
+ const os = require('os');
23
27
 
24
28
  // Import engine components
25
29
  const CodeScanner = require('./packages/scanner/code-scanner.js');
@@ -40,6 +44,9 @@ const CopyPasteDriftDetector = require('./packages/scanner/copy-paste-detector.j
40
44
  const CodebasePassport = require('./packages/scanner/codebase-passport.js');
41
45
  const ExecutiveReport = require('./packages/scanner/executive-report.js');
42
46
  const BaselineManager = require('./packages/scanner/baseline-manager.js');
47
+ const MonitorStore = require('./packages/scanner/monitor-store.js');
48
+ const { MonitorService, resolveIntervalMs } = require('./packages/scanner/monitor-service.js');
49
+ const { collectFiles } = require('./packages/scanner/file-collector.js');
43
50
 
44
51
  // ─── Helpers ───────────────────────────────────────────────
45
52
 
@@ -66,7 +73,7 @@ function banner() {
66
73
  console.log('');
67
74
  console.log(c('cyan', ' ╔══════════════════════════════════════╗'));
68
75
  console.log(c('cyan', ' ║') + c('bold', ' THUBAN Code Health Engine ') + c('cyan', '║'));
69
- console.log(c('cyan', ' ║') + c('gray', ' Scan · Map · Detect · Fix ') + c('cyan', '║'));
76
+ console.log(c('cyan', ' ║') + c('gray', ' v0.3.3 · Public Beta · thuban.dev ') + c('cyan', '║'));
70
77
  console.log(c('cyan', ' ╚══════════════════════════════════════╝'));
71
78
  console.log('');
72
79
  }
@@ -93,7 +100,8 @@ function printHelp() {
93
100
  // ─── SCAN & DETECT ───────────────────────────────────────
94
101
  console.log(c('bold', ' SCAN & DETECT'));
95
102
  console.log('');
96
- console.log(` ${c('cyan', 'scan')} [path] Full health scan — score, grade, all issues`);
103
+ console.log(` ${c('cyan', 'scan')} [path|url] Full health scan — score, grade, all issues`);
104
+ console.log(` ${c('cyan', 'compare')} [a] [b] Compare two codebases side by side`);
97
105
  console.log(` ${c('cyan', 'hallucinate')} [path] Find phantom APIs and invented imports`);
98
106
  console.log(` ${c('cyan', 'ghosts')} [path] Dead functions nobody calls`);
99
107
  console.log(` ${c('cyan', 'ai-score')} [path] Probability each function is AI-generated`);
@@ -114,6 +122,9 @@ function printHelp() {
114
122
  console.log(` ${c('cyan', 'inject')} [path] Add Mother Code DNA to every file`);
115
123
  console.log(` ${c('cyan', 'gate')} Install pre-commit hook ${c('gray', '(blocks hallucinations)')}`);
116
124
  console.log(` ${c('cyan', 'watch')} [path] Live sentinel — scans files as you save`);
125
+ console.log(` ${c('cyan', 'monitor')} [path] Recurring scans with saved history and alerts ${c('gray', '(Pro)')}`);
126
+ console.log(` ${c('cyan', 'history')} [path] View recurring scan history for a repo ${c('gray', '(Pro)')}`);
127
+ console.log(` ${c('cyan', 'trend')} [path] Show issue trends across recurring scans ${c('gray', '(Pro)')}`);
117
128
  console.log(` ${c('cyan', 'baseline')} [path] Snapshot issues so future scans show only NEW ones`);
118
129
  console.log('');
119
130
 
@@ -166,6 +177,9 @@ function printHelp() {
166
177
  console.log(c('gray', ' # Scan a specific folder, ignore tests'));
167
178
  console.log(` ${c('white', 'npx thuban scan ./src --ignore "**/*.test.js"')}`);
168
179
  console.log('');
180
+ console.log(c('gray', ' Report a bug or request a feature:'));
181
+ console.log(` ${c('cyan', 'https://github.com/SilverwingsBenefitsGit/thuban/issues')}`);
182
+ console.log('');
169
183
  }
170
184
 
171
185
  function parseArgs(args) {
@@ -183,6 +197,10 @@ function parseArgs(args) {
183
197
  licenseKey: null,
184
198
  baseline: false,
185
199
  baselineCreate: false,
200
+ frequency: 'daily',
201
+ intervalMs: null,
202
+ notify: 'inbox',
203
+ once: false,
186
204
  };
187
205
 
188
206
  const noPathCommands = ['activate', 'status', 'upgrade', 'help', 'version'];
@@ -197,6 +215,10 @@ function parseArgs(args) {
197
215
  else if (arg === '--git-commit' || arg === '--commit') { opts.gitCommit = true; }
198
216
  else if (arg === '--baseline') { opts.baseline = true; }
199
217
  else if (arg === '--baseline-create') { opts.baselineCreate = true; }
218
+ else if (arg === '--frequency' && args[i + 1]) { opts.frequency = args[++i]; }
219
+ else if (arg === '--interval-ms' && args[i + 1]) { opts.intervalMs = parseInt(args[++i], 10); }
220
+ else if (arg === '--notify' && args[i + 1]) { opts.notify = args[++i]; }
221
+ else if (arg === '--once') { opts.once = true; }
200
222
  else if (arg === '--verbose') { opts.verbose = true; }
201
223
  else if (arg === '--ignore' && args[i + 1]) { opts.ignore.push(args[++i]); }
202
224
  else if (arg === '--max-issues' && args[i + 1]) { opts.maxIssues = parseInt(args[++i], 10); }
@@ -204,7 +226,14 @@ function parseArgs(args) {
204
226
  else if (arg === '--version' || arg === '-v') { opts.command = 'version'; }
205
227
  else if (!opts.command) { opts.command = arg; }
206
228
  else if (opts.command === 'activate' && !opts.licenseKey) { opts.licenseKey = arg; }
207
- else if (!noPathCommands.includes(opts.command)) { opts.targetPath = path.resolve(arg); }
229
+ else if (!noPathCommands.includes(opts.command)) {
230
+ // Don't resolve git URLs as local paths
231
+ if (arg.match(/^(https?:\/\/|git@)/)) {
232
+ opts.targetPath = arg;
233
+ } else {
234
+ opts.targetPath = path.resolve(arg);
235
+ }
236
+ }
208
237
  i++;
209
238
  }
210
239
 
@@ -213,55 +242,138 @@ function parseArgs(args) {
213
242
 
214
243
  // ─── Collect files recursively ───────────────────────────────
215
244
 
216
- function collectFiles(dir, ignorePatterns = []) {
217
- const defaultIgnore = ['node_modules', '.git', 'dist', 'build', '.next', '__pycache__', '.venv', 'venv', 'coverage', '.cache', '.turbo'];
218
- const allIgnore = [...defaultIgnore, ...ignorePatterns];
219
- const extensions = ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.py', '.pyw', '.go', '.rs', '.java', '.kt', '.cs', '.json'];
220
- const MAX_FILE_SIZE = 1024 * 1024; // 1MB per file — skip anything larger
221
- const MAX_FILES = 50000; // Hard cap to prevent memory issues
222
- const files = [];
223
-
224
- function walk(currentDir) {
225
- if (files.length >= MAX_FILES) return;
226
- let entries;
227
- try {
228
- entries = fs.readdirSync(currentDir, { withFileTypes: true });
229
- } catch (e) {
230
- return;
245
+ function summarizeRemainingFiles(scannedFiles, allFiles, rootPath) {
246
+ const scannedSet = new Set(scannedFiles);
247
+ const remainingFiles = allFiles.filter(file => !scannedSet.has(file));
248
+ const directoryCounts = new Map();
249
+
250
+ for (const file of remainingFiles) {
251
+ const relativePath = path.relative(rootPath, file);
252
+ const segments = relativePath.split(path.sep).filter(Boolean);
253
+ const directory = segments.length > 1 ? segments[0] : '(root)';
254
+ directoryCounts.set(directory, (directoryCounts.get(directory) || 0) + 1);
255
+ }
256
+
257
+ const topDirectories = Array.from(directoryCounts.entries())
258
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
259
+ .slice(0, 3)
260
+ .map(([directory, count]) => ({ directory, count }));
261
+
262
+ return {
263
+ remainingFiles,
264
+ remainingCount: remainingFiles.length,
265
+ topDirectories,
266
+ previewFiles: remainingFiles.slice(0, 5).map(file => path.relative(rootPath, file)),
267
+ };
268
+ }
269
+
270
+ function requireProFeature(featureName) {
271
+ const lm = new LicenseManager();
272
+ const access = lm.canUseProFeature(featureName);
273
+ if (!access.allowed) {
274
+ const upgradeUrl = 'https://thuban.dev/pricing';
275
+ console.log('');
276
+ console.log(c('yellow', ' ╔══════════════════════════════════════════════════════════════╗'));
277
+ console.log(c('yellow', ' ║') + c('bold', ' PRO FEATURE REQUIRED ') + c('yellow', '║'));
278
+ console.log(c('yellow', ' ╠══════════════════════════════════════════════════════════════╣'));
279
+ console.log(c('yellow', ' ║') + ` ${featureName} is available on Thuban Pro.`.padEnd(62) + c('yellow', '║'));
280
+ console.log(c('yellow', ' ║') + ' Free Thuban gives you a one-off checkup. Pro gives you'.padEnd(62) + c('yellow', '║'));
281
+ console.log(c('yellow', ' ║') + ' a pair of eyes on your codebase all day every day with'.padEnd(62) + c('yellow', '║'));
282
+ console.log(c('yellow', ' ║') + ' recurring scans, saved history, trends, and alerts.'.padEnd(62) + c('yellow', '║'));
283
+ console.log(c('yellow', ' ║') + ' ' + c('yellow', '║'));
284
+ console.log(c('yellow', ' ║') + ` Upgrade now: ${upgradeUrl}`.padEnd(62) + c('yellow', '║'));
285
+ console.log(c('yellow', ' ║') + ' CLI shortcut: thuban upgrade'.padEnd(62) + c('yellow', '║'));
286
+ console.log(c('yellow', ' ╚══════════════════════════════════════════════════════════════╝'));
287
+ console.log('');
288
+ process.exit(0);
289
+ }
290
+ return lm;
291
+ }
292
+
293
+ function resolveRepoConfigOrExit(store, targetPath) {
294
+ const repo = store.getRepo(targetPath);
295
+ if (!repo) {
296
+ console.log('');
297
+ console.log(c('yellow', ' No monitor configuration found for this repo.'));
298
+ console.log(c('gray', ' Run `thuban monitor <path> --once` or `thuban monitor <path>` first.'));
299
+ console.log('');
300
+ process.exit(0);
301
+ }
302
+ return repo;
303
+ }
304
+
305
+ function printFileCapUpgradePrompt({ scannedCount, totalCount, remainingSummary, upgradeUrl }) {
306
+ const criticalDirectoryCount = remainingSummary.topDirectories.length;
307
+
308
+ console.log(c('yellow', ' ╔══════════════════════════════════════════════════════════════╗'));
309
+ console.log(c('yellow', ' ║') + c('bold', ' FREE TIER LIMIT REACHED ') + c('yellow', '║'));
310
+ console.log(c('yellow', ' ╠══════════════════════════════════════════════════════════════╣'));
311
+ console.log(c('yellow', ' ║') + ` You have scanned ${scannedCount} of ${totalCount} files.`.padEnd(62) + c('yellow', '║'));
312
+ console.log(c('yellow', ' ║') + ' Upgrade to Thuban Pro for continuous monitoring —'.padEnd(62) + c('yellow', '║'));
313
+ console.log(c('yellow', ' ║') + ' a pair of eyes on your codebase, all day, every day.'.padEnd(62) + c('yellow', '║'));
314
+ console.log(c('yellow', ' ║') + ' Your always-on guardian with unlimited scans.'.padEnd(62) + c('yellow', '║'));
315
+ console.log(c('yellow', ' ║') + ` ${remainingSummary.remainingCount} more files remaining including ${criticalDirectoryCount}`.padEnd(62) + c('yellow', '║'));
316
+ console.log(c('yellow', ' ║') + ` critical director${criticalDirectoryCount === 1 ? 'y' : 'ies'}.`.padEnd(62) + c('yellow', '║'));
317
+ console.log(c('yellow', ' ║') + ' ' + c('yellow', '║'));
318
+ console.log(c('yellow', ' ║') + ` Upgrade now: ${upgradeUrl}`.padEnd(62) + c('yellow', '║'));
319
+ console.log(c('yellow', ' ║') + ` CLI shortcut: thuban upgrade`.padEnd(62) + c('yellow', '║'));
320
+ console.log(c('yellow', ' ╚══════════════════════════════════════════════════════════════╝'));
321
+ console.log('');
322
+
323
+ if (remainingSummary.topDirectories.length > 0) {
324
+ console.log(c('bold', ' Remaining scan preview'));
325
+ for (const item of remainingSummary.topDirectories) {
326
+ console.log(` ${c('yellow', '•')} ${c('cyan', item.directory)} ${c('gray', `(${item.count} files)`)}`);
231
327
  }
232
- for (const entry of entries) {
233
- if (files.length >= MAX_FILES) return;
234
- if (allIgnore.some(p => entry.name === p || entry.name.match(p))) continue;
235
- const fullPath = path.join(currentDir, entry.name);
236
- if (entry.isDirectory()) {
237
- walk(fullPath);
238
- } else if (extensions.some(ext => entry.name.endsWith(ext))) {
239
- try {
240
- const stat = fs.statSync(fullPath);
241
- if (stat.size <= MAX_FILE_SIZE) {
242
- files.push(fullPath);
243
- }
244
- } catch (e) {
245
- // Skip files we can't stat
246
- }
328
+ if (remainingSummary.previewFiles.length > 0) {
329
+ console.log('');
330
+ console.log(c('gray', ' Next files that would be scanned:'));
331
+ for (const file of remainingSummary.previewFiles) {
332
+ console.log(` ${c('gray', '·')} ${file}`);
247
333
  }
248
334
  }
335
+ console.log('');
249
336
  }
250
-
251
- walk(dir);
252
- return files;
253
337
  }
254
338
 
255
339
  // ─── Commands ───────────────────────────────────────────────
256
340
 
257
341
  async function cmdScan(opts) {
258
342
  const startTime = Date.now();
259
- const targetPath = path.resolve(opts.targetPath);
343
+ const gitUrlPattern = /^(https?:\/\/|git@)/;
344
+ const isGitUrl = gitUrlPattern.test(opts.targetPath);
345
+ let targetPath = isGitUrl ? opts.targetPath : path.resolve(opts.targetPath);
346
+ let clonedRepo = null;
347
+
348
+ // ─── Git URL detection — clone and scan ─────────────────
349
+ if (isGitUrl) {
350
+ if (!opts.json) {
351
+ console.log('');
352
+ console.log(c('cyan', ' Cloning repository...'));
353
+ console.log(c('gray', ` ${opts.targetPath}`));
354
+ }
355
+ const tmpDir = path.join(os.tmpdir(), 'thuban-scan-' + Date.now());
356
+ const { execFileSync } = require('child_process');
357
+ try {
358
+ execFileSync('git', ['clone', '--depth', '1', opts.targetPath, tmpDir], { stdio: 'pipe', timeout: 60000 });
359
+ targetPath = tmpDir;
360
+ clonedRepo = tmpDir;
361
+ if (!opts.json) {
362
+ console.log(c('cyan', ' Cloned successfully'));
363
+ console.log('');
364
+ }
365
+ } catch (e) {
366
+ console.error(c('red', ` Failed to clone repository: ${e.message}`));
367
+ console.error(c('gray', ' Check the URL is correct and the repo is accessible'));
368
+ process.exit(1);
369
+ }
370
+ }
260
371
 
261
372
  // ─── License check ───────────────────────────────────────
262
373
  const lm = new LicenseManager();
263
374
  const tier = lm.getTierConfig();
264
375
  const scanCheck = lm.canScan();
376
+ const upgradeUrl = 'https://thuban.dev/pricing';
265
377
 
266
378
  if (!scanCheck.allowed && !opts.json) {
267
379
  console.log('');
@@ -272,8 +384,8 @@ async function cmdScan(opts) {
272
384
  console.log(` You've used ${c('bold', scanCheck.used + '/' + scanCheck.limit)} free scans this month.`);
273
385
  console.log(` Resets on ${c('cyan', scanCheck.resetsOn)}.`);
274
386
  console.log('');
275
- console.log(` ${c('cyan', 'Upgrade to Pro')} for unlimited scans, full reports, and auto-fix:`);
276
- console.log(` ${c('bold', 'https://thuban.dev/pricing')}`);
387
+ console.log(` ${c('cyan', 'Upgrade to Pro')} for unlimited scans, full reports, auto-fix, and an always-on pair of eyes via thuban monitor:`);
388
+ console.log(` ${c('bold', upgradeUrl)}`);
277
389
  console.log('');
278
390
  console.log(` Or activate a key: ${c('cyan', 'thuban activate <your-key>')}`);
279
391
  console.log('');
@@ -309,10 +421,11 @@ async function cmdScan(opts) {
309
421
  // File limit gating
310
422
  const files = tier.maxFiles < Infinity ? allFiles.slice(0, tier.maxFiles) : allFiles;
311
423
  const isFileCapped = totalFileCount > tier.maxFiles;
424
+ const remainingSummary = isFileCapped ? summarizeRemainingFiles(files, allFiles, targetPath) : null;
312
425
 
313
426
  if (!opts.json && isFileCapped) {
314
427
  console.log(c('yellow', ` Free tier: scanning ${tier.maxFiles} of ${totalFileCount} files`));
315
- console.log(c('gray', ` Upgrade to Pro to scan your entire codebase`));
428
+ console.log(c('gray', ` Partial results below. Upgrade to scan your entire codebase.`));
316
429
  console.log('');
317
430
  } else if (!opts.json) {
318
431
  console.log(c('gray', ` Found ${files.length} files to scan...`));
@@ -338,12 +451,8 @@ async function cmdScan(opts) {
338
451
 
339
452
  // ─── Collect all issues ──────────────────────────────────
340
453
  const allIssues = [];
341
- for (const [file, fileResults] of Object.entries(scanResults)) {
342
- if (fileResults.issues) {
343
- for (const issue of fileResults.issues) {
344
- allIssues.push({ file: path.relative(targetPath, file), ...issue });
345
- }
346
- }
454
+ for (const issue of (scanResults.issues || [])) {
455
+ allIssues.push({ file: path.relative(targetPath, issue.file), ...issue });
347
456
  }
348
457
 
349
458
  // Add hallucination results as issues
@@ -389,6 +498,11 @@ async function cmdScan(opts) {
389
498
  issues: tier.showFullDetails ? allIssues : allIssues.slice(0, tier.teaserIssues),
390
499
  totalIssues: allIssues.length,
391
500
  gated: !tier.showFullDetails,
501
+ fileLimitReached: isFileCapped,
502
+ upgradeUrl: isFileCapped ? upgradeUrl : null,
503
+ remainingFiles: remainingSummary ? remainingSummary.remainingCount : 0,
504
+ remainingDirectories: remainingSummary ? remainingSummary.topDirectories : [],
505
+ remainingPreview: remainingSummary ? remainingSummary.previewFiles : [],
392
506
  }, null, 2));
393
507
  return;
394
508
  }
@@ -427,6 +541,7 @@ async function cmdScan(opts) {
427
541
 
428
542
  console.log(c('cyan', ' ╔══════════════════════════════════════════════════════╗'));
429
543
  console.log(c('cyan', ' ║') + c('bold', ' THUBAN SCAN RESULTS ') + c('cyan', '║'));
544
+ console.log(c('cyan', ' ║') + c('gray', ' v0.3.3 · Public Beta ') + c('cyan', '║'));
430
545
  console.log(c('cyan', ' ╠══════════════════════════════════════════════════════╣'));
431
546
  console.log(c('cyan', ' ║') + ` Files scanned: ${c('bold', String(files.length).padEnd(6))}` + (isFileCapped ? c('yellow', ` of ${totalFileCount}`) : ' ') + c('cyan', ' ║'));
432
547
  console.log(c('cyan', ' ║') + ` Health score: ${c(scoreColor, (grade + ' (' + Math.round(score) + '/100)').padEnd(20))}` + c('cyan', ' ║'));
@@ -436,6 +551,15 @@ async function cmdScan(opts) {
436
551
  console.log(c('cyan', ' ╚══════════════════════════════════════════════════════╝'));
437
552
  console.log('');
438
553
 
554
+ // ─── Python/non-JS scoring disclosure ───────────────────
555
+ const hasNonJsFiles = files.some(f => !f.endsWith('.js') && !f.endsWith('.ts') && !f.endsWith('.jsx') && !f.endsWith('.tsx') && !f.endsWith('.mjs'));
556
+ if (hasNonJsFiles) {
557
+ const nonJsExts = [...new Set(files.filter(f => !/\.(js|ts|jsx|tsx|mjs)$/.test(f)).map(f => path.extname(f)).filter(Boolean))];
558
+ console.log(c('gray', ` Note: Score includes Security + Hallucination + Code Quality categories.`));
559
+ console.log(c('gray', ` Architecture + Dependency analysis evaluated for JS/TS only. Detected non-JS: ${nonJsExts.join(', ')}`));
560
+ console.log('');
561
+ }
562
+
439
563
  // ─── CRITICAL: Hallucinated APIs (will crash) ────────────
440
564
  if (hallucinations.length > 0) {
441
565
  console.log(c('bgRed', ' CRITICAL ') + c('red', c('bold', ` ${hallucinations.length} Hallucinated API${hallucinations.length > 1 ? 's' : ''}`)));
@@ -633,9 +757,12 @@ async function cmdScan(opts) {
633
757
  }
634
758
 
635
759
  if (isFileCapped) {
636
- console.log(c('yellow', ` ⚠ ${totalFileCount - tier.maxFiles} files were not scanned (free tier limit: ${tier.maxFiles} files)`));
637
- console.log(c('gray', ` Your actual score may be worse. Upgrade to scan everything.`));
638
- console.log('');
760
+ printFileCapUpgradePrompt({
761
+ scannedCount: files.length,
762
+ totalCount: totalFileCount,
763
+ remainingSummary,
764
+ upgradeUrl,
765
+ });
639
766
  }
640
767
 
641
768
  // ─── Continuous monitoring pitch ────────────────────────
@@ -720,6 +847,109 @@ async function cmdDeps(opts) {
720
847
  }
721
848
  }
722
849
 
850
+ async function cmdMonitor(opts) {
851
+ requireProFeature('Scheduled monitoring');
852
+ const targetPath = path.resolve(opts.targetPath);
853
+ const service = new MonitorService();
854
+ const repoConfig = service.configureRepo({
855
+ repoPath: targetPath,
856
+ frequency: opts.frequency,
857
+ intervalMs: resolveIntervalMs(opts.frequency, opts.intervalMs),
858
+ notification: { channels: String(opts.notify || 'inbox').split(',').map(item => item.trim()).filter(Boolean) },
859
+ });
860
+
861
+ if (opts.once) {
862
+ const result = await service.runRepoScan(repoConfig);
863
+ if (opts.json) {
864
+ console.log(JSON.stringify(result.run, null, 2));
865
+ return;
866
+ }
867
+ console.log('');
868
+ console.log(c('bold', ' THUBAN MONITOR RUN'));
869
+ console.log(` Repo: ${repoConfig.repoPath}`);
870
+ console.log(` New issues: ${result.diff.added.length}`);
871
+ console.log(` Resolved issues: ${result.diff.resolved.length}`);
872
+ console.log(` History saved: ${result.runPath}`);
873
+ console.log('');
874
+ return;
875
+ }
876
+
877
+ console.log('');
878
+ console.log(c('bold', ' THUBAN MONITOR ACTIVE'));
879
+ console.log(` Repo: ${repoConfig.repoPath}`);
880
+ console.log(` Frequency: ${repoConfig.frequency} (${repoConfig.intervalMs}ms)`);
881
+ console.log(` Notifications: ${(repoConfig.notification.channels || []).join(', ')}`);
882
+ console.log(c('gray', ' Press Ctrl+C to stop.'));
883
+ console.log('');
884
+
885
+ service.start(repoConfig);
886
+ process.on('SIGINT', () => {
887
+ service.stopAll();
888
+ process.exit(0);
889
+ });
890
+ }
891
+
892
+ async function cmdHistory(opts) {
893
+ requireProFeature('Scan history');
894
+ const store = new MonitorStore();
895
+ const repo = resolveRepoConfigOrExit(store, opts.targetPath);
896
+ const runs = store.listRuns(repo.repoId);
897
+
898
+ if (opts.json) {
899
+ console.log(JSON.stringify({ repo, runs }, null, 2));
900
+ return;
901
+ }
902
+
903
+ console.log('');
904
+ console.log(c('bold', ' THUBAN HISTORY'));
905
+ console.log(` Repo: ${repo.repoPath}`);
906
+ console.log(` Runs: ${runs.length}`);
907
+ console.log('');
908
+
909
+ for (const run of runs.slice(-10).reverse()) {
910
+ console.log(` ${c('cyan', run.timestamp)} issues=${run.metrics.issueCount} new=${run.diff?.added?.length || 0} resolved=${run.diff?.resolved?.length || 0}`);
911
+ }
912
+
913
+ const notifications = store.getNotifications(10).filter(item => item.repoId === repo.repoId);
914
+ if (notifications.length) {
915
+ console.log('');
916
+ console.log(c('bold', ' RECENT ALERTS'));
917
+ for (const notification of notifications) {
918
+ console.log(` ${c('yellow', notification.timestamp)} ${notification.summary}`);
919
+ }
920
+ }
921
+ console.log('');
922
+ }
923
+
924
+ async function cmdTrend(opts) {
925
+ requireProFeature('Trend reporting');
926
+ const store = new MonitorStore();
927
+ const repo = resolveRepoConfigOrExit(store, opts.targetPath);
928
+ const runs = store.listRuns(repo.repoId);
929
+ const trend = runs.map(run => ({
930
+ timestamp: run.timestamp,
931
+ issueCount: run.metrics.issueCount,
932
+ hallucinations: run.metrics.hallucinations,
933
+ secrets: run.metrics.secrets,
934
+ techDebtScore: run.metrics.techDebtScore,
935
+ techDebtHours: run.metrics.techDebtHours,
936
+ }));
937
+
938
+ if (opts.json) {
939
+ console.log(JSON.stringify({ repo, trend }, null, 2));
940
+ return;
941
+ }
942
+
943
+ console.log('');
944
+ console.log(c('bold', ' THUBAN TREND'));
945
+ console.log(` Repo: ${repo.repoPath}`);
946
+ console.log('');
947
+ for (const point of trend.slice(-12)) {
948
+ console.log(` ${c('cyan', point.timestamp)} issues=${point.issueCount} hallucinations=${point.hallucinations} secrets=${point.secrets} debtScore=${point.techDebtScore ?? 'n/a'} debtHours=${point.techDebtHours ?? 'n/a'}`);
949
+ }
950
+ console.log('');
951
+ }
952
+
723
953
  async function cmdDrift(opts) {
724
954
  const startTime = Date.now();
725
955
  const targetPath = path.resolve(opts.targetPath);
@@ -790,7 +1020,11 @@ async function cmdInject(opts) {
790
1020
  console.log('');
791
1021
  }
792
1022
 
793
- const files = collectFiles(targetPath, opts.ignore).filter(f => f.endsWith('.js') || f.endsWith('.ts'));
1023
+ const files = collectFiles(targetPath, opts.ignore).filter(f =>
1024
+ f.endsWith('.js') || f.endsWith('.ts') || f.endsWith('.jsx') || f.endsWith('.tsx') ||
1025
+ f.endsWith('.py') || f.endsWith('.java') || f.endsWith('.cs') || f.endsWith('.go') ||
1026
+ f.endsWith('.kt') || f.endsWith('.rs') || f.endsWith('.php') || f.endsWith('.rb')
1027
+ );
794
1028
  const graph = new DependencyGraph({ rootPath: targetPath });
795
1029
  await graph.build(files);
796
1030
 
@@ -809,8 +1043,26 @@ async function cmdInject(opts) {
809
1043
  continue;
810
1044
  }
811
1045
 
812
- const widget = generator.generateWidget(file, content);
813
- if (!widget) { skipped++; continue; }
1046
+ const rawWidget = await generator.generateWidget(file, content);
1047
+ if (!rawWidget) { skipped++; continue; }
1048
+ // generateWidget returns an object with { widgetString, ... }
1049
+ const rawWidgetStr = (rawWidget && rawWidget.widgetString) ? rawWidget.widgetString : String(rawWidget);
1050
+
1051
+ // Select correct comment style by file extension
1052
+ const ext = path.extname(file).toLowerCase();
1053
+ let widget;
1054
+ if (ext === '.py' || ext === '.rb') {
1055
+ // Python and Ruby use # line comments
1056
+ widget = rawWidgetStr.replace(/^\/\*\*\n/, '').replace(/\s*\*\/\s*$/, '')
1057
+ .split('\n').map(l => l.replace(/^\s*\*\s?/, '# ')).join('\n');
1058
+ } else if (ext === '.go' || ext === '.rs') {
1059
+ // Go and Rust use // line comments
1060
+ widget = rawWidgetStr.replace(/^\/\*\*\n/, '').replace(/\s*\*\/\s*$/, '')
1061
+ .split('\n').map(l => l.replace(/^\s*\*\s?/, '// ')).join('\n');
1062
+ } else {
1063
+ // Java, Kotlin, C#, PHP all support /** */ block comments natively
1064
+ widget = rawWidgetStr;
1065
+ }
814
1066
 
815
1067
  if (opts.fix) {
816
1068
  // Actually inject
@@ -846,7 +1098,7 @@ async function cmdInject(opts) {
846
1098
  } else {
847
1099
  console.log(c('green', ` ✓ Injected Mother Code into ${injected} files`));
848
1100
  }
849
- console.log(` ${c('gray', `Skipped: ${skipped} (already annotated or non-JS/TS)`)}`);
1101
+ console.log(` ${c('gray', `Skipped: ${skipped} (already annotated or unsupported)`)}`);
850
1102
  console.log('');
851
1103
  }
852
1104
 
@@ -891,12 +1143,10 @@ async function cmdReport(opts) {
891
1143
  let totalIssues = 0;
892
1144
  let criticalCount = 0;
893
1145
  let highCount = 0;
894
- for (const [, r] of Object.entries(scanResults)) {
895
- if (r.issues) {
896
- totalIssues += r.issues.length;
897
- criticalCount += r.issues.filter(i => i.severity === 'critical').length;
898
- highCount += r.issues.filter(i => i.severity === 'high').length;
899
- }
1146
+ for (const i of (scanResults.issues || [])) {
1147
+ totalIssues++;
1148
+ if (i.severity === 'critical') criticalCount++;
1149
+ if (i.severity === 'high') highCount++;
900
1150
  }
901
1151
 
902
1152
  const orphans = graph.getOrphanFiles();
@@ -966,6 +1216,8 @@ async function cmdReport(opts) {
966
1216
  if (orphans.length > 5) console.log(` ${c('gray', '→')} Consider removing ${orphans.length} orphan files`);
967
1217
  console.log('');
968
1218
  }
1219
+
1220
+ // Clean up cloned repo (clonedRepo only exists in cmdScan scope)
969
1221
  }
970
1222
 
971
1223
  // ─── Tech Debt & Fix Commands ────────────────────────────
@@ -1113,13 +1365,24 @@ async function cmdFix(opts) {
1113
1365
  console.log(c('cyan', ' ╔══════════════════════════════════════════════════════╗'));
1114
1366
  console.log(c('cyan', ' ║') + c('bold', ' FIX RESULTS ') + c('cyan', '║'));
1115
1367
  console.log(c('cyan', ' ╠══════════════════════════════════════════════════════╣'));
1116
- console.log(c('cyan', ' ║') + ` Fixed: ${c('green', String(result.fixed).padEnd(20))}` + c('cyan', ' ║'));
1368
+ // Split annotations (inject_widget/regenerate_widget) from real code fixes
1369
+ const annotationsAdded = (result.fixes || []).filter(f => f.status === 'fixed' && (f.fixAction === 'inject_widget' || f.fixAction === 'regenerate_widget')).length;
1370
+ const issuesFixed = result.fixed - annotationsAdded;
1371
+ if (annotationsAdded > 0) {
1372
+ console.log(c('cyan', ' ║') + ` Annotations added: ${c('green', String(annotationsAdded).padEnd(19))}` + c('cyan', ' ║'));
1373
+ }
1374
+ if (issuesFixed > 0) {
1375
+ console.log(c('cyan', ' ║') + ` Issues fixed: ${c('green', String(issuesFixed).padEnd(20))}` + c('cyan', ' ║'));
1376
+ }
1377
+ if (annotationsAdded === 0 && issuesFixed === 0) {
1378
+ console.log(c('cyan', ' ║') + ` Fixed: ${c('green', String(result.fixed).padEnd(20))}` + c('cyan', ' ║'));
1379
+ }
1117
1380
  console.log(c('cyan', ' ║') + ` Flagged for review: ${c('yellow', String(result.flagged).padEnd(19))}` + c('cyan', ' ║'));
1118
1381
  if (result.failed > 0) {
1119
1382
  console.log(c('cyan', ' ║') + ` Failed: ${c('red', String(result.failed).padEnd(20))}` + c('cyan', ' ║'));
1120
1383
  }
1121
- if (result.skippedUnsafe > 0) {
1122
- console.log(c('cyan', ' ║') + ` Skipped (unsafe): ${c('gray', String(result.skippedUnsafe).padEnd(20))}` + c('cyan', ' ║'));
1384
+ if (result.skipped > 0) {
1385
+ console.log(c('cyan', ' ║') + ` Skipped: ${c('gray', String(result.skipped).padEnd(20))}` + c('cyan', ' ║'));
1123
1386
  }
1124
1387
  console.log(c('cyan', ' ╚══════════════════════════════════════════════════════╝'));
1125
1388
  console.log('');
@@ -1136,9 +1399,9 @@ async function cmdFix(opts) {
1136
1399
  console.log('');
1137
1400
  }
1138
1401
 
1139
- if (result.skippedUnsafe > 0) {
1140
- console.log(c('gray', ` ${result.skippedUnsafe} unsafe fixes were skipped. These change runtime behaviour.`));
1141
- console.log(c('gray', ' Review them with: ') + c('cyan', 'npx thuban fix . --fix --unsafe'));
1402
+ if (result.skipped > 0) {
1403
+ console.log(c('gray', ` ${result.skipped} items were skipped (no auto-fix available or pattern not matched).`));
1404
+ console.log(c('gray', ' Review them manually or run: ') + c('cyan', 'npx thuban dashboard .'));
1142
1405
  console.log('');
1143
1406
  }
1144
1407
 
@@ -1363,12 +1626,11 @@ async function cmdDiff(opts) {
1363
1626
  let totalIssues = 0;
1364
1627
  const fileIssues = {};
1365
1628
 
1366
- for (const [file, result] of Object.entries(scanResults)) {
1367
- if (result.issues && result.issues.length > 0) {
1368
- const relFile = path.relative(targetPath, file);
1369
- fileIssues[relFile] = result.issues;
1370
- totalIssues += result.issues.length;
1371
- }
1629
+ for (const issue of (scanResults.issues || [])) {
1630
+ const relFile = path.relative(targetPath, issue.file);
1631
+ if (!fileIssues[relFile]) fileIssues[relFile] = [];
1632
+ fileIssues[relFile].push(issue);
1633
+ totalIssues++;
1372
1634
  }
1373
1635
 
1374
1636
  totalIssues += hallResults.stats.totalIssues;
@@ -1471,7 +1733,7 @@ function cmdUpgrade() {
1471
1733
  console.log(c('bold', ' THUBAN PRICING'));
1472
1734
  console.log('');
1473
1735
  console.log(` ${c('gray', 'FREE')} ${c('bold', '£0/mo')}`);
1474
- console.log(` 3 scans/month, 100 files, summary only`);
1736
+ console.log(` 5 scans/month, 100 files, summary only`);
1475
1737
  console.log('');
1476
1738
  console.log(` ${c('cyan', 'PRO')} ${c('bold', '£19/mo')} ${c('gray', '(or £149/year — save 35%)')}`);
1477
1739
  console.log(` Unlimited scans, full details, auto-fix, HTML dashboard`);
@@ -1585,12 +1847,8 @@ async function cmdCost(opts) {
1585
1847
 
1586
1848
  // Collect all issues
1587
1849
  const allIssues = [];
1588
- for (const [file, fileResults] of Object.entries(scanResults)) {
1589
- if (fileResults.issues) {
1590
- for (const issue of fileResults.issues) {
1591
- allIssues.push({ file: path.relative(targetPath, file), ...issue });
1592
- }
1593
- }
1850
+ for (const issue of (scanResults.issues || [])) {
1851
+ allIssues.push({ file: path.relative(targetPath, issue.file), ...issue });
1594
1852
  }
1595
1853
  for (const item of hallResults.phantomAPIs) {
1596
1854
  allIssues.push({ category: 'hallucination', severity: 'critical', message: item.name });
@@ -1831,12 +2089,8 @@ async function cmdExecutive(opts) {
1831
2089
 
1832
2090
  // Collect all issues for cost calculator
1833
2091
  const allIssues = [];
1834
- for (const [file, fileResults] of Object.entries(scanResults)) {
1835
- if (fileResults.issues) {
1836
- for (const issue of fileResults.issues) {
1837
- allIssues.push({ file: path.relative(targetPath, file), ...issue });
1838
- }
1839
- }
2092
+ for (const issue of (scanResults.issues || [])) {
2093
+ allIssues.push({ file: path.relative(targetPath, issue.file), ...issue });
1840
2094
  }
1841
2095
  for (const item of hallResults.phantomAPIs) {
1842
2096
  allIssues.push({ category: 'hallucination', severity: 'critical', message: item.name });
@@ -1910,12 +2164,8 @@ async function cmdBaseline(opts) {
1910
2164
  ]);
1911
2165
 
1912
2166
  const allIssues = [];
1913
- for (const [file, fileResults] of Object.entries(scanResults)) {
1914
- if (fileResults.issues) {
1915
- for (const issue of fileResults.issues) {
1916
- allIssues.push({ file: path.relative(targetPath, file), ...issue });
1917
- }
1918
- }
2167
+ for (const issue of (scanResults.issues || [])) {
2168
+ allIssues.push({ file: path.relative(targetPath, issue.file), ...issue });
1919
2169
  }
1920
2170
  for (const item of hallResults.phantomAPIs) {
1921
2171
  allIssues.push({ file: item.file, category: 'hallucination', severity: 'critical', id: item.id, message: item.name });
@@ -1948,6 +2198,292 @@ async function cmdBaseline(opts) {
1948
2198
  }
1949
2199
  }
1950
2200
 
2201
+ // ─── Telemetry (opt-in anonymous usage stats) ───────────────
2202
+
2203
+ function getTelemetryConfigPath() {
2204
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
2205
+ return path.join(home, '.thuban-telemetry.json');
2206
+ }
2207
+
2208
+ function isTelemetryEnabled() {
2209
+ try {
2210
+ const config = JSON.parse(fs.readFileSync(getTelemetryConfigPath(), 'utf-8'));
2211
+ return config.enabled === true;
2212
+ } catch { return false; }
2213
+ }
2214
+
2215
+ function sendAnonymousTelemetry(data) {
2216
+ if (!isTelemetryEnabled()) return;
2217
+ try {
2218
+ const payload = JSON.stringify({
2219
+ v: data.version || '0.3.1',
2220
+ cmd: data.command,
2221
+ files: data.fileCount,
2222
+ score: data.score,
2223
+ grade: data.grade,
2224
+ langs: data.languages,
2225
+ issues: data.issueCount,
2226
+ duration: data.duration,
2227
+ os: process.platform,
2228
+ node: process.version,
2229
+ ts: new Date().toISOString()
2230
+ });
2231
+ // Fire and forget — never blocks the CLI
2232
+ const https = require('https');
2233
+ const req = https.request({
2234
+ hostname: 'thuban-telemetry.silverwingsbenefits.workers.dev',
2235
+ path: '/report',
2236
+ method: 'POST',
2237
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
2238
+ timeout: 3000
2239
+ });
2240
+ req.on('error', () => {}); // silently ignore
2241
+ req.write(payload);
2242
+ req.end();
2243
+ } catch { /* never fail the CLI for telemetry */ }
2244
+ }
2245
+
2246
+ async function cmdTelemetry(opts) {
2247
+ const configPath = getTelemetryConfigPath();
2248
+ const args = process.argv.slice(2);
2249
+
2250
+ if (args.includes('--on')) {
2251
+ fs.writeFileSync(configPath, JSON.stringify({ enabled: true, updated: new Date().toISOString() }));
2252
+ console.log('');
2253
+ console.log(c('cyan', ' Telemetry enabled'));
2254
+ console.log(c('gray', ' Anonymous usage stats will be sent (no code, no paths, no PII)'));
2255
+ console.log(c('gray', ' Turn off anytime: npx thuban telemetry --off'));
2256
+ console.log('');
2257
+ } else if (args.includes('--off')) {
2258
+ fs.writeFileSync(configPath, JSON.stringify({ enabled: false, updated: new Date().toISOString() }));
2259
+ console.log('');
2260
+ console.log(c('cyan', ' Telemetry disabled'));
2261
+ console.log(c('gray', ' No data will be collected'));
2262
+ console.log('');
2263
+ } else {
2264
+ const enabled = isTelemetryEnabled();
2265
+ console.log('');
2266
+ console.log(c('bold', ' TELEMETRY STATUS'));
2267
+ console.log('');
2268
+ console.log(` Status: ${enabled ? c('cyan', 'Enabled') : c('gray', 'Disabled (default)')}`);
2269
+ console.log('');
2270
+ console.log(c('gray', ' Thuban telemetry is 100% opt-in and anonymous.'));
2271
+ console.log(c('gray', ' We collect: file count, score, grade, languages, OS, scan duration.'));
2272
+ console.log(c('gray', ' We never collect: code, file names, paths, project names, or PII.'));
2273
+ console.log('');
2274
+ console.log(` Enable: ${c('cyan', 'npx thuban telemetry --on')}`);
2275
+ console.log(` Disable: ${c('cyan', 'npx thuban telemetry --off')}`);
2276
+ console.log('');
2277
+ }
2278
+ }
2279
+
2280
+ // ─── Internal scan (returns data without printing) ──────────
2281
+
2282
+ async function runScanSilent(targetInput) {
2283
+ const gitUrlPattern = /^(https?:\/\/|git@)/;
2284
+ const isGitUrl = gitUrlPattern.test(targetInput);
2285
+ let targetPath = isGitUrl ? targetInput : path.resolve(targetInput);
2286
+ let clonedRepo = null;
2287
+ let label = targetInput;
2288
+
2289
+ if (isGitUrl) {
2290
+ const tmpDir = path.join(os.tmpdir(), 'thuban-compare-' + Date.now() + '-' + Math.random().toString(36).slice(2,6));
2291
+ const { execFileSync } = require('child_process');
2292
+ execFileSync('git', ['clone', '--depth', '1', targetInput, tmpDir], { stdio: 'pipe', timeout: 60000 });
2293
+ targetPath = tmpDir;
2294
+ clonedRepo = tmpDir;
2295
+ // Extract repo name for label
2296
+ const parts = targetInput.replace(/\.git$/, '').split('/');
2297
+ label = parts[parts.length - 1] || targetInput;
2298
+ } else {
2299
+ label = path.basename(path.resolve(targetInput)) || targetInput;
2300
+ }
2301
+
2302
+ const startTime = Date.now();
2303
+ const scanner = new CodeScanner();
2304
+ const graph = new DependencyGraph();
2305
+ const hallDetector = new HallucinationDetector({ rootPath: targetPath });
2306
+
2307
+ const files = collectFiles(targetPath, []);
2308
+ const scanResults = {};
2309
+ let annotated = 0;
2310
+ let driftCount = 0;
2311
+
2312
+ for (const file of files) {
2313
+ try {
2314
+ const result = scanner.scanFile(file);
2315
+ const relPath = path.relative(targetPath, file);
2316
+ scanResults[file] = result;
2317
+ graph.addFile(file, targetPath);
2318
+ if (result.motherCode && result.motherCode.hasDNA) annotated++;
2319
+ if (result.drifts && result.drifts.length > 0) driftCount++;
2320
+ } catch (e) { /* skip */ }
2321
+ }
2322
+
2323
+ // Run hallucination detection (same as main scan)
2324
+ const hallResults = hallDetector.scan(files, targetPath);
2325
+
2326
+ const elapsed = Date.now() - startTime;
2327
+ let totalIssues = 0, criticalCount = 0, highCount = 0;
2328
+ const issueByCat = {};
2329
+
2330
+ // Count code scanner issues
2331
+ for (const [, r] of Object.entries(scanResults)) {
2332
+ if (r.issues) {
2333
+ totalIssues += r.issues.length;
2334
+ for (const iss of r.issues) {
2335
+ if (iss.severity === 'critical') criticalCount++;
2336
+ if (iss.severity === 'high') highCount++;
2337
+ const cat = iss.category || iss.type || 'other';
2338
+ issueByCat[cat] = (issueByCat[cat] || 0) + 1;
2339
+ }
2340
+ }
2341
+ }
2342
+
2343
+ // Count hallucination issues
2344
+ const hallIssueCount = (hallResults.phantomAPIs || []).length
2345
+ + (hallResults.phantomImports || []).length
2346
+ + (hallResults.deprecatedAPIs || []).length;
2347
+ totalIssues += hallIssueCount;
2348
+ criticalCount += (hallResults.phantomAPIs || []).length + (hallResults.phantomImports || []).length;
2349
+ highCount += (hallResults.deprecatedAPIs || []).length;
2350
+ if (hallIssueCount > 0) issueByCat['hallucination'] = hallIssueCount;
2351
+
2352
+ const orphans = graph.getOrphanFiles();
2353
+ const circular = graph.circularDeps || [];
2354
+ const coverage = files.length > 0 ? Math.round((annotated / files.length) * 100) : 0;
2355
+
2356
+ const score = Math.max(0, Math.round(
2357
+ 100 - (criticalCount * 15) - (highCount * 5) - (circular.length * 10)
2358
+ - (orphans.length * 1) - ((100 - coverage) * 0.1) - (driftCount * 3)
2359
+ ));
2360
+
2361
+ const grade = score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';
2362
+
2363
+ if (clonedRepo) {
2364
+ try { fs.rmSync(clonedRepo, { recursive: true, force: true }); } catch {}
2365
+ }
2366
+
2367
+ return {
2368
+ label,
2369
+ files: files.length,
2370
+ score,
2371
+ grade,
2372
+ elapsed,
2373
+ issues: { total: totalIssues, critical: criticalCount, high: highCount },
2374
+ categories: issueByCat,
2375
+ dependencies: { circular: circular.length, orphans: orphans.length },
2376
+ motherCode: { annotated, coverage, drifted: driftCount }
2377
+ };
2378
+ }
2379
+
2380
+ // ─── Compare Command ────────────────────────────────────────
2381
+
2382
+ async function cmdCompare(opts) {
2383
+ const args = process.argv.slice(2);
2384
+ // Find the two paths after 'compare'
2385
+ const paths = args.filter(a => a !== 'compare' && !a.startsWith('--'));
2386
+
2387
+ if (paths.length < 2) {
2388
+ console.log('');
2389
+ console.log(c('red', ' Compare requires two paths or URLs'));
2390
+ console.log('');
2391
+ console.log(c('gray', ' Usage:'));
2392
+ console.log(` ${c('cyan', 'npx thuban compare ./project-a ./project-b')}`);
2393
+ console.log(` ${c('cyan', 'npx thuban compare . https://github.com/user/repo')}`);
2394
+ console.log(` ${c('cyan', 'npx thuban compare https://github.com/a/repo https://github.com/b/repo')}`);
2395
+ console.log('');
2396
+ process.exit(1);
2397
+ }
2398
+
2399
+ console.log('');
2400
+ console.log(c('bold', ' ╔══════════════════════════════════════╗'));
2401
+ console.log(c('bold', ' ║ THUBAN — Codebase Comparison ║'));
2402
+ console.log(c('bold', ' ╚══════════════════════════════════════╝'));
2403
+ console.log('');
2404
+
2405
+ console.log(c('cyan', ` Scanning: ${paths[0]}`));
2406
+ const resultA = await runScanSilent(paths[0]);
2407
+ console.log(c('cyan', ` Scanning: ${paths[1]}`));
2408
+ const resultB = await runScanSilent(paths[1]);
2409
+
2410
+ console.log('');
2411
+
2412
+ // ─── Side by side comparison ────────────────────────────
2413
+ const pad = (str, len) => String(str).padEnd(len);
2414
+ const padL = (str, len) => String(str).padStart(len);
2415
+ const colW = 20;
2416
+ const labelA = resultA.label.length > colW ? resultA.label.slice(0, colW - 2) + '..' : resultA.label;
2417
+ const labelB = resultB.label.length > colW ? resultB.label.slice(0, colW - 2) + '..' : resultB.label;
2418
+
2419
+ const scoreColorA = resultA.score >= 80 ? 'green' : resultA.score >= 60 ? 'yellow' : 'red';
2420
+ const scoreColorB = resultB.score >= 80 ? 'green' : resultB.score >= 60 ? 'yellow' : 'red';
2421
+
2422
+ const arrow = (a, b) => {
2423
+ if (a === b) return c('gray', ' =');
2424
+ return a > b ? c('green', ' ↑') : c('red', ' ↓');
2425
+ };
2426
+
2427
+ const better = (a, b, lowerIsBetter) => {
2428
+ if (a === b) return c('gray', ' draw');
2429
+ if (lowerIsBetter) return a < b ? c('green', ' ← winner') : c('red', '');
2430
+ return a > b ? c('green', ' ← winner') : c('red', '');
2431
+ };
2432
+
2433
+ console.log(c('bold', ' ┌──────────────────────┬──────────────────────┬──────────────────────┐'));
2434
+ console.log(c('bold', ` │ ${pad('METRIC', colW)} │ ${pad(labelA, colW)} │ ${pad(labelB, colW)} │`));
2435
+ console.log(c('bold', ' ├──────────────────────┼──────────────────────┼──────────────────────┤'));
2436
+
2437
+ const row = (label, valA, valB) => {
2438
+ console.log(` │ ${c('gray', pad(label, colW))} │ ${pad(valA, colW)} │ ${pad(valB, colW)} │`);
2439
+ };
2440
+
2441
+ row('Health Score', `${c(scoreColorA, resultA.score + '/100')}`, `${c(scoreColorB, resultB.score + '/100')}`);
2442
+ row('Grade', `${c(scoreColorA, resultA.grade)}`, `${c(scoreColorB, resultB.grade)}`);
2443
+ row('Files Scanned', String(resultA.files), String(resultB.files));
2444
+ row('Total Issues', String(resultA.issues.total), String(resultB.issues.total));
2445
+ row('Critical Issues', String(resultA.issues.critical), String(resultB.issues.critical));
2446
+ row('High Issues', String(resultA.issues.high), String(resultB.issues.high));
2447
+ row('Circular Deps', String(resultA.dependencies.circular), String(resultB.dependencies.circular));
2448
+ row('Orphan Files', String(resultA.dependencies.orphans), String(resultB.dependencies.orphans));
2449
+ row('Mother Code DNA', `${resultA.motherCode.coverage}%`, `${resultB.motherCode.coverage}%`);
2450
+ row('Drift Issues', String(resultA.motherCode.drifted), String(resultB.motherCode.drifted));
2451
+ row('Scan Time', `${resultA.elapsed}ms`, `${resultB.elapsed}ms`);
2452
+
2453
+ console.log(c('bold', ' └──────────────────────┴──────────────────────┴──────────────────────┘'));
2454
+ console.log('');
2455
+
2456
+ // ─── Verdict ────────────────────────────────────────────
2457
+ const diff = resultA.score - resultB.score;
2458
+ if (diff === 0) {
2459
+ console.log(c('cyan', ` VERDICT: Both codebases score identically (${resultA.score}/100)`));
2460
+ } else {
2461
+ const winner = diff > 0 ? resultA : resultB;
2462
+ const loser = diff > 0 ? resultB : resultA;
2463
+ console.log(c('cyan', ` VERDICT: ${winner.label} is healthier`));
2464
+ console.log(c('gray', ` ${winner.label} scores ${winner.score}/100 (${winner.grade}) vs ${loser.label} at ${loser.score}/100 (${loser.grade})`));
2465
+ console.log(c('gray', ` ${Math.abs(diff)} point difference — ${Math.abs(diff) > 20 ? 'significant gap' : Math.abs(diff) > 10 ? 'moderate gap' : 'close race'}`));
2466
+ }
2467
+
2468
+ // ─── Issue breakdown comparison ─────────────────────────
2469
+ const allCats = new Set([...Object.keys(resultA.categories), ...Object.keys(resultB.categories)]);
2470
+ if (allCats.size > 0) {
2471
+ console.log('');
2472
+ console.log(c('bold', ' ISSUE BREAKDOWN'));
2473
+ for (const cat of allCats) {
2474
+ const countA = resultA.categories[cat] || 0;
2475
+ const countB = resultB.categories[cat] || 0;
2476
+ const catLabel = cat.charAt(0).toUpperCase() + cat.slice(1);
2477
+ console.log(c('gray', ` ${pad(catLabel, 22)} ${padL(countA, 5)} vs ${padL(countB, 5)} ${countA < countB ? c('green', `← ${labelA}`) : countA > countB ? c('green', `→ ${labelB}`) : c('gray', 'tied')}`));
2478
+ }
2479
+ }
2480
+
2481
+ console.log('');
2482
+ console.log(c('gray', ` Full scan: npx thuban scan ${paths[0]}`));
2483
+ console.log(c('gray', ` Full scan: npx thuban scan ${paths[1]}`));
2484
+ console.log('');
2485
+ }
2486
+
1951
2487
  async function main() {
1952
2488
  const args = process.argv.slice(2);
1953
2489
  const opts = parseArgs(args);
@@ -1964,9 +2500,9 @@ async function main() {
1964
2500
  }
1965
2501
 
1966
2502
  // Verify target path exists
1967
- const noPathCommands2 = ['activate', 'status', 'upgrade'];
2503
+ const noPathCommands2 = ['activate', 'status', 'upgrade', 'telemetry', 'compare'];
1968
2504
 
1969
- if (!noPathCommands2.includes(opts.command) && !fs.existsSync(opts.targetPath)) {
2505
+ if (!noPathCommands2.includes(opts.command) && !opts.targetPath.match(/^(https?:\/\/|git@)/) && !fs.existsSync(opts.targetPath)) {
1970
2506
  console.error(c('red', ` Error: Path not found: ${opts.targetPath}`));
1971
2507
  process.exit(1);
1972
2508
  }
@@ -2005,6 +2541,15 @@ async function main() {
2005
2541
  case 'watch':
2006
2542
  await cmdWatch(opts);
2007
2543
  break;
2544
+ case 'monitor':
2545
+ await cmdMonitor(opts);
2546
+ break;
2547
+ case 'history':
2548
+ await cmdHistory(opts);
2549
+ break;
2550
+ case 'trend':
2551
+ await cmdTrend(opts);
2552
+ break;
2008
2553
  case 'dashboard':
2009
2554
  case 'dash':
2010
2555
  await cmdDashboard(opts);
@@ -2049,12 +2594,18 @@ async function main() {
2049
2594
  case 'baseline':
2050
2595
  await cmdBaseline(opts);
2051
2596
  break;
2597
+ case 'telemetry':
2598
+ await cmdTelemetry(opts);
2599
+ break;
2600
+ case 'compare':
2601
+ await cmdCompare(opts);
2602
+ break;
2052
2603
  default:
2053
2604
  console.error('');
2054
2605
  console.error(c('red', ` Unknown command: ${opts.command}`));
2055
2606
  console.error('');
2056
2607
  // Suggest closest match
2057
- const allCmds = ['scan','deps','drift','inject','health','report','debt','fix','hallucinate','watch','dashboard','diff','gate','cost','ghosts','ai-score','clones','passport','executive','baseline','activate','status','upgrade'];
2608
+ const allCmds = ['scan','compare','deps','drift','inject','health','report','debt','fix','hallucinate','watch','dashboard','diff','gate','cost','ghosts','ai-score','clones','passport','executive','baseline','activate','status','upgrade','telemetry'];
2058
2609
  const suggestions = allCmds.filter(cmd => cmd.startsWith(opts.command.slice(0,2)) || cmd.includes(opts.command));
2059
2610
  if (suggestions.length > 0) {
2060
2611
  console.error(c('gray', ` Did you mean: `) + c('cyan', suggestions.join(', ')) + c('gray', '?'));