thuban 0.3.1 → 0.3.2

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.
Files changed (2) hide show
  1. package/cli.js +342 -6
  2. package/package.json +1 -1
package/cli.js CHANGED
@@ -20,6 +20,7 @@
20
20
 
21
21
  const path = require('path');
22
22
  const fs = require('fs');
23
+ const os = require('os');
23
24
 
24
25
  // Import engine components
25
26
  const CodeScanner = require('./packages/scanner/code-scanner.js');
@@ -93,7 +94,8 @@ function printHelp() {
93
94
  // ─── SCAN & DETECT ───────────────────────────────────────
94
95
  console.log(c('bold', ' SCAN & DETECT'));
95
96
  console.log('');
96
- console.log(` ${c('cyan', 'scan')} [path] Full health scan — score, grade, all issues`);
97
+ console.log(` ${c('cyan', 'scan')} [path|url] Full health scan — score, grade, all issues`);
98
+ console.log(` ${c('cyan', 'compare')} [a] [b] Compare two codebases side by side`);
97
99
  console.log(` ${c('cyan', 'hallucinate')} [path] Find phantom APIs and invented imports`);
98
100
  console.log(` ${c('cyan', 'ghosts')} [path] Dead functions nobody calls`);
99
101
  console.log(` ${c('cyan', 'ai-score')} [path] Probability each function is AI-generated`);
@@ -166,6 +168,9 @@ function printHelp() {
166
168
  console.log(c('gray', ' # Scan a specific folder, ignore tests'));
167
169
  console.log(` ${c('white', 'npx thuban scan ./src --ignore "**/*.test.js"')}`);
168
170
  console.log('');
171
+ console.log(c('gray', ' Report a bug or request a feature:'));
172
+ console.log(` ${c('cyan', 'https://github.com/SilverwingsBenefitsGit/thuban/issues')}`);
173
+ console.log('');
169
174
  }
170
175
 
171
176
  function parseArgs(args) {
@@ -204,7 +209,14 @@ function parseArgs(args) {
204
209
  else if (arg === '--version' || arg === '-v') { opts.command = 'version'; }
205
210
  else if (!opts.command) { opts.command = arg; }
206
211
  else if (opts.command === 'activate' && !opts.licenseKey) { opts.licenseKey = arg; }
207
- else if (!noPathCommands.includes(opts.command)) { opts.targetPath = path.resolve(arg); }
212
+ else if (!noPathCommands.includes(opts.command)) {
213
+ // Don't resolve git URLs as local paths
214
+ if (arg.match(/^(https?:\/\/|git@)/)) {
215
+ opts.targetPath = arg;
216
+ } else {
217
+ opts.targetPath = path.resolve(arg);
218
+ }
219
+ }
208
220
  i++;
209
221
  }
210
222
 
@@ -256,7 +268,34 @@ function collectFiles(dir, ignorePatterns = []) {
256
268
 
257
269
  async function cmdScan(opts) {
258
270
  const startTime = Date.now();
259
- const targetPath = path.resolve(opts.targetPath);
271
+ const gitUrlPattern = /^(https?:\/\/|git@)/;
272
+ const isGitUrl = gitUrlPattern.test(opts.targetPath);
273
+ let targetPath = isGitUrl ? opts.targetPath : path.resolve(opts.targetPath);
274
+ let clonedRepo = null;
275
+
276
+ // ─── Git URL detection — clone and scan ─────────────────
277
+ if (isGitUrl) {
278
+ if (!opts.json) {
279
+ console.log('');
280
+ console.log(c('cyan', ' Cloning repository...'));
281
+ console.log(c('gray', ` ${opts.targetPath}`));
282
+ }
283
+ const tmpDir = path.join(os.tmpdir(), 'thuban-scan-' + Date.now());
284
+ const { execSync } = require('child_process');
285
+ try {
286
+ execSync(`git clone --depth 1 "${opts.targetPath}" "${tmpDir}"`, { stdio: 'pipe', timeout: 60000 });
287
+ targetPath = tmpDir;
288
+ clonedRepo = tmpDir;
289
+ if (!opts.json) {
290
+ console.log(c('cyan', ' Cloned successfully'));
291
+ console.log('');
292
+ }
293
+ } catch (e) {
294
+ console.error(c('red', ` Failed to clone repository: ${e.message}`));
295
+ console.error(c('gray', ' Check the URL is correct and the repo is accessible'));
296
+ process.exit(1);
297
+ }
298
+ }
260
299
 
261
300
  // ─── License check ───────────────────────────────────────
262
301
  const lm = new LicenseManager();
@@ -966,6 +1005,11 @@ async function cmdReport(opts) {
966
1005
  if (orphans.length > 5) console.log(` ${c('gray', '→')} Consider removing ${orphans.length} orphan files`);
967
1006
  console.log('');
968
1007
  }
1008
+
1009
+ // Clean up cloned repo
1010
+ if (clonedRepo) {
1011
+ try { fs.rmSync(clonedRepo, { recursive: true, force: true }); } catch {}
1012
+ }
969
1013
  }
970
1014
 
971
1015
  // ─── Tech Debt & Fix Commands ────────────────────────────
@@ -1948,6 +1992,292 @@ async function cmdBaseline(opts) {
1948
1992
  }
1949
1993
  }
1950
1994
 
1995
+ // ─── Telemetry (opt-in anonymous usage stats) ───────────────
1996
+
1997
+ function getTelemetryConfigPath() {
1998
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
1999
+ return path.join(home, '.thuban-telemetry.json');
2000
+ }
2001
+
2002
+ function isTelemetryEnabled() {
2003
+ try {
2004
+ const config = JSON.parse(fs.readFileSync(getTelemetryConfigPath(), 'utf-8'));
2005
+ return config.enabled === true;
2006
+ } catch { return false; }
2007
+ }
2008
+
2009
+ function sendAnonymousTelemetry(data) {
2010
+ if (!isTelemetryEnabled()) return;
2011
+ try {
2012
+ const payload = JSON.stringify({
2013
+ v: data.version || '0.3.1',
2014
+ cmd: data.command,
2015
+ files: data.fileCount,
2016
+ score: data.score,
2017
+ grade: data.grade,
2018
+ langs: data.languages,
2019
+ issues: data.issueCount,
2020
+ duration: data.duration,
2021
+ os: process.platform,
2022
+ node: process.version,
2023
+ ts: new Date().toISOString()
2024
+ });
2025
+ // Fire and forget — never blocks the CLI
2026
+ const https = require('https');
2027
+ const req = https.request({
2028
+ hostname: 'thuban-telemetry.silverwingsbenefits.workers.dev',
2029
+ path: '/report',
2030
+ method: 'POST',
2031
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
2032
+ timeout: 3000
2033
+ });
2034
+ req.on('error', () => {}); // silently ignore
2035
+ req.write(payload);
2036
+ req.end();
2037
+ } catch { /* never fail the CLI for telemetry */ }
2038
+ }
2039
+
2040
+ async function cmdTelemetry(opts) {
2041
+ const configPath = getTelemetryConfigPath();
2042
+ const args = process.argv.slice(2);
2043
+
2044
+ if (args.includes('--on')) {
2045
+ fs.writeFileSync(configPath, JSON.stringify({ enabled: true, updated: new Date().toISOString() }));
2046
+ console.log('');
2047
+ console.log(c('cyan', ' Telemetry enabled'));
2048
+ console.log(c('gray', ' Anonymous usage stats will be sent (no code, no paths, no PII)'));
2049
+ console.log(c('gray', ' Turn off anytime: npx thuban telemetry --off'));
2050
+ console.log('');
2051
+ } else if (args.includes('--off')) {
2052
+ fs.writeFileSync(configPath, JSON.stringify({ enabled: false, updated: new Date().toISOString() }));
2053
+ console.log('');
2054
+ console.log(c('cyan', ' Telemetry disabled'));
2055
+ console.log(c('gray', ' No data will be collected'));
2056
+ console.log('');
2057
+ } else {
2058
+ const enabled = isTelemetryEnabled();
2059
+ console.log('');
2060
+ console.log(c('bold', ' TELEMETRY STATUS'));
2061
+ console.log('');
2062
+ console.log(` Status: ${enabled ? c('cyan', 'Enabled') : c('gray', 'Disabled (default)')}`);
2063
+ console.log('');
2064
+ console.log(c('gray', ' Thuban telemetry is 100% opt-in and anonymous.'));
2065
+ console.log(c('gray', ' We collect: file count, score, grade, languages, OS, scan duration.'));
2066
+ console.log(c('gray', ' We never collect: code, file names, paths, project names, or PII.'));
2067
+ console.log('');
2068
+ console.log(` Enable: ${c('cyan', 'npx thuban telemetry --on')}`);
2069
+ console.log(` Disable: ${c('cyan', 'npx thuban telemetry --off')}`);
2070
+ console.log('');
2071
+ }
2072
+ }
2073
+
2074
+ // ─── Internal scan (returns data without printing) ──────────
2075
+
2076
+ async function runScanSilent(targetInput) {
2077
+ const gitUrlPattern = /^(https?:\/\/|git@)/;
2078
+ const isGitUrl = gitUrlPattern.test(targetInput);
2079
+ let targetPath = isGitUrl ? targetInput : path.resolve(targetInput);
2080
+ let clonedRepo = null;
2081
+ let label = targetInput;
2082
+
2083
+ if (isGitUrl) {
2084
+ 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 });
2087
+ targetPath = tmpDir;
2088
+ clonedRepo = tmpDir;
2089
+ // Extract repo name for label
2090
+ const parts = targetInput.replace(/\.git$/, '').split('/');
2091
+ label = parts[parts.length - 1] || targetInput;
2092
+ } else {
2093
+ label = path.basename(path.resolve(targetInput)) || targetInput;
2094
+ }
2095
+
2096
+ const startTime = Date.now();
2097
+ const scanner = new CodeScanner();
2098
+ const graph = new DependencyGraph();
2099
+ const hallDetector = new HallucinationDetector({ rootPath: targetPath });
2100
+
2101
+ const files = collectFiles(targetPath, []);
2102
+ const scanResults = {};
2103
+ let annotated = 0;
2104
+ let driftCount = 0;
2105
+
2106
+ for (const file of files) {
2107
+ try {
2108
+ const result = scanner.scanFile(file);
2109
+ const relPath = path.relative(targetPath, file);
2110
+ scanResults[file] = result;
2111
+ graph.addFile(file, targetPath);
2112
+ if (result.motherCode && result.motherCode.hasDNA) annotated++;
2113
+ if (result.drifts && result.drifts.length > 0) driftCount++;
2114
+ } catch (e) { /* skip */ }
2115
+ }
2116
+
2117
+ // Run hallucination detection (same as main scan)
2118
+ const hallResults = hallDetector.scan(files, targetPath);
2119
+
2120
+ const elapsed = Date.now() - startTime;
2121
+ let totalIssues = 0, criticalCount = 0, highCount = 0;
2122
+ const issueByCat = {};
2123
+
2124
+ // Count code scanner issues
2125
+ for (const [, r] of Object.entries(scanResults)) {
2126
+ if (r.issues) {
2127
+ totalIssues += r.issues.length;
2128
+ for (const iss of r.issues) {
2129
+ if (iss.severity === 'critical') criticalCount++;
2130
+ if (iss.severity === 'high') highCount++;
2131
+ const cat = iss.category || iss.type || 'other';
2132
+ issueByCat[cat] = (issueByCat[cat] || 0) + 1;
2133
+ }
2134
+ }
2135
+ }
2136
+
2137
+ // Count hallucination issues
2138
+ const hallIssueCount = (hallResults.phantomAPIs || []).length
2139
+ + (hallResults.phantomImports || []).length
2140
+ + (hallResults.deprecatedAPIs || []).length;
2141
+ totalIssues += hallIssueCount;
2142
+ criticalCount += (hallResults.phantomAPIs || []).length + (hallResults.phantomImports || []).length;
2143
+ highCount += (hallResults.deprecatedAPIs || []).length;
2144
+ if (hallIssueCount > 0) issueByCat['hallucination'] = hallIssueCount;
2145
+
2146
+ const orphans = graph.getOrphanFiles();
2147
+ const circular = graph.circularDeps || [];
2148
+ const coverage = files.length > 0 ? Math.round((annotated / files.length) * 100) : 0;
2149
+
2150
+ const score = Math.max(0, Math.round(
2151
+ 100 - (criticalCount * 15) - (highCount * 5) - (circular.length * 10)
2152
+ - (orphans.length * 1) - ((100 - coverage) * 0.1) - (driftCount * 3)
2153
+ ));
2154
+
2155
+ const grade = score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';
2156
+
2157
+ if (clonedRepo) {
2158
+ try { fs.rmSync(clonedRepo, { recursive: true, force: true }); } catch {}
2159
+ }
2160
+
2161
+ return {
2162
+ label,
2163
+ files: files.length,
2164
+ score,
2165
+ grade,
2166
+ elapsed,
2167
+ issues: { total: totalIssues, critical: criticalCount, high: highCount },
2168
+ categories: issueByCat,
2169
+ dependencies: { circular: circular.length, orphans: orphans.length },
2170
+ motherCode: { annotated, coverage, drifted: driftCount }
2171
+ };
2172
+ }
2173
+
2174
+ // ─── Compare Command ────────────────────────────────────────
2175
+
2176
+ async function cmdCompare(opts) {
2177
+ const args = process.argv.slice(2);
2178
+ // Find the two paths after 'compare'
2179
+ const paths = args.filter(a => a !== 'compare' && !a.startsWith('--'));
2180
+
2181
+ if (paths.length < 2) {
2182
+ console.log('');
2183
+ console.log(c('red', ' Compare requires two paths or URLs'));
2184
+ console.log('');
2185
+ console.log(c('gray', ' Usage:'));
2186
+ console.log(` ${c('cyan', 'npx thuban compare ./project-a ./project-b')}`);
2187
+ console.log(` ${c('cyan', 'npx thuban compare . https://github.com/user/repo')}`);
2188
+ console.log(` ${c('cyan', 'npx thuban compare https://github.com/a/repo https://github.com/b/repo')}`);
2189
+ console.log('');
2190
+ process.exit(1);
2191
+ }
2192
+
2193
+ console.log('');
2194
+ console.log(c('bold', ' ╔══════════════════════════════════════╗'));
2195
+ console.log(c('bold', ' ║ THUBAN — Codebase Comparison ║'));
2196
+ console.log(c('bold', ' ╚══════════════════════════════════════╝'));
2197
+ console.log('');
2198
+
2199
+ console.log(c('cyan', ` Scanning: ${paths[0]}`));
2200
+ const resultA = await runScanSilent(paths[0]);
2201
+ console.log(c('cyan', ` Scanning: ${paths[1]}`));
2202
+ const resultB = await runScanSilent(paths[1]);
2203
+
2204
+ console.log('');
2205
+
2206
+ // ─── Side by side comparison ────────────────────────────
2207
+ const pad = (str, len) => String(str).padEnd(len);
2208
+ const padL = (str, len) => String(str).padStart(len);
2209
+ const colW = 20;
2210
+ const labelA = resultA.label.length > colW ? resultA.label.slice(0, colW - 2) + '..' : resultA.label;
2211
+ const labelB = resultB.label.length > colW ? resultB.label.slice(0, colW - 2) + '..' : resultB.label;
2212
+
2213
+ const scoreColorA = resultA.score >= 80 ? 'green' : resultA.score >= 60 ? 'yellow' : 'red';
2214
+ const scoreColorB = resultB.score >= 80 ? 'green' : resultB.score >= 60 ? 'yellow' : 'red';
2215
+
2216
+ const arrow = (a, b) => {
2217
+ if (a === b) return c('gray', ' =');
2218
+ return a > b ? c('green', ' ↑') : c('red', ' ↓');
2219
+ };
2220
+
2221
+ const better = (a, b, lowerIsBetter) => {
2222
+ if (a === b) return c('gray', ' draw');
2223
+ if (lowerIsBetter) return a < b ? c('green', ' ← winner') : c('red', '');
2224
+ return a > b ? c('green', ' ← winner') : c('red', '');
2225
+ };
2226
+
2227
+ console.log(c('bold', ' ┌──────────────────────┬──────────────────────┬──────────────────────┐'));
2228
+ console.log(c('bold', ` │ ${pad('METRIC', colW)} │ ${pad(labelA, colW)} │ ${pad(labelB, colW)} │`));
2229
+ console.log(c('bold', ' ├──────────────────────┼──────────────────────┼──────────────────────┤'));
2230
+
2231
+ const row = (label, valA, valB) => {
2232
+ console.log(` │ ${c('gray', pad(label, colW))} │ ${pad(valA, colW)} │ ${pad(valB, colW)} │`);
2233
+ };
2234
+
2235
+ row('Health Score', `${c(scoreColorA, resultA.score + '/100')}`, `${c(scoreColorB, resultB.score + '/100')}`);
2236
+ row('Grade', `${c(scoreColorA, resultA.grade)}`, `${c(scoreColorB, resultB.grade)}`);
2237
+ row('Files Scanned', String(resultA.files), String(resultB.files));
2238
+ row('Total Issues', String(resultA.issues.total), String(resultB.issues.total));
2239
+ row('Critical Issues', String(resultA.issues.critical), String(resultB.issues.critical));
2240
+ row('High Issues', String(resultA.issues.high), String(resultB.issues.high));
2241
+ row('Circular Deps', String(resultA.dependencies.circular), String(resultB.dependencies.circular));
2242
+ row('Orphan Files', String(resultA.dependencies.orphans), String(resultB.dependencies.orphans));
2243
+ row('Mother Code DNA', `${resultA.motherCode.coverage}%`, `${resultB.motherCode.coverage}%`);
2244
+ row('Drift Issues', String(resultA.motherCode.drifted), String(resultB.motherCode.drifted));
2245
+ row('Scan Time', `${resultA.elapsed}ms`, `${resultB.elapsed}ms`);
2246
+
2247
+ console.log(c('bold', ' └──────────────────────┴──────────────────────┴──────────────────────┘'));
2248
+ console.log('');
2249
+
2250
+ // ─── Verdict ────────────────────────────────────────────
2251
+ const diff = resultA.score - resultB.score;
2252
+ if (diff === 0) {
2253
+ console.log(c('cyan', ` VERDICT: Both codebases score identically (${resultA.score}/100)`));
2254
+ } else {
2255
+ const winner = diff > 0 ? resultA : resultB;
2256
+ const loser = diff > 0 ? resultB : resultA;
2257
+ console.log(c('cyan', ` VERDICT: ${winner.label} is healthier`));
2258
+ console.log(c('gray', ` ${winner.label} scores ${winner.score}/100 (${winner.grade}) vs ${loser.label} at ${loser.score}/100 (${loser.grade})`));
2259
+ console.log(c('gray', ` ${Math.abs(diff)} point difference — ${Math.abs(diff) > 20 ? 'significant gap' : Math.abs(diff) > 10 ? 'moderate gap' : 'close race'}`));
2260
+ }
2261
+
2262
+ // ─── Issue breakdown comparison ─────────────────────────
2263
+ const allCats = new Set([...Object.keys(resultA.categories), ...Object.keys(resultB.categories)]);
2264
+ if (allCats.size > 0) {
2265
+ console.log('');
2266
+ console.log(c('bold', ' ISSUE BREAKDOWN'));
2267
+ for (const cat of allCats) {
2268
+ const countA = resultA.categories[cat] || 0;
2269
+ const countB = resultB.categories[cat] || 0;
2270
+ const catLabel = cat.charAt(0).toUpperCase() + cat.slice(1);
2271
+ 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')}`));
2272
+ }
2273
+ }
2274
+
2275
+ console.log('');
2276
+ console.log(c('gray', ` Full scan: npx thuban scan ${paths[0]}`));
2277
+ console.log(c('gray', ` Full scan: npx thuban scan ${paths[1]}`));
2278
+ console.log('');
2279
+ }
2280
+
1951
2281
  async function main() {
1952
2282
  const args = process.argv.slice(2);
1953
2283
  const opts = parseArgs(args);
@@ -1964,9 +2294,9 @@ async function main() {
1964
2294
  }
1965
2295
 
1966
2296
  // Verify target path exists
1967
- const noPathCommands2 = ['activate', 'status', 'upgrade'];
2297
+ const noPathCommands2 = ['activate', 'status', 'upgrade', 'telemetry', 'compare'];
1968
2298
 
1969
- if (!noPathCommands2.includes(opts.command) && !fs.existsSync(opts.targetPath)) {
2299
+ if (!noPathCommands2.includes(opts.command) && !opts.targetPath.match(/^(https?:\/\/|git@)/) && !fs.existsSync(opts.targetPath)) {
1970
2300
  console.error(c('red', ` Error: Path not found: ${opts.targetPath}`));
1971
2301
  process.exit(1);
1972
2302
  }
@@ -2049,12 +2379,18 @@ async function main() {
2049
2379
  case 'baseline':
2050
2380
  await cmdBaseline(opts);
2051
2381
  break;
2382
+ case 'telemetry':
2383
+ await cmdTelemetry(opts);
2384
+ break;
2385
+ case 'compare':
2386
+ await cmdCompare(opts);
2387
+ break;
2052
2388
  default:
2053
2389
  console.error('');
2054
2390
  console.error(c('red', ` Unknown command: ${opts.command}`));
2055
2391
  console.error('');
2056
2392
  // 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'];
2393
+ 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
2394
  const suggestions = allCmds.filter(cmd => cmd.startsWith(opts.command.slice(0,2)) || cmd.includes(opts.command));
2059
2395
  if (suggestions.length > 0) {
2060
2396
  console.error(c('gray', ` Did you mean: `) + c('cyan', suggestions.join(', ')) + c('gray', '?'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thuban",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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"