@astrolabe-dev/cli 1.0.11 → 1.0.13

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/dist/index.js CHANGED
@@ -8,8 +8,11 @@ import { join, dirname, basename, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { execSync } from 'node:child_process';
10
10
  import { createInterface } from 'node:readline';
11
- import { createKnowledgeGraph, scanPhase, structurePhase, frameworkPhase, markdownPhase, parseEmitPhase, resolutionPhase, routesPhase, toolsPhase, ormPhase, crossFilePhase, mroPhase, communityPhase, processTracingPhase, cobolPhase, accessTrackingPhase, securityScanPhase, callResolutionPhase, scopeResolutionPhase, initParser, createSqliteStore, createFtsSearch, createLogger, createPhaseContext, runPipeline, startMcpServer, loadRegistry, saveRegistry, removeRepo, getGitRemote, acquireDbLock, generateSkill, loadMeta, saveMeta, computeFileDiff, buildMeta, installHooks, createGroup, removeGroup, addRepoToGroup, removeRepoFromGroup, listGroups, getGroupStatus, groupQuery, getGroupContracts, syncGroupContracts, autoSetup, generateAgentFiles, startHttpServer, generateWiki, startEvalServer, migrateFromGitNexus, } from '@astrolabe-dev/core';
11
+ import { createKnowledgeGraph, scanPhase, structurePhase, frameworkPhase, markdownPhase, parseEmitPhase, resolutionPhase, routesPhase, toolsPhase, ormPhase, crossFilePhase, mroPhase, communityPhase, processTracingPhase, cobolPhase, accessTrackingPhase, securityScanPhase, coveragePhase, callResolutionPhase, scopeResolutionPhase, initParser, createSqliteStore, createFtsSearch, createLogger, createPhaseContext, runPipeline, startMcpServer, loadRegistry, saveRegistry, removeRepo, getGitRemote, acquireDbLock, generateSkill, loadMeta, saveMeta, computeFileDiff, buildMeta, installHooks, createGroup, removeGroup, addRepoToGroup, removeRepoFromGroup, listGroups, getGroupStatus, groupQuery, getGroupContracts, syncGroupContracts, autoSetup, generateAgentFiles, startHttpServer, generateWiki, startEvalServer, countGraphlets, buildAdjacencyMap, detectPatterns, scoreArchitectureHealth, detectClones, computeSpectralMetrics, detectAntiPatterns, migrateFromGitNexus, detectCutVertices, detectBridges, computeGraphCoverageMetrics, EDGE_DECAY_FACTORS, applyDecay, noisyOr, exportGnnDataset, computeEmbeddings, propagateEmbeddings, createSemanticEdges, computeSnapshotMetrics, detectTrends, } from '@astrolabe-dev/core';
12
+ // #463: Coverage report parser
13
+ import { parseCoverageReport, detectFormat, annotateGraphWithCoverage } from '@astrolabe-dev/core';
12
14
  import { PIPELINE_TIMING_KEY, PIPELINE_MEMORY_KEY } from '@astrolabe-dev/core';
15
+ import { startWatch } from './watch.js';
13
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
17
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
15
18
  function getGitCommit(repoPath) {
@@ -123,6 +126,7 @@ program
123
126
  structurePhase, frameworkPhase, markdownPhase, parseEmitPhase,
124
127
  resolutionPhase, routesPhase, toolsPhase, ormPhase, crossFilePhase,
125
128
  mroPhase, communityPhase, processTracingPhase, accessTrackingPhase,
129
+ coveragePhase,
126
130
  cobolPhase,
127
131
  callResolutionPhase, scopeResolutionPhase, securityScanPhase,
128
132
  ], ctx);
@@ -142,6 +146,7 @@ program
142
146
  structurePhase, frameworkPhase, markdownPhase, parseEmitPhase,
143
147
  resolutionPhase, routesPhase, toolsPhase, ormPhase, crossFilePhase,
144
148
  mroPhase, communityPhase, processTracingPhase, accessTrackingPhase,
149
+ coveragePhase,
145
150
  cobolPhase,
146
151
  callResolutionPhase, scopeResolutionPhase, securityScanPhase,
147
152
  ], context);
@@ -172,6 +177,21 @@ program
172
177
  // Save graph to SQLite
173
178
  const store = createSqliteStore(dbPath);
174
179
  store.saveGraph(graph);
180
+ // #807: Auto-save snapshot for temporal evolution tracking
181
+ try {
182
+ const branch = (() => { try {
183
+ return execSync('git rev-parse --abbrev-ref HEAD', { cwd: repoPath, encoding: 'utf-8' }).trim();
184
+ }
185
+ catch {
186
+ return 'unknown';
187
+ } })();
188
+ const snapshot = computeSnapshotMetrics(graph, lastCommit, branch);
189
+ store.saveSnapshot(snapshot);
190
+ log.info('Snapshot saved', { id: snapshot.id, healthScore: snapshot.healthScore });
191
+ }
192
+ catch (err) {
193
+ log.warn('Failed to save snapshot', { error: String(err) });
194
+ }
175
195
  // FTS index is created lazily on first query — no eager creation here
176
196
  store.close();
177
197
  // Save meta.json with current file hashes for next incremental run
@@ -328,6 +348,39 @@ program
328
348
  console.log('No .astrolabe directory found.');
329
349
  }
330
350
  });
351
+ // ── watch ──────────────────────────────────────────────────────────────────────
352
+ program
353
+ .command('watch <repo-path>')
354
+ .description('Watch for file changes and incrementally re-index (#462)')
355
+ .option('-d, --db <path>', 'Database path', '.astrolabe/astrolabe.db')
356
+ .option('--log-level <level>', 'Log level (debug, info, warn, error)', 'info')
357
+ .option('--debounce <ms>', 'Debounce interval in milliseconds', parseInt, 500)
358
+ .action(async (repoPath, opts) => {
359
+ const absRepo = resolve(repoPath);
360
+ const dbPath = resolve(opts.db);
361
+ console.log(`#462: Starting watch mode for ${absRepo}...`);
362
+ console.log(`Database: ${dbPath}`);
363
+ console.log('Press Ctrl+C to stop.');
364
+ try {
365
+ const watcher = await startWatch(absRepo, {
366
+ dbPath,
367
+ logLevel: opts.logLevel,
368
+ debounceMs: opts.debounce,
369
+ });
370
+ // #462: Graceful shutdown on SIGINT/SIGTERM
371
+ const shutdown = async () => {
372
+ console.log('\nShutting down watcher...');
373
+ await watcher.close();
374
+ process.exit(0);
375
+ };
376
+ process.on('SIGINT', shutdown);
377
+ process.on('SIGTERM', shutdown);
378
+ }
379
+ catch (err) {
380
+ console.error(`Watch mode failed: ${err.message}`);
381
+ process.exit(1);
382
+ }
383
+ });
331
384
  // ── index ────────────────────────────────────────────────────────────────────
