@kodus/kodus-graph 0.2.3 → 0.2.5

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.5",
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,19 @@ 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
+ // Detect real changes vs. pure displacement using content_hash.
78
+ // content_hash is per-node (SHA256 of the function/class source text).
79
+ // If both have content_hash: definitive comparison — same hash = identical content.
80
+ // If either is missing (legacy data): fallback to size heuristic.
81
+ if (n.line_start !== newN.line_start || n.line_end !== newN.line_end) {
82
+ if (n.content_hash && newN.content_hash) {
83
+ if (n.content_hash !== newN.content_hash) changes.push('line_range');
84
+ } else {
85
+ const oldSize = n.line_end - n.line_start;
86
+ const newSize = newN.line_end - newN.line_start;
87
+ if (oldSize !== newSize) changes.push('line_range');
88
+ }
89
+ }
78
90
  if ((n.params || '') !== (newN.params || '')) changes.push('params');
79
91
  if ((n.return_type || '') !== (newN.return_type || '')) changes.push('return_type');
80
92
  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(
@@ -26,6 +26,7 @@ export function buildGraphData(
26
26
  return_type: f.returnType || undefined,
27
27
  is_test: false,
28
28
  file_hash: fileHashes.get(f.file) || '',
29
+ content_hash: f.content_hash,
29
30
  });
30
31
  }
31
32
 
@@ -41,6 +42,7 @@ export function buildGraphData(
41
42
  language: detectLang(c.file),
42
43
  is_test: false,
43
44
  file_hash: fileHashes.get(c.file) || '',
45
+ content_hash: c.content_hash,
44
46
  });
45
47
  }
46
48
 
@@ -56,6 +58,7 @@ export function buildGraphData(
56
58
  language: detectLang(i.file),
57
59
  is_test: false,
58
60
  file_hash: fileHashes.get(i.file) || '',
61
+ content_hash: i.content_hash,
59
62
  });
60
63
  }
61
64
 
@@ -71,6 +74,7 @@ export function buildGraphData(
71
74
  language: detectLang(e.file),
72
75
  is_test: false,
73
76
  file_hash: fileHashes.get(e.file) || '',
77
+ content_hash: e.content_hash,
74
78
  });
75
79
  }
76
80
 
@@ -86,6 +90,7 @@ export function buildGraphData(
86
90
  language: detectLang(t.file),
87
91
  is_test: true,
88
92
  file_hash: fileHashes.get(t.file) || '',
93
+ content_hash: t.content_hash,
89
94
  });
90
95
  }
91
96
 
@@ -19,6 +19,7 @@ export interface GraphNode {
19
19
  modifiers?: string;
20
20
  is_test: boolean;
21
21
  file_hash: string;
22
+ content_hash?: string;
22
23
  }
23
24
 
24
25
  // ── Graph edge (matches ast_edges table) ──
@@ -185,6 +186,7 @@ export interface RawFunction {
185
186
  kind: 'Function' | 'Method' | 'Constructor';
186
187
  className: string;
187
188
  qualified: string;
189
+ content_hash?: string;
188
190
  }
189
191
 
