@kodus/kodus-graph 0.2.11 → 0.2.13

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.
@@ -37,27 +37,55 @@ export function enrichChangedFunctions(graph, changedFiles, diff, allFlows, minC
37
37
  }
38
38
  return true;
39
39
  });
40
+ // Pre-index INHERITS edges: child → parent qualified name
41
+ const childToParent = new Map();
42
+ for (const edge of graph.edges) {
43
+ if (edge.kind === 'INHERITS') {
44
+ childToParent.set(edge.source_qualified, edge.target_qualified);
45
+ }
46
+ }
40
47
  return changedFunctions
41
48
  .sort((a, b) => a.file_path.localeCompare(b.file_path) || a.line_start - b.line_start)
42
49
  .map((node) => {
43
- // Callers
50
+ // Callers — include both direct callers AND callers of the overridden parent method.
51
+ // When code calls `base.method()`, the edge points to the parent class method,
52
+ // so overrides show 0 callers without this inheritance resolution.
44
53
  const callers = [];
45
- for (const edge of graph.reverseAdjacency.get(node.qualified_name) || []) {
46
- if (edge.kind !== 'CALLS') {
47
- continue;
54
+ const seenCallers = new Set();
55
+ const collectCallers = (targetQN) => {
56
+ for (const edge of graph.reverseAdjacency.get(targetQN) || []) {
57
+ if (edge.kind !== 'CALLS') {
58
+ continue;
59
+ }
60
+ if (seenCallers.has(edge.source_qualified)) {
61
+ continue;
62
+ }
63
+ if ((edge.confidence ?? 1.0) < minConfidence) {
64
+ continue;
65
+ }
66
+ seenCallers.add(edge.source_qualified);
67
+ const sourceNode = graph.byQualified.get(edge.source_qualified);
68
+ callers.push({
69
+ qualified_name: edge.source_qualified,
70
+ name: sourceNode?.name || edge.source_qualified.split('::').pop() || 'unknown',
71
+ file_path: sourceNode?.file_path || edge.file_path,
72
+ line: edge.line,
73
+ confidence: edge.confidence ?? 1.0,
74
+ });
48
75
  }
49
- // null/undefined confidence = high confidence (edge came from DB or parser without scoring)
50
- if ((edge.confidence ?? 1.0) < minConfidence) {
51
- continue;
76
+ };
77
+ // Direct callers
78
+ collectCallers(node.qualified_name);
79
+ // Inherited callers: if this is a method override, also collect callers of the parent method.
80
+ // e.g. OptimizedCursorPaginator::get_result inherits callers of BasePaginator::get_result
81
+ const qnParts = node.qualified_name.split('::');
82
+ if (qnParts.length >= 3) {
83
+ const methodName = qnParts[qnParts.length - 1];
84
+ const className = qnParts.slice(0, -1).join('::');
85
+ const parentClass = childToParent.get(className);
86
+ if (parentClass) {
87
+ collectCallers(`${parentClass}::${methodName}`);
52
88
  }
53
- const sourceNode = graph.byQualified.get(edge.source_qualified);
54
- callers.push({
55
- qualified_name: edge.source_qualified,
56
- name: sourceNode?.name || edge.source_qualified.split('::').pop() || 'unknown',
57
- file_path: sourceNode?.file_path || edge.file_path,
58
- line: edge.line,
59
- confidence: edge.confidence ?? 1.0,
60
- });
61
89
  }
62
90
  // Callees
63
91
  const callees = [];
@@ -15,8 +15,9 @@ export function formatPrompt(output) {
15
15
  const risk = analysis.risk;
16
16
  const br = analysis.blast_radius;
17
17
  const meta = analysis.metadata;
18
- // ── Header: one-line stats ──
19
- lines.push(`${meta.changed_functions_count} changed | ${br.total_functions} impacted | ${br.total_files} files | risk ${risk.level} ${risk.score} | ${meta.untested_count} untested`);
18
+ // ── Header: one-line stats (untested scoped to changed functions only) ──
19
+ const changedUntested = analysis.changed_functions.filter((f) => !f.has_test_coverage).length;
20
+ lines.push(`${meta.changed_functions_count} changed (${changedUntested} untested) | ${br.total_functions} impacted | ${br.total_files} files | risk ${risk.level} ${risk.score}`);
20
21
  lines.push('');
21
22
  // ── Changed functions ──
22
23
  if (analysis.changed_functions.length > 0) {
@@ -26,8 +27,10 @@ export function formatPrompt(output) {
26
27
  for (const fn of analysis.changed_functions) {
27
28
  const status = fn.is_new ? 'new' : fn.diff_changes.length > 0 ? 'modified' : 'unchanged';
28
29
  const tested = fn.has_test_coverage ? 'tested' : 'untested';
30
+ // Class-qualified signature for methods (language-agnostic via qualified_name)
31
+ const displayName = classQualifiedSignature(fn.qualified_name, fn.signature);
29
32
  // Main line
30
- lines.push(` ${fn.signature} [${fn.file_path}:${fn.line_start}-${fn.line_end}] ${status} | ${fn.callers.length} callers | ${tested}`);
33
+ lines.push(` ${displayName} [${fn.file_path}:${fn.line_start}-${fn.line_end}] ${status} | ${fn.callers.length} callers | ${tested}`);
31
34
  // Contract changes — high value for agent to spot breaking changes
32
35
  for (const cd of fn.contract_diffs) {
33
36
  lines.push(` ⚠ ${cd.field}: ${cd.old_value} → ${cd.new_value}`);
@@ -87,6 +90,15 @@ export function formatPrompt(output) {
87
90
  lines.push('');
88
91
  }
89
92
  }
93
+ // ── Imports for changed files (helps agent spot missing/new dependencies) ──
94
+ const importLines = buildImportsSection(output, analysis);
95
+ if (importLines.length > 0) {
96
+ lines.push('IMPORTS:');
97
+ for (const line of importLines) {
98
+ lines.push(line);
99
+ }
100
+ lines.push('');
101
+ }
90
102
  // ── Hierarchy (compact) ──
91
103
  if (analysis.inheritance.length > 0) {
92
104
  lines.push('HIERARCHY:');
@@ -136,6 +148,20 @@ export function formatPrompt(output) {
136
148
  function shortName(qualifiedName) {
137
149
  return qualifiedName.split('::').pop() || qualifiedName;
138
150
  }
151
+ /**
152
+ * Build class-qualified signature for methods.
153
+ * "file::Class::method" + "method(params) -> ret" → "Class.method(params) -> ret"
154
+ * For top-level functions ("file::func"), returns signature as-is.
155
+ * Language-agnostic: works for any language since qualified_name always uses "::" separator.
156
+ */
157
+ function classQualifiedSignature(qualifiedName, signature) {
158
+ const parts = qualifiedName.split('::');
159
+ if (parts.length < 3) {
160
+ return signature; // top-level function, no class
161
+ }
162
+ const className = parts[parts.length - 2];
163
+ return `${className}.${signature}`;
164
+ }
139
165
  /**
140
166
  * Build a map of changed functions → sibling implementations.
141
167
  * A "sibling" is a function with the same method name in a class that shares
@@ -199,3 +225,53 @@ function buildSiblingMap(analysis, output) {
199
225
  }
200
226
  return result;
201
227
  }
228
+ /**
229
+ * Build compact IMPORTS section for changed files.
230
+ * Shows each import edge from a changed file with:
231
+ * - NEW tag if the import was added in this change (not in oldGraph)
232
+ * - ⚠ UNRESOLVED if the import target has no corresponding node in the graph
233
+ * Groups by source file for readability.
234
+ */
235
+ function buildImportsSection(output, analysis) {
236
+ const changedFiles = new Set(analysis.structural_diff.changed_files);
237
+ // Collect IMPORTS edges from changed files
238
+ const importEdges = output.graph.edges.filter((e) => e.kind === 'IMPORTS' && changedFiles.has(e.file_path));
239
+ if (importEdges.length === 0) {
240
+ return [];
241
+ }
242
+ // Set of new import edges (added in this diff)
243
+ const newImportKeys = new Set(analysis.structural_diff.edges.added
244
+ .filter((e) => e.kind === 'IMPORTS')
245
+ .map((e) => `${e.source_qualified}→${e.target_qualified}`));
246
+ // Set of all node qualified names — to detect unresolved targets
247
+ const allNodes = new Set(output.graph.nodes.map((n) => n.qualified_name));
248
+ // Group by source file
249
+ const byFile = new Map();
250
+ for (const edge of importEdges) {
251
+ const existing = byFile.get(edge.file_path) || [];
252
+ existing.push(edge);
253
+ byFile.set(edge.file_path, existing);
254
+ }
255
+ const lines = [];
256
+ for (const [filePath, edges] of byFile) {
257
+ for (const edge of edges) {
258
+ const key = `${edge.source_qualified}→${edge.target_qualified}`;
259
+ const tags = [];
260
+ if (newImportKeys.has(key)) {
261
+ tags.push('NEW');
262
+ }
263
+ // Check if target exists as a node in the graph
264
+ // For IMPORTS, target_qualified is usually "file::Symbol".
265
+ // If neither the exact target nor any node starting with the target exists, it's unresolved.
266
+ const targetExists = allNodes.has(edge.target_qualified) ||
267
+ // Also check if the target is a file-level reference (e.g. "src/utils.ts::default")
268
+ [...allNodes].some((qn) => qn.startsWith(`${edge.target_qualified}::`));
269
+ if (!targetExists) {
270
+ tags.push('⚠ UNRESOLVED');
271
+ }
272
+ const tagStr = tags.length > 0 ? ` (${tags.join(', ')})` : '';
273
+ lines.push(` ${filePath} → ${shortName(edge.target_qualified)}${tagStr}`);
274
+ }
275
+ }
276
+ return lines;
277
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kodus/kodus-graph",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
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
  "main": "./dist/cli.js",