@kodus/kodus-graph 0.2.10 → 0.2.11

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.
@@ -1,2 +1,13 @@
1
1
  import type { ContextV2Output } from './context-builder';
2
+ /**
3
+ * Compact prompt format optimized for LLM agent consumption.
4
+ *
5
+ * Design principles (derived from Langsmith trace analysis):
6
+ * - Agent forms hypotheses on FIRST LLM call using graph + diff → dense signal, no noise
7
+ * - Agent then uses grep/readFile with names from the graph → names must be grepable (file:line)
8
+ * - Inheritance enables cross-class comparison (e.g. sibling method implementations) → keep hierarchy
9
+ * - Test Gaps list and Structural Changes were never referenced by agent → removed
10
+ * - Contract changes on callers are high-value signals → inline with ⚠
11
+ * - Flows show how HTTP/test paths cross changed code → inline per function
12
+ */
2
13
  export declare function formatPrompt(output: ContextV2Output): string;
@@ -1,173 +1,201 @@
1
+ /**
2
+ * Compact prompt format optimized for LLM agent consumption.
3
+ *
4
+ * Design principles (derived from Langsmith trace analysis):
5
+ * - Agent forms hypotheses on FIRST LLM call using graph + diff → dense signal, no noise
6
+ * - Agent then uses grep/readFile with names from the graph → names must be grepable (file:line)
7
+ * - Inheritance enables cross-class comparison (e.g. sibling method implementations) → keep hierarchy
8
+ * - Test Gaps list and Structural Changes were never referenced by agent → removed
9
+ * - Contract changes on callers are high-value signals → inline with ⚠
10
+ * - Flows show how HTTP/test paths cross changed code → inline per function
11
+ */
1
12
  export function formatPrompt(output) {
2
13
  const { analysis } = output;
3
14
  const lines = [];
4
- // Header
5
15
  const risk = analysis.risk;
6
16
  const br = analysis.blast_radius;
7
17
  const meta = analysis.metadata;
8
- lines.push('# Code Review Context');
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`);
9
20
  lines.push('');
10
- lines.push(`Risk: ${risk.level} (${risk.score}) | ${br.total_functions} functions impacted across ${br.total_files} files | ${meta.untested_count} untested`);
11
- lines.push('');
12
- // Changed functions
21
+ // ── Changed functions ──
13
22
  if (analysis.changed_functions.length > 0) {
14
- lines.push('## Changed Functions');
15
- lines.push('');
23
+ lines.push('CHANGED:');
24
+ // Build a set of qualified names that have siblings (same method name in sibling classes)
25
+ const siblingMap = buildSiblingMap(analysis, output);
16
26
  for (const fn of analysis.changed_functions) {
17
- lines.push(`### ${fn.signature} [${fn.file_path}:${fn.line_start}-${fn.line_end}]`);
18
- // Status
19
- if (fn.is_new) {
20
- lines.push('Status: new');
21
- }
22
- else if (fn.diff_changes.length > 0) {
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
- }
31
- }
32
- else {
33
- lines.push('Status: unchanged');
34
- }
35
- // Callers
27
+ const status = fn.is_new ? 'new' : fn.diff_changes.length > 0 ? 'modified' : 'unchanged';
28
+ const tested = fn.has_test_coverage ? 'tested' : 'untested';
29
+ // Main line
30
+ lines.push(` ${fn.signature} [${fn.file_path}:${fn.line_start}-${fn.line_end}] ${status} | ${fn.callers.length} callers | ${tested}`);
31
+ // Contract changes — high value for agent to spot breaking changes
32
+ for (const cd of fn.contract_diffs) {
33
+ lines.push(` ⚠ ${cd.field}: ${cd.old_value} → ${cd.new_value}`);
34
+ }
35
+ if (fn.caller_impact) {
36
+ lines.push(` ${fn.caller_impact}`);
37
+ }
38
+ // Callers (← notation) — top N, then summary
36
39
  if (fn.callers.length > 0) {
37
- lines.push('Callers:');
38
- for (const c of fn.callers) {
39
- const conf = c.confidence < 0.85 ? ` confidence=${c.confidence.toFixed(2)}` : '';
40
- lines.push(` - ${c.name} [${c.file_path}:${c.line}]${conf}`);
40
+ const MAX_CALLERS = 5;
41
+ const shown = fn.callers.slice(0, MAX_CALLERS);
42
+ for (const c of shown) {
43
+ const conf = c.confidence < 0.85 ? ` ~${Math.round(c.confidence * 100)}%` : '';
44
+ lines.push(` ← ${c.name} [${c.file_path}:${c.line}]${conf}`);
45
+ }
46
+ if (fn.callers.length > MAX_CALLERS) {
47
+ const remaining = fn.callers.slice(MAX_CALLERS);
48
+ const uniqueFiles = new Set(remaining.map((c) => c.file_path)).size;
49
+ lines.push(` ... +${remaining.length} callers in ${uniqueFiles} files`);
41
50
  }
42
51
  }
43
- else {
44
- lines.push('Callers: none');
45
- }
46
- // Callees
52
+ // Callees (→ compact chain)
47
53
  if (fn.callees.length > 0) {
48
- lines.push('Callees:');
49
- for (const c of fn.callees) {
50
- lines.push(` - ${c.signature} [${c.file_path}]`);
54
+ const MAX_CALLEES = 8;
55
+ const names = fn.callees.slice(0, MAX_CALLEES).map((c) => c.name);
56
+ let calleeLine = ` ${names.join(', ')}`;
57
+ if (fn.callees.length > MAX_CALLEES) {
58
+ calleeLine += `, ... +${fn.callees.length - MAX_CALLEES}`;
51
59
  }
60
+ lines.push(calleeLine);
52
61
  }
53
- else {
54
- lines.push('Callees: none');
62
+ // Similar: sibling class with same method name — enables cross-class comparison
63
+ const siblings = siblingMap.get(fn.qualified_name);
64
+ if (siblings && siblings.length > 0) {
65
+ for (const sib of siblings) {
66
+ lines.push(` similar: ${sib.name} [${sib.file_path}:${sib.line_start}]`);
67
+ }
55
68
  }
56
- // Test coverage
57
- lines.push(`Test coverage: ${fn.has_test_coverage ? 'yes' : 'no'}`);
58
- // Affected flows
69
+ // Affected flows inline
59
70
  if (fn.in_flows.length > 0) {
60
- lines.push('Affected flows:');
71
+ const MAX_FLOWS = 3;
72
+ let flowCount = 0;
61
73
  for (const ep of fn.in_flows) {
74
+ if (flowCount >= MAX_FLOWS) {
75
+ lines.push(` ... +${fn.in_flows.length - MAX_FLOWS} flows`);
76
+ break;
77
+ }
62
78
  const flow = analysis.affected_flows.find((f) => f.entry_point === ep);
63
79
  if (flow) {
64
80
  const prefix = flow.type === 'http' ? 'HTTP' : 'TEST';
65
- lines.push(` - ${prefix}: ${flow.path.map((q) => q.split('::').pop()).join(' → ')}`);
66
- }
67
- else {
68
- lines.push(` - ${ep.split('::').pop()}`);
81
+ const path = flow.path.map((q) => shortName(q)).join(' → ');
82
+ lines.push(` flow: ${prefix} ${path}`);
69
83
  }
84
+ flowCount++;
70
85
  }
71
86
  }
72
- else {
73
- lines.push('Affected flows: none');
74
- }
75
87
  lines.push('');
76
88
  }
77
89
  }
78
- // Inheritance
90
+ // ── Hierarchy (compact) ──
79
91
  if (analysis.inheritance.length > 0) {
80
- lines.push('## Inheritance');
81
- lines.push('');
92
+ lines.push('HIERARCHY:');
82
93
  for (const entry of analysis.inheritance) {
83
- const name = entry.qualified_name.split('::').pop();
94
+ const name = shortName(entry.qualified_name);
84
95
  const parts = [];
85
96
  if (entry.extends) {
86
- parts.push(`extends ${entry.extends.split('::').pop()}`);
97
+ parts.push(`extends ${shortName(entry.extends)}`);
87
98
  }
88
99
  if (entry.implements.length > 0) {
89
- parts.push(`implements ${entry.implements.map((i) => i.split('::').pop()).join(', ')}`);
100
+ parts.push(`impl ${entry.implements.map((i) => shortName(i)).join(', ')}`);
101
+ }
102
+ let line = ` ${name}`;
103
+ if (parts.length > 0) {
104
+ line += ` ${parts.join(' | ')}`;
90
105
  }
91
- lines.push(`- ${name} ${parts.join(', ')}`);
92
106
  if (entry.children.length > 0) {
93
- lines.push(` Children: ${entry.children.map((c) => c.split('::').pop()).join(', ')}`);
107
+ line += ` | children: ${entry.children.map((c) => shortName(c)).join(', ')}`;
94
108
  }
109
+ lines.push(line);
95
110
  }
96
111
  lines.push('');
97
112
  }
98
- // Blast radius by depth
113
+ // ── Blast radius by depth (compact) ──
99
114
  const byDepth = analysis.blast_radius.by_depth;
100
115
  const depthKeys = Object.keys(byDepth).sort();
101
116
  if (depthKeys.length > 0) {
102
- lines.push('## Blast Radius');
103
- lines.push('');
117
+ lines.push('BLAST RADIUS:');
104
118
  for (const depth of depthKeys) {
105
- const names = byDepth[depth].map((q) => q.split('::').pop());
106
- lines.push(`Depth ${depth}: ${names.join(', ')} (${names.length} functions)`);
119
+ const qnames = byDepth[depth];
120
+ const names = qnames.map((q) => shortName(q));
121
+ const MAX_SHOW = 8;
122
+ if (names.length <= MAX_SHOW) {
123
+ lines.push(` depth ${depth}: ${names.join(', ')} (${names.length})`);
124
+ }
125
+ else {
126
+ const shown = names.slice(0, MAX_SHOW);
127
+ lines.push(` depth ${depth}: ${shown.join(', ')} ... +${names.length - MAX_SHOW} (${names.length} total)`);
128
+ }
107
129
  }
108
130
  lines.push('');
109
131
  }
110
- // Test gaps
111
- if (analysis.test_gaps.length > 0) {
112
- lines.push('## Test Gaps');
113
- lines.push('');
114
- for (const gap of analysis.test_gaps) {
115
- const name = gap.function.split('::').pop();
116
- lines.push(`- ${name} [${gap.file_path}:${gap.line_start}]`);
132
+ return lines.join('\n');
133
+ }
134
+ // ── Helpers ──
135
+ /** Extract short name from qualified_name (e.g. "mod::Class::method" → "method") */
136
+ function shortName(qualifiedName) {
137
+ return qualifiedName.split('::').pop() || qualifiedName;
138
+ }
139
+ /**
140
+ * Build a map of changed functions → sibling implementations.
141
+ * A "sibling" is a function with the same method name in a class that shares
142
+ * the same parent (extends same base). This enables cross-class comparison
143
+ * (e.g. OptimizedCursorPaginator.get_item_key vs DateTimePaginator.get_item_key).
144
+ *
145
+ * Uses full graph edges (not just analysis.inheritance which is filtered to changed files).
146
+ */
147
+ function buildSiblingMap(analysis, output) {
148
+ const result = new Map();
149
+ // Build parent→children index from ALL INHERITS edges in the graph (not just changed files)
150
+ const parentToChildren = new Map();
151
+ for (const edge of output.graph.edges) {
152
+ if (edge.kind !== 'INHERITS') {
153
+ continue;
117
154
  }
118
- lines.push('');
155
+ const existing = parentToChildren.get(edge.target_qualified) || [];
156
+ existing.push(edge.source_qualified);
157
+ parentToChildren.set(edge.target_qualified, existing);
119
158
  }
120
- // Structural diff
121
- const diff = analysis.structural_diff;
122
- const hasNodeChanges = diff.summary.added > 0 || diff.summary.removed > 0 || diff.summary.modified > 0;
123
- const hasEdgeChanges = diff.edges.added.length > 0 || diff.edges.removed.length > 0;
124
- if (hasNodeChanges || hasEdgeChanges) {
125
- lines.push('## Structural Changes');
126
- lines.push('');
127
- if (hasNodeChanges) {
128
- const parts = [];
129
- if (diff.summary.added > 0) {
130
- parts.push(`${diff.summary.added} added`);
131
- }
132
- if (diff.summary.removed > 0) {
133
- parts.push(`${diff.summary.removed} removed`);
134
- }
135
- if (diff.summary.modified > 0) {
136
- parts.push(`${diff.summary.modified} modified`);
137
- }
138
- lines.push(parts.join(', '));
159
+ // Index nodes by qualified name for fast lookup
160
+ const nodeByQN = new Map(output.graph.nodes.map((n) => [n.qualified_name, n]));
161
+ // For each changed function, find if its class has siblings with the same method
162
+ const changedQNs = new Set(analysis.changed_functions.map((f) => f.qualified_name));
163
+ for (const fn of analysis.changed_functions) {
164
+ // Extract class name from qualified_name (e.g. "file::Class::method" → "file::Class")
165
+ const parts = fn.qualified_name.split('::');
166
+ if (parts.length < 3) {
167
+ continue; // need at least file::class::method
139
168
  }
140
- if (diff.nodes.removed.length > 0) {
141
- lines.push('');
142
- lines.push('Removed:');
143
- for (const n of diff.nodes.removed) {
144
- const name = n.qualified_name.split('::').pop();
145
- lines.push(` - [${n.kind}] ${name} [${n.file_path}:${n.line_start}]`);
146
- }
147
- }
148
- if (diff.nodes.modified.length > 0) {
149
- lines.push('');
150
- lines.push('Modified:');
151
- for (const m of diff.nodes.modified) {
152
- const name = m.qualified_name.split('::').pop();
153
- lines.push(` - ${name} (${m.changes.join(', ')})`);
154
- }
169
+ const methodName = parts[parts.length - 1];
170
+ const className = parts.slice(0, -1).join('::');
171
+ // Find what this class extends (from INHERITS edges)
172
+ const parentEdge = output.graph.edges.find((e) => e.kind === 'INHERITS' && e.source_qualified === className);
173
+ if (!parentEdge) {
174
+ continue;
155
175
  }
156
- if (hasEdgeChanges) {
157
- lines.push('');
158
- lines.push('Dependency changes:');
159
- for (const e of diff.edges.added) {
160
- const src = e.source_qualified.split('::').pop();
161
- const tgt = e.target_qualified.split('::').pop();
162
- lines.push(` + ${e.kind}: ${src} ${tgt}`);
163
- }
164
- for (const e of diff.edges.removed) {
165
- const src = e.source_qualified.split('::').pop();
166
- const tgt = e.target_qualified.split('::').pop();
167
- lines.push(` - ${e.kind}: ${src} → ${tgt}`);
176
+ // Find sibling classes (same parent)
177
+ const siblings = parentToChildren.get(parentEdge.target_qualified) || [];
178
+ for (const siblingClass of siblings) {
179
+ if (siblingClass === className) {
180
+ continue;
181
+ }
182
+ // Look for same method name in sibling class
183
+ const siblingMethodQN = `${siblingClass}::${methodName}`;
184
+ // Don't list if the sibling is also in changed functions (it's already shown)
185
+ if (changedQNs.has(siblingMethodQN)) {
186
+ continue;
187
+ }
188
+ const siblingNode = nodeByQN.get(siblingMethodQN);
189
+ if (siblingNode) {
190
+ const existing = result.get(fn.qualified_name) || [];
191
+ existing.push({
192
+ name: `${shortName(siblingClass)}.${methodName}`,
193
+ file_path: siblingNode.file_path,
194
+ line_start: siblingNode.line_start,
195
+ });
196
+ result.set(fn.qualified_name, existing);
168
197
  }
169
198
  }
170
- lines.push('');
171
199
  }
172
- return lines.join('\n');
200
+ return result;
173
201
  }
@@ -145,7 +145,10 @@ export function buildGraphData(raw, callEdges, importEdges, _repoDir, fileHashes
145
145
  }
146
146
  }
147
147
  }
148
- sourceQualified = resolved || `${ce.source}::unknown`;
148
+ if (!resolved) {
149
+ continue; // Skip top-level calls with no enclosing function
150
+ }
151
+ sourceQualified = resolved;
149
152
  }
150
153
  edges.push({
151
154
  kind: 'CALLS',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kodus/kodus-graph",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
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",