190
192
  export interface RawClass {
@@ -195,6 +197,7 @@ export interface RawClass {
195
197
  extends: string;
196
198
  implements: string;
197
199
  qualified: string;
200
+ content_hash?: string;
198
201
  }
199
202
 
200
203
  export interface RawInterface {
@@ -204,6 +207,7 @@ export interface RawInterface {
204
207
  line_end: number;
205
208
  methods: string[];
206
209
  qualified: string;
210
+ content_hash?: string;
207
211
  }
208
212
 
209
213
  export interface RawEnum {
@@ -212,6 +216,7 @@ export interface RawEnum {
212
216
  line_start: number;
213
217
  line_end: number;
214
218
  qualified: string;
219
+ content_hash?: string;
215
220
  }
216
221
 
217
222
  export interface RawTest {
@@ -220,6 +225,7 @@ export interface RawTest {
220
225
  line_start: number;
221
226
  line_end: number;
222
227
  qualified: string;
228
+ content_hash?: string;
223
229
  }
224
230
 
225
231
  export interface RawImport {
@@ -1,5 +1,6 @@
1
1
  import type { SgNode, SgRoot } from '@ast-grep/napi';
2
2
  import type { RawCallSite, RawGraph } from '../../graph/types';
3
+ import { computeContentHash } from '../../shared/file-hash';
3
4
  import { NOISE } from '../../shared/filters';
4
5
  import { log } from '../../shared/logger';
5
6
  import { LANG_KINDS } from '../languages';
@@ -24,6 +25,7 @@ export function extractGeneric(root: SgRoot, fp: string, lang: string, seen: Set
24
25
  extends: '',
25
26
  implements: '',
26
27
  qualified: `${fp}::${name}`,
28
+ content_hash: computeContentHash(node.text()),
27
29
  });
28
30
  }
29
31
  } catch (err) {
@@ -58,6 +60,7 @@ export function extractGeneric(root: SgRoot, fp: string, lang: string, seen: Set
58
60
  kind: className ? 'Method' : 'Function',
59
61
  className,
60
62
  qualified: className ? `${fp}::${className}.${name}` : `${fp}::${name}`,
63
+ content_hash: computeContentHash(node.text()),
61
64
  });
62
65
  }
63
66
  } catch (err) {
@@ -1,5 +1,6 @@
1
1
  import type { SgNode, SgRoot } from '@ast-grep/napi';
2
2
  import type { RawCallSite, RawGraph } from '../../graph/types';
3
+ import { computeContentHash } from '../../shared/file-hash';
3
4
  import { NOISE } from '../../shared/filters';
4
5
  import { LANG_KINDS } from '../languages';
5
6
 
@@ -28,6 +29,7 @@ export function extractPython(root: SgRoot, fp: string, seen: Set<string>, graph
28
29
  extends: extendsName,
29
30
  implements: '',
30
31
  qualified: `${fp}::${name}`,
32
+ content_hash: computeContentHash(node.text()),
31
33
  });
32
34
  }
33
35
 
@@ -55,6 +57,7 @@ export function extractPython(root: SgRoot, fp: string, seen: Set<string>, graph
55
57
  line_start: line,
56
58
  line_end: node.range().end.line,
57
59
  qualified: `${fp}::test:${name}`,
60
+ content_hash: computeContentHash(node.text()),
58
61
  });
59
62
  }
60
63
 
@@ -68,6 +71,7 @@ export function extractPython(root: SgRoot, fp: string, seen: Set<string>, graph
68
71
  kind: name === '__init__' ? 'Constructor' : className ? 'Method' : 'Function',
69
72
  className,
70
73
  qualified: className ? `${fp}::${className}.${name}` : `${fp}::${name}`,
74
+ content_hash: computeContentHash(node.text()),
71
75
  });
72
76
  }
73
77
 
@@ -1,5 +1,6 @@
1
1
  import type { SgNode, SgRoot } from '@ast-grep/napi';
2
2
  import type { RawCallSite, RawGraph } from '../../graph/types';
3
+ import { computeContentHash } from '../../shared/file-hash';
3
4
  import { NOISE } from '../../shared/filters';
4
5
  import { log } from '../../shared/logger';
5
6
  import { LANG_KINDS } from '../languages';
@@ -23,6 +24,7 @@ export function extractRuby(root: SgRoot, fp: string, seen: Set<string>, graph:
23
24
  extends: superclass,
24
25
  implements: '',
25
26
  qualified: `${fp}::${name}`,
27
+ content_hash: computeContentHash(node.text()),
26
28
  });
27
29
  }
28
30
 
@@ -39,6 +41,7 @@ export function extractRuby(root: SgRoot, fp: string, seen: Set<string>, graph:
39
41
  extends: '',
40
42
  implements: '',
41
43
  qualified: `${fp}::${name}`,
44
+ content_hash: computeContentHash(node.text()),
42
45
  });
43
46
  }
44
47
 
@@ -63,6 +66,7 @@ export function extractRuby(root: SgRoot, fp: string, seen: Set<string>, graph:
63
66
  kind: className ? 'Method' : 'Function',
64
67
  className,
65
68
  qualified: className ? `${fp}::${className}.${name}` : `${fp}::${name}`,
69
+ content_hash: computeContentHash(node.text()),
66
70
  });
67
71
  }
68
72
 
@@ -88,6 +92,7 @@ export function extractRuby(root: SgRoot, fp: string, seen: Set<string>, graph:
88
92
  line_start: m.range().start.line,
89
93
  line_end: m.range().end.line,
90
94
  qualified: `${fp}::test:${name}`,
95
+ content_hash: computeContentHash(m.text()),
91
96
  });
92
97
  }
93
98
  } catch (err) {
@@ -1,6 +1,7 @@
1
1
  import type { SgNode, SgRoot } from '@ast-grep/napi';
2
2
  import { Lang } from '@ast-grep/napi';
3
3
  import type { RawCallSite, RawGraph } from '../../graph/types';
4
+ import { computeContentHash } from '../../shared/file-hash';
4
5
  import { NOISE } from '../../shared/filters';
5
6
  import { LANG_KINDS } from '../languages';
6
7
 
@@ -52,6 +53,7 @@ export function extractTypeScript(
52
53
  extends: extendsName,
53
54
  implements: implementsName,
54
55
  qualified: `${fp}::${name}`,
56
+ content_hash: computeContentHash(node.text()),
55
57
  });
56
58
  }
57
59
  }
@@ -112,6 +114,7 @@ export function extractTypeScript(
112
114
  kind: 'Constructor',
113
115
  className,
114
116
  qualified: `${fp}::${className}.constructor`,
117
+ content_hash: computeContentHash(node.text()),
115
118
  });
116
119
  } else {
117
120
  graph.functions.push({
@@ -124,6 +127,7 @@ export function extractTypeScript(
124
127
  kind: className ? 'Method' : 'Function',
125
128
  className,
126
129
  qualified: className ? `${fp}::${className}.${name}` : `${fp}::${name}`,
130
+ content_hash: computeContentHash(node.text()),
127
131
  });
128
132
  }
129
133
  }
@@ -148,6 +152,7 @@ export function extractTypeScript(
148
152
  kind: 'Function',
149
153
  className: '',
150
154
  qualified: `${fp}::${name}`,
155
+ content_hash: computeContentHash(node.text()),
151
156
  });
152
157
  }
153
158
 
@@ -172,6 +177,7 @@ export function extractTypeScript(
172
177
  kind: 'Function',
173
178
  className: '',
174
179
  qualified: `${fp}::${name}`,
180
+ content_hash: computeContentHash(node.text()),
175
181
  });
176
182
  }
177
183
 
@@ -198,6 +204,7 @@ export function extractTypeScript(
198
204
  line_end: node.range().end.line,
199
205
  methods,
200
206
  qualified: `${fp}::${name}`,
207
+ content_hash: computeContentHash(node.text()),
201
208
  });
202
209
  }
203
210
 
@@ -213,6 +220,7 @@ export function extractTypeScript(
213
220
  line_start: node.range().start.line,
214
221
  line_end: node.range().end.line,
215
222
  qualified: `${fp}::${name}`,
223
+ content_hash: computeContentHash(node.text()),
216
224
  });
217
225
  }
218
226
 
@@ -288,6 +296,7 @@ export function extractTypeScript(
288
296
  line_start: m.range().start.line,
289
297
  line_end: m.range().end.line,
290
298
  qualified: `${fp}::test:${name}`,
299
+ content_hash: computeContentHash(m.text()),
291
300
  });
292
301
  }
293
302
  }
@@ -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
+ }
@@ -5,3 +5,8 @@ export function computeFileHash(filePath: string): string {
5
5
  const content = readFileSync(filePath);
6
6
  return createHash('sha256').update(content).digest('hex');
7
7
  }
8
+
9
+ /** Hash a node's source text (function body, class body, etc.) */
10
+ export function computeContentHash(sourceText: string): string {
11
+ return createHash('sha256').update(sourceText).digest('hex');
12
+ }
@@ -10,6 +10,7 @@ const GraphNodeSchema = z.object({
10
10
  language: z.string(),
11
11
  is_test: z.boolean(),
12
12
  file_hash: z.string(),
13
+ content_hash: z.string().optional(),
13
14
  parent_name: z.string().optional(),
14
15
  params: z.string().optional(),
15
16
  return_type: z.string().optional(),