@kodus/kodus-graph 0.2.3 → 0.2.4
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 +1 -1
- package/src/analysis/diff.ts +4 -1
- package/src/analysis/enrich.ts +3 -2
- package/src/cli.ts +2 -1
- package/src/commands/analyze.ts +65 -3
- package/src/commands/parse.ts +20 -1
- package/src/resolver/call-resolver.ts +31 -2
- package/src/resolver/re-export-resolver.ts +66 -0
package/package.json
CHANGED
package/src/analysis/diff.ts
CHANGED
|
@@ -74,7 +74,10 @@ export function computeStructuralDiff(
|
|
|
74
74
|
} else {
|
|
75
75
|
const newN = newNodesMap.get(qn)!;
|
|
76
76
|
const changes: string[] = [];
|
|
77
|
-
|
|
77
|
+
// Only flag line_range when the file content actually changed (different file_hash).
|
|
78
|
+
// When file_hash matches, line shifts are just displacement from other edits — not real modifications.
|
|
79
|
+
const hashChanged = !n.file_hash || !newN.file_hash || n.file_hash !== newN.file_hash;
|
|
80
|
+
if (hashChanged && (n.line_start !== newN.line_start || n.line_end !== newN.line_end)) changes.push('line_range');
|
|
78
81
|
if ((n.params || '') !== (newN.params || '')) changes.push('params');
|
|
79
82
|
if ((n.return_type || '') !== (newN.return_type || '')) changes.push('return_type');
|
|
80
83
|
if (changes.length > 0) modified.push({ qualified_name: qn, changes });
|
package/src/analysis/enrich.ts
CHANGED
|
@@ -50,14 +50,15 @@ export function enrichChangedFunctions(
|
|
|
50
50
|
const callers: CallerRef[] = [];
|
|
51
51
|
for (const edge of graph.reverseAdjacency.get(node.qualified_name) || []) {
|
|
52
52
|
if (edge.kind !== 'CALLS') continue;
|
|
53
|
-
|
|
53
|
+
// null/undefined confidence = high confidence (edge came from DB or parser without scoring)
|
|
54
|
+
if ((edge.confidence ?? 1.0) < minConfidence) continue;
|
|
54
55
|
const sourceNode = graph.byQualified.get(edge.source_qualified);
|
|
55
56
|
callers.push({
|
|
56
57
|
qualified_name: edge.source_qualified,
|
|
57
58
|
name: sourceNode?.name || edge.source_qualified.split('::').pop() || 'unknown',
|
|
58
59
|
file_path: sourceNode?.file_path || edge.file_path,
|
|
59
60
|
line: edge.line,
|
|
60
|
-
confidence: edge.confidence
|
|
61
|
+
confidence: edge.confidence ?? 1.0,
|
|
61
62
|
});
|
|
62
63
|
}
|
|
63
64
|
|
package/src/cli.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { executeUpdate } from './commands/update';
|
|
|
13
13
|
|
|
14
14
|
const program = new Command();
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
import pkg from '../package.json';
|
|
17
|
+
program.name('kodus-graph').description('Code graph builder for Kodus code review').version(pkg.version);
|
|
17
18
|
|
|
18
19
|
program
|
|
19
20
|
.command('parse')
|
package/src/commands/analyze.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'fs';
|
|
2
|
-
import { resolve } from 'path';
|
|
2
|
+
import { resolve, relative } from 'path';
|
|
3
3
|
import { computeBlastRadius } from '../analysis/blast-radius';
|
|
4
4
|
import { computeRiskScore } from '../analysis/risk-score';
|
|
5
5
|
import { findTestGaps } from '../analysis/test-gaps';
|
|
6
6
|
import { buildGraphData } from '../graph/builder';
|
|
7
7
|
import { mergeGraphs } from '../graph/merger';
|
|
8
|
-
import type { AnalysisOutput, MainGraphInput } from '../graph/types';
|
|
8
|
+
import type { AnalysisOutput, ImportEdge, MainGraphInput } from '../graph/types';
|
|
9
9
|
import { parseBatch } from '../parser/batch';
|
|
10
10
|
import { discoverFiles } from '../parser/discovery';
|
|
11
|
+
import { resolveAllCalls } from '../resolver/call-resolver';
|
|
12
|
+
import { createImportMap } from '../resolver/import-map';
|
|
13
|
+
import { loadTsconfigAliases, resolveImport } from '../resolver/import-resolver';
|
|
14
|
+
import { buildReExportMap } from '../resolver/re-export-resolver';
|
|
15
|
+
import { createSymbolTable } from '../resolver/symbol-table';
|
|
16
|
+
import { computeFileHash } from '../shared/file-hash';
|
|
17
|
+
import { log } from '../shared/logger';
|
|
11
18
|
import { GraphInputSchema } from '../shared/schemas';
|
|
12
19
|
|
|
13
20
|
interface AnalyzeOptions {
|
|
@@ -46,7 +53,62 @@ export async function executeAnalyze(opts: AnalyzeOptions): Promise<void> {
|
|
|
46
53
|
// Parse changed files locally
|
|
47
54
|
const localFiles = discoverFiles(repoDir, opts.files);
|
|
48
55
|
const rawGraph = await parseBatch(localFiles, repoDir);
|
|
49
|
-
|
|
56
|
+
|
|
57
|
+
// Resolve imports
|
|
58
|
+
const tsconfigAliases = loadTsconfigAliases(repoDir);
|
|
59
|
+
const symbolTable = createSymbolTable();
|
|
60
|
+
const importMap = createImportMap();
|
|
61
|
+
const importEdges: ImportEdge[] = [];
|
|
62
|
+
|
|
63
|
+
for (const f of rawGraph.functions) symbolTable.add(f.file, f.name, f.qualified);
|
|
64
|
+
for (const c of rawGraph.classes) symbolTable.add(c.file, c.name, c.qualified);
|
|
65
|
+
for (const i of rawGraph.interfaces) symbolTable.add(i.file, i.name, i.qualified);
|
|
66
|
+
|
|
67
|
+
// Pre-resolve re-exports so barrel imports follow through to actual definitions
|
|
68
|
+
const barrelMap = buildReExportMap(rawGraph.reExports, repoDir, tsconfigAliases);
|
|
69
|
+
|
|
70
|
+
for (const imp of rawGraph.imports) {
|
|
71
|
+
const langKey = imp.lang === 'python' ? 'python' : imp.lang === 'ruby' ? 'ruby' : 'typescript';
|
|
72
|
+
const resolved = resolveImport(resolve(repoDir, imp.file), imp.module, langKey, repoDir, tsconfigAliases);
|
|
73
|
+
const resolvedRel = resolved ? relative(repoDir, resolved) : null;
|
|
74
|
+
importEdges.push({
|
|
75
|
+
source: imp.file,
|
|
76
|
+
target: resolvedRel || imp.module,
|
|
77
|
+
resolved: !!resolvedRel,
|
|
78
|
+
line: imp.line,
|
|
79
|
+
});
|
|
80
|
+
const target = resolvedRel || imp.module;
|
|
81
|
+
for (const name of imp.names) {
|
|
82
|
+
let finalTarget = target;
|
|
83
|
+
if (resolvedRel) {
|
|
84
|
+
const reExportedFiles = barrelMap.get(resolvedRel);
|
|
85
|
+
if (reExportedFiles) {
|
|
86
|
+
for (const reFile of reExportedFiles) {
|
|
87
|
+
if (symbolTable.lookupExact(reFile, name)) {
|
|
88
|
+
finalTarget = reFile;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
importMap.add(imp.file, name, finalTarget);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Resolve calls
|
|
99
|
+
const { callEdges } = resolveAllCalls(rawGraph.rawCalls, rawGraph.diMaps, symbolTable, importMap);
|
|
100
|
+
|
|
101
|
+
// Build graph with file hashes
|
|
102
|
+
const fileHashes = new Map<string, string>();
|
|
103
|
+
for (const f of localFiles) {
|
|
104
|
+
try {
|
|
105
|
+
fileHashes.set(relative(repoDir, f), computeFileHash(f));
|
|
106
|
+
} catch (err) {
|
|
107
|
+
log.warn('Failed to compute file hash', { file: f, error: String(err) });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const localGraphData = buildGraphData(rawGraph, callEdges, importEdges, repoDir, fileHashes);
|
|
50
112
|
|
|
51
113
|
// Merge with main graph (or use local only)
|
|
52
114
|
const mergedGraph = mainGraph ? mergeGraphs(mainGraph, localGraphData, opts.files) : localGraphData;
|
package/src/commands/parse.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { discoverFiles } from '../parser/discovery';
|
|
|
8
8
|
import { resolveAllCalls } from '../resolver/call-resolver';
|
|
9
9
|
import { createImportMap } from '../resolver/import-map';
|
|
10
10
|
import { loadTsconfigAliases, resolveImport } from '../resolver/import-resolver';
|
|
11
|
+
import { buildReExportMap } from '../resolver/re-export-resolver';
|
|
11
12
|
import { createSymbolTable } from '../resolver/symbol-table';
|
|
12
13
|
import { computeFileHash } from '../shared/file-hash';
|
|
13
14
|
import { log } from '../shared/logger';
|
|
@@ -45,6 +46,9 @@ export async function executeParse(opts: ParseOptions): Promise<void> {
|
|
|
45
46
|
for (const c of rawGraph.classes) symbolTable.add(c.file, c.name, c.qualified);
|
|
46
47
|
for (const i of rawGraph.interfaces) symbolTable.add(i.file, i.name, i.qualified);
|
|
47
48
|
|
|
49
|
+
// Pre-resolve re-exports so barrel imports follow through to actual definitions
|
|
50
|
+
const barrelMap = buildReExportMap(rawGraph.reExports, repoDir, tsconfigAliases);
|
|
51
|
+
|
|
48
52
|
for (const imp of rawGraph.imports) {
|
|
49
53
|
const langKey = imp.lang === 'python' ? 'python' : imp.lang === 'ruby' ? 'ruby' : 'typescript';
|
|
50
54
|
const resolved = resolveImport(resolve(repoDir, imp.file), imp.module, langKey, repoDir, tsconfigAliases);
|
|
@@ -56,7 +60,22 @@ export async function executeParse(opts: ParseOptions): Promise<void> {
|
|
|
56
60
|
line: imp.line,
|
|
57
61
|
});
|
|
58
62
|
const target = resolvedRel || imp.module;
|
|
59
|
-
for (const name of imp.names)
|
|
63
|
+
for (const name of imp.names) {
|
|
64
|
+
// If target is a barrel file, follow re-exports to find the actual definition
|
|
65
|
+
let finalTarget = target;
|
|
66
|
+
if (resolvedRel) {
|
|
67
|
+
const reExportedFiles = barrelMap.get(resolvedRel);
|
|
68
|
+
if (reExportedFiles) {
|
|
69
|
+
for (const reFile of reExportedFiles) {
|
|
70
|
+
if (symbolTable.lookupExact(reFile, name)) {
|
|
71
|
+
finalTarget = reFile;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
importMap.add(imp.file, name, finalTarget);
|
|
78
|
+
}
|
|
60
79
|
}
|
|
61
80
|
|
|
62
81
|
process.stderr.write(
|
|
@@ -153,15 +153,44 @@ function resolveByName(
|
|
|
153
153
|
return { target: candidates[0], confidence: 0.5, strategy: 'unique' };
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
// Strategy 4: Ambiguous (0.30)
|
|
156
|
+
// Strategy 4: Ambiguous (0.30) — pick closest candidate by directory proximity
|
|
157
157
|
const candidates = symbolTable.lookupGlobal(callName);
|
|
158
158
|
if (candidates.length > 1) {
|
|
159
|
-
|
|
159
|
+
const best = pickClosestCandidate(candidates, currentFile);
|
|
160
|
+
return { target: best, confidence: 0.3, strategy: 'ambiguous' };
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
return null;
|
|
163
164
|
}
|
|
164
165
|
|
|
166
|
+
// ── Proximity-based candidate selection ──
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Pick the candidate whose file path is closest to the caller's file.
|
|
170
|
+
* Counts shared leading path segments — more shared = closer.
|
|
171
|
+
*/
|
|
172
|
+
function pickClosestCandidate(candidates: string[], callerFile: string): string {
|
|
173
|
+
const callerParts = callerFile.split('/');
|
|
174
|
+
let best = candidates[0];
|
|
175
|
+
let bestScore = -1;
|
|
176
|
+
|
|
177
|
+
for (const candidate of candidates) {
|
|
178
|
+
const candidateFile = candidate.includes('::') ? candidate.split('::')[0] : candidate;
|
|
179
|
+
const parts = candidateFile.split('/');
|
|
180
|
+
let shared = 0;
|
|
181
|
+
for (let i = 0; i < Math.min(callerParts.length, parts.length); i++) {
|
|
182
|
+
if (callerParts[i] === parts[i]) shared++;
|
|
183
|
+
else break;
|
|
184
|
+
}
|
|
185
|
+
if (shared > bestScore) {
|
|
186
|
+
bestScore = shared;
|
|
187
|
+
best = candidate;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return best;
|
|
192
|
+
}
|
|
193
|
+
|
|
165
194
|
// ── Public wrapper for unit testing ──
|
|
166
195
|
|
|
167
196
|
export function resolveCall(
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export (barrel) resolver.
|
|
3
|
+
*
|
|
4
|
+
* Follows `export { X } from './module'` chains so that the import map
|
|
5
|
+
* points to the file where the symbol is actually defined, not the
|
|
6
|
+
* barrel index file.
|
|
7
|
+
*
|
|
8
|
+
* Without this, an import like `import { Foo } from '@lib'` resolves
|
|
9
|
+
* to `@lib/index.ts`, but `Foo` is defined in `@lib/foo.ts`.
|
|
10
|
+
* The call resolver can't find `Foo` in the barrel's symbol table and
|
|
11
|
+
* falls to lower-confidence tiers.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { relative, resolve } from 'path';
|
|
15
|
+
import { resolveImport } from './import-resolver';
|
|
16
|
+
|
|
17
|
+
interface RawReExport {
|
|
18
|
+
module: string;
|
|
19
|
+
file: string;
|
|
20
|
+
line: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a map: barrel file (relative) → list of resolved re-export target files (relative).
|
|
25
|
+
*
|
|
26
|
+
* Follows one level of re-exports (covers >95% of real-world barrel patterns).
|
|
27
|
+
*/
|
|
28
|
+
export function buildReExportMap(
|
|
29
|
+
reExports: RawReExport[],
|
|
30
|
+
repoDir: string,
|
|
31
|
+
tsconfigAliases?: Map<string, string[]>,
|
|
32
|
+
): Map<string, string[]> {
|
|
33
|
+
const barrelMap = new Map<string, string[]>();
|
|
34
|
+
|
|
35
|
+
for (const re of reExports) {
|
|
36
|
+
const absFrom = resolve(repoDir, re.file);
|
|
37
|
+
const resolved = resolveImport(absFrom, re.module, 'typescript', repoDir, tsconfigAliases);
|
|
38
|
+
if (!resolved) continue;
|
|
39
|
+
|
|
40
|
+
const resolvedRel = relative(repoDir, resolved);
|
|
41
|
+
const list = barrelMap.get(re.file);
|
|
42
|
+
if (list) {
|
|
43
|
+
if (!list.includes(resolvedRel)) list.push(resolvedRel);
|
|
44
|
+
} else {
|
|
45
|
+
barrelMap.set(re.file, [resolvedRel]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Follow one extra level: if a re-export target is itself a barrel, flatten
|
|
50
|
+
for (const [barrel, targets] of barrelMap) {
|
|
51
|
+
const extra: string[] = [];
|
|
52
|
+
for (const target of targets) {
|
|
53
|
+
const nested = barrelMap.get(target);
|
|
54
|
+
if (nested) {
|
|
55
|
+
for (const n of nested) {
|
|
56
|
+
if (n !== barrel && !targets.includes(n) && !extra.includes(n)) {
|
|
57
|
+
extra.push(n);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (extra.length > 0) targets.push(...extra);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return barrelMap;
|
|
66
|
+
}
|