@kodus/kodus-graph 0.1.0
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/package.json +62 -0
- package/src/analysis/blast-radius.ts +54 -0
- package/src/analysis/communities.ts +135 -0
- package/src/analysis/diff.ts +120 -0
- package/src/analysis/flows.ts +112 -0
- package/src/analysis/review-context.ts +141 -0
- package/src/analysis/risk-score.ts +62 -0
- package/src/analysis/search.ts +76 -0
- package/src/analysis/test-gaps.ts +21 -0
- package/src/cli.ts +192 -0
- package/src/commands/analyze.ts +66 -0
- package/src/commands/communities.ts +19 -0
- package/src/commands/context.ts +69 -0
- package/src/commands/diff.ts +96 -0
- package/src/commands/flows.ts +19 -0
- package/src/commands/parse.ts +100 -0
- package/src/commands/search.ts +41 -0
- package/src/commands/update.ts +166 -0
- package/src/graph/builder.ts +170 -0
- package/src/graph/edges.ts +101 -0
- package/src/graph/loader.ts +100 -0
- package/src/graph/merger.ts +25 -0
- package/src/graph/types.ts +218 -0
- package/src/parser/batch.ts +74 -0
- package/src/parser/discovery.ts +42 -0
- package/src/parser/extractor.ts +37 -0
- package/src/parser/extractors/generic.ts +87 -0
- package/src/parser/extractors/python.ts +127 -0
- package/src/parser/extractors/ruby.ts +142 -0
- package/src/parser/extractors/typescript.ts +329 -0
- package/src/parser/languages.ts +122 -0
- package/src/resolver/call-resolver.ts +179 -0
- package/src/resolver/import-map.ts +27 -0
- package/src/resolver/import-resolver.ts +72 -0
- package/src/resolver/languages/csharp.ts +7 -0
- package/src/resolver/languages/go.ts +7 -0
- package/src/resolver/languages/java.ts +7 -0
- package/src/resolver/languages/php.ts +7 -0
- package/src/resolver/languages/python.ts +35 -0
- package/src/resolver/languages/ruby.ts +21 -0
- package/src/resolver/languages/rust.ts +7 -0
- package/src/resolver/languages/typescript.ts +168 -0
- package/src/resolver/symbol-table.ts +53 -0
- package/src/shared/file-hash.ts +7 -0
- package/src/shared/filters.ts +243 -0
- package/src/shared/logger.ts +14 -0
- package/src/shared/qualified-name.ts +5 -0
- package/src/shared/safe-path.ts +31 -0
- package/src/shared/schemas.ts +31 -0
- package/src/shared/temp.ts +17 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { writeFileSync } from 'fs';
|
|
2
|
+
import { findCallees, findCallers, searchNodes } from '../analysis/search';
|
|
3
|
+
import { loadGraph } from '../graph/loader';
|
|
4
|
+
import type { GraphNode } from '../graph/types';
|
|
5
|
+
|
|
6
|
+
interface SearchCommandOptions {
|
|
7
|
+
graph: string;
|
|
8
|
+
query?: string;
|
|
9
|
+
kind?: string;
|
|
10
|
+
file?: string;
|
|
11
|
+
callersOf?: string;
|
|
12
|
+
calleesOf?: string;
|
|
13
|
+
limit: number;
|
|
14
|
+
out?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function executeSearch(opts: SearchCommandOptions): void {
|
|
18
|
+
const graph = loadGraph(opts.graph);
|
|
19
|
+
|
|
20
|
+
let results: GraphNode[];
|
|
21
|
+
let queryInfo: Record<string, string | null>;
|
|
22
|
+
|
|
23
|
+
if (opts.callersOf) {
|
|
24
|
+
results = findCallers(graph, opts.callersOf);
|
|
25
|
+
queryInfo = { callers_of: opts.callersOf, kind: null, file: null };
|
|
26
|
+
} else if (opts.calleesOf) {
|
|
27
|
+
results = findCallees(graph, opts.calleesOf);
|
|
28
|
+
queryInfo = { callees_of: opts.calleesOf, kind: null, file: null };
|
|
29
|
+
} else {
|
|
30
|
+
results = searchNodes(graph, { query: opts.query, kind: opts.kind, file: opts.file, limit: opts.limit });
|
|
31
|
+
queryInfo = { pattern: opts.query || null, kind: opts.kind || null, file: opts.file || null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const output = JSON.stringify({ results, total: results.length, query: queryInfo }, null, 2);
|
|
35
|
+
|
|
36
|
+
if (opts.out) {
|
|
37
|
+
writeFileSync(opts.out, output);
|
|
38
|
+
} else {
|
|
39
|
+
process.stdout.write(`${output}\n`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, relative, resolve } from 'path';
|
|
3
|
+
import { performance } from 'perf_hooks';
|
|
4
|
+
import { buildGraphData } from '../graph/builder';
|
|
5
|
+
import { loadGraph } from '../graph/loader';
|
|
6
|
+
import type { GraphEdge, GraphNode, ImportEdge, ParseOutput } from '../graph/types';
|
|
7
|
+
import { parseBatch } from '../parser/batch';
|
|
8
|
+
import { discoverFiles } from '../parser/discovery';
|
|
9
|
+
import { resolveAllCalls } from '../resolver/call-resolver';
|
|
10
|
+
import { createImportMap } from '../resolver/import-map';
|
|
11
|
+
import { loadTsconfigAliases, resolveImport } from '../resolver/import-resolver';
|
|
12
|
+
import { createSymbolTable } from '../resolver/symbol-table';
|
|
13
|
+
import { computeFileHash } from '../shared/file-hash';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_GRAPH_PATH = '.kodus-graph/graph.json';
|
|
16
|
+
|
|
17
|
+
interface UpdateCommandOptions {
|
|
18
|
+
repoDir: string;
|
|
19
|
+
graph?: string;
|
|
20
|
+
out?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function executeUpdate(opts: UpdateCommandOptions): Promise<void> {
|
|
24
|
+
const t0 = performance.now();
|
|
25
|
+
const repoDir = resolve(opts.repoDir);
|
|
26
|
+
const graphPath = resolve(repoDir, opts.graph || DEFAULT_GRAPH_PATH);
|
|
27
|
+
const outPath = resolve(repoDir, opts.out || opts.graph || DEFAULT_GRAPH_PATH);
|
|
28
|
+
|
|
29
|
+
if (!existsSync(graphPath)) {
|
|
30
|
+
process.stderr.write(`Error: graph file not found: ${graphPath}. Run "kodus-graph parse" first.\n`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const oldGraph = loadGraph(graphPath);
|
|
35
|
+
process.stderr.write(`[1/5] Loaded previous graph (${oldGraph.nodes.length} nodes)\n`);
|
|
36
|
+
|
|
37
|
+
// Build file hash index from old graph
|
|
38
|
+
const oldHashes = new Map<string, string>();
|
|
39
|
+
for (const node of oldGraph.nodes) {
|
|
40
|
+
if (node.file_hash && !oldHashes.has(node.file_path)) {
|
|
41
|
+
oldHashes.set(node.file_path, node.file_hash);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Discover current files
|
|
46
|
+
const allFiles = discoverFiles(repoDir);
|
|
47
|
+
const allRel = allFiles.map((f) => relative(repoDir, f));
|
|
48
|
+
const currentFiles = new Set(allRel);
|
|
49
|
+
const oldFiles = new Set(oldHashes.keys());
|
|
50
|
+
|
|
51
|
+
// Classify files
|
|
52
|
+
const added: string[] = [];
|
|
53
|
+
const modified: string[] = [];
|
|
54
|
+
const deleted: string[] = [];
|
|
55
|
+
const unchanged: string[] = [];
|
|
56
|
+
|
|
57
|
+
for (const file of currentFiles) {
|
|
58
|
+
const absPath = resolve(repoDir, file);
|
|
59
|
+
if (!oldHashes.has(file)) {
|
|
60
|
+
added.push(file);
|
|
61
|
+
} else {
|
|
62
|
+
const currentHash = computeFileHash(absPath);
|
|
63
|
+
if (currentHash !== oldHashes.get(file)) {
|
|
64
|
+
modified.push(file);
|
|
65
|
+
} else {
|
|
66
|
+
unchanged.push(file);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const file of oldFiles) {
|
|
72
|
+
if (!currentFiles.has(file)) deleted.push(file);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const toReparse = [...added, ...modified];
|
|
76
|
+
process.stderr.write(
|
|
77
|
+
`[2/5] Files: ${added.length} added, ${modified.length} modified, ${deleted.length} deleted, ${unchanged.length} unchanged\n`,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (toReparse.length === 0 && deleted.length === 0) {
|
|
81
|
+
process.stderr.write('[3/5] No changes detected, graph is up to date\n');
|
|
82
|
+
const output: ParseOutput = {
|
|
83
|
+
metadata: {
|
|
84
|
+
...oldGraph.metadata,
|
|
85
|
+
duration_ms: Math.round(performance.now() - t0),
|
|
86
|
+
files_unchanged: unchanged.length,
|
|
87
|
+
incremental: true,
|
|
88
|
+
},
|
|
89
|
+
nodes: oldGraph.nodes,
|
|
90
|
+
edges: oldGraph.edges,
|
|
91
|
+
};
|
|
92
|
+
ensureDir(outPath);
|
|
93
|
+
writeFileSync(outPath, JSON.stringify(output, null, 2));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Re-parse changed files
|
|
98
|
+
const absToReparse = toReparse.map((f) => resolve(repoDir, f));
|
|
99
|
+
const rawGraph = await parseBatch(absToReparse, repoDir);
|
|
100
|
+
process.stderr.write(`[3/5] Re-parsed ${toReparse.length} files\n`);
|
|
101
|
+
|
|
102
|
+
// Resolve imports and calls for new files
|
|
103
|
+
const tsconfigAliases = loadTsconfigAliases(repoDir);
|
|
104
|
+
const symbolTable = createSymbolTable();
|
|
105
|
+
const importMap = createImportMap();
|
|
106
|
+
const importEdges: ImportEdge[] = [];
|
|
107
|
+
|
|
108
|
+
for (const f of rawGraph.functions) symbolTable.add(f.file, f.name, f.qualified);
|
|
109
|
+
for (const c of rawGraph.classes) symbolTable.add(c.file, c.name, c.qualified);
|
|
110
|
+
for (const i of rawGraph.interfaces) symbolTable.add(i.file, i.name, i.qualified);
|
|
111
|
+
|
|
112
|
+
for (const imp of rawGraph.imports) {
|
|
113
|
+
const langKey = imp.lang === 'python' ? 'python' : imp.lang === 'ruby' ? 'ruby' : 'typescript';
|
|
114
|
+
const resolved = resolveImport(resolve(repoDir, imp.file), imp.module, langKey, repoDir, tsconfigAliases);
|
|
115
|
+
const resolvedRel = resolved ? relative(repoDir, resolved) : null;
|
|
116
|
+
importEdges.push({ source: imp.file, target: resolvedRel || imp.module, resolved: !!resolvedRel, line: imp.line });
|
|
117
|
+
const target = resolvedRel || imp.module;
|
|
118
|
+
for (const name of imp.names) importMap.add(imp.file, name, target);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { callEdges } = resolveAllCalls(rawGraph.rawCalls, rawGraph.diMaps, symbolTable, importMap);
|
|
122
|
+
|
|
123
|
+
const fileHashes = new Map<string, string>();
|
|
124
|
+
for (const f of absToReparse) {
|
|
125
|
+
try {
|
|
126
|
+
fileHashes.set(relative(repoDir, f), computeFileHash(f));
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const newGraphData = buildGraphData(rawGraph, callEdges, importEdges, repoDir, fileHashes);
|
|
131
|
+
process.stderr.write(`[4/5] Built new graph fragment (${newGraphData.nodes.length} nodes)\n`);
|
|
132
|
+
|
|
133
|
+
// Merge: keep old nodes/edges NOT in changed/deleted files, add new ones
|
|
134
|
+
const changedOrDeleted = new Set([...toReparse, ...deleted]);
|
|
135
|
+
const mergedNodes: GraphNode[] = oldGraph.nodes.filter((n) => !changedOrDeleted.has(n.file_path));
|
|
136
|
+
const mergedEdges: GraphEdge[] = oldGraph.edges.filter((e) => !changedOrDeleted.has(e.file_path));
|
|
137
|
+
|
|
138
|
+
mergedNodes.push(...newGraphData.nodes);
|
|
139
|
+
mergedEdges.push(...newGraphData.edges);
|
|
140
|
+
|
|
141
|
+
process.stderr.write(`[5/5] Merged: ${mergedNodes.length} nodes, ${mergedEdges.length} edges\n`);
|
|
142
|
+
|
|
143
|
+
const output: ParseOutput = {
|
|
144
|
+
metadata: {
|
|
145
|
+
repo_dir: repoDir,
|
|
146
|
+
files_parsed: toReparse.length,
|
|
147
|
+
files_unchanged: unchanged.length,
|
|
148
|
+
total_nodes: mergedNodes.length,
|
|
149
|
+
total_edges: mergedEdges.length,
|
|
150
|
+
duration_ms: Math.round(performance.now() - t0),
|
|
151
|
+
parse_errors: rawGraph.parseErrors,
|
|
152
|
+
extract_errors: rawGraph.extractErrors,
|
|
153
|
+
incremental: true,
|
|
154
|
+
},
|
|
155
|
+
nodes: mergedNodes,
|
|
156
|
+
edges: mergedEdges,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
ensureDir(outPath);
|
|
160
|
+
writeFileSync(outPath, JSON.stringify(output, null, 2));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function ensureDir(filePath: string): void {
|
|
164
|
+
const dir = dirname(filePath);
|
|
165
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
166
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { deriveEdges } from './edges';
|
|
2
|
+
import type { GraphData, GraphEdge, GraphNode, ImportEdge, RawCallEdge, RawGraph } from './types';
|
|
3
|
+
|
|
4
|
+
export function buildGraphData(
|
|
5
|
+
raw: RawGraph,
|
|
6
|
+
callEdges: RawCallEdge[],
|
|
7
|
+
importEdges: ImportEdge[],
|
|
8
|
+
_repoDir: string,
|
|
9
|
+
fileHashes: Map<string, string>,
|
|
10
|
+
): GraphData {
|
|
11
|
+
const nodes: GraphNode[] = [];
|
|
12
|
+
const edges: GraphEdge[] = [];
|
|
13
|
+
|
|
14
|
+
// Functions -> nodes
|
|
15
|
+
for (const f of raw.functions) {
|
|
16
|
+
nodes.push({
|
|
17
|
+
kind: f.kind,
|
|
18
|
+
name: f.name,
|
|
19
|
+
qualified_name: f.qualified,
|
|
20
|
+
file_path: f.file,
|
|
21
|
+
line_start: f.line_start,
|
|
22
|
+
line_end: f.line_end,
|
|
23
|
+
language: detectLang(f.file),
|
|
24
|
+
parent_name: f.className || undefined,
|
|
25
|
+
params: f.params || undefined,
|
|
26
|
+
return_type: f.returnType || undefined,
|
|
27
|
+
is_test: false,
|
|
28
|
+
file_hash: fileHashes.get(f.file) || '',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Classes -> nodes
|
|
33
|
+
for (const c of raw.classes) {
|
|
34
|
+
nodes.push({
|
|
35
|
+
kind: 'Class',
|
|
36
|
+
name: c.name,
|
|
37
|
+
qualified_name: c.qualified,
|
|
38
|
+
file_path: c.file,
|
|
39
|
+
line_start: c.line_start,
|
|
40
|
+
line_end: c.line_end,
|
|
41
|
+
language: detectLang(c.file),
|
|
42
|
+
is_test: false,
|
|
43
|
+
file_hash: fileHashes.get(c.file) || '',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Interfaces -> nodes
|
|
48
|
+
for (const i of raw.interfaces) {
|
|
49
|
+
nodes.push({
|
|
50
|
+
kind: 'Interface',
|
|
51
|
+
name: i.name,
|
|
52
|
+
qualified_name: i.qualified,
|
|
53
|
+
file_path: i.file,
|
|
54
|
+
line_start: i.line_start,
|
|
55
|
+
line_end: i.line_end,
|
|
56
|
+
language: detectLang(i.file),
|
|
57
|
+
is_test: false,
|
|
58
|
+
file_hash: fileHashes.get(i.file) || '',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Enums -> nodes
|
|
63
|
+
for (const e of raw.enums) {
|
|
64
|
+
nodes.push({
|
|
65
|
+
kind: 'Enum',
|
|
66
|
+
name: e.name,
|
|
67
|
+
qualified_name: e.qualified,
|
|
68
|
+
file_path: e.file,
|
|
69
|
+
line_start: e.line_start,
|
|
70
|
+
line_end: e.line_end,
|
|
71
|
+
language: detectLang(e.file),
|
|
72
|
+
is_test: false,
|
|
73
|
+
file_hash: fileHashes.get(e.file) || '',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Tests -> nodes
|
|
78
|
+
for (const t of raw.tests) {
|
|
79
|
+
nodes.push({
|
|
80
|
+
kind: 'Test',
|
|
81
|
+
name: t.name,
|
|
82
|
+
qualified_name: t.qualified,
|
|
83
|
+
file_path: t.file,
|
|
84
|
+
line_start: t.line_start,
|
|
85
|
+
line_end: t.line_end,
|
|
86
|
+
language: detectLang(t.file),
|
|
87
|
+
is_test: true,
|
|
88
|
+
file_hash: fileHashes.get(t.file) || '',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// CALLS edges
|
|
93
|
+
for (const ce of callEdges) {
|
|
94
|
+
edges.push({
|
|
95
|
+
kind: 'CALLS',
|
|
96
|
+
source_qualified: ce.source.includes('::') ? ce.source : `${ce.source}::unknown`,
|
|
97
|
+
target_qualified: ce.target,
|
|
98
|
+
file_path: ce.source.includes('::') ? ce.source.split('::')[0] : ce.source,
|
|
99
|
+
line: ce.line,
|
|
100
|
+
confidence: ce.confidence,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// IMPORTS edges
|
|
105
|
+
for (const ie of importEdges) {
|
|
106
|
+
edges.push({
|
|
107
|
+
kind: 'IMPORTS',
|
|
108
|
+
source_qualified: ie.source,
|
|
109
|
+
target_qualified: ie.target,
|
|
110
|
+
file_path: ie.source,
|
|
111
|
+
line: ie.line,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Derived edges
|
|
116
|
+
const derived = deriveEdges(raw, importEdges);
|
|
117
|
+
|
|
118
|
+
for (const e of derived.inherits) {
|
|
119
|
+
edges.push({
|
|
120
|
+
kind: 'INHERITS',
|
|
121
|
+
source_qualified: e.source,
|
|
122
|
+
target_qualified: e.target,
|
|
123
|
+
file_path: e.file || '',
|
|
124
|
+
line: 0,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
for (const e of derived.implements) {
|
|
128
|
+
edges.push({
|
|
129
|
+
kind: 'IMPLEMENTS',
|
|
130
|
+
source_qualified: e.source,
|
|
131
|
+
target_qualified: e.target,
|
|
132
|
+
file_path: e.file || '',
|
|
133
|
+
line: 0,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
for (const e of derived.testedBy) {
|
|
137
|
+
edges.push({
|
|
138
|
+
kind: 'TESTED_BY',
|
|
139
|
+
source_qualified: e.source,
|
|
140
|
+
target_qualified: e.target,
|
|
141
|
+
file_path: e.target || '',
|
|
142
|
+
line: 0,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
for (const e of derived.contains) {
|
|
146
|
+
edges.push({
|
|
147
|
+
kind: 'CONTAINS',
|
|
148
|
+
source_qualified: e.source,
|
|
149
|
+
target_qualified: e.target,
|
|
150
|
+
file_path: e.source,
|
|
151
|
+
line: 0,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { nodes, edges };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function detectLang(file: string): string {
|
|
159
|
+
if (file.endsWith('.ts') || file.endsWith('.tsx')) return 'typescript';
|
|
160
|
+
if (file.endsWith('.js') || file.endsWith('.jsx') || file.endsWith('.mjs') || file.endsWith('.cjs'))
|
|
161
|
+
return 'javascript';
|
|
162
|
+
if (file.endsWith('.py')) return 'python';
|
|
163
|
+
if (file.endsWith('.rb')) return 'ruby';
|
|
164
|
+
if (file.endsWith('.go')) return 'go';
|
|
165
|
+
if (file.endsWith('.java')) return 'java';
|
|
166
|
+
if (file.endsWith('.rs')) return 'rust';
|
|
167
|
+
if (file.endsWith('.cs')) return 'csharp';
|
|
168
|
+
if (file.endsWith('.php')) return 'php';
|
|
169
|
+
return 'unknown';
|
|
170
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { basename, extname } from 'path';
|
|
2
|
+
import type { ImportEdge, RawGraph } from './types';
|
|
3
|
+
|
|
4
|
+
interface DerivedEdge {
|
|
5
|
+
source: string;
|
|
6
|
+
target: string;
|
|
7
|
+
file?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DerivedEdges {
|
|
11
|
+
inherits: DerivedEdge[];
|
|
12
|
+
implements: DerivedEdge[];
|
|
13
|
+
testedBy: DerivedEdge[];
|
|
14
|
+
contains: DerivedEdge[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extract the "stem" from a test file name by stripping test-related
|
|
19
|
+
* prefixes/suffixes. Returns null if no test pattern was found.
|
|
20
|
+
*/
|
|
21
|
+
export function extractTestStem(testFile: string): string | null {
|
|
22
|
+
const base = basename(testFile, extname(testFile));
|
|
23
|
+
const cleaned = base
|
|
24
|
+
.replace(/_spec$/, '') // user_spec → user (Ruby/RSpec)
|
|
25
|
+
.replace(/_test$/, '') // user_test → user (Python/Go)
|
|
26
|
+
.replace(/^test_/, '') // test_user → user (Python)
|
|
27
|
+
.replace(/\.test$/, '') // user.test → user (JS/TS)
|
|
28
|
+
.replace(/\.spec$/, '') // user.spec → user (JS/TS)
|
|
29
|
+
.replace(/-test$/, '') // user-test → user
|
|
30
|
+
.replace(/-spec$/, '') // user-spec → user
|
|
31
|
+
.replace(/^spec_/, '') // spec_user → user
|
|
32
|
+
.replace(/Test$/, '') // UserTest → User (Java)
|
|
33
|
+
.replace(/Spec$/, ''); // UserSpec → User (Scala)
|
|
34
|
+
if (!cleaned || cleaned === base) return null;
|
|
35
|
+
return cleaned;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function deriveEdges(graph: RawGraph, importEdges: ImportEdge[]): DerivedEdges {
|
|
39
|
+
// INHERITS: class extends another class
|
|
40
|
+
const inherits = graph.classes
|
|
41
|
+
.filter((c) => c.extends)
|
|
42
|
+
.map((c) => ({ source: c.qualified, target: c.extends, file: c.file }));
|
|
43
|
+
|
|
44
|
+
// IMPLEMENTS: class implements interface
|
|
45
|
+
const implements_ = graph.classes
|
|
46
|
+
.filter((c) => c.implements)
|
|
47
|
+
.map((c) => ({ source: c.qualified, target: c.implements, file: c.file }));
|
|
48
|
+
|
|
49
|
+
// TESTED_BY: two heuristics, deduplicated
|
|
50
|
+
const testFiles = new Set(graph.tests.map((t) => t.file));
|
|
51
|
+
const testedBySet = new Set<string>();
|
|
52
|
+
const testedBy: DerivedEdge[] = [];
|
|
53
|
+
|
|
54
|
+
const addTestedBy = (source: string, target: string) => {
|
|
55
|
+
const key = `${source}|${target}`;
|
|
56
|
+
if (testedBySet.has(key)) return;
|
|
57
|
+
testedBySet.add(key);
|
|
58
|
+
testedBy.push({ source, target });
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Heuristic 1: Resolved imports from test files (high signal)
|
|
62
|
+
for (const e of importEdges) {
|
|
63
|
+
if (testFiles.has(e.source) && e.resolved) {
|
|
64
|
+
addTestedBy(e.target, e.source);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Heuristic 2: File-name matching (catches Ruby, Python, and any
|
|
69
|
+
// language where imports don't resolve)
|
|
70
|
+
const allSourceFiles = new Set<string>();
|
|
71
|
+
for (const f of graph.functions) allSourceFiles.add(f.file);
|
|
72
|
+
for (const c of graph.classes) allSourceFiles.add(c.file);
|
|
73
|
+
for (const i of graph.interfaces) allSourceFiles.add(i.file);
|
|
74
|
+
for (const e of graph.enums) allSourceFiles.add(e.file);
|
|
75
|
+
for (const tf of testFiles) allSourceFiles.delete(tf);
|
|
76
|
+
|
|
77
|
+
const sourceByBase = new Map<string, string[]>();
|
|
78
|
+
for (const file of allSourceFiles) {
|
|
79
|
+
const base = basename(file, extname(file));
|
|
80
|
+
const list = sourceByBase.get(base);
|
|
81
|
+
if (list) list.push(file);
|
|
82
|
+
else sourceByBase.set(base, [file]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const testFile of testFiles) {
|
|
86
|
+
const stem = extractTestStem(testFile);
|
|
87
|
+
if (!stem) continue;
|
|
88
|
+
const matches = sourceByBase.get(stem);
|
|
89
|
+
if (!matches) continue;
|
|
90
|
+
for (const sourceFile of matches) {
|
|
91
|
+
addTestedBy(sourceFile, testFile);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// CONTAINS: file contains function/class
|
|
96
|
+
const contains: DerivedEdge[] = [];
|
|
97
|
+
for (const f of graph.functions) contains.push({ source: f.file, target: f.qualified });
|
|
98
|
+
for (const c of graph.classes) contains.push({ source: c.file, target: c.qualified });
|
|
99
|
+
|
|
100
|
+
return { inherits, implements: implements_, testedBy, contains };
|
|
101
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// src/graph/loader.ts
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { GraphEdge, GraphNode, ParseMetadata } from './types';
|
|
5
|
+
|
|
6
|
+
const ParseOutputSchema = z.object({
|
|
7
|
+
metadata: z.object({
|
|
8
|
+
repo_dir: z.string(),
|
|
9
|
+
files_parsed: z.number(),
|
|
10
|
+
total_nodes: z.number(),
|
|
11
|
+
total_edges: z.number(),
|
|
12
|
+
duration_ms: z.number(),
|
|
13
|
+
parse_errors: z.number(),
|
|
14
|
+
extract_errors: z.number(),
|
|
15
|
+
files_unchanged: z.number().optional(),
|
|
16
|
+
incremental: z.boolean().optional(),
|
|
17
|
+
}),
|
|
18
|
+
nodes: z.array(
|
|
19
|
+
z.object({
|
|
20
|
+
kind: z.enum(['Function', 'Method', 'Constructor', 'Class', 'Interface', 'Enum', 'Test']),
|
|
21
|
+
name: z.string(),
|
|
22
|
+
qualified_name: z.string(),
|
|
23
|
+
file_path: z.string(),
|
|
24
|
+
line_start: z.number(),
|
|
25
|
+
line_end: z.number(),
|
|
26
|
+
language: z.string(),
|
|
27
|
+
is_test: z.boolean(),
|
|
28
|
+
file_hash: z.string(),
|
|
29
|
+
parent_name: z.string().optional(),
|
|
30
|
+
params: z.string().optional(),
|
|
31
|
+
return_type: z.string().optional(),
|
|
32
|
+
modifiers: z.string().optional(),
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
35
|
+
edges: z.array(
|
|
36
|
+
z.object({
|
|
37
|
+
kind: z.enum(['CALLS', 'IMPORTS', 'INHERITS', 'IMPLEMENTS', 'TESTED_BY', 'CONTAINS']),
|
|
38
|
+
source_qualified: z.string(),
|
|
39
|
+
target_qualified: z.string(),
|
|
40
|
+
file_path: z.string(),
|
|
41
|
+
line: z.number(),
|
|
42
|
+
confidence: z.number().optional(),
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export interface IndexedGraph {
|
|
48
|
+
nodes: GraphNode[];
|
|
49
|
+
edges: GraphEdge[];
|
|
50
|
+
byQualified: Map<string, GraphNode>;
|
|
51
|
+
byFile: Map<string, GraphNode[]>;
|
|
52
|
+
adjacency: Map<string, GraphEdge[]>;
|
|
53
|
+
reverseAdjacency: Map<string, GraphEdge[]>;
|
|
54
|
+
edgesByKind: Map<string, GraphEdge[]>;
|
|
55
|
+
metadata: ParseMetadata;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function loadGraph(path: string): IndexedGraph {
|
|
59
|
+
let raw: unknown;
|
|
60
|
+
try {
|
|
61
|
+
raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new Error(`Failed to read graph file: ${path} — ${String(err)}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const parsed = ParseOutputSchema.parse(raw);
|
|
67
|
+
|
|
68
|
+
const nodes = parsed.nodes as GraphNode[];
|
|
69
|
+
const edges = parsed.edges as GraphEdge[];
|
|
70
|
+
const metadata = parsed.metadata as ParseMetadata;
|
|
71
|
+
|
|
72
|
+
const byQualified = new Map<string, GraphNode>();
|
|
73
|
+
const byFile = new Map<string, GraphNode[]>();
|
|
74
|
+
const adjacency = new Map<string, GraphEdge[]>();
|
|
75
|
+
const reverseAdjacency = new Map<string, GraphEdge[]>();
|
|
76
|
+
const edgesByKind = new Map<string, GraphEdge[]>();
|
|
77
|
+
|
|
78
|
+
for (const node of nodes) {
|
|
79
|
+
byQualified.set(node.qualified_name, node);
|
|
80
|
+
const list = byFile.get(node.file_path);
|
|
81
|
+
if (list) list.push(node);
|
|
82
|
+
else byFile.set(node.file_path, [node]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const edge of edges) {
|
|
86
|
+
const fwd = adjacency.get(edge.source_qualified);
|
|
87
|
+
if (fwd) fwd.push(edge);
|
|
88
|
+
else adjacency.set(edge.source_qualified, [edge]);
|
|
89
|
+
|
|
90
|
+
const rev = reverseAdjacency.get(edge.target_qualified);
|
|
91
|
+
if (rev) rev.push(edge);
|
|
92
|
+
else reverseAdjacency.set(edge.target_qualified, [edge]);
|
|
93
|
+
|
|
94
|
+
const byKind = edgesByKind.get(edge.kind);
|
|
95
|
+
if (byKind) byKind.push(edge);
|
|
96
|
+
else edgesByKind.set(edge.kind, [edge]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { nodes, edges, byQualified, byFile, adjacency, reverseAdjacency, edgesByKind, metadata };
|
|
100
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { GraphData, MainGraphInput } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Merge local parse (PR changed files) with the main graph (from Postgres).
|
|
5
|
+
* Replaces all nodes/edges from changed files with the local parse.
|
|
6
|
+
* Keeps everything else from the main graph intact.
|
|
7
|
+
*/
|
|
8
|
+
export function mergeGraphs(
|
|
9
|
+
mainGraph: MainGraphInput | null,
|
|
10
|
+
localParse: GraphData,
|
|
11
|
+
changedFiles: string[],
|
|
12
|
+
): GraphData {
|
|
13
|
+
if (!mainGraph) return localParse;
|
|
14
|
+
|
|
15
|
+
const changedSet = new Set(changedFiles);
|
|
16
|
+
|
|
17
|
+
// Keep main graph nodes/edges NOT in changed files
|
|
18
|
+
const mainNodes = mainGraph.nodes.filter((n) => !changedSet.has(n.file_path));
|
|
19
|
+
const mainEdges = mainGraph.edges.filter((e) => !changedSet.has(e.file_path));
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
nodes: [...mainNodes, ...localParse.nodes],
|
|
23
|
+
edges: [...mainEdges, ...localParse.edges],
|
|
24
|
+
};
|
|
25
|
+
}
|