@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 +805 -12
- package/dist/index.js.map +1 -1
- package/dist/watch.d.ts +23 -0
- package/dist/watch.d.ts.map +1 -0
- package/dist/watch.js +190 -0
- package/dist/watch.js.map +1 -0
- package/package.json +2 -1
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|