@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/README.md +74 -37
- package/package.json +10 -10
- package/src/builder.js +252 -38
- package/src/cli.js +44 -8
- package/src/config.js +1 -1
- package/src/db.js +4 -0
- package/src/embedder.js +3 -3
- package/src/extractors/csharp.js +248 -0
- package/src/extractors/go.js +172 -0
- package/src/extractors/hcl.js +73 -0
- package/src/extractors/helpers.js +10 -0
- package/src/extractors/index.js +9 -0
- package/src/extractors/java.js +230 -0
- package/src/extractors/javascript.js +414 -0
- package/src/extractors/php.js +243 -0
- package/src/extractors/python.js +150 -0
- package/src/extractors/ruby.js +188 -0
- package/src/extractors/rust.js +225 -0
- package/src/index.js +2 -0
- package/src/journal.js +109 -0
- package/src/mcp.js +47 -4
- package/src/parser.js +28 -1890
- package/src/queries.js +586 -4
- package/src/registry.js +24 -7
- package/src/resolve.js +4 -3
- package/src/watcher.js +25 -0
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 =
|
|
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:
|
|
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
|
-
*
|
|
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
|
}
|