thuban 0.3.2 → 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,6 +14,9 @@
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
  */
@@ -41,6 +44,9 @@ const CopyPasteDriftDetector = require('./packages/scanner/copy-paste-detector.j
41
44
  const CodebasePassport = require('./packages/scanner/codebase-passport.js');
42
45
  const ExecutiveReport = require('./packages/scanner/executive-report.js');
43
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');
44
50
 
45
51
  // ─── Helpers ───────────────────────────────────────────────
46
52
 
@@ -67,7 +73,7 @@ function banner() {
67
73
  console.log('');
68
74
  console.log(c('cyan', ' ╔══════════════════════════════════════╗'));
69
75
  console.log(c('cyan', ' ║') + c('bold', ' THUBAN Code Health Engine ') + c('cyan', '║'));
70
- 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', '║'));
71
77
  console.log(c('cyan', ' ╚══════════════════════════════════════╝'));
72
78
  console.log('');
73
79
  }
@@ -116,6 +122,9 @@ function printHelp() {
116
122
  console.log(` ${c('cyan', 'inject')} [path] Add Mother Code DNA to every file`);
117
123
  console.log(` ${c('cyan', 'gate')} Install pre-commit hook ${c('gray', '(blocks hallucinations)')}`);
118
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)')}`);
119
128
  console.log(` ${c('cyan', 'baseline')} [path] Snapshot issues so future scans show only NEW ones`);
120
129
  console.log('');
121
130
 
@@ -188,6 +197,10 @@ function parseArgs(args) {
188
197
  licenseKey: null,
189
198
  baseline: false,
190
199
  baselineCreate: false,
200
+ frequency: 'daily',
201
+ intervalMs: null,
202
+ notify: 'inbox',
203
+ once: false,
191
204
  };
192
205
 
193
206
  const noPathCommands = ['activate', 'status', 'upgrade', 'help', 'version'];
@@ -202,6 +215,10 @@ function parseArgs(args) {
202
215
  else if (arg === '--git-commit' || arg === '--commit') { opts.gitCommit = true; }
203
216
  else if (arg === '--baseline') { opts.baseline = true; }
204
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; }
205
222
  else if (arg === '--verbose') { opts.verbose = true; }
206
223
  else if (arg === '--ignore' && args[i + 1]) { opts.ignore.push(args[++i]); }
207
224
  else if (arg === '--max-issues' && args[i + 1]) { opts.maxIssues = parseInt(args[++i], 10); }
@@ -225,43 +242,98 @@ function parseArgs(args) {
225
242
 
226
243
  // ─── Collect files recursively ───────────────────────────────
227
244
 
228
- function collectFiles(dir, ignorePatterns = []) {
229
- const defaultIgnore = ['node_modules', '.git', 'dist', 'build', '.next', '__pycache__', '.venv', 'venv', 'coverage', '.cache', '.turbo'];
230
- const allIgnore = [...defaultIgnore, ...ignorePatterns];
231
- const extensions = ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.py', '.pyw', '.go', '.rs', '.java', '.kt', '.cs', '.json'];
232
- const MAX_FILE_SIZE = 1024 * 1024; // 1MB per file — skip anything larger
233
- const MAX_FILES = 50000; // Hard cap to prevent memory issues
234
- const files = [];
235
-
236
- function walk(currentDir) {
237
- if (files.length >= MAX_FILES) return;
238
- let entries;
239
- try {
240
- entries = fs.readdirSync(currentDir, { withFileTypes: true });
241
- } catch (e) {
242
- 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)`)}`);
243
327
  }
244
- for (const entry of entries) {
245
- if (files.length >= MAX_FILES) return;
246
- if (allIgnore.some(p => entry.name === p || entry.name.match(p))) continue;
247
- const fullPath = path.join(currentDir, entry.name);
248
- if (entry.isDirectory()) {
249
- walk(fullPath);
250
- } else if (extensions.some(ext => entry.name.endsWith(ext))) {
251
- try {
252
- const stat = fs.statSync(fullPath);
253
- if (stat.size <= MAX_FILE_SIZE) {
254
- files.push(fullPath);
255
- }
256
- } catch (e) {
257
- // Skip files we can't stat
258
- }
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}`);
259
333
  }
260
334
  }
335
+ console.log('');
261
336
  }
262
-
263
- walk(dir);
264
- return files;
265
337
  }
266
338
 
267
339
  // ─── Commands ───────────────────────────────────────────────
@@ -281,9 +353,9 @@ async function cmdScan(opts) {
281
353
  console.log(c('gray', ` ${opts.targetPath}`));
282
354
  }
283
355
  const tmpDir = path.join(os.tmpdir(), 'thuban-scan-' + Date.now());
284
- const { execSync } = require('child_process');
356
+ const { execFileSync } = require('child_process');
285
357
  try {
286
- execSync(`git clone --depth 1 "${opts.targetPath}" "${tmpDir}"`, { stdio: 'pipe', timeout: 60000 });
358
+ execFileSync('git', ['clone', '--depth', '1', opts.targetPath, tmpDir], { stdio: 'pipe', timeout: 60000 });
287
359
  targetPath = tmpDir;
288
360
  clonedRepo = tmpDir;
289
361
  if (!opts.json) {
@@ -301,6 +373,7 @@ async function cmdScan(opts) {
301
373
  const lm = new LicenseManager();
302
374
  const tier = lm.getTierConfig();
303
375
  const scanCheck = lm.canScan();
376
+ const upgradeUrl = 'https://thuban.dev/pricing';
304
377
 
305
378
  if (!scanCheck.allowed && !opts.json) {
306
379
  console.log('');
@@ -311,8 +384,8 @@ async function cmdScan(opts) {
311
384
  console.log(` You've used ${c('bold', scanCheck.used + '/' + scanCheck.limit)} free scans this month.`);
312
385
  console.log(` Resets on ${c('cyan', scanCheck.resetsOn)}.`);
313
386
  console.log('');
314
- console.log(` ${c('cyan', 'Upgrade to Pro')} for unlimited scans, full reports, and auto-fix:`);
315
- 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)}`);
316
389
  console.log('');
317
390
  console.log(` Or activate a key: ${c('cyan', 'thuban activate <your-key>')}`);
318
391
  console.log('');
@@ -348,10 +421,11 @@ async function cmdScan(opts) {
348
421
  // File limit gating
349
422
  const files = tier.maxFiles < Infinity ? allFiles.slice(0, tier.maxFiles) : allFiles;
350
423
  const isFileCapped = totalFileCount > tier.maxFiles;
424
+ const remainingSummary = isFileCapped ? summarizeRemainingFiles(files, allFiles, targetPath) : null;
351
425
 
352
426
  if (!opts.json && isFileCapped) {
353
427
  console.log(c('yellow', ` Free tier: scanning ${tier.maxFiles} of ${totalFileCount} files`));
354
- 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.`));
355
429
  console.log('');