332
385
  program
333
386
  .command('index [path]')
@@ -503,6 +556,8 @@ program
503
556
  .command('impact <symbol-name>')
504
557
  .description('Show code impact analysis for a symbol')
505
558
  .option('-d, --db <path>', 'Database path', '.astrolabe/astrolabe.db')
559
+ .option('--probabilistic', 'Use probabilistic confidence-decay scoring')
560
+ .option('--decay <schedule>', 'Decay schedule for probabilistic mode', 'linear')
506
561
  .action((symbolName, opts) => {
507
562
  const store = createSqliteStore(opts.db);
508
563
  try {
@@ -523,20 +578,107 @@ program
523
578
  }
524
579
  bucket.push({ neighborId: rel.sourceId, type: rel.type, direction: 'incoming' });
525
580
  }
526
- let found = 0;
527
- for (const node of graph.iterNodes()) {
528
- if (node.properties.name === symbolName) {
529
- found++;
530
- console.log(`${node.label}: ${node.id}`);
531
- const neighbors = adj.get(node.id) ?? [];
532
- for (const { neighborId, type, direction } of neighbors) {
533
- const other = graph.getNode(neighborId);
534
- console.log(` ${direction === 'outgoing' ? '→' : '←'} ${type} ${other?.properties.name ?? neighborId}`);
581
+ if (opts.probabilistic) {
582
+ // #806: Probabilistic multi-hop BFS with confidence decay and Noisy-OR fusion
583
+ const schedule = (opts.decay ?? 'linear');
584
+ const maxDepth = 3;
585
+ // Find the target node
586
+ let targetId = null;
587
+ for (const node of graph.iterNodes()) {
588
+ if (node.properties.name === symbolName) {
589
+ targetId = node.id;
590
+ break;
591
+ }
592
+ }
593
+ if (!targetId) {
594
+ console.log(`Symbol "${symbolName}" not found.`);
595
+ return;
596
+ }
597
+ // Pre-build adjacency with confidence (incoming + outgoing)
598
+ const probAdj = new Map();
599
+ for (const rel of graph.iterRelationships()) {
600
+ if (rel.type === 'STEP_IN_PROCESS')
601
+ continue;
602
+ // outgoing: source → target
603
+ let bucket = probAdj.get(rel.sourceId);
604
+ if (!bucket) {
605
+ bucket = [];
606
+ probAdj.set(rel.sourceId, bucket);
607
+ }
608
+ bucket.push({ neighborId: rel.targetId, type: rel.type, confidence: rel.confidence, direction: 'outgoing' });
609
+ // incoming: target → source
610
+ bucket = probAdj.get(rel.targetId);
611
+ if (!bucket) {
612
+ bucket = [];
613
+ probAdj.set(rel.targetId, bucket);
614
+ }
615
+ bucket.push({ neighborId: rel.sourceId, type: rel.type, confidence: rel.confidence, direction: 'incoming' });
616
+ }
617
+ // BFS with confidence decay
618
+ const nodeScores = new Map();
619
+ nodeScores.set(targetId, { paths: [], bestScore: 1.0 });
620
+ const queue = [{ id: targetId, depth: 0 }];
621
+ const visited = new Set([targetId]);
622
+ while (queue.length > 0) {
623
+ const current = queue.shift();
624
+ if (current.depth >= maxDepth)
625
+ continue;
626
+ for (const { neighborId, type, confidence } of (probAdj.get(current.id) ?? [])) {
627
+ if (neighborId === targetId)
628
+ continue;
629
+ const edgeDecay = EDGE_DECAY_FACTORS[type] ?? 0.7;
630
+ const parentEntry = nodeScores.get(current.id);
631
+ const parentConfidence = parentEntry?.bestScore ?? 1.0;
632
+ let decayedConfidence = parentConfidence * edgeDecay * Math.max(confidence, 0.1);
633
+ decayedConfidence = applyDecay(decayedConfidence, current.depth + 1, schedule);
634
+ const existing = nodeScores.get(neighborId);
635
+ const pathEntry = { via: current.id, depth: current.depth + 1, confidence: decayedConfidence };
636
+ if (existing) {
637
+ existing.paths.push(pathEntry);
638
+ const allProbs = existing.paths.map((p) => p.confidence);
639
+ existing.bestScore = noisyOr(allProbs);
640
+ }
641
+ else {
642
+ nodeScores.set(neighborId, { paths: [pathEntry], bestScore: decayedConfidence });
643
+ if (!visited.has(neighborId)) {
644
+ visited.add(neighborId);
645
+ queue.push({ id: neighborId, depth: current.depth + 1 });
646
+ }
647
+ }
648
+ }
649
+ }
650
+ console.log(`Probabilistic impact for "${symbolName}" (decay: ${schedule}, maxDepth: ${maxDepth}):`);
651
+ const sorted = [...nodeScores]
652
+ .filter(([id]) => id !== targetId)
653
+ .map(([id, entry]) => {
654
+ const node = graph.getNode(id);
655
+ return { name: node?.properties.name ?? id, score: Math.round(entry.bestScore * 1000) / 1000, paths: entry.paths.length };
656
+ })
657
+ .sort((a, b) => b.score - a.score);
658
+ for (const { name, score, paths } of sorted) {
659
+ const cat = score >= 0.8 ? 'DIRECT' : score >= 0.4 ? 'TRANSITIVE' : 'LOW-RISK';
660
+ const pathInfo = paths > 1 ? ` (${paths} paths)` : '';
661
+ console.log(` [${cat}] ${name} (confidence: ${score.toFixed(3)})${pathInfo}`);
662
+ }
663
+ if (sorted.length === 0)
664
+ console.log(' (no connected nodes)');
665
+ }
666
+ else {
667
+ let found = 0;
668
+ for (const node of graph.iterNodes()) {
669
+ if (node.properties.name === symbolName) {
670
+ found++;
671
+ console.log(`${node.label}: ${node.id}`);
672
+ const neighbors = adj.get(node.id) ?? [];
673
+ for (const { neighborId, type, direction } of neighbors) {
674
+ const other = graph.getNode(neighborId);
675
+ console.log(` ${direction === 'outgoing' ? '→' : '←'} ${type} ${other?.properties.name ?? neighborId}`);
676
+ }
535
677
  }
536
678
  }
679
+ if (found === 0)
680
+ console.log(`Symbol "${symbolName}" not found.`);
537
681
  }
538
- if (found === 0)
539
- console.log(`Symbol "${symbolName}" not found.`);
540
682
  }
541
683
  finally {
542
684
  store.close();
@@ -947,6 +1089,200 @@ program.command('wiki <repoPath>')
947
1089
  console.log(`Gist: ${result.gistUrl}`);
948
1090
  }
949
1091
  });
1092
+ // ── analyze-architecture ──────────────────────────────────────────────────────
1093
+ program
1094
+ .command('analyze-architecture [repoPath]')
1095
+ .description('Detect architectural patterns using graphlet-based structural analysis (#461)')
1096
+ .option('-d, --db <path>', 'Database path', '.astrolabe/astrolabe.db')
1097
+ .option('--json', 'Output raw JSON')
1098
+ .action((repoPath, opts) => {
1099
+ const dbPath = repoPath ? join(repoPath, '.astrolabe', 'astrolabe.db') : opts.db;
1100
+ if (!existsSync(dbPath)) {
1101
+ console.log('No knowledge graph found. Run `astrolabe analyze` first.');
1102
+ return;
1103
+ }
1104
+ const store = createSqliteStore(dbPath);
1105
+ const graph = store.loadGraph();
1106
+ store.close();
1107
+ // Build adjacency map from CALLS, IMPORTS, EXTENDS edges
1108
+ const nodeIds = new Set();
1109
+ for (const node of graph.iterNodes())
1110
+ nodeIds.add(node.id);
1111
+ const adjMap = buildAdjacencyMap(graph.iterRelationships(), nodeIds);
1112
+ const profile = countGraphlets(graph.iterNodes(), adjMap);
1113
+ // Extract community info from Community nodes
1114
+ const communities = [];
1115
+ for (const node of graph.iterNodes()) {
1116
+ if (node.label === 'Community') {
1117
+ communities.push({ id: node.id, nodeCount: node.properties.symbolCount ?? 0 });
1118
+ }
1119
+ }
1120
+ const patterns = detectPatterns(profile);
1121
+ const health = scoreArchitectureHealth(profile, communities, adjMap);
1122
+ if (opts.json) {
1123
+ console.log(JSON.stringify({ profile, patterns, health }, null, 2));
1124
+ return;
1125
+ }
1126
+ const totalMotifs3 = profile.motif3.empty + profile.motif3.oneEdge + profile.motif3.twoEdge + profile.motif3.triangle;
1127
+ const totalMotifs4 = profile.motif4.chain + profile.motif4.star + profile.motif4.diamond + profile.motif4.cycle + profile.motif4.clique;
1128
+ console.log(`\n=== Architecture Analysis ===`);
1129
+ console.log(`Nodes: ${profile.nodeCount} | Edges: ${profile.edgeCount} | ${profile.sampled ? `Sampled (${profile.sampleSize} nodes)` : 'Full enumeration'}`);
1130
+ console.log(`\n--- 3-Node Motifs (${totalMotifs3} total) ---`);
1131
+ console.log(` empty: ${profile.motif3.empty}`);
1132
+ console.log(` oneEdge: ${profile.motif3.oneEdge}`);
1133
+ console.log(` twoEdge: ${profile.motif3.twoEdge}`);
1134
+ console.log(` triangle: ${profile.motif3.triangle}`);
1135
+ console.log(`\n--- 4-Node Motifs (${totalMotifs4} total) ---`);
1136
+ console.log(` chain: ${profile.motif4.chain}`);
1137
+ console.log(` star: ${profile.motif4.star}`);
1138
+ console.log(` diamond: ${profile.motif4.diamond}`);
1139
+ console.log(` cycle: ${profile.motif4.cycle}`);
1140
+ console.log(` clique: ${profile.motif4.clique}`);
1141
+ console.log(`\n--- Detected Patterns ---`);
1142
+ for (const p of patterns)
1143
+ console.log(` ${p.name}: ${(p.confidence * 100).toFixed(0)}% — ${p.description}`);
1144
+ console.log(`\n--- Health Score: ${health.overallScore}/100 ---`);
1145
+ console.log(` Cohesion: ${(health.cohesion * 100).toFixed(1)}% | Modularity: ${(health.modularity * 100).toFixed(1)}% | Complexity: ${(health.complexity * 100).toFixed(1)}%`);
1146
+ if (health.antiPatterns.length > 0) {
1147
+ console.log(`\n--- Anti-Patterns ---`);
1148
+ for (const ap of health.antiPatterns)
1149
+ console.log(` [${ap.severity}] ${ap.name}: ${ap.description}`);
1150
+ }
1151
+ console.log();
1152
+ });
1153
+ // ── detect-smells ──────────────────────────────────────────────────────────
1154
+ program
1155
+ .command('detect-smells [repoPath]')
1156
+ .description('Detect architecture smells in the knowledge graph (cycles, god modules, unstable deps, meshes)')
1157
+ .option('-d, --db <path>', 'Database path', '.astrolabe/astrolabe.db')
1158
+ .option('--json', 'Output as JSON')
1159
+ .option('--fan-in <number>', 'Fan-in threshold for hub detection')
1160
+ .option('--fan-out <number>', 'Fan-out threshold for hub detection')
1161
+ .option('--density <number>', 'Density threshold for mesh detection (0-1)')
1162
+ .action((repoPath, opts) => {
1163
+ const dbPath = repoPath ? join(repoPath, '.astrolabe', 'astrolabe.db') : opts.db;
1164
+ if (!existsSync(dbPath)) {
1165
+ console.log('No knowledge graph found. Run `astrolabe analyze` first.');
1166
+ return;
1167
+ }
1168
+ const store = createSqliteStore(dbPath);
1169
+ const graph = store.loadGraph();
1170
+ store.close();
1171
+ const options = {};
1172
+ if (opts.fanIn)
1173
+ options.fanInThreshold = Number(opts.fanIn);
1174
+ if (opts.fanOut)
1175
+ options.fanOutThreshold = Number(opts.fanOut);
1176
+ if (opts.density)
1177
+ options.densityThreshold = Number(opts.density);
1178
+ const results = detectAntiPatterns(graph, options);
1179
+ if (opts.json) {
1180
+ console.log(JSON.stringify(results, null, 2));
1181
+ return;
1182
+ }
1183
+ console.log('\n=== Architecture Smells ===\n');
1184
+ if (results.sccs?.length) {
1185
+ console.log(`Cyclic Dependencies: ${results.sccs.length} cycle(s) found`);
1186
+ for (const scc of results.sccs) {
1187
+ console.log(` Cycle #${scc.id}: ${scc.size} nodes — ${scc.nodeIds.slice(0, 5).join(', ')}${scc.nodeIds.length > 5 ? '...' : ''}`);
1188
+ }
1189
+ console.log();
1190
+ }
1191
+ if (results.hubs?.length) {
1192
+ console.log(`Hub-like Modules: ${results.hubs.length} detected`);
1193
+ for (const hub of results.hubs) {
1194
+ console.log(` ${hub.nodeId} — fanIn: ${hub.fanIn}, fanOut: ${hub.fanOut}, classification: ${hub.classification}`);
1195
+ }
1196
+ console.log();
1197
+ }
1198
+ if (results.meshes?.length) {
1199
+ console.log(`Dependency Meshes: ${results.meshes.length} detected`);
1200
+ for (const mesh of results.meshes) {
1201
+ console.log(` Mesh: ${mesh.nodeIds.length} nodes, density: ${mesh.density.toFixed(3)}, anchors: ${mesh.acyclicAnchors.length}`);
1202
+ }
1203
+ console.log();
1204
+ }
1205
+ if (results.cutVertices?.length) {
1206
+ console.log(`Cut Vertices (SPoF): ${results.cutVertices.length} detected`);
1207
+ for (const cv of results.cutVertices) {
1208
+ console.log(` ${cv.nodeId} — would split graph into ${cv.componentCountAfterRemoval} components`);
1209
+ }
1210
+ console.log();
1211
+ }
1212
+ if (results.bridges?.length) {
1213
+ console.log(`Bridge Edges: ${results.bridges.length} detected`);
1214
+ for (const bridge of results.bridges.slice(0, 10)) {
1215
+ console.log(` ${bridge.sourceId} → ${bridge.targetId}`);
1216
+ }
1217
+ if (results.bridges.length > 10)
1218
+ console.log(` ... and ${results.bridges.length - 10} more`);
1219
+ console.log();
1220
+ }
1221
+ if (!results.sccs?.length && !results.hubs?.length && !results.meshes?.length) {
1222
+ console.log('No architecture smells detected.');
1223
+ }
1224
+ });
1225
+ // ── ingest-coverage (#463) ──────────────────────────────────────────────────
1226
+ program
1227
+ .command('ingest-coverage <report-file>')
1228
+ .description('Import test coverage data into the knowledge graph (#463)')
1229
+ .option('-d, --db <path>', 'Database path', '.astrolabe/astrolabe.db')
1230
+ .option('--format <type>', 'Coverage format: lcov, istanbul, cobertura (auto-detected if omitted)')
1231
+ .option('--repo <name>', 'Repository name (optional if only one indexed)')
1232
+ .option('--json', 'Output as JSON')
1233
+ .action(async (reportFile, opts) => {
1234
+ if (!existsSync(reportFile)) {
1235
+ console.error(`Coverage report not found: ${reportFile}`);
1236
+ process.exit(1);
1237
+ }
1238
+ if (!existsSync(opts.db)) {
1239
+ console.error('No analysis found. Run `astrolabe analyze <repo>` first.');
1240
+ process.exit(1);
1241
+ }
1242
+ // #463: Read and parse coverage report
1243
+ const content = readFileSync(resolve(reportFile), 'utf-8');
1244
+ const format = opts.format ?? detectFormat(content);
1245
+ if (!format) {
1246
+ console.error('Could not detect coverage format. Use --format to specify: lcov, istanbul, or cobertura.');
1247
+ process.exit(1);
1248
+ }
1249
+ const report = parseCoverageReport(content, format);
1250
+ // Load graph from DB
1251
+ const store = createSqliteStore(opts.db);
1252
+ try {
1253
+ const graph = store.loadGraph();
1254
+ const result = annotateGraphWithCoverage(graph, report);
1255
+ // Save annotated graph back to DB
1256
+ store.saveGraph(graph);
1257
+ if (opts.json) {
1258
+ console.log(JSON.stringify({
1259
+ format,
1260
+ report: {
1261
+ files: report.files.length,
1262
+ totalLines: report.totalLines,
1263
+ coveredLines: report.coveredLines,
1264
+ lineCoveragePercent: report.lineCoveragePercent.toFixed(1),
1265
+ totalFunctions: report.totalFunctions,
1266
+ coveredFunctions: report.coveredFunctions,
1267
+ functionCoveragePercent: report.functionCoveragePercent.toFixed(1),
1268
+ },
1269
+ annotation: result,
1270
+ }, null, 2));
1271
+ }
1272
+ else {
1273
+ console.log(`Coverage report ingested (${format} format):`);
1274
+ console.log(` Files in report: ${report.files.length}`);
1275
+ console.log(` Line coverage: ${report.coveredLines}/${report.totalLines} (${report.lineCoveragePercent.toFixed(1)}%)`);
1276
+ console.log(` Function coverage: ${report.coveredFunctions}/${report.totalFunctions} (${report.functionCoveragePercent.toFixed(1)}%)`);
1277
+ console.log(` Graph nodes annotated: ${result.filesProcessed} files`);
1278
+ console.log(` Uncovered nodes: ${result.uncoveredNodes}`);
1279
+ }
1280
+ }
1281
+ finally {
1282
+ store.close();
1283
+ }
1284
+ });
1285
+ // ── scan-secrets ──────────────────────────────────────────────────────────────
950
1286
  program
951
1287
  .command('scan-secrets [repo-path]')
952
1288
  .description('Scan for secrets and security-sensitive patterns in indexed code (#464)')
@@ -1088,5 +1424,462 @@ program
1088
1424
  store.close();
1089
1425
  }
