@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.
- package/dist/analysis/enrich.js +43 -15
- package/dist/analysis/prompt-formatter.js +79 -3
- 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 = [];
|
|
@@ -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
|
-
|
|
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(` ${
|
|
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