@kodus/kodus-graph 0.2.10 → 0.2.12
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/dist/analysis/enrich.js +43 -15
- package/dist/analysis/prompt-formatter.d.ts +11 -0
- package/dist/analysis/prompt-formatter.js +164 -119
- package/dist/graph/builder.js +4 -1
- package/package.json +1 -1
package/dist/analysis/enrich.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 = [];
|
|
@@ -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,218 @@
|
|
|
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
|
-
|
|
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}`);
|
|
9
21
|
lines.push('');
|
|
10
|
-
|
|
11
|
-
lines.push('');
|
|
12
|
-
// Changed functions
|
|
22
|
+
// ── Changed functions ──
|
|
13
23
|
if (analysis.changed_functions.length > 0) {
|
|
14
|
-
lines.push('
|
|
15
|
-
|
|
24
|
+
lines.push('CHANGED:');
|
|
25
|
+
// Build a set of qualified names that have siblings (same method name in sibling classes)
|
|
26
|
+
const siblingMap = buildSiblingMap(analysis, output);
|
|
16
27
|
for (const fn of analysis.changed_functions) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
else {
|
|
33
|
-
lines.push('Status: unchanged');
|
|
34
|
-
}
|
|
35
|
-
// Callers
|
|
28
|
+
const status = fn.is_new ? 'new' : fn.diff_changes.length > 0 ? 'modified' : 'unchanged';
|
|
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);
|
|
32
|
+
// Main line
|
|
33
|
+
lines.push(` ${displayName} [${fn.file_path}:${fn.line_start}-${fn.line_end}] ${status} | ${fn.callers.length} callers | ${tested}`);
|
|
34
|
+
// Contract changes — high value for agent to spot breaking changes
|
|
35
|
+
for (const cd of fn.contract_diffs) {
|
|
36
|
+
lines.push(` ⚠ ${cd.field}: ${cd.old_value} → ${cd.new_value}`);
|
|
37
|
+
}
|
|
38
|
+
if (fn.caller_impact) {
|
|
39
|
+
lines.push(` ⚠ ${fn.caller_impact}`);
|
|
40
|
+
}
|
|
41
|
+
// Callers (← notation) — top N, then summary
|
|
36
42
|
if (fn.callers.length > 0) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
const MAX_CALLERS = 5;
|
|
44
|
+
const shown = fn.callers.slice(0, MAX_CALLERS);
|
|
45
|
+
for (const c of shown) {
|
|
46
|
+
const conf = c.confidence < 0.85 ? ` ~${Math.round(c.confidence * 100)}%` : '';
|
|
47
|
+
lines.push(` ← ${c.name} [${c.file_path}:${c.line}]${conf}`);
|
|
48
|
+
}
|
|
49
|
+
if (fn.callers.length > MAX_CALLERS) {
|
|
50
|
+
const remaining = fn.callers.slice(MAX_CALLERS);
|
|
51
|
+
const uniqueFiles = new Set(remaining.map((c) => c.file_path)).size;
|
|
52
|
+
lines.push(` ... +${remaining.length} callers in ${uniqueFiles} files`);
|
|
41
53
|
}
|
|
42
54
|
}
|
|
43
|
-
|
|
44
|
-
lines.push('Callers: none');
|
|
45
|
-
}
|
|
46
|
-
// Callees
|
|
55
|
+
// Callees (→ compact chain)
|
|
47
56
|
if (fn.callees.length > 0) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
const MAX_CALLEES = 8;
|
|
58
|
+
const names = fn.callees.slice(0, MAX_CALLEES).map((c) => c.name);
|
|
59
|
+
let calleeLine = ` → ${names.join(', ')}`;
|
|
60
|
+
if (fn.callees.length > MAX_CALLEES) {
|
|
61
|
+
calleeLine += `, ... +${fn.callees.length - MAX_CALLEES}`;
|
|
51
62
|
}
|
|
63
|
+
lines.push(calleeLine);
|
|
52
64
|
}
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
// Similar: sibling class with same method name — enables cross-class comparison
|
|
66
|
+
const siblings = siblingMap.get(fn.qualified_name);
|
|
67
|
+
if (siblings && siblings.length > 0) {
|
|
68
|
+
for (const sib of siblings) {
|
|
69
|
+
lines.push(` similar: ${sib.name} [${sib.file_path}:${sib.line_start}]`);
|
|
70
|
+
}
|
|
55
71
|
}
|
|
56
|
-
//
|
|
57
|
-
lines.push(`Test coverage: ${fn.has_test_coverage ? 'yes' : 'no'}`);
|
|
58
|
-
// Affected flows
|
|
72
|
+
// Affected flows inline
|
|
59
73
|
if (fn.in_flows.length > 0) {
|
|
60
|
-
|
|
74
|
+
const MAX_FLOWS = 3;
|
|
75
|
+
let flowCount = 0;
|
|
61
76
|
for (const ep of fn.in_flows) {
|
|
77
|
+
if (flowCount >= MAX_FLOWS) {
|
|
78
|
+
lines.push(` ... +${fn.in_flows.length - MAX_FLOWS} flows`);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
62
81
|
const flow = analysis.affected_flows.find((f) => f.entry_point === ep);
|
|
63
82
|
if (flow) {
|
|
64
83
|
const prefix = flow.type === 'http' ? 'HTTP' : 'TEST';
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
else {
|
|
68
|
-
lines.push(` - ${ep.split('::').pop()}`);
|
|
84
|
+
const path = flow.path.map((q) => shortName(q)).join(' → ');
|
|
85
|
+
lines.push(` flow: ${prefix} ${path}`);
|
|
69
86
|
}
|
|
87
|
+
flowCount++;
|
|
70
88
|
}
|
|
71
89
|
}
|
|
72
|
-
else {
|
|
73
|
-
lines.push('Affected flows: none');
|
|
74
|
-
}
|
|
75
90
|
lines.push('');
|
|
76
91
|
}
|
|
77
92
|
}
|
|
78
|
-
//
|
|
93
|
+
// ── Hierarchy (compact) ──
|
|
79
94
|
if (analysis.inheritance.length > 0) {
|
|
80
|
-
lines.push('
|
|
81
|
-
lines.push('');
|
|
95
|
+
lines.push('HIERARCHY:');
|
|
82
96
|
for (const entry of analysis.inheritance) {
|
|
83
|
-
const name = entry.qualified_name
|
|
97
|
+
const name = shortName(entry.qualified_name);
|
|
84
98
|
const parts = [];
|
|
85
99
|
if (entry.extends) {
|
|
86
|
-
parts.push(`extends ${entry.extends
|
|
100
|
+
parts.push(`extends ${shortName(entry.extends)}`);
|
|
87
101
|
}
|
|
88
102
|
if (entry.implements.length > 0) {
|
|
89
|
-
parts.push(`
|
|
103
|
+
parts.push(`impl ${entry.implements.map((i) => shortName(i)).join(', ')}`);
|
|
104
|
+
}
|
|
105
|
+
let line = ` ${name}`;
|
|
106
|
+
if (parts.length > 0) {
|
|
107
|
+
line += ` ${parts.join(' | ')}`;
|
|
90
108
|
}
|
|
91
|
-
lines.push(`- ${name} ${parts.join(', ')}`);
|
|
92
109
|
if (entry.children.length > 0) {
|
|
93
|
-
|
|
110
|
+
line += ` | children: ${entry.children.map((c) => shortName(c)).join(', ')}`;
|
|
94
111
|
}
|
|
112
|
+
lines.push(line);
|
|
95
113
|
}
|
|
96
114
|
lines.push('');
|
|
97
115
|
}
|
|
98
|
-
// Blast radius by depth
|
|
116
|
+
// ── Blast radius by depth (compact) ──
|
|
99
117
|
const byDepth = analysis.blast_radius.by_depth;
|
|
100
118
|
const depthKeys = Object.keys(byDepth).sort();
|
|
101
119
|
if (depthKeys.length > 0) {
|
|
102
|
-
lines.push('
|
|
103
|
-
lines.push('');
|
|
120
|
+
lines.push('BLAST RADIUS:');
|
|
104
121
|
for (const depth of depthKeys) {
|
|
105
|
-
const
|
|
106
|
-
|
|
122
|
+
const qnames = byDepth[depth];
|
|
123
|
+
const names = qnames.map((q) => shortName(q));
|
|
124
|
+
const MAX_SHOW = 8;
|
|
125
|
+
if (names.length <= MAX_SHOW) {
|
|
126
|
+
lines.push(` depth ${depth}: ${names.join(', ')} (${names.length})`);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const shown = names.slice(0, MAX_SHOW);
|
|
130
|
+
lines.push(` depth ${depth}: ${shown.join(', ')} ... +${names.length - MAX_SHOW} (${names.length} total)`);
|
|
131
|
+
}
|
|
107
132
|
}
|
|
108
133
|
lines.push('');
|
|
109
134
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
135
|
+
return lines.join('\n');
|
|
136
|
+
}
|
|
137
|
+
// ── Helpers ──
|
|
138
|
+
/** Extract short name from qualified_name (e.g. "mod::Class::method" → "method") */
|
|
139
|
+
function shortName(qualifiedName) {
|
|
140
|
+
return qualifiedName.split('::').pop() || qualifiedName;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Build class-qualified signature for methods.
|
|
144
|
+
* "file::Class::method" + "method(params) -> ret" → "Class.method(params) -> ret"
|
|
145
|
+
* For top-level functions ("file::func"), returns signature as-is.
|
|
146
|
+
* Language-agnostic: works for any language since qualified_name always uses "::" separator.
|
|
147
|
+
*/
|
|
148
|
+
function classQualifiedSignature(qualifiedName, signature) {
|
|
149
|
+
const parts = qualifiedName.split('::');
|
|
150
|
+
if (parts.length < 3) {
|
|
151
|
+
return signature; // top-level function, no class
|
|
119
152
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
lines.push(parts.join(', '));
|
|
153
|
+
const className = parts[parts.length - 2];
|
|
154
|
+
return `${className}.${signature}`;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Build a map of changed functions → sibling implementations.
|
|
158
|
+
* A "sibling" is a function with the same method name in a class that shares
|
|
159
|
+
* the same parent (extends same base). This enables cross-class comparison
|
|
160
|
+
* (e.g. OptimizedCursorPaginator.get_item_key vs DateTimePaginator.get_item_key).
|
|
161
|
+
*
|
|
162
|
+
* Uses full graph edges (not just analysis.inheritance which is filtered to changed files).
|
|
163
|
+
*/
|
|
164
|
+
function buildSiblingMap(analysis, output) {
|
|
165
|
+
const result = new Map();
|
|
166
|
+
// Build parent→children index from ALL INHERITS edges in the graph (not just changed files)
|
|
167
|
+
const parentToChildren = new Map();
|
|
168
|
+
for (const edge of output.graph.edges) {
|
|
169
|
+
if (edge.kind !== 'INHERITS') {
|
|
170
|
+
continue;
|
|
139
171
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
172
|
+
const existing = parentToChildren.get(edge.target_qualified) || [];
|
|
173
|
+
existing.push(edge.source_qualified);
|
|
174
|
+
parentToChildren.set(edge.target_qualified, existing);
|
|
175
|
+
}
|
|
176
|
+
// Index nodes by qualified name for fast lookup
|
|
177
|
+
const nodeByQN = new Map(output.graph.nodes.map((n) => [n.qualified_name, n]));
|
|
178
|
+
// For each changed function, find if its class has siblings with the same method
|
|
179
|
+
const changedQNs = new Set(analysis.changed_functions.map((f) => f.qualified_name));
|
|
180
|
+
for (const fn of analysis.changed_functions) {
|
|
181
|
+
// Extract class name from qualified_name (e.g. "file::Class::method" → "file::Class")
|
|
182
|
+
const parts = fn.qualified_name.split('::');
|
|
183
|
+
if (parts.length < 3) {
|
|
184
|
+
continue; // need at least file::class::method
|
|
147
185
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
186
|
+
const methodName = parts[parts.length - 1];
|
|
187
|
+
const className = parts.slice(0, -1).join('::');
|
|
188
|
+
// Find what this class extends (from INHERITS edges)
|
|
189
|
+
const parentEdge = output.graph.edges.find((e) => e.kind === 'INHERITS' && e.source_qualified === className);
|
|
190
|
+
if (!parentEdge) {
|
|
191
|
+
continue;
|
|
155
192
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
193
|
+
// Find sibling classes (same parent)
|
|
194
|
+
const siblings = parentToChildren.get(parentEdge.target_qualified) || [];
|
|
195
|
+
for (const siblingClass of siblings) {
|
|
196
|
+
if (siblingClass === className) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// Look for same method name in sibling class
|
|
200
|
+
const siblingMethodQN = `${siblingClass}::${methodName}`;
|
|
201
|
+
// Don't list if the sibling is also in changed functions (it's already shown)
|
|
202
|
+
if (changedQNs.has(siblingMethodQN)) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const siblingNode = nodeByQN.get(siblingMethodQN);
|
|
206
|
+
if (siblingNode) {
|
|
207
|
+
const existing = result.get(fn.qualified_name) || [];
|
|
208
|
+
existing.push({
|
|
209
|
+
name: `${shortName(siblingClass)}.${methodName}`,
|
|
210
|
+
file_path: siblingNode.file_path,
|
|
211
|
+
line_start: siblingNode.line_start,
|
|
212
|
+
});
|
|
213
|
+
result.set(fn.qualified_name, existing);
|
|
168
214
|
}
|
|
169
215
|
}
|
|
170
|
-
lines.push('');
|
|
171
216
|
}
|
|
172
|
-
return
|
|
217
|
+
return result;
|
|
173
218
|
}
|
package/dist/graph/builder.js
CHANGED
|
@@ -145,7 +145,10 @@ export function buildGraphData(raw, callEdges, importEdges, _repoDir, fileHashes
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
|
-
|
|
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