1090
1426
  });
1427
+ // ── detect-clones (#810) ─────────────────────────────────────────────────────
1428
+ program
1429
+ .command('detect-clones [repoPath]')
1430
+ .description('Detect structurally similar functions using Weisfeiler-Lehman graph kernels (#810)')
1431
+ .option('-d, --db <path>', 'Database path', '.astrolabe/astrolabe.db')
1432
+ .option('--threshold <n>', 'Similarity threshold (0-1)', '0.6')
1433
+ .option('--json', 'Output raw JSON')
1434
+ .action((repoPath, opts) => {
1435
+ const dbPath = repoPath ? join(repoPath, '.astrolabe', 'astrolabe.db') : opts.db;
1436
+ if (!existsSync(dbPath)) {
1437
+ console.log('No knowledge graph found. Run `astrolabe analyze` first.');
1438
+ return;
1439
+ }
1440
+ const threshold = parseFloat(opts.threshold);
1441
+ if (isNaN(threshold) || threshold < 0 || threshold > 1) {
1442
+ console.log('Threshold must be a number between 0 and 1.');
1443
+ return;
1444
+ }
1445
+ const store = createSqliteStore(dbPath);
1446
+ try {
1447
+ const graph = store.loadGraph();
1448
+ // Build adjacency + node names (same as MCP handler)
1449
+ const adjList = new Map();
1450
+ const nodeNames = new Map();
1451
+ for (const node of graph.iterNodes()) {
1452
+ if (!adjList.has(node.id))
1453
+ adjList.set(node.id, []);
1454
+ nodeNames.set(node.id, node.properties.name ?? node.id);
1455
+ }
1456
+ for (const rel of graph.iterRelationships()) {
1457
+ if (rel.type === 'STEP_IN_PROCESS' || rel.type === 'MEMBER_OF' || rel.type === 'ENTRY_POINT_OF')
1458
+ continue;
1459
+ if (rel.type !== 'CALLS' && rel.type !== 'IMPORTS')
1460
+ continue;
1461
+ let targets = adjList.get(rel.sourceId);
1462
+ if (!targets) {
1463
+ targets = [];
1464
+ adjList.set(rel.sourceId, targets);
1465
+ }
1466
+ targets.push(rel.targetId);
1467
+ if (!adjList.has(rel.targetId))
1468
+ adjList.set(rel.targetId, []);
1469
+ }
1470
+ const result = detectClones(adjList, nodeNames, { threshold });
1471
+ if (opts.json) {
1472
+ console.log(JSON.stringify(result, null, 2));
1473
+ return;
1474
+ }
1475
+ console.log(`\n=== Clone Detection (WL Graph Kernel) ===`);
1476
+ console.log(`Functions analyzed: ${result.totalFunctions}`);
1477
+ console.log(`Clone pairs found: ${result.totalPairs} (threshold: ${threshold})`);
1478
+ console.log(` Exact clones (>=95%): ${result.summary.exactClones}`);
1479
+ console.log(` Near-misses (80-95%): ${result.summary.nearMisses}`);
1480
+ console.log(` Potential clones (60-80%): ${result.summary.potentialClones}`);
1481
+ if (result.clusters.length > 0) {
1482
+ console.log(`\n--- Clone Clusters (${result.clusters.length}) ---`);
1483
+ for (const c of result.clusters.slice(0, 10)) {
1484
+ console.log(` Cluster ${c.clusterId}: ${c.memberCount} functions, representative: ${c.representativeFunction}`);
1485
+ const memberNames = c.members.map((m) => m.name).join(', ');
1486
+ console.log(` Members: ${memberNames}`);
1487
+ }
1488
+ if (result.clusters.length > 10)
1489
+ console.log(` ... and ${result.clusters.length - 10} more clusters`);
1490
+ }
1491
+ if (result.topPairs.length > 0) {
1492
+ console.log(`\n--- Top Similar Pairs ---`);
1493
+ for (const pair of result.topPairs.slice(0, 15)) {
1494
+ console.log(` ${(pair.similarity * 100).toFixed(0)}%: ${pair.functionA.name} ↔ ${pair.functionB.name}`);
1495
+ }
1496
+ }
1497
+ }
1498
+ finally {
1499
+ store.close();
1500
+ }
1501
+ });
1502
+ // ── spectral (#812) ──────────────────────────────────────────────────────────
1503
+ program
1504
+ .command('spectral [repoPath]')
1505
+ .description('Spectral graph analysis — density, entropy, flow hierarchy, topology classification (#812)')
1506
+ .option('-d, --db <path>', 'Database path', '.astrolabe/astrolabe.db')
1507
+ .option('--json', 'Output raw JSON')
1508
+ .action((repoPath, opts) => {
1509
+ const dbPath = repoPath ? join(repoPath, '.astrolabe', 'astrolabe.db') : opts.db;
1510
+ if (!existsSync(dbPath)) {
1511
+ console.log('No knowledge graph found. Run `astrolabe analyze` first.');
1512
+ return;
1513
+ }
1514
+ const store = createSqliteStore(dbPath);
1515
+ try {
1516
+ const graph = store.loadGraph();
1517
+ // Build adjacency list from CALLS and IMPORTS edges
1518
+ const adjList = new Map();
1519
+ for (const node of graph.iterNodes()) {
1520
+ if (!adjList.has(node.id))
1521
+ adjList.set(node.id, []);
1522
+ }
1523
+ for (const rel of graph.iterRelationships()) {
1524
+ if (rel.type === 'STEP_IN_PROCESS' || rel.type === 'MEMBER_OF' || rel.type === 'ENTRY_POINT_OF')
1525
+ continue;
1526
+ if (rel.type !== 'CALLS' && rel.type !== 'IMPORTS')
1527
+ continue;
1528
+ let targets = adjList.get(rel.sourceId);
1529
+ if (!targets) {
1530
+ targets = [];
1531
+ adjList.set(rel.sourceId, targets);
1532
+ }
1533
+ targets.push(rel.targetId);
1534
+ if (!adjList.has(rel.targetId))
1535
+ adjList.set(rel.targetId, []);
1536
+ }
1537
+ const metrics = computeSpectralMetrics(adjList);
1538
+ if (opts.json) {
1539
+ console.log(JSON.stringify(metrics, null, 2));
1540
+ return;
1541
+ }
1542
+ console.log(`\n=== Spectral Graph Analysis ===`);
1543
+ console.log(`Nodes: ${metrics.nodeCount} | Edges: ${metrics.edgeCount}`);
1544
+ console.log();
1545
+ console.log(`Density: ${(metrics.density * 100).toFixed(2)}% (${metrics.density < 0.1 ? 'sparse/loosely coupled' : metrics.density > 0.3 ? 'dense/tightly coupled' : 'moderate'})`);
1546
+ console.log(`Degree Entropy: ${metrics.degreeEntropy.toFixed(3)} (${metrics.degreeEntropy < 1 ? 'uniform' : metrics.degreeEntropy > 3 ? 'hub-centric/skewed' : 'moderate diversity'})`);
1547
+ console.log(`Avg Degree: ${metrics.avgDegree.toFixed(1)}`);
1548
+ console.log(`Max Degree: ${metrics.maxDegree}`);
1549
+ console.log(`Flow Hierarchy: ${(metrics.flowHierarchy * 100).toFixed(1)}% acyclic (${metrics.flowHierarchy > 0.7 ? 'highly hierarchical' : metrics.flowHierarchy < 0.3 ? 'highly cyclic' : 'mixed'})`);
1550
+ if (metrics.modularityQ > 0) {
1551
+ console.log(`Modularity Q: ${metrics.modularityQ.toFixed(4)} (${metrics.modularityQ > 0.5 ? 'well-modularized' : 'low modularity'})`);
1552
+ }
1553
+ console.log(`Topology: ${metrics.topologyType} (confidence: ${metrics.topologyConfidence.toFixed(2)})`);
1554
+ console.log();
1555
+ }
1556
+ finally {
1557
+ store.close();
1558
+ }
1559
+ });
1560
+ // ── resilience (#805) ─────────────────────────────────────────────────────────
1561
+ program
1562
+ .command('resilience [repoPath]')
1563
+ .description('Analyze graph resilience — detect single points of failure (SPoF) and critical edges')
1564
+ .option('-d, --db <path>', 'Database path', '.astrolabe/astrolabe.db')
1565
+ .option('--json', 'Output raw JSON')
1566
+ .action((repoPath, opts) => {
1567
+ const dbPath = repoPath ? join(repoPath, '.astrolabe', 'astrolabe.db') : opts.db;
1568
+ if (!existsSync(dbPath)) {
1569
+ console.log('No knowledge graph found. Run `astrolabe analyze` first.');
1570
+ return;
1571
+ }
1572
+ const store = createSqliteStore(dbPath);
1573
+ try {
1574
+ const graph = store.loadGraph();
1575
+ // Build adjacency list from CALLS and IMPORTS edges (same as MCP handler)
1576
+ const adjList = new Map();
1577
+ for (const node of graph.iterNodes()) {
1578
+ if (!adjList.has(node.id))
1579
+ adjList.set(node.id, []);
1580
+ }
1581
+ for (const rel of graph.iterRelationships()) {
1582
+ if (rel.type === 'STEP_IN_PROCESS' || rel.type === 'MEMBER_OF' || rel.type === 'ENTRY_POINT_OF')
1583
+ continue;
1584
+ if (rel.type !== 'CALLS' && rel.type !== 'IMPORTS')
1585
+ continue;
1586
+ let targets = adjList.get(rel.sourceId);
1587
+ if (!targets) {
1588
+ targets = [];
1589
+ adjList.set(rel.sourceId, targets);
1590
+ }
1591
+ targets.push(rel.targetId);
1592
+ if (!adjList.has(rel.targetId))
1593
+ adjList.set(rel.targetId, []);
1594
+ }
1595
+ const cutVertices = detectCutVertices(adjList);
1596
+ const bridges = detectBridges(adjList);
1597
+ // Resolve names (staging API: CutVertexResult.nodeId, BridgeResult.sourceId/targetId)
1598
+ const namedCutVertices = cutVertices.map((cv) => {
1599
+ const node = graph.getNode(cv.nodeId);
1600
+ return { id: cv.nodeId, name: node?.properties.name ?? cv.nodeId };
1601
+ });
1602
+ const namedBridges = bridges.map((b) => {
1603
+ const srcNode = graph.getNode(b.sourceId);
1604
+ const tgtNode = graph.getNode(b.targetId);
1605
+ return {
1606
+ source: { id: b.sourceId, name: srcNode?.properties.name ?? b.sourceId },
1607
+ target: { id: b.targetId, name: tgtNode?.properties.name ?? b.targetId },
1608
+ };
1609
+ });
1610
+ const nodeCount = adjList.size;
1611
+ const edgeCount = Array.from(adjList.values()).reduce((s, t) => s + t.length, 0);
1612
+ if (opts.json) {
1613
+ console.log(JSON.stringify({
1614
+ nodeCount,
1615
+ edgeCount,
1616
+ cutVertices: { count: cutVertices.length, nodes: namedCutVertices },
1617
+ bridgeEdges: { count: bridges.length, edges: namedBridges },
1618
+ isBiconnected: cutVertices.length === 0 && bridges.length === 0,
1619
+ }, null, 2));
1620
+ return;
1621
+ }
1622
+ console.log(`\n=== Graph Resilience Analysis ===`);
1623
+ console.log(`Nodes: ${nodeCount} | Edges: ${edgeCount}`);
1624
+ console.log();
1625
+ if (cutVertices.length === 0 && bridges.length === 0) {
1626
+ console.log('\u2713 The graph is biconnected \u2014 no single points of failure detected.');
1627
+ }
1628
+ else {
1629
+ if (cutVertices.length > 0) {
1630
+ console.log(`--- Single Points of Failure (${cutVertices.length} cut vertices) ---`);
1631
+ console.log('These nodes, if removed, would disconnect the dependency graph:');
1632
+ for (const n of namedCutVertices.slice(0, 20)) {
1633
+ console.log(` \u26A0 ${n.name}`);
1634
+ }
1635
+ if (namedCutVertices.length > 20) {
1636
+ console.log(` ... and ${namedCutVertices.length - 20} more`);
1637
+ }
1638
+ console.log();
1639
+ }
1640
+ if (bridges.length > 0) {
1641
+ console.log(`--- Critical Dependency Bridges (${bridges.length} bridge edges) ---`);
1642
+ console.log('These edges are the ONLY connection between subsystems:');
1643
+ for (const b of namedBridges.slice(0, 20)) {
1644
+ console.log(` \u26A1 ${b.source.name} \u2192 ${b.target.name}`);
1645
+ }
1646
+ if (namedBridges.length > 20) {
1647
+ console.log(` ... and ${namedBridges.length - 20} more`);
1648
+ }
1649
+ console.log();
1650
+ }
1651
+ }
1652
+ }
1653
+ finally {
1654
+ store.close();
1655
+ }
1656
+ });
1657
+ // ── test-coverage (#811) ─────────────────────────────────────────────────────
1658
+ program
1659
+ .command('test-coverage [repoPath]')
1660
+ .description('Analyze test coverage using graph structure — per-community metrics and gap prioritization (#811)')
1661
+ .option('-d, --db <path>', 'Database path', '.astrolabe/astrolabe.db')
1662
+ .option('--json', 'Output raw JSON')
1663
+ .action((repoPath, opts) => {
1664
+ const dbPath = repoPath ? join(repoPath, '.astrolabe', 'astrolabe.db') : opts.db;
1665
+ if (!existsSync(dbPath)) {
1666
+ console.log('No knowledge graph found. Run `astrolabe analyze` first.');
1667
+ return;
1668
+ }
1669
+ const store = createSqliteStore(dbPath);
1670
+ try {
1671
+ const graph = store.loadGraph();
1672
+ const metrics = computeGraphCoverageMetrics(graph);
1673
+ if (opts.json) {
1674
+ console.log(JSON.stringify(metrics, null, 2));
1675
+ return;
1676
+ }
1677
+ if (metrics.totalFunctionNodes === 0) {
1678
+ console.log('No function nodes found. Run `astrolabe analyze` first.');
1679
+ return;
1680
+ }
1681
+ console.log(`\n=== Test Coverage Analysis (Graph-Aware) ===`);
1682
+ console.log(`\nOverall:`);
1683
+ console.log(` Node coverage: ${metrics.overallNodeCoveragePercent.toFixed(1)}% (${metrics.coveredFunctionNodes} covered / ${metrics.partialFunctionNodes} partial / ${metrics.uncoveredFunctionNodes} uncovered of ${metrics.totalFunctionNodes} total)`);
1684
+ console.log(` Edge coverage: ${metrics.overallEdgeCoveragePercent.toFixed(1)}% (${metrics.coveredCallEdges} covered / ${metrics.totalCallEdges} total call edges)`);
1685
+ console.log(`\n--- Per-Community Coverage (${metrics.communities.length} communities) ---`);
1686
+ for (const c of metrics.communities) {
1687
+ const bar = '█'.repeat(Math.round(c.nodeCoveragePercent / 10)) + '░'.repeat(10 - Math.round(c.nodeCoveragePercent / 10));
1688
+ console.log(` ${c.communityName}: ${bar} ${c.nodeCoveragePercent.toFixed(0)}% nodes`);
1689
+ for (const gap of c.topGaps.slice(0, 3)) {
1690
+ console.log(` ⚠ ${gap.label}:${gap.name} (impact: ${gap.impact})`);
1691
+ }
1692
+ }
1693
+ if (metrics.topUntestedHighImpact.length > 0) {
1694
+ console.log(`\n--- Top Untested High-Impact Symbols ---`);
1695
+ for (const gap of metrics.topUntestedHighImpact.slice(0, 15)) {
1696
+ console.log(` ⚠ [${gap.community}] ${gap.label}:${gap.name} — ${gap.impact} dependents`);
1697
+ }
1698
+ if (metrics.topUntestedHighImpact.length > 15) {
1699
+ console.log(` ... and ${metrics.topUntestedHighImpact.length - 15} more`);
1700
+ }
1701
+ }
1702
+ console.log();
1703
+ }
1704
+ finally {
1705
+ store.close();
1706
+ }
1707
+ });
1708
+ // ── gnn-export (GNN feature engineering and dataset export, #809) ───────────
1709
+ program
1710
+ .command('gnn-export [repoPath]')
1711
+ .description('Export GNN-ready feature vectors and dataset from the knowledge graph (#809)')
1712
+ .option('-d, --db <path>', 'Database path', '.astrolabe/astrolabe.db')
1713
+ .option('-o, --output <path>', 'Output directory', '.astrolabe/gnn-dataset/')
1714
+ .option('--format <format>', 'Output format: csv or json', 'csv')
1715
+ .option('--include-embeddings', 'Include 384-D embedding vectors in node features')
1716
+ .action((repoPath, opts) => {
1717
+ const dbPath = repoPath ? join(repoPath, '.astrolabe', 'astrolabe.db') : opts.db;
1718
+ if (!existsSync(dbPath)) {
1719
+ console.error(`Database not found: ${dbPath}`);
1720
+ console.error('Run `astrolabe analyze <repo>` first.');
1721
+ process.exit(1);
1722
+ }
1723
+ const format = (opts.format === 'json' ? 'json' : 'csv');
1724
+ const outputPath = resolve(opts.output);
1725
+ const store = createSqliteStore(resolve(dbPath));
1726
+ try {
1727
+ const graph = store.loadGraph();
1728
+ console.log(`Loaded graph: ${graph.nodeCount.toLocaleString()} nodes, ${graph.relationshipCount.toLocaleString()} edges`);
1729
+ const result = exportGnnDataset(graph, outputPath, {
1730
+ dbPath: resolve(dbPath),
1731
+ includeEmbeddings: opts.includeEmbeddings,
1732
+ format,
1733
+ });
1734
+ console.log(`\nGNN dataset exported to: ${result.exportPath}`);
1735
+ console.log(` Nodes: ${result.nodeCount.toLocaleString()}`);
1736
+ console.log(` Edges: ${result.edgeCount.toLocaleString()}`);
1737
+ console.log(` Feature dims: ${result.featureDimensions}`);
1738
+ console.log(` Format: ${format}`);
1739
+ console.log(` Embeddings: ${opts.includeEmbeddings ? 'included' : 'not included'}`);
1740
+ console.log(`\nOutput files:`);
1741
+ console.log(` ${join(outputPath, format === 'json' ? 'nodes.json' : 'nodes.csv')}`);
1742
+ console.log(` ${join(outputPath, format === 'json' ? 'edges.json' : 'edges.csv')}`);
1743
+ console.log(` ${join(outputPath, 'node_labels.json')}`);
1744
+ console.log(` ${join(outputPath, 'edge_types.json')}`);
1745
+ }
1746
+ finally {
1747
+ store.close();
1748
+ }
1749
+ });
1750
+ // ── embed (semantic embedding computation, #813) ──────────────────────────────
1751
+ program
1752
+ .command('embed [repoPath]')
1753
+ .description('Compute semantic embeddings for code nodes with optional propagation (#813)')
1754
+ .option('-d, --db <path>', 'Database path', '.astrolabe/astrolabe.db')
1755
+ .option('--propagate', 'Propagate embeddings along graph edges (neighbor averaging)', false)
1756
+ .option('--propagate-hops <n>', 'Number of hops for embedding propagation', '1')
1757
+ .option('--threshold <n>', 'Cosine similarity threshold for SEMANTICALLY_SIMILAR edges', '0.85')
1758
+ .option('--provider <type>', 'Embedding provider (auto, transformers, tfidf, remote)', 'auto')
1759
+ .option('--no-edges', 'Skip creating SEMANTICALLY_SIMILAR edges')
1760
+ .option('--log-level <level>', 'Log level', 'info')
1761
+ .action(async (repoPath, opts) => {
1762
+ const resolvedPath = repoPath ? resolve(repoPath) : process.cwd();
1763
+ const dbPath = opts.db !== '.astrolabe/astrolabe.db' ? resolve(opts.db) : join(resolvedPath, '.astrolabe', 'astrolabe.db');
1764
+ const propagateHops = parseInt(opts.propagateHops, 10) || 1;
1765
+ const threshold = parseFloat(opts.threshold) || 0.85;
1766
+ if (!existsSync(dbPath)) {
1767
+ console.error(`Database not found: ${dbPath}`);
1768
+ console.error('Run `astrolabe analyze <repo>` first.');
1769
+ process.exit(1);
1770
+ }
1771
+ const store = createSqliteStore(dbPath);
1772
+ try {
1773
+ const graph = store.loadGraph();
1774
+ console.log(`Loaded graph: ${graph.nodeCount.toLocaleString()} nodes, ${graph.relationshipCount.toLocaleString()} edges`);
1775
+ // Step 1: Compute embeddings
1776
+ const providerType = opts.provider;
1777
+ const result = await computeEmbeddings(graph, { provider: providerType, dbPath });
1778
+ console.log(`Embeddings computed: ${result.nodeCount.toLocaleString()} nodes (${result.dimensions}D)`);
1779
+ // Step 2: Propagate if requested
1780
+ if (opts.propagate) {
1781
+ const propagated = propagateEmbeddings(graph, result.embeddings, propagateHops);
1782
+ console.log(`Embeddings propagated: ${propagated.size.toLocaleString()} nodes, ${propagateHops} hop(s)`);
1783
+ }
1784
+ // Step 3: Create semantic edges (unless --no-edges)
1785
+ if (opts.edges) {
1786
+ const edges = createSemanticEdges(graph, result.embeddings, threshold);
1787
+ console.log(`Semantic edges created: ${edges.edgesAdded} edges (threshold=${threshold})`);
1788
+ if (edges.edgesAdded > 0) {
1789
+ store.saveGraph(graph);
1790
+ }
1791
+ }
1792
+ console.log('Embedding computation complete.');
1793
+ }
1794
+ finally {
1795
+ store.close();
1796
+ }
1797
+ });
1798
+ // ── evolution (temporal graph snapshots) ────────────────────────────────────
1799
+ // #807: Temporal graph evolution — view snapshots and trends
1800
+ program
1801
+ .command('evolution [repoPath]')
1802
+ .description('View temporal graph evolution — snapshots, health trends, and diffs over time')
1803
+ .option('-d, --db <path>', 'Database path', '.astrolabe/astrolabe.db')
1804
+ .option('--since <iso-date>', 'ISO 8601 start date for snapshot range')
1805
+ .option('--until <iso-date>', 'ISO 8601 end date for snapshot range')
1806
+ .option('--format <format>', 'Output format: table (default) or json', 'table')
1807
+ .action((repoPath, opts) => {
1808
+ const dbPath = repoPath ? join(repoPath, '.astrolabe', 'astrolabe.db') : opts.db;
1809
+ if (!existsSync(dbPath)) {
1810
+ console.log('No analysis found. Run `astrolabe analyze <repo>` first.');
1811
+ return;
1812
+ }
1813
+ const store = createSqliteStore(dbPath);
1814
+ try {
1815
+ const snapshots = store.loadSnapshots(opts.since, opts.until);
1816
+ if (snapshots.length === 0) {
1817
+ console.log('No snapshots found. Run `astrolabe analyze` to create the first snapshot.');
1818
+ return;
1819
+ }
1820
+ const trend = detectTrends(snapshots);
1821
+ if (opts.format === 'json') {
1822
+ const output = snapshots.map((s) => ({
1823
+ id: s.id,
1824
+ timestamp: s.timestamp,
1825
+ commitSha: s.commitSha.slice(0, 7),
1826
+ branch: s.branch,
1827
+ nodes: s.nodeCount,
1828
+ edges: s.edgeCount,
1829
+ communities: s.communityCount,
1830
+ health: s.healthScore,
1831
+ cohesion: Math.round(s.cohesion * 1000) / 1000,
1832
+ complexity: Math.round(s.complexity * 1000) / 1000,
1833
+ cycles: s.cycleCount,
1834
+ hubs: s.hubCount,
1835
+ unstableDeps: s.unstableDepCount,
1836
+ }));
1837
+ console.log(JSON.stringify({
1838
+ repo: repoPath ?? 'unknown',
1839
+ snapshotCount: snapshots.length,
1840
+ trend,
1841
+ snapshots: output,
1842
+ }, null, 2));
1843
+ return;
1844
+ }
1845
+ // Table format
1846
+ const headers = ['Timestamp', 'Nodes', 'Edges', 'Health', 'Cycles', 'Hubs', 'Trend'];
1847
+ const colWidths = [20, 8, 8, 8, 8, 6, 10];
1848
+ const pad = (s, w) => s.padEnd(w);
1849
+ console.log();
1850
+ console.log(`Temporal Graph Evolution (${snapshots.length} snapshot(s))`);
1851
+ console.log(`Repo: ${repoPath ?? 'unknown'}`);
1852
+ console.log();
1853
+ console.log(headers.map((h, i) => pad(h, colWidths[i])).join(''));
1854
+ console.log(colWidths.map((w) => '-'.repeat(w)).join(''));
1855
+ for (let i = 0; i < snapshots.length; i++) {
1856
+ const s = snapshots[i];
1857
+ let trendIcon = '';
1858
+ if (i > 0) {
1859
+ const delta = s.healthScore - snapshots[i - 1].healthScore;
1860
+ trendIcon = delta > 1 ? '\u2191' : delta < -1 ? '\u2193' : '\u2192';
1861
+ }
1862
+ const cols = [
1863
+ s.timestamp.replace('T', ' ').slice(0, 19),
1864
+ String(s.nodeCount),
1865
+ String(s.edgeCount),
1866
+ s.healthScore.toFixed(1),
1867
+ String(s.cycleCount),
1868
+ String(s.hubCount),
1869
+ trendIcon,
1870
+ ];
1871
+ console.log(cols.map((c, ci) => pad(c, colWidths[ci])).join(''));
1872
+ }
1873
+ // Trend summary
1874
+ console.log();
1875
+ const trendLabel = trend.direction === 'improving' ? '\u2191 IMPROVING' : trend.direction === 'degrading' ? '\u2193 DEGRADING' : '\u2192 STABLE';
1876
+ console.log(`Health Trend: ${trendLabel} (slope: ${trend.slope > 0 ? '+' : ''}${trend.slope.toFixed(3)}/snapshot, confidence: ${(trend.confidence * 100).toFixed(0)}%)`);
1877
+ console.log(`Current Score: ${trend.currentScore.toFixed(1)} \u2192 Projected: ${trend.projectedScore.toFixed(1)}`);
1878
+ console.log();
1879
+ }
1880
+ finally {
1881
+ store.close();
1882
+ }
1883
+ });
1091
1884
  program.parse();
1092
1885
  //# sourceMappingURL=index.js.map