356
430
  } else if (!opts.json) {
357
431
  console.log(c('gray', ` Found ${files.length} files to scan...`));
@@ -377,12 +451,8 @@ async function cmdScan(opts) {
377
451
 
378
452
  // ─── Collect all issues ──────────────────────────────────
379
453
  const allIssues = [];
380
- for (const [file, fileResults] of Object.entries(scanResults)) {
381
- if (fileResults.issues) {
382
- for (const issue of fileResults.issues) {
383
- allIssues.push({ file: path.relative(targetPath, file), ...issue });
384
- }
385
- }
454
+ for (const issue of (scanResults.issues || [])) {
455
+ allIssues.push({ file: path.relative(targetPath, issue.file), ...issue });
386
456
  }
387
457
 
388
458
  // Add hallucination results as issues
@@ -428,6 +498,11 @@ async function cmdScan(opts) {
428
498
  issues: tier.showFullDetails ? allIssues : allIssues.slice(0, tier.teaserIssues),
429
499
  totalIssues: allIssues.length,
430
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 : [],
431
506
  }, null, 2));
432
507
  return;
433
508
  }
@@ -466,6 +541,7 @@ async function cmdScan(opts) {
466
541
 
467
542
  console.log(c('cyan', ' ╔══════════════════════════════════════════════════════╗'));
468
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', '║'));
469
545
  console.log(c('cyan', ' ╠══════════════════════════════════════════════════════╣'));
470
546
  console.log(c('cyan', ' ║') + ` Files scanned: ${c('bold', String(files.length).padEnd(6))}` + (isFileCapped ? c('yellow', ` of ${totalFileCount}`) : ' ') + c('cyan', ' ║'));
471
547
  console.log(c('cyan', ' ║') + ` Health score: ${c(scoreColor, (grade + ' (' + Math.round(score) + '/100)').padEnd(20))}` + c('cyan', ' ║'));
@@ -475,6 +551,15 @@ async function cmdScan(opts) {
475
551
  console.log(c('cyan', ' ╚══════════════════════════════════════════════════════╝'));
476
552
  console.log('');
477
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
+
478
563
  // ─── CRITICAL: Hallucinated APIs (will crash) ────────────
479
564
  if (hallucinations.length > 0) {
480
565
  console.log(c('bgRed', ' CRITICAL ') + c('red', c('bold', ` ${hallucinations.length} Hallucinated API${hallucinations.length > 1 ? 's' : ''}`)));
@@ -672,9 +757,12 @@ async function cmdScan(opts) {
672
757
  }
673
758
 
674
759
  if (isFileCapped) {
675
- console.log(c('yellow', ` ⚠ ${totalFileCount - tier.maxFiles} files were not scanned (free tier limit: ${tier.maxFiles} files)`));
676
- console.log(c('gray', ` Your actual score may be worse. Upgrade to scan everything.`));
677
- console.log('');
760
+ printFileCapUpgradePrompt({
761
+ scannedCount: files.length,
762
+ totalCount: totalFileCount,
763
+ remainingSummary,
764
+ upgradeUrl,
765
+ });
678
766
  }
679
767
 
680
768
  // ─── Continuous monitoring pitch ────────────────────────
@@ -759,6 +847,109 @@ async function cmdDeps(opts) {
759
847
  }
760
848
  }
761
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
+
762
953
  async function cmdDrift(opts) {
763
954
  const startTime = Date.now();
764
955
  const targetPath = path.resolve(opts.targetPath);
@@ -829,7 +1020,11 @@ async function cmdInject(opts) {
829
1020
  console.log('');
830
1021
  }
831
1022
 
832
- 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
+ );
833
1028
  const graph = new DependencyGraph({ rootPath: targetPath });
834
1029
  await graph.build(files);
835
1030
 
@@ -848,8 +1043,26 @@ async function cmdInject(opts) {
848
1043
  continue;
849
1044
  }
850
1045
 
851
- const widget = generator.generateWidget(file, content);
852
- 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
+ }
853
1066
 
854
1067
  if (opts.fix) {
855
1068
  // Actually inject
@@ -885,7 +1098,7 @@ async function cmdInject(opts) {
885
1098
  } else {
886
1099
  console.log(c('green', ` ✓ Injected Mother Code into ${injected} files`));
887
1100
  }
888
- console.log(` ${c('gray', `Skipped: ${skipped} (already annotated or non-JS/TS)`)}`);
1101
+ console.log(` ${c('gray', `Skipped: ${skipped} (already annotated or unsupported)`)}`);
889
1102
  console.log('');
890
1103
  }
891
1104
 
@@ -930,12 +1143,10 @@ async function cmdReport(opts) {
930
1143
  let totalIssues = 0;
931
1144
  let criticalCount = 0;
932
1145
  let highCount = 0;
933
- for (const [, r] of Object.entries(scanResults)) {
934
- if (r.issues) {
935
- totalIssues += r.issues.length;
936
- criticalCount += r.issues.filter(i => i.severity === 'critical').length;
937
- highCount += r.issues.filter(i => i.severity === 'high').length;
938
- }
1146
+ for (const i of (scanResults.issues || [])) {
1147
+ totalIssues++;
1148
+ if (i.severity === 'critical') criticalCount++;
1149
+ if (i.severity === 'high') highCount++;
939
1150
  }
940
1151
 
941
1152
  const orphans = graph.getOrphanFiles();
@@ -1006,10 +1217,7 @@ async function cmdReport(opts) {
1006
1217
  console.log('');
1007
1218
  }
1008
1219
 
1009
- // Clean up cloned repo
1010
- if (clonedRepo) {
1011
- try { fs.rmSync(clonedRepo, { recursive: true, force: true }); } catch {}
1012
- }
1220
+ // Clean up cloned repo (clonedRepo only exists in cmdScan scope)
1013
1221
  }
1014
1222
 
1015
1223
  // ─── Tech Debt & Fix Commands ────────────────────────────
@@ -1157,13 +1365,24 @@ async function cmdFix(opts) {
1157
1365
  console.log(c('cyan', ' ╔══════════════════════════════════════════════════════╗'));
1158
1366
  console.log(c('cyan', ' ║') + c('bold', ' FIX RESULTS ') + c('cyan', '║'));
1159
1367
  console.log(c('cyan', ' ╠══════════════════════════════════════════════════════╣'));
1160
- 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
+ }
1161
1380
  console.log(c('cyan', ' ║') + ` Flagged for review: ${c('yellow', String(result.flagged).padEnd(19))}` + c('cyan', ' ║'));
1162
1381
  if (result.failed > 0) {
1163
1382
  console.log(c('cyan', ' ║') + ` Failed: ${c('red', String(result.failed).padEnd(20))}` + c('cyan', ' ║'));
1164
1383
  }
1165
- if (result.skippedUnsafe > 0) {
1166
- 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', ' ║'));
1167
1386
  }
1168
1387
  console.log(c('cyan', ' ╚══════════════════════════════════════════════════════╝'));
1169
1388
  console.log('');
@@ -1180,9 +1399,9 @@ async function cmdFix(opts) {
1180
1399
  console.log('');
1181
1400
  }
1182
1401
 
1183
- if (result.skippedUnsafe > 0) {
1184
- console.log(c('gray', ` ${result.skippedUnsafe} unsafe fixes were skipped. These change runtime behaviour.`));
1185
- 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 .'));
1186
1405
  console.log('');
1187
1406
  }
1188
1407
 
@@ -1407,12 +1626,11 @@ async function cmdDiff(opts) {
1407
1626
  let totalIssues = 0;
1408
1627
  const fileIssues = {};
1409
1628
 
1410
- for (const [file, result] of Object.entries(scanResults)) {
1411
- if (result.issues && result.issues.length > 0) {
1412
- const relFile = path.relative(targetPath, file);
1413
- fileIssues[relFile] = result.issues;
1414
- totalIssues += result.issues.length;
1415
- }
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++;
1416
1634
  }
1417
1635
 
1418
1636
  totalIssues += hallResults.stats.totalIssues;
@@ -1515,7 +1733,7 @@ function cmdUpgrade() {
1515
1733
  console.log(c('bold', ' THUBAN PRICING'));
1516
1734
  console.log('');
1517
1735
  console.log(` ${c('gray', 'FREE')} ${c('bold', '£0/mo')}`);
1518
- console.log(` 3 scans/month, 100 files, summary only`);
1736
+ console.log(` 5 scans/month, 100 files, summary only`);
1519
1737
  console.log('');
1520
1738
  console.log(` ${c('cyan', 'PRO')} ${c('bold', '£19/mo')} ${c('gray', '(or £149/year — save 35%)')}`);
1521
1739
  console.log(` Unlimited scans, full details, auto-fix, HTML dashboard`);
@@ -1629,12 +1847,8 @@ async function cmdCost(opts) {
1629
1847
 
1630
1848
  // Collect all issues
1631
1849
  const allIssues = [];
1632
- for (const [file, fileResults] of Object.entries(scanResults)) {
1633
- if (fileResults.issues) {
1634
- for (const issue of fileResults.issues) {
1635
- allIssues.push({ file: path.relative(targetPath, file), ...issue });
1636
- }
1637
- }
1850
+ for (const issue of (scanResults.issues || [])) {
1851
+ allIssues.push({ file: path.relative(targetPath, issue.file), ...issue });
1638
1852
  }
1639
1853
  for (const item of hallResults.phantomAPIs) {
1640
1854
  allIssues.push({ category: 'hallucination', severity: 'critical', message: item.name });
@@ -1875,12 +2089,8 @@ async function cmdExecutive(opts) {
1875
2089
 
1876
2090
  // Collect all issues for cost calculator
1877
2091
  const allIssues = [];
1878
- for (const [file, fileResults] of Object.entries(scanResults)) {
1879
- if (fileResults.issues) {
1880
- for (const issue of fileResults.issues) {
1881
- allIssues.push({ file: path.relative(targetPath, file), ...issue });
1882
- }
1883
- }
2092
+ for (const issue of (scanResults.issues || [])) {
2093
+ allIssues.push({ file: path.relative(targetPath, issue.file), ...issue });
1884
2094
  }
1885
2095
  for (const item of hallResults.phantomAPIs) {
1886
2096
  allIssues.push({ category: 'hallucination', severity: 'critical', message: item.name });
@@ -1954,12 +2164,8 @@ async function cmdBaseline(opts) {
1954
2164
  ]);
1955
2165
 
1956
2166
  const allIssues = [];
1957
- for (const [file, fileResults] of Object.entries(scanResults)) {
1958
- if (fileResults.issues) {
1959
- for (const issue of fileResults.issues) {
1960
- allIssues.push({ file: path.relative(targetPath, file), ...issue });
1961
- }
1962
- }
2167
+ for (const issue of (scanResults.issues || [])) {
2168
+ allIssues.push({ file: path.relative(targetPath, issue.file), ...issue });
1963
2169
  }
1964
2170
  for (const item of hallResults.phantomAPIs) {
1965
2171
  allIssues.push({ file: item.file, category: 'hallucination', severity: 'critical', id: item.id, message: item.name });
@@ -2082,8 +2288,8 @@ async function runScanSilent(targetInput) {
2082
2288
 
2083
2289
  if (isGitUrl) {
2084
2290
  const tmpDir = path.join(os.tmpdir(), 'thuban-compare-' + Date.now() + '-' + Math.random().toString(36).slice(2,6));
2085
- const { execSync } = require('child_process');
2086
- execSync(`git clone --depth 1 "${targetInput}" "${tmpDir}"`, { stdio: 'pipe', timeout: 60000 });
2291
+ const { execFileSync } = require('child_process');
2292
+ execFileSync('git', ['clone', '--depth', '1', targetInput, tmpDir], { stdio: 'pipe', timeout: 60000 });
2087
2293
  targetPath = tmpDir;
2088
2294
  clonedRepo = tmpDir;
2089
2295
  // Extract repo name for label
@@ -2335,6 +2541,15 @@ async function main() {
2335
2541
  case 'watch':
2336
2542
  await cmdWatch(opts);
2337
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;
2338
2553
  case 'dashboard':
2339
2554
  case 'dash':
2340
2555
  await cmdDashboard(opts);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thuban",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "The safety layer for AI-coded software. Detect hallucinated APIs, ghost code, tech debt, and architecture drift. One command. Zero dependencies.",
5
5
  "bin": {
6
6
  "thuban": "./cli.js"
@@ -45,5 +45,8 @@
45
45
  "packages/scanner/*.js",
46
46
  "README.md",
47
47
  "LICENSE"
48
- ]
48
+ ],
49
+ "dependencies": {
50
+ "docx": "^9.7.1"
51
+ }
49
52
  }