@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kodus/kodus-graph",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Code graph builder for Kodus code review — parses source code into structural graphs with nodes, edges, and analysis",
5
5
  "type": "module",
6
6
  "bin": {
@@ -74,7 +74,10 @@ export function computeStructuralDiff(
74
74
  } else {
75
75
  const newN = newNodesMap.get(qn)!;
76
76
  const changes: string[] = [];
77
- if (n.line_start !== newN.line_start || n.line_end !== newN.line_end) changes.push('line_range');
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 });
@@ -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
- if ((edge.confidence ?? 0) < minConfidence) continue;
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 || 0,
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
- program.name('kodus-graph').description('Code graph builder for Kodus code review').version('0.2.1');
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')
@@ -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
- const localGraphData = buildGraphData(rawGraph, [], [], repoDir, new Map());
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;
@@ -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) importMap.add(imp.file, name, target);
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
- return { target: callName, confidence: 0.3, strategy: 'ambiguous' };
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
+ }