@kodus/kodus-graph 0.2.9 → 0.2.10

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.
Files changed (39) hide show
  1. package/dist/analysis/blast-radius.d.ts +1 -1
  2. package/dist/analysis/blast-radius.js +19 -21
  3. package/dist/analysis/context-builder.js +13 -4
  4. package/dist/analysis/diff.d.ts +6 -0
  5. package/dist/analysis/diff.js +16 -1
  6. package/dist/analysis/enrich.d.ts +1 -1
  7. package/dist/analysis/enrich.js +37 -9
  8. package/dist/analysis/prompt-formatter.js +8 -1
  9. package/dist/cli.js +2 -0
  10. package/dist/commands/analyze.js +5 -3
  11. package/dist/commands/diff.js +2 -2
  12. package/dist/commands/parse.d.ts +1 -0
  13. package/dist/commands/parse.js +3 -3
  14. package/dist/commands/update.js +2 -2
  15. package/dist/graph/builder.d.ts +5 -1
  16. package/dist/graph/builder.js +35 -3
  17. package/dist/graph/edges.d.ts +5 -1
  18. package/dist/graph/edges.js +61 -7
  19. package/dist/graph/types.d.ts +3 -0
  20. package/dist/parser/batch.d.ts +1 -0
  21. package/dist/parser/batch.js +18 -3
  22. package/dist/parser/languages.js +1 -0
  23. package/dist/resolver/external-detector.d.ts +11 -0
  24. package/dist/resolver/external-detector.js +820 -0
  25. package/dist/resolver/fs-cache.d.ts +8 -0
  26. package/dist/resolver/fs-cache.js +36 -0
  27. package/dist/resolver/import-resolver.js +130 -32
  28. package/dist/resolver/languages/csharp.d.ts +2 -0
  29. package/dist/resolver/languages/csharp.js +69 -6
  30. package/dist/resolver/languages/go.js +8 -7
  31. package/dist/resolver/languages/java.js +102 -17
  32. package/dist/resolver/languages/php.js +26 -5
  33. package/dist/resolver/languages/python.js +79 -3
  34. package/dist/resolver/languages/ruby.d.ts +16 -1
  35. package/dist/resolver/languages/ruby.js +58 -7
  36. package/dist/resolver/languages/rust.js +8 -7
  37. package/dist/resolver/languages/typescript.d.ts +8 -0
  38. package/dist/resolver/languages/typescript.js +193 -17
  39. package/package.json +1 -1
@@ -1,2 +1,2 @@
1
1
  import type { BlastRadiusResult, GraphData } from '../graph/types';
2
- export declare function computeBlastRadius(graph: GraphData, changedFiles: string[], maxDepth?: number): BlastRadiusResult;
2
+ export declare function computeBlastRadius(graph: GraphData, changedQualifiedNames: string[], maxDepth?: number, minConfidence?: number): BlastRadiusResult;
@@ -1,30 +1,28 @@
1
- export function computeBlastRadius(graph, changedFiles, maxDepth = 2) {
2
- // Build adjacency list from CALLS edges (callers of changed nodes)
1
+ export function computeBlastRadius(graph, changedQualifiedNames, maxDepth = 2, minConfidence) {
2
+ const minConf = minConfidence ?? 0.5;
3
+ // Build adjacency list filtering by confidence
3
4
  const adj = new Map();
4
- for (const edge of graph.edges) {
5
- if (edge.kind !== 'CALLS' && edge.kind !== 'IMPORTS') {
6
- continue;
7
- }
8
- // Reverse direction: target -> source (who calls/imports this?)
9
- if (!adj.has(edge.target_qualified)) {
10
- adj.set(edge.target_qualified, new Set());
5
+ const addEdge = (from, to) => {
6
+ if (!adj.has(from)) {
7
+ adj.set(from, new Set());
11
8
  }
12
- adj.get(edge.target_qualified).add(edge.source_qualified);
13
- // Forward direction too for IMPORTS
9
+ adj.get(from).add(to);
10
+ };
11
+ for (const edge of graph.edges) {
14
12
  if (edge.kind === 'IMPORTS') {
15
- if (!adj.has(edge.source_qualified)) {
16
- adj.set(edge.source_qualified, new Set());
17
- }
18
- adj.get(edge.source_qualified).add(edge.target_qualified);
13
+ // IMPORTS: no confidence filter, bidirectional
14
+ addEdge(edge.target_qualified, edge.source_qualified);
15
+ addEdge(edge.source_qualified, edge.target_qualified);
16
+ }
17
+ else if (edge.kind === 'CALLS' && (edge.confidence ?? 1.0) >= minConf) {
18
+ // CALLS: only edges with sufficient confidence, reverse direction
19
+ addEdge(edge.target_qualified, edge.source_qualified);
19
20
  }
20
21
  }
21
- // Seed: all nodes in changed files
22
- const changedSet = new Set(changedFiles);
23
- const seeds = graph.nodes.filter((n) => changedSet.has(n.file_path)).map((n) => n.qualified_name);
24
- // BFS
25
- const visited = new Set(seeds);
22
+ // Seeds: qualified names directly (no more file-level)
23
+ const visited = new Set(changedQualifiedNames);
26
24
  const byDepth = {};
27
- let frontier = seeds;
25
+ let frontier = [...changedQualifiedNames];
28
26
  for (let depth = 1; depth <= maxDepth; depth++) {
29
27
  const next = [];
30
28
  for (const node of frontier) {
@@ -18,13 +18,22 @@ export function buildContextV2(opts) {
18
18
  const newNodesInChanged = mergedGraph.nodes.filter((n) => changedSet.has(n.file_path));
19
19
  const newEdgesInChanged = mergedGraph.edges.filter((e) => changedSet.has(e.file_path));
20
20
  const structuralDiff = computeStructuralDiff(oldIndexed, newNodesInChanged, newEdgesInChanged, changedFiles);
21
- const blastRadius = computeBlastRadius(mergedGraph, changedFiles, maxDepth);
21
+ // Extract truly changed qualified names from structural diff (added + modified + removed)
22
+ const trulyChangedQN = new Set([
23
+ ...structuralDiff.nodes.added.map((n) => n.qualified_name),
24
+ ...structuralDiff.nodes.modified.map((n) => n.qualified_name),
25
+ ...structuralDiff.nodes.removed.map((n) => n.qualified_name),
26
+ ]);
27
+ const blastRadius = computeBlastRadius(mergedGraph, [...trulyChangedQN], maxDepth, minConfidence);
22
28
  const allFlows = detectFlows(indexed, { maxDepth: 10, type: 'all' });
23
29
  const testGaps = opts.skipTests ? [] : findTestGaps(mergedGraph, changedFiles);
24
30
  const risk = computeRiskScore(mergedGraph, changedFiles, blastRadius, { skipTests: opts.skipTests });
25
31
  const inheritance = extractInheritance(indexed, changedFiles);
26
- // Phase 3: Filter affected flows
27
- const changedFuncSet = new Set(mergedGraph.nodes.filter((n) => changedSet.has(n.file_path) && !n.is_test).map((n) => n.qualified_name));
32
+ // Phase 3: Filter affected flows — only truly changed (added+modified+removed), non-test functions
33
+ const changedFuncSet = new Set([...trulyChangedQN].filter((qn) => {
34
+ const node = indexed.byQualified.get(qn);
35
+ return node && !node.is_test;
36
+ }));
28
37
  const affectedFlows = [];
29
38
  for (const flow of allFlows.flows) {
30
39
  const touches = flow.path.filter((qn) => changedFuncSet.has(qn));
@@ -39,7 +48,7 @@ export function buildContextV2(opts) {
39
48
  }
40
49
  }
41
50
  // Phase 3: Enrichment
42
- const enriched = enrichChangedFunctions(indexed, changedFiles, structuralDiff, allFlows.flows, minConfidence);
51
+ const enriched = enrichChangedFunctions(indexed, changedFiles, structuralDiff, allFlows.flows, minConfidence, true);
43
52
  // Phase 4: Assembly
44
53
  const totalCallers = enriched.reduce((s, f) => s + f.callers.length, 0);
45
54
  const totalCallees = enriched.reduce((s, f) => s + f.callees.length, 0);
@@ -7,9 +7,15 @@ export interface NodeChange {
7
7
  line_start: number;
8
8
  line_end: number;
9
9
  }
10
+ export interface ContractDiff {
11
+ field: 'params' | 'return_type' | 'modifiers';
12
+ old_value: string;
13
+ new_value: string;
14
+ }
10
15
  export interface ModifiedNode {
11
16
  qualified_name: string;
12
17
  changes: string[];
18
+ contract_diffs: ContractDiff[];
13
19
  }
14
20
  export interface DiffResult {
15
21
  changed_files: string[];
@@ -84,14 +84,29 @@ export function computeStructuralDiff(oldGraph, newNodes, newEdges, changedFiles
84
84
  });
85
85
  }
86
86
  }
87
+ const contractDiffs = [];
87
88
  if ((n.params || '') !== (newN.params || '')) {
88
89
  changes.push('params');
90
+ contractDiffs.push({ field: 'params', old_value: n.params || '()', new_value: newN.params || '()' });
89
91
  }
90
92
  if ((n.return_type || '') !== (newN.return_type || '')) {
91
93
  changes.push('return_type');
94
+ contractDiffs.push({
95
+ field: 'return_type',
96
+ old_value: n.return_type || 'void',
97
+ new_value: newN.return_type || 'void',
98
+ });
99
+ }
100
+ if ((n.modifiers || '') !== (newN.modifiers || '')) {
101
+ changes.push('modifiers');
102
+ contractDiffs.push({
103
+ field: 'modifiers',
104
+ old_value: n.modifiers || '',
105
+ new_value: newN.modifiers || '',
106
+ });
92
107
  }
93
108
  if (changes.length > 0) {
94
- modified.push({ qualified_name: qn, changes });
109
+ modified.push({ qualified_name: qn, changes, contract_diffs: contractDiffs });
95
110
  }
96
111
  }
97
112
  }
@@ -2,4 +2,4 @@ import type { IndexedGraph } from '../graph/loader';
2
2
  import type { EnrichedFunction } from '../graph/types';
3
3
  import type { DiffResult } from './diff';
4
4
  import type { Flow } from './flows';
5
- export declare function enrichChangedFunctions(graph: IndexedGraph, changedFiles: string[], diff: DiffResult, allFlows: Flow[], minConfidence: number): EnrichedFunction[];
5
+ export declare function enrichChangedFunctions(graph: IndexedGraph, changedFiles: string[], diff: DiffResult, allFlows: Flow[], minConfidence: number, onlyChanged?: boolean): EnrichedFunction[];
@@ -1,8 +1,8 @@
1
- export function enrichChangedFunctions(graph, changedFiles, diff, allFlows, minConfidence) {
1
+ export function enrichChangedFunctions(graph, changedFiles, diff, allFlows, minConfidence, onlyChanged = false) {
2
2
  const changedSet = new Set(changedFiles);
3
3
  // Pre-index diff results
4
4
  const addedSet = new Set(diff.nodes.added.map((n) => n.qualified_name));
5
- const modifiedMap = new Map(diff.nodes.modified.map((m) => [m.qualified_name, m.changes]));
5
+ const modifiedMap = new Map(diff.nodes.modified.map((m) => [m.qualified_name, m]));
6
6
  // Pre-index TESTED_BY
7
7
  const testedFiles = new Set(graph.edges.filter((e) => e.kind === 'TESTED_BY').map((e) => e.source_qualified));
8
8
  // Pre-index flows by function
@@ -21,12 +21,22 @@ export function enrichChangedFunctions(graph, changedFiles, diff, allFlows, minC
21
21
  }
22
22
  }
23
23
  // Filter functions in changed files
24
- const changedFunctions = graph.nodes.filter((n) => changedSet.has(n.file_path) &&
25
- !n.is_test &&
26
- n.kind !== 'Constructor' &&
27
- n.kind !== 'Class' &&
28
- n.kind !== 'Interface' &&
29
- n.kind !== 'Enum');
24
+ const changedFunctions = graph.nodes.filter((n) => {
25
+ if (!changedSet.has(n.file_path)) {
26
+ return false;
27
+ }
28
+ if (n.is_test ||
29
+ n.kind === 'Constructor' ||
30
+ n.kind === 'Class' ||
31
+ n.kind === 'Interface' ||
32
+ n.kind === 'Enum') {
33
+ return false;
34
+ }
35
+ if (onlyChanged) {
36
+ return addedSet.has(n.qualified_name) || modifiedMap.has(n.qualified_name);
37
+ }
38
+ return true;
39
+ });
30
40
  return changedFunctions
31
41
  .sort((a, b) => a.file_path.localeCompare(b.file_path) || a.line_start - b.line_start)
32
42
  .map((node) => {
@@ -78,7 +88,23 @@ export function enrichChangedFunctions(graph, changedFiles, diff, allFlows, minC
78
88
  const signature = `${shortName}${params}${ret}`;
79
89
  // Diff
80
90
  const isNew = addedSet.has(node.qualified_name);
81
- const diffChanges = isNew ? [] : modifiedMap.get(node.qualified_name) || [];
91
+ const modifiedNode = modifiedMap.get(node.qualified_name);
92
+ const diffChanges = isNew ? [] : modifiedNode?.changes || [];
93
+ const contractDiffs = isNew ? [] : (modifiedNode?.contract_diffs ?? []);
94
+ // Caller impact
95
+ let callerImpact;
96
+ if (contractDiffs.length > 0 && callers.length > 0) {
97
+ const impacts = [];
98
+ const paramsDiff = contractDiffs.find((d) => d.field === 'params');
99
+ const returnDiff = contractDiffs.find((d) => d.field === 'return_type');
100
+ if (paramsDiff) {
101
+ impacts.push(`${callers.length} callers may need param update`);
102
+ }
103
+ if (returnDiff) {
104
+ impacts.push(`${callers.length} callers may assume old return type`);
105
+ }
106
+ callerImpact = impacts.length > 0 ? impacts.join('; ') : undefined;
107
+ }
82
108
  return {
83
109
  qualified_name: node.qualified_name,
84
110
  name: node.name,
@@ -91,6 +117,8 @@ export function enrichChangedFunctions(graph, changedFiles, diff, allFlows, minC
91
117
  callees,
92
118
  has_test_coverage: testedFiles.has(node.file_path),
93
119
  diff_changes: diffChanges,
120
+ contract_diffs: contractDiffs,
121
+ caller_impact: callerImpact,
94
122
  is_new: isNew,
95
123
  in_flows: flowsByFunction.get(node.qualified_name) || [],
96
124
  };
@@ -20,7 +20,14 @@ export function formatPrompt(output) {
20
20
  lines.push('Status: new');
21
21
  }
22
22
  else if (fn.diff_changes.length > 0) {
23
- lines.push(`Status: modified (${fn.diff_changes.join(', ')})`);
23
+ lines.push('Status: modified');
24
+ lines.push(` Changes: ${fn.diff_changes.join(', ')}`);
25
+ for (const cd of fn.contract_diffs) {
26
+ lines.push(` - ${cd.field}: ${cd.old_value} -> ${cd.new_value}`);
27
+ }
28
+ if (fn.caller_impact) {
29
+ lines.push(` Impact: ${fn.caller_impact}`);
30
+ }
24
31
  }
25
32
  else {
26
33
  lines.push('Status: unchanged');
package/dist/cli.js CHANGED
@@ -24,6 +24,7 @@ program
24
24
  .option('--include <glob...>', 'Include only files matching glob (repeatable)')
25
25
  .option('--exclude <glob...>', 'Exclude files matching glob (repeatable)')
26
26
  .option('--skip-tests', 'Skip test detection (no Test nodes, TESTED_BY edges, or test gaps)')
27
+ .option('--max-memory <mb>', 'Maximum memory usage in MB (default: 768)', (v) => parseInt(v, 10))
27
28
  .requiredOption('--out <path>', 'Output JSON file path')
28
29
  .action(async (opts) => {
29
30
  const repoDir = resolve(opts.repoDir);
@@ -39,6 +40,7 @@ program
39
40
  include: opts.include,
40
41
  exclude: opts.exclude,
41
42
  skipTests: opts.skipTests ?? false,
43
+ maxMemoryMB: opts.maxMemory,
42
44
  });
43
45
  });
44
46
  program
@@ -60,7 +60,7 @@ export async function executeAnalyze(opts) {
60
60
  // Pre-resolve re-exports so barrel imports follow through to actual definitions
61
61
  const barrelMap = buildReExportMap(rawGraph.reExports, repoDir, tsconfigAliases);
62
62
  for (const imp of rawGraph.imports) {
63
- const langKey = imp.lang === 'python' ? 'python' : imp.lang === 'ruby' ? 'ruby' : 'typescript';
63
+ const langKey = imp.lang;
64
64
  const resolved = resolveImport(resolve(repoDir, imp.file), imp.module, langKey, repoDir, tsconfigAliases);
65
65
  const resolvedRel = resolved ? relative(repoDir, resolved) : null;
66
66
  importEdges.push({
@@ -98,11 +98,13 @@ export async function executeAnalyze(opts) {
98
98
  log.warn('Failed to compute file hash', { file: f, error: String(err) });
99
99
  }
100
100
  }
101
- const localGraphData = buildGraphData(rawGraph, callEdges, importEdges, repoDir, fileHashes);
101
+ const localGraphData = buildGraphData(rawGraph, callEdges, importEdges, repoDir, fileHashes, symbolTable, importMap);
102
102
  // Merge with main graph (or use local only)
103
103
  const mergedGraph = mainGraph ? mergeGraphs(mainGraph, localGraphData, opts.files) : localGraphData;
104
104
  // Analyze
105
- const blastRadius = computeBlastRadius(mergedGraph, opts.files);
105
+ // Temporary: convert file-level to function-level until Mudança 3 provides trulyChangedQN
106
+ const changedQN = mergedGraph.nodes.filter((n) => opts.files.includes(n.file_path)).map((n) => n.qualified_name);
107
+ const blastRadius = computeBlastRadius(mergedGraph, changedQN);
106
108
  const riskScore = computeRiskScore(mergedGraph, opts.files, blastRadius, { skipTests: opts.skipTests });
107
109
  const testGaps = opts.skipTests ? [] : findTestGaps(mergedGraph, opts.files);
108
110
  const output = {
@@ -57,7 +57,7 @@ export async function executeDiff(opts) {
57
57
  symbolTable.add(i.file, i.name, i.qualified);
58
58
  }
59
59
  for (const imp of rawGraph.imports) {
60
- const langKey = imp.lang === 'python' ? 'python' : imp.lang === 'ruby' ? 'ruby' : 'typescript';
60
+ const langKey = imp.lang;
61
61
  const resolved = resolveImport(resolve(repoDir, imp.file), imp.module, langKey, repoDir, tsconfigAliases);
62
62
  const resolvedRel = resolved ? relative(repoDir, resolved) : null;
63
63
  importEdges.push({
@@ -79,7 +79,7 @@ export async function executeDiff(opts) {
79
79
  }
80
80
  catch { }
81
81
  }
82
- const newGraphData = buildGraphData(rawGraph, callEdges, importEdges, repoDir, fileHashes);
82
+ const newGraphData = buildGraphData(rawGraph, callEdges, importEdges, repoDir, fileHashes, symbolTable, importMap);
83
83
  process.stderr.write(`[3/4] Re-parsed ${absFiles.length} files (${newGraphData.nodes.length} nodes)\n`);
84
84
  // Compute diff
85
85
  const relChangedFiles = changedFiles.map((f) => (f.startsWith('/') ? relative(repoDir, f) : f));
@@ -6,5 +6,6 @@ export interface ParseOptions {
6
6
  include?: string[];
7
7
  exclude?: string[];
8
8
  skipTests?: boolean;
9
+ maxMemoryMB?: number;
9
10
  }
10
11
  export declare function executeParse(opts: ParseOptions): Promise<void>;
@@ -18,7 +18,7 @@ export async function executeParse(opts) {
18
18
  const files = discoverFiles(repoDir, opts.all ? undefined : opts.files, opts.include, opts.exclude);
19
19
  process.stderr.write(`[1/5] Discovered ${files.length} files\n`);
20
20
  // Phase 2: Parse + extract
21
- let rawGraph = await parseBatch(files, repoDir, { skipTests: opts.skipTests });
21
+ let rawGraph = await parseBatch(files, repoDir, { skipTests: opts.skipTests, maxMemoryMB: opts.maxMemoryMB });
22
22
  process.stderr.write(`[2/5] Parsed ${rawGraph.functions.length} functions, ${rawGraph.classes.length} classes, ${rawGraph.rawCalls.length} call sites\n`);
23
23
  // Phase 3: Resolve imports
24
24
  const tsconfigAliases = loadTsconfigAliases(repoDir);
@@ -37,7 +37,7 @@ export async function executeParse(opts) {
37
37
  // Pre-resolve re-exports so barrel imports follow through to actual definitions
38
38
  const barrelMap = buildReExportMap(rawGraph.reExports, repoDir, tsconfigAliases);
39
39
  for (const imp of rawGraph.imports) {
40
- const langKey = imp.lang === 'python' ? 'python' : imp.lang === 'ruby' ? 'ruby' : 'typescript';
40
+ const langKey = imp.lang;
41
41
  const resolved = resolveImport(resolve(repoDir, imp.file), imp.module, langKey, repoDir, tsconfigAliases);
42
42
  const resolvedRel = resolved ? relative(repoDir, resolved) : null;
43
43
  importEdges.push({
@@ -80,7 +80,7 @@ export async function executeParse(opts) {
80
80
  }
81
81
  const parseErrors = rawGraph.parseErrors;
82
82
  const extractErrors = rawGraph.extractErrors;
83
- const graphData = buildGraphData(rawGraph, callEdges, importEdges, repoDir, fileHashes);
83
+ const graphData = buildGraphData(rawGraph, callEdges, importEdges, repoDir, fileHashes, symbolTable, importMap);
84
84
  process.stderr.write(`[5/5] Built graph: ${graphData.nodes.length} nodes, ${graphData.edges.length} edges\n`);
85
85
  // Release intermediaries — no longer needed after buildGraphData
86
86
  rawGraph = null;
@@ -97,7 +97,7 @@ export async function executeUpdate(opts) {
97
97
  symbolTable.add(i.file, i.name, i.qualified);
98
98
  }
99
99
  for (const imp of rawGraph.imports) {
100
- const langKey = imp.lang === 'python' ? 'python' : imp.lang === 'ruby' ? 'ruby' : 'typescript';
100
+ const langKey = imp.lang;
101
101
  const resolved = resolveImport(resolve(repoDir, imp.file), imp.module, langKey, repoDir, tsconfigAliases);
102
102
  const resolvedRel = resolved ? relative(repoDir, resolved) : null;
103
103
  importEdges.push({
@@ -119,7 +119,7 @@ export async function executeUpdate(opts) {
119
119
  }
120
120
  catch { }
121
121
  }
122
- const newGraphData = buildGraphData(rawGraph, callEdges, importEdges, repoDir, fileHashes);
122
+ const newGraphData = buildGraphData(rawGraph, callEdges, importEdges, repoDir, fileHashes, symbolTable, importMap);
123
123
  process.stderr.write(`[4/5] Built new graph fragment (${newGraphData.nodes.length} nodes)\n`);
124
124
  // Merge: keep old nodes/edges NOT in changed/deleted files, add new ones
125
125
  const changedOrDeleted = new Set([...toReparse, ...deleted]);
@@ -1,2 +1,6 @@
1
1
  import type { GraphData, ImportEdge, RawCallEdge, RawGraph } from './types';
2
- export declare function buildGraphData(raw: RawGraph, callEdges: RawCallEdge[], importEdges: ImportEdge[], _repoDir: string, fileHashes: Map<string, string>): GraphData;
2
+ export declare function buildGraphData(raw: RawGraph, callEdges: RawCallEdge[], importEdges: ImportEdge[], _repoDir: string, fileHashes: Map<string, string>, symbolTable?: {
3
+ lookupGlobal(name: string): string[];
4
+ }, importMap?: {
5
+ lookup(file: string, name: string): string | null;
6
+ }): GraphData;
@@ -1,5 +1,5 @@
1
1
  import { deriveEdges } from './edges';
2
- export function buildGraphData(raw, callEdges, importEdges, _repoDir, fileHashes) {
2
+ export function buildGraphData(raw, callEdges, importEdges, _repoDir, fileHashes, symbolTable, importMap) {
3
3
  const nodes = [];
4
4
  const edges = [];
5
5
  // Functions -> nodes
@@ -85,6 +85,23 @@ export function buildGraphData(raw, callEdges, importEdges, _repoDir, fileHashes
85
85
  content_hash: t.content_hash,
86
86
  });
87
87
  }
88
+ // Build a set of all parsed file paths for validation (filter external targets)
89
+ const parsedFiles = new Set();
90
+ for (const f of raw.functions) {
91
+ parsedFiles.add(f.file);
92
+ }
93
+ for (const c of raw.classes) {
94
+ parsedFiles.add(c.file);
95
+ }
96
+ for (const i of raw.interfaces) {
97
+ parsedFiles.add(i.file);
98
+ }
99
+ for (const e of raw.enums) {
100
+ parsedFiles.add(e.file);
101
+ }
102
+ for (const t of raw.tests) {
103
+ parsedFiles.add(t.file);
104
+ }
88
105
  // Build file→functions index to resolve caller from line number
89
106
  const functionsByFile = new Map();
90
107
  for (const node of nodes) {
@@ -106,6 +123,11 @@ export function buildGraphData(raw, callEdges, importEdges, _repoDir, fileHashes
106
123
  }
107
124
  // CALLS edges — resolve caller function from call line number
108
125
  for (const ce of callEdges) {
126
+ // Skip calls to external packages (target file not in repo)
127
+ const targetFile = ce.target.split('::')[0];
128
+ if (targetFile && !parsedFiles.has(targetFile)) {
129
+ continue;
130
+ }
109
131
  const sourceFile = ce.source.includes('::') ? ce.source.split('::')[0] : ce.source;
110
132
  let sourceQualified;
111
133
  if (ce.source.includes('::')) {
@@ -134,8 +156,11 @@ export function buildGraphData(raw, callEdges, importEdges, _repoDir, fileHashes
134
156
  confidence: ce.confidence,
135
157
  });
136
158
  }
137
- // IMPORTS edges
159
+ // IMPORTS edges — only emit resolved imports (skip external/unresolved packages)
138
160
  for (const ie of importEdges) {
161
+ if (!ie.resolved) {
162
+ continue;
163
+ }
139
164
  edges.push({
140
165
  kind: 'IMPORTS',
141
166
  source_qualified: ie.source,
@@ -145,7 +170,14 @@ export function buildGraphData(raw, callEdges, importEdges, _repoDir, fileHashes
145
170
  });
146
171
  }
147
172
  // Derived edges
148
- const derived = deriveEdges(raw, importEdges);
173
+ const derived = deriveEdges(raw, importEdges, symbolTable, importMap);
174
+ // Release raw graph arrays — no longer needed after deriveEdges
175
+ raw.functions = [];
176
+ raw.classes = [];
177
+ raw.interfaces = [];
178
+ raw.enums = [];
179
+ raw.tests = [];
180
+ raw.rawCalls = [];
149
181
  for (const e of derived.inherits) {
150
182
  edges.push({
151
183
  kind: 'INHERITS',
@@ -15,5 +15,9 @@ export interface DerivedEdges {
15
15
  * prefixes/suffixes. Returns null if no test pattern was found.
16
16
  */
17
17
  export declare function extractTestStem(testFile: string): string | null;
18
- export declare function deriveEdges(graph: RawGraph, importEdges: ImportEdge[]): DerivedEdges;
18
+ export declare function deriveEdges(graph: RawGraph, importEdges: ImportEdge[], symbolTable?: {
19
+ lookupGlobal(name: string): string[];
20
+ }, importMap?: {
21
+ lookup(file: string, name: string): string | null;
22
+ }): DerivedEdges;
19
23
  export {};
@@ -21,16 +21,70 @@ export function extractTestStem(testFile) {
21
21
  }
22
22
  return cleaned;
23
23
  }
24
- export function deriveEdges(graph, importEdges) {
25
- // INHERITS: class extends another class
26
- const inherits = graph.classes
27
- .filter((c) => c.extends)
28
- .map((c) => ({ source: c.qualified, target: c.extends, file: c.file }));
29
- // IMPLEMENTS: class implements interface(s)
24
+ /**
25
+ * Resolve a bare type name (e.g. "User", "IAuthService") to its qualified name
26
+ * using import map, same-file lookup, and global symbol table.
27
+ * Returns null if the name cannot be resolved (external class/interface).
28
+ */
29
+ function resolveTypeName(name, file, graph, symbolTable, importMap) {
30
+ // 1. Check import map — was it imported in this file?
31
+ const importedFrom = importMap?.lookup(file, name);
32
+ if (importedFrom) {
33
+ // Look up the qualified name in the imported file
34
+ const candidates = symbolTable?.lookupGlobal(name) ?? [];
35
+ const match = candidates.find((q) => q.startsWith(`${importedFrom}::`));
36
+ if (match) {
37
+ return match;
38
+ }
39
+ // If importedFrom is not a local file (not in graph), it's an external package — skip
40
+ const isLocal = graph.classes.some((c) => c.file === importedFrom) ||
41
+ graph.interfaces.some((i) => i.file === importedFrom) ||
42
+ graph.functions.some((f) => f.file === importedFrom);
43
+ if (!isLocal) {
44
+ return null;
45
+ }
46
+ // Fallback: construct qualified name from local import target
47
+ return `${importedFrom}::${name}`;
48
+ }
49
+ // 2. Check same file — class or interface defined in same file
50
+ const sameFileClass = graph.classes.find((other) => other.name === name && other.file === file);
51
+ if (sameFileClass) {
52
+ return sameFileClass.qualified;
53
+ }
54
+ const sameFileInterface = graph.interfaces.find((other) => other.name === name && other.file === file);
55
+ if (sameFileInterface) {
56
+ return sameFileInterface.qualified;
57
+ }
58
+ // 3. Check global symbol table — unique match only
59
+ const globalCandidates = symbolTable?.lookupGlobal(name) ?? [];
60
+ if (globalCandidates.length === 1) {
61
+ return globalCandidates[0];
62
+ }
63
+ // 4. External class/interface (React.Component, Error, etc.) — unresolvable
64
+ return null;
65
+ }
66
+ export function deriveEdges(graph, importEdges, symbolTable, importMap) {
67
+ // INHERITS: class extends another class — resolve to qualified names
68
+ const inherits = [];
69
+ for (const c of graph.classes) {
70
+ if (!c.extends) {
71
+ continue;
72
+ }
73
+ const resolved = resolveTypeName(c.extends, c.file, graph, symbolTable, importMap);
74
+ if (resolved) {
75
+ inherits.push({ source: c.qualified, target: resolved, file: c.file });
76
+ }
77
+ // Skip unresolvable external classes (React.Component, Error, etc.)
78
+ }
79
+ // IMPLEMENTS: class implements interface(s) — resolve to qualified names
30
80
  const implements_ = [];
31
81
  for (const c of graph.classes) {
32
82
  for (const iface of c.implements) {
33
- implements_.push({ source: c.qualified, target: iface, file: c.file });
83
+ const resolved = resolveTypeName(iface, c.file, graph, symbolTable, importMap);
84
+ if (resolved) {
85
+ implements_.push({ source: c.qualified, target: resolved, file: c.file });
86
+ }
87
+ // Skip unresolvable external interfaces
34
88
  }
35
89
  }
36
90
  // TESTED_BY: two heuristics, deduplicated
@@ -1,3 +1,4 @@
1
+ import type { ContractDiff } from '../analysis/diff';
1
2
  export type NodeKind = 'Function' | 'Method' | 'Constructor' | 'Class' | 'Interface' | 'Enum' | 'Test';
2
3
  export type EdgeKind = 'CALLS' | 'IMPORTS' | 'INHERITS' | 'IMPLEMENTS' | 'TESTED_BY' | 'CONTAINS';
3
4
  export interface GraphNode {
@@ -122,6 +123,8 @@ export interface EnrichedFunction {
122
123
  callees: CalleeRef[];
123
124
  has_test_coverage: boolean;
124
125
  diff_changes: string[];
126
+ contract_diffs: ContractDiff[];
127
+ caller_impact?: string;
125
128
  is_new: boolean;
126
129
  in_flows: string[];
127
130
  }
@@ -1,4 +1,5 @@
1
1
  import type { ParseBatchResult } from '../graph/types';
2
2
  export declare function parseBatch(files: string[], repoRoot: string, options?: {
3
3
  skipTests?: boolean;
4
+ maxMemoryMB?: number;
4
5
  }): Promise<ParseBatchResult>;
@@ -5,7 +5,8 @@ import { NOISE } from '../shared/filters';
5
5
  import { log } from '../shared/logger';
6
6
  import { extractCallsFromFile, extractFromFile } from './extractor';
7
7
  import { getLanguage } from './languages';
8
- const BATCH_SIZE = 50;
8
+ const INITIAL_BATCH = 50;
9
+ const MEMORY_THRESHOLD_RATIO = 0.7;
9
10
  export async function parseBatch(files, repoRoot, options) {
10
11
  const graph = {
11
12
  functions: [],
@@ -21,8 +22,10 @@ export async function parseBatch(files, repoRoot, options) {
21
22
  const seen = new Set();
22
23
  let parseErrors = 0;
23
24
  let extractErrors = 0;
24
- for (let i = 0; i < files.length; i += BATCH_SIZE) {
25
- const batch = files.slice(i, i + BATCH_SIZE);
25
+ let batchSize = INITIAL_BATCH;
26
+ const maxMemBytes = (options?.maxMemoryMB ?? 768) * 1024 * 1024;
27
+ for (let i = 0; i < files.length; i += batchSize) {
28
+ const batch = files.slice(i, i + batchSize);
26
29
  const promises = batch.map(async (filePath) => {
27
30
  const lang = getLanguage(extname(filePath));
28
31
  if (!lang) {
@@ -70,6 +73,18 @@ export async function parseBatch(files, repoRoot, options) {
70
73
  }
71
74
  });
72
75
  await Promise.all(promises);
76
+ // Dynamic batch sizing: reduce if memory pressure detected
77
+ const rss = process.memoryUsage().rss;
78
+ if (rss > maxMemBytes * MEMORY_THRESHOLD_RATIO) {
79
+ const oldBatch = batchSize;
80
+ batchSize = Math.max(5, Math.floor(batchSize / 2));
81
+ log.warn('Memory pressure detected, reducing batch size', {
82
+ rssMB: Math.round(rss / 1024 / 1024),
83
+ maxMB: Math.round(maxMemBytes / 1024 / 1024),
84
+ oldBatchSize: oldBatch,
85
+ newBatchSize: batchSize,
86
+ });
87
+ }
73
88
  }
74
89
  if (options?.skipTests) {
75
90
  graph.tests = [];
@@ -242,6 +242,7 @@ function derivedKinds(config) {
242
242
  result.method = firstOf(config.method);
243
243
  }
244
244
  if (firstOf(config.constructorKinds)) {
245
+ // biome-ignore lint/complexity/useLiteralKeys: bracket notation required — dot notation resolves to Function.prototype.constructor (TS2322)
245
246
  result['constructor'] = firstOf(config.constructorKinds);
246
247
  }
247
248
  if (firstOf(config.interface)) {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * External package detector.
3
+ * Reads dependency manifests (package.json, requirements.txt, go.mod, etc.)
4
+ * to determine if an import target is an external (third-party) package.
5
+ */
6
+ export declare function clearExternalCache(): void;
7
+ /**
8
+ * Check if an import is an external (third-party) package.
9
+ * Returns the package name if external, null if not detected as external.
10
+ */
11
+ export declare function detectExternal(modulePath: string, lang: string, repoRoot: string): string | null;