@optave/codegraph 2.0.0 → 2.1.1-dev.00f091c

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/src/queries.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
2
3
  import path from 'node:path';
4
+ import { findCycles } from './cycles.js';
3
5
  import { findDbPath, openReadonlyOrFail } from './db.js';
6
+ import { LANGUAGE_REGISTRY } from './parser.js';
4
7
 
5
8
  const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./;
6
9
  function isTestFile(filePath) {
@@ -190,14 +193,14 @@ export function moduleMapData(customDbPath, limit = 20) {
190
193
  const nodes = db
191
194
  .prepare(`
192
195
  SELECT n.*,
193
- (SELECT COUNT(*) FROM edges WHERE source_id = n.id) as out_edges,
194
- (SELECT COUNT(*) FROM edges WHERE target_id = n.id) as in_edges
196
+ (SELECT COUNT(*) FROM edges WHERE source_id = n.id AND kind != 'contains') as out_edges,
197
+ (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind != 'contains') as in_edges
195
198
  FROM nodes n
196
199
  WHERE n.kind = 'file'
197
200
  AND n.file NOT LIKE '%.test.%'
198
201
  AND n.file NOT LIKE '%.spec.%'
199
202
  AND n.file NOT LIKE '%__test__%'
200
- ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id) DESC
203
+ ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind != 'contains') DESC
201
204
  LIMIT ?
202
205
  `)
203
206
  .all(limit);
@@ -451,9 +454,25 @@ export function diffImpactData(customDbPath, opts = {}) {
451
454
  const dbPath = findDbPath(customDbPath);
452
455
  const repoRoot = path.resolve(path.dirname(dbPath), '..');
453
456
 
457
+ // Verify we're in a git repository before running git diff
458
+ let checkDir = repoRoot;
459
+ let isGitRepo = false;
460
+ while (checkDir) {
461
+ if (fs.existsSync(path.join(checkDir, '.git'))) {
462
+ isGitRepo = true;
463
+ break;
464
+ }
465
+ const parent = path.dirname(checkDir);
466
+ if (parent === checkDir) break;
467
+ checkDir = parent;
468
+ }
469
+ if (!isGitRepo) {
470
+ db.close();
471
+ return { error: `Not a git repository: ${repoRoot}` };
472
+ }
473
+
454
474
  let diffOutput;
455
475
  try {
456
- // FIX: Use execFileSync with array args to prevent shell injection
457
476
  const args = opts.staged
458
477
  ? ['diff', '--cached', '--unified=0', '--no-color']
459
478
  : ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color'];
@@ -461,6 +480,7 @@ export function diffImpactData(customDbPath, opts = {}) {
461
480
  cwd: repoRoot,
462
481
  encoding: 'utf-8',
463
482
  maxBuffer: 10 * 1024 * 1024,
483
+ stdio: ['pipe', 'pipe', 'pipe'],
464
484
  });
465
485
  } catch (e) {
466
486
  db.close();
@@ -596,6 +616,172 @@ export function listFunctionsData(customDbPath, opts = {}) {
596
616
  return { count: rows.length, functions: rows };
597
617
  }
598
618
 
619
+ export function statsData(customDbPath) {
620
+ const db = openReadonlyOrFail(customDbPath);
621
+
622
+ // Node breakdown by kind
623
+ const nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all();
624
+ const nodesByKind = {};
625
+ let totalNodes = 0;
626
+ for (const r of nodeRows) {
627
+ nodesByKind[r.kind] = r.c;
628
+ totalNodes += r.c;
629
+ }
630
+
631
+ // Edge breakdown by kind
632
+ const edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all();
633
+ const edgesByKind = {};
634
+ let totalEdges = 0;
635
+ for (const r of edgeRows) {
636
+ edgesByKind[r.kind] = r.c;
637
+ totalEdges += r.c;
638
+ }
639
+
640
+ // File/language distribution — map extensions via LANGUAGE_REGISTRY
641
+ const extToLang = new Map();
642
+ for (const entry of LANGUAGE_REGISTRY) {
643
+ for (const ext of entry.extensions) {
644
+ extToLang.set(ext, entry.id);
645
+ }
646
+ }
647
+ const fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all();
648
+ const byLanguage = {};
649
+ for (const row of fileNodes) {
650
+ const ext = path.extname(row.file).toLowerCase();
651
+ const lang = extToLang.get(ext) || 'other';
652
+ byLanguage[lang] = (byLanguage[lang] || 0) + 1;
653
+ }
654
+ const langCount = Object.keys(byLanguage).length;
655
+
656
+ // Cycles
657
+ const fileCycles = findCycles(db, { fileLevel: true });
658
+ const fnCycles = findCycles(db, { fileLevel: false });
659
+
660
+ // Top 5 coupling hotspots (fan-in + fan-out, file nodes)
661
+ const hotspotRows = db
662
+ .prepare(`
663
+ SELECT n.file,
664
+ (SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in,
665
+ (SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out
666
+ FROM nodes n
667
+ WHERE n.kind = 'file'
668
+ ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id)
669
+ + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC
670
+ LIMIT 5
671
+ `)
672
+ .all();
673
+ const hotspots = hotspotRows.map((r) => ({
674
+ file: r.file,
675
+ fanIn: r.fan_in,
676
+ fanOut: r.fan_out,
677
+ }));
678
+
679
+ // Embeddings metadata
680
+ let embeddings = null;
681
+ try {
682
+ const count = db.prepare('SELECT COUNT(*) as c FROM embeddings').get();
683
+ if (count && count.c > 0) {
684
+ const meta = {};
685
+ const metaRows = db.prepare('SELECT key, value FROM embedding_meta').all();
686
+ for (const r of metaRows) meta[r.key] = r.value;
687
+ embeddings = {
688
+ count: count.c,
689
+ model: meta.model || null,
690
+ dim: meta.dim ? parseInt(meta.dim, 10) : null,
691
+ builtAt: meta.built_at || null,
692
+ };
693
+ }
694
+ } catch {
695
+ /* embeddings table may not exist */
696
+ }
697
+
698
+ db.close();
699
+ return {
700
+ nodes: { total: totalNodes, byKind: nodesByKind },
701
+ edges: { total: totalEdges, byKind: edgesByKind },
702
+ files: { total: fileNodes.length, languages: langCount, byLanguage },
703
+ cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length },
704
+ hotspots,
705
+ embeddings,
706
+ };
707
+ }
708
+
709
+ export function stats(customDbPath, opts = {}) {
710
+ const data = statsData(customDbPath);
711
+ if (opts.json) {
712
+ console.log(JSON.stringify(data, null, 2));
713
+ return;
714
+ }
715
+
716
+ // Human-readable output
717
+ console.log('\n# Codegraph Stats\n');
718
+
719
+ // Nodes
720
+ console.log(`Nodes: ${data.nodes.total} total`);
721
+ const kindEntries = Object.entries(data.nodes.byKind).sort((a, b) => b[1] - a[1]);
722
+ const kindParts = kindEntries.map(([k, v]) => `${k} ${v}`);
723
+ // Print in rows of 3
724
+ for (let i = 0; i < kindParts.length; i += 3) {
725
+ const row = kindParts
726
+ .slice(i, i + 3)
727
+ .map((p) => p.padEnd(18))
728
+ .join('');
729
+ console.log(` ${row}`);
730
+ }
731
+
732
+ // Edges
733
+ console.log(`\nEdges: ${data.edges.total} total`);
734
+ const edgeEntries = Object.entries(data.edges.byKind).sort((a, b) => b[1] - a[1]);
735
+ const edgeParts = edgeEntries.map(([k, v]) => `${k} ${v}`);
736
+ for (let i = 0; i < edgeParts.length; i += 3) {
737
+ const row = edgeParts
738
+ .slice(i, i + 3)
739
+ .map((p) => p.padEnd(18))
740
+ .join('');
741
+ console.log(` ${row}`);
742
+ }
743
+
744
+ // Files
745
+ console.log(`\nFiles: ${data.files.total} (${data.files.languages} languages)`);
746
+ const langEntries = Object.entries(data.files.byLanguage).sort((a, b) => b[1] - a[1]);
747
+ const langParts = langEntries.map(([k, v]) => `${k} ${v}`);
748
+ for (let i = 0; i < langParts.length; i += 3) {
749
+ const row = langParts
750
+ .slice(i, i + 3)
751
+ .map((p) => p.padEnd(18))
752
+ .join('');
753
+ console.log(` ${row}`);
754
+ }
755
+
756
+ // Cycles
757
+ console.log(
758
+ `\nCycles: ${data.cycles.fileLevel} file-level, ${data.cycles.functionLevel} function-level`,
759
+ );
760
+
761
+ // Hotspots
762
+ if (data.hotspots.length > 0) {
763
+ console.log(`\nTop ${data.hotspots.length} coupling hotspots:`);
764
+ for (let i = 0; i < data.hotspots.length; i++) {
765
+ const h = data.hotspots[i];
766
+ console.log(
767
+ ` ${String(i + 1).padStart(2)}. ${h.file.padEnd(35)} fan-in: ${String(h.fanIn).padStart(3)} fan-out: ${String(h.fanOut).padStart(3)}`,
768
+ );
769
+ }
770
+ }
771
+
772
+ // Embeddings
773
+ if (data.embeddings) {
774
+ const e = data.embeddings;
775
+ console.log(
776
+ `\nEmbeddings: ${e.count} vectors (${e.model || 'unknown'}, ${e.dim || '?'}d) built ${e.builtAt || 'unknown'}`,
777
+ );
778
+ } else {
779
+ console.log('\nEmbeddings: not built');
780
+ }
781
+
782
+ console.log();
783
+ }
784
+
599
785
  // ─── Human-readable output (original formatting) ───────────────────────
600
786
 
601
787
  export function queryName(name, customDbPath, opts = {}) {
@@ -759,6 +945,402 @@ export function fnDeps(name, customDbPath, opts = {}) {
759
945
  }
760
946
  }
761
947
 
948
+ // ─── Context helpers (private) ──────────────────────────────────────────
949
+
950
+ function readSourceRange(repoRoot, file, startLine, endLine) {
951
+ try {
952
+ const absPath = path.resolve(repoRoot, file);
953
+ const content = fs.readFileSync(absPath, 'utf-8');
954
+ const lines = content.split('\n');
955
+ const start = Math.max(0, (startLine || 1) - 1);
956
+ const end = Math.min(lines.length, endLine || startLine + 50);
957
+ return lines.slice(start, end).join('\n');
958
+ } catch {
959
+ return null;
960
+ }
961
+ }
962
+
963
+ function extractSummary(fileLines, line) {
964
+ if (!fileLines || !line || line <= 1) return null;
965
+ const idx = line - 2; // line above the definition (0-indexed)
966
+ // Scan up to 10 lines above for JSDoc or comment
967
+ let jsdocEnd = -1;
968
+ for (let i = idx; i >= Math.max(0, idx - 10); i--) {
969
+ const trimmed = fileLines[i].trim();
970
+ if (trimmed.endsWith('*/')) {
971
+ jsdocEnd = i;
972
+ break;
973
+ }
974
+ if (trimmed.startsWith('//') || trimmed.startsWith('#')) {
975
+ // Single-line comment immediately above
976
+ const text = trimmed
977
+ .replace(/^\/\/\s*/, '')
978
+ .replace(/^#\s*/, '')
979
+ .trim();
980
+ return text.length > 100 ? `${text.slice(0, 100)}...` : text;
981
+ }
982
+ if (trimmed !== '' && !trimmed.startsWith('*') && !trimmed.startsWith('/*')) break;
983
+ }
984
+ if (jsdocEnd >= 0) {
985
+ // Find opening /**
986
+ for (let i = jsdocEnd; i >= Math.max(0, jsdocEnd - 20); i--) {
987
+ if (fileLines[i].trim().startsWith('/**')) {
988
+ // Extract first non-tag, non-empty line
989
+ for (let j = i + 1; j <= jsdocEnd; j++) {
990
+ const docLine = fileLines[j]
991
+ .trim()
992
+ .replace(/^\*\s?/, '')
993
+ .trim();
994
+ if (docLine && !docLine.startsWith('@') && docLine !== '/' && docLine !== '*/') {
995
+ return docLine.length > 100 ? `${docLine.slice(0, 100)}...` : docLine;
996
+ }
997
+ }
998
+ break;
999
+ }
1000
+ }
1001
+ }
1002
+ return null;
1003
+ }
1004
+
1005
+ function extractSignature(fileLines, line) {
1006
+ if (!fileLines || !line) return null;
1007
+ const idx = line - 1;
1008
+ // Gather up to 5 lines to handle multi-line params
1009
+ const chunk = fileLines.slice(idx, Math.min(fileLines.length, idx + 5)).join('\n');
1010
+
1011
+ // JS/TS: function name(params) or (params) => or async function
1012
+ let m = chunk.match(
1013
+ /(?:export\s+)?(?:async\s+)?function\s*\*?\s*\w*\s*\(([^)]*)\)\s*(?::\s*([^\n{]+))?/,
1014
+ );
1015
+ if (m) {
1016
+ return {
1017
+ params: m[1].trim() || null,
1018
+ returnType: m[2] ? m[2].trim().replace(/\s*\{$/, '') : null,
1019
+ };
1020
+ }
1021
+ // Arrow: const name = (params) => or (params):ReturnType =>
1022
+ m = chunk.match(/=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*([^=>\n{]+))?\s*=>/);
1023
+ if (m) {
1024
+ return {
1025
+ params: m[1].trim() || null,
1026
+ returnType: m[2] ? m[2].trim() : null,
1027
+ };
1028
+ }
1029
+ // Python: def name(params) -> return:
1030
+ m = chunk.match(/def\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^:\n]+))?/);
1031
+ if (m) {
1032
+ return {
1033
+ params: m[1].trim() || null,
1034
+ returnType: m[2] ? m[2].trim() : null,
1035
+ };
1036
+ }
1037
+ // Go: func (recv) name(params) (returns)
1038
+ m = chunk.match(/func\s+(?:\([^)]*\)\s+)?\w+\s*\(([^)]*)\)\s*(?:\(([^)]+)\)|(\w[^\n{]*))?/);
1039
+ if (m) {
1040
+ return {
1041
+ params: m[1].trim() || null,
1042
+ returnType: (m[2] || m[3] || '').trim() || null,
1043
+ };
1044
+ }
1045
+ // Rust: fn name(params) -> ReturnType
1046
+ m = chunk.match(/fn\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^\n{]+))?/);
1047
+ if (m) {
1048
+ return {
1049
+ params: m[1].trim() || null,
1050
+ returnType: m[2] ? m[2].trim() : null,
1051
+ };
1052
+ }
1053
+ return null;
1054
+ }
1055
+
1056
+ // ─── contextData ────────────────────────────────────────────────────────
1057
+
1058
+ export function contextData(name, customDbPath, opts = {}) {
1059
+ const db = openReadonlyOrFail(customDbPath);
1060
+ const depth = opts.depth || 0;
1061
+ const noSource = opts.noSource || false;
1062
+ const noTests = opts.noTests || false;
1063
+ const includeTests = opts.includeTests || false;
1064
+
1065
+ const dbPath = findDbPath(customDbPath);
1066
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
1067
+
1068
+ let nodes = db
1069
+ .prepare(
1070
+ `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function', 'method', 'class') ORDER BY file, line`,
1071
+ )
1072
+ .all(`%${name}%`);
1073
+ if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
1074
+ if (nodes.length === 0) {
1075
+ db.close();
1076
+ return { name, results: [] };
1077
+ }
1078
+
1079
+ // Limit to first 5 results
1080
+ nodes = nodes.slice(0, 5);
1081
+
1082
+ // File-lines cache to avoid re-reading the same file
1083
+ const fileCache = new Map();
1084
+ function getFileLines(file) {
1085
+ if (fileCache.has(file)) return fileCache.get(file);
1086
+ try {
1087
+ const absPath = path.resolve(repoRoot, file);
1088
+ const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
1089
+ fileCache.set(file, lines);
1090
+ return lines;
1091
+ } catch {
1092
+ fileCache.set(file, null);
1093
+ return null;
1094
+ }
1095
+ }
1096
+
1097
+ const results = nodes.map((node) => {
1098
+ const fileLines = getFileLines(node.file);
1099
+
1100
+ // Source
1101
+ const source = noSource ? null : readSourceRange(repoRoot, node.file, node.line, node.end_line);
1102
+
1103
+ // Signature
1104
+ const signature = fileLines ? extractSignature(fileLines, node.line) : null;
1105
+
1106
+ // Callees
1107
+ const calleeRows = db
1108
+ .prepare(
1109
+ `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
1110
+ FROM edges e JOIN nodes n ON e.target_id = n.id
1111
+ WHERE e.source_id = ? AND e.kind = 'calls'`,
1112
+ )
1113
+ .all(node.id);
1114
+ const filteredCallees = noTests ? calleeRows.filter((c) => !isTestFile(c.file)) : calleeRows;
1115
+
1116
+ const callees = filteredCallees.map((c) => {
1117
+ const cLines = getFileLines(c.file);
1118
+ const summary = cLines ? extractSummary(cLines, c.line) : null;
1119
+ let calleeSource = null;
1120
+ if (depth >= 1) {
1121
+ calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line);
1122
+ }
1123
+ return {
1124
+ name: c.name,
1125
+ kind: c.kind,
1126
+ file: c.file,
1127
+ line: c.line,
1128
+ endLine: c.end_line || null,
1129
+ summary,
1130
+ source: calleeSource,
1131
+ };
1132
+ });
1133
+
1134
+ // Deep callee expansion via BFS (depth > 1, capped at 5)
1135
+ if (depth > 1) {
1136
+ const visited = new Set(filteredCallees.map((c) => c.id));
1137
+ visited.add(node.id);
1138
+ let frontier = filteredCallees.map((c) => c.id);
1139
+ const maxDepth = Math.min(depth, 5);
1140
+ for (let d = 2; d <= maxDepth; d++) {
1141
+ const nextFrontier = [];
1142
+ for (const fid of frontier) {
1143
+ const deeper = db
1144
+ .prepare(
1145
+ `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
1146
+ FROM edges e JOIN nodes n ON e.target_id = n.id
1147
+ WHERE e.source_id = ? AND e.kind = 'calls'`,
1148
+ )
1149
+ .all(fid);
1150
+ for (const c of deeper) {
1151
+ if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
1152
+ visited.add(c.id);
1153
+ nextFrontier.push(c.id);
1154
+ const cLines = getFileLines(c.file);
1155
+ callees.push({
1156
+ name: c.name,
1157
+ kind: c.kind,
1158
+ file: c.file,
1159
+ line: c.line,
1160
+ endLine: c.end_line || null,
1161
+ summary: cLines ? extractSummary(cLines, c.line) : null,
1162
+ source: readSourceRange(repoRoot, c.file, c.line, c.end_line),
1163
+ });
1164
+ }
1165
+ }
1166
+ }
1167
+ frontier = nextFrontier;
1168
+ if (frontier.length === 0) break;
1169
+ }
1170
+ }
1171
+
1172
+ // Callers
1173
+ let callerRows = db
1174
+ .prepare(
1175
+ `SELECT n.name, n.kind, n.file, n.line
1176
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1177
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1178
+ )
1179
+ .all(node.id);
1180
+
1181
+ // Method hierarchy resolution
1182
+ if (node.kind === 'method' && node.name.includes('.')) {
1183
+ const methodName = node.name.split('.').pop();
1184
+ const relatedMethods = resolveMethodViaHierarchy(db, methodName);
1185
+ for (const rm of relatedMethods) {
1186
+ if (rm.id === node.id) continue;
1187
+ const extraCallers = db
1188
+ .prepare(
1189
+ `SELECT n.name, n.kind, n.file, n.line
1190
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1191
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1192
+ )
1193
+ .all(rm.id);
1194
+ callerRows.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name })));
1195
+ }
1196
+ }
1197
+ if (noTests) callerRows = callerRows.filter((c) => !isTestFile(c.file));
1198
+
1199
+ const callers = callerRows.map((c) => ({
1200
+ name: c.name,
1201
+ kind: c.kind,
1202
+ file: c.file,
1203
+ line: c.line,
1204
+ viaHierarchy: c.viaHierarchy || undefined,
1205
+ }));
1206
+
1207
+ // Related tests: callers that live in test files
1208
+ const testCallerRows = db
1209
+ .prepare(
1210
+ `SELECT n.name, n.kind, n.file, n.line
1211
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1212
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1213
+ )
1214
+ .all(node.id);
1215
+ const testCallers = testCallerRows.filter((c) => isTestFile(c.file));
1216
+
1217
+ const testsByFile = new Map();
1218
+ for (const tc of testCallers) {
1219
+ if (!testsByFile.has(tc.file)) testsByFile.set(tc.file, []);
1220
+ testsByFile.get(tc.file).push(tc);
1221
+ }
1222
+
1223
+ const relatedTests = [];
1224
+ for (const [file] of testsByFile) {
1225
+ const tLines = getFileLines(file);
1226
+ const testNames = [];
1227
+ if (tLines) {
1228
+ for (const tl of tLines) {
1229
+ const tm = tl.match(/(?:it|test|describe)\s*\(\s*['"`]([^'"`]+)['"`]/);
1230
+ if (tm) testNames.push(tm[1]);
1231
+ }
1232
+ }
1233
+ const testSource = includeTests && tLines ? tLines.join('\n') : undefined;
1234
+ relatedTests.push({
1235
+ file,
1236
+ testCount: testNames.length,
1237
+ testNames,
1238
+ source: testSource,
1239
+ });
1240
+ }
1241
+
1242
+ return {
1243
+ name: node.name,
1244
+ kind: node.kind,
1245
+ file: node.file,
1246
+ line: node.line,
1247
+ endLine: node.end_line || null,
1248
+ source,
1249
+ signature,
1250
+ callees,
1251
+ callers,
1252
+ relatedTests,
1253
+ };
1254
+ });
1255
+
1256
+ db.close();
1257
+ return { name, results };
1258
+ }
1259
+
1260
+ export function context(name, customDbPath, opts = {}) {
1261
+ const data = contextData(name, customDbPath, opts);
1262
+ if (opts.json) {
1263
+ console.log(JSON.stringify(data, null, 2));
1264
+ return;
1265
+ }
1266
+ if (data.results.length === 0) {
1267
+ console.log(`No function/method/class matching "${name}"`);
1268
+ return;
1269
+ }
1270
+
1271
+ for (const r of data.results) {
1272
+ const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
1273
+ console.log(`\n# ${r.name} (${r.kind}) — ${r.file}:${lineRange}\n`);
1274
+
1275
+ // Signature
1276
+ if (r.signature) {
1277
+ console.log('## Type/Shape Info');
1278
+ if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`);
1279
+ if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`);
1280
+ console.log();
1281
+ }
1282
+
1283
+ // Source
1284
+ if (r.source) {
1285
+ console.log('## Source');
1286
+ for (const line of r.source.split('\n')) {
1287
+ console.log(` ${line}`);
1288
+ }
1289
+ console.log();
1290
+ }
1291
+
1292
+ // Callees
1293
+ if (r.callees.length > 0) {
1294
+ console.log(`## Direct Dependencies (${r.callees.length})`);
1295
+ for (const c of r.callees) {
1296
+ const summary = c.summary ? ` — ${c.summary}` : '';
1297
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${summary}`);
1298
+ if (c.source) {
1299
+ for (const line of c.source.split('\n').slice(0, 10)) {
1300
+ console.log(` | ${line}`);
1301
+ }
1302
+ }
1303
+ }
1304
+ console.log();
1305
+ }
1306
+
1307
+ // Callers
1308
+ if (r.callers.length > 0) {
1309
+ console.log(`## Callers (${r.callers.length})`);
1310
+ for (const c of r.callers) {
1311
+ const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : '';
1312
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`);
1313
+ }
1314
+ console.log();
1315
+ }
1316
+
1317
+ // Related tests
1318
+ if (r.relatedTests.length > 0) {
1319
+ console.log('## Related Tests');
1320
+ for (const t of r.relatedTests) {
1321
+ console.log(` ${t.file} — ${t.testCount} tests`);
1322
+ for (const tn of t.testNames) {
1323
+ console.log(` - ${tn}`);
1324
+ }
1325
+ if (t.source) {
1326
+ console.log(' Source:');
1327
+ for (const line of t.source.split('\n').slice(0, 20)) {
1328
+ console.log(` | ${line}`);
1329
+ }
1330
+ }
1331
+ }
1332
+ console.log();
1333
+ }
1334
+
1335
+ if (r.callees.length === 0 && r.callers.length === 0 && r.relatedTests.length === 0) {
1336
+ console.log(
1337
+ ' (no call edges or tests found — may be invoked dynamically or via re-exports)',
1338
+ );
1339
+ console.log();
1340
+ }
1341
+ }
1342
+ }
1343
+
762
1344
  export function fnImpact(name, customDbPath, opts = {}) {
763
1345
  const data = fnImpactData(name, customDbPath, opts);
764
1346
  if (opts.json) {
package/src/registry.js CHANGED
@@ -3,7 +3,11 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { debug, warn } from './logger.js';
5
5
 
6
- export const REGISTRY_PATH = path.join(os.homedir(), '.codegraph', 'registry.json');
6
+ export const REGISTRY_PATH =
7
+ process.env.CODEGRAPH_REGISTRY_PATH || path.join(os.homedir(), '.codegraph', 'registry.json');
8
+
9
+ /** Default TTL: entries not accessed within 30 days are pruned. */
10
+ export const DEFAULT_TTL_DAYS = 30;
7
11
 
8
12
  /**
9
13
  * Load the registry from disk.
@@ -69,10 +73,12 @@ export function registerRepo(rootDir, name, registryPath = REGISTRY_PATH) {
69
73
  }
70
74
  }
71
75
 
76
+ const now = new Date().toISOString();
72
77
  registry.repos[repoName] = {
73
78
  path: absRoot,
74
79
  dbPath: path.join(absRoot, '.codegraph', 'graph.db'),
75
- addedAt: new Date().toISOString(),
80
+ addedAt: registry.repos[repoName]?.addedAt || now,
81
+ lastAccessedAt: now,
76
82
  };
77
83
 
78
84
  saveRegistry(registry, registryPath);
@@ -102,6 +108,7 @@ export function listRepos(registryPath = REGISTRY_PATH) {
102
108
  path: entry.path,
103
109
  dbPath: entry.dbPath,
104
110
  addedAt: entry.addedAt,
111
+ lastAccessedAt: entry.lastAccessedAt || entry.addedAt,
105
112
  }))
106
113
  .sort((a, b) => a.name.localeCompare(b.name));
107
114
  }
@@ -118,21 +125,31 @@ export function resolveRepoDbPath(name, registryPath = REGISTRY_PATH) {
118
125
  warn(`Registry: database missing for "${name}" at ${entry.dbPath}`);
119
126
  return undefined;
120
127
  }
128
+ // Touch lastAccessedAt on successful resolution
129
+ entry.lastAccessedAt = new Date().toISOString();
130
+ saveRegistry(registry, registryPath);
121
131
  return entry.dbPath;
122
132
  }
123
133
 
124
134
  /**
125
- * Remove registry entries whose repo directory no longer exists on disk.
126
- * Only checks the repo directory (not the DB file — a missing DB is normal pre-build state).
127
- * Returns an array of `{ name, path }` for each pruned entry.
135
+ * Remove registry entries whose repo directory no longer exists on disk,
136
+ * or that haven't been accessed within `ttlDays` days.
137
+ * Returns an array of `{ name, path, reason }` for each pruned entry.
128
138
  */
129
- export function pruneRegistry(registryPath = REGISTRY_PATH) {
139
+ export function pruneRegistry(registryPath = REGISTRY_PATH, ttlDays = DEFAULT_TTL_DAYS) {
130
140
  const registry = loadRegistry(registryPath);
131
141
  const pruned = [];
142
+ const cutoff = Date.now() - ttlDays * 24 * 60 * 60 * 1000;
132
143
 
133
144
  for (const [name, entry] of Object.entries(registry.repos)) {
134
145
  if (!fs.existsSync(entry.path)) {
135
- pruned.push({ name, path: entry.path });
146
+ pruned.push({ name, path: entry.path, reason: 'missing' });
147
+ delete registry.repos[name];
148
+ continue;
149
+ }
150
+ const lastAccess = Date.parse(entry.lastAccessedAt || entry.addedAt);
151
+ if (lastAccess < cutoff) {
152
+ pruned.push({ name, path: entry.path, reason: 'expired' });
136
153
  delete registry.repos[name];
137
154
  }
138
155
  }