@kodus/kodus-graph 0.2.9 → 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.
- package/dist/analysis/blast-radius.d.ts +1 -1
- package/dist/analysis/blast-radius.js +19 -21
- package/dist/analysis/context-builder.js +13 -4
- package/dist/analysis/diff.d.ts +6 -0
- package/dist/analysis/diff.js +16 -1
- package/dist/analysis/enrich.d.ts +1 -1
- package/dist/analysis/enrich.js +37 -9
- package/dist/analysis/prompt-formatter.d.ts +11 -0
- package/dist/analysis/prompt-formatter.js +147 -112
- package/dist/cli.js +2 -0
- package/dist/commands/analyze.js +5 -3
- package/dist/commands/diff.js +2 -2
- package/dist/commands/parse.d.ts +1 -0
- package/dist/commands/parse.js +3 -3
- package/dist/commands/update.js +2 -2
- package/dist/graph/builder.d.ts +5 -1
- package/dist/graph/builder.js +39 -4
- package/dist/graph/edges.d.ts +5 -1
- package/dist/graph/edges.js +61 -7
- package/dist/graph/types.d.ts +3 -0
- package/dist/parser/batch.d.ts +1 -0
- package/dist/parser/batch.js +18 -3
- package/dist/parser/languages.js +1 -0
- package/dist/resolver/external-detector.d.ts +11 -0
- package/dist/resolver/external-detector.js +820 -0
- package/dist/resolver/fs-cache.d.ts +8 -0
- package/dist/resolver/fs-cache.js +36 -0
- package/dist/resolver/import-resolver.js +130 -32
- package/dist/resolver/languages/csharp.d.ts +2 -0
- package/dist/resolver/languages/csharp.js +69 -6
- package/dist/resolver/languages/go.js +8 -7
- package/dist/resolver/languages/java.js +102 -17
- package/dist/resolver/languages/php.js +26 -5
- package/dist/resolver/languages/python.js +79 -3
- package/dist/resolver/languages/ruby.d.ts +16 -1
- package/dist/resolver/languages/ruby.js +58 -7
- package/dist/resolver/languages/rust.js +8 -7
- package/dist/resolver/languages/typescript.d.ts +8 -0
- package/dist/resolver/languages/typescript.js +193 -17
- 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,
|
|
2
|
+
export declare function computeBlastRadius(graph: GraphData, changedQualifiedNames: string[], maxDepth?: number, minConfidence?: number): BlastRadiusResult;
|
|
@@ -1,30 +1,28 @@
|
|
|
1
|
-
export function computeBlastRadius(graph,
|
|
2
|
-
|
|
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
|
-
|
|
5
|
-
if (
|
|
6
|
-
|
|
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(
|
|
13
|
-
|
|
9
|
+
adj.get(from).add(to);
|
|
10
|
+
};
|
|
11
|
+
for (const edge of graph.edges) {
|
|
14
12
|
if (edge.kind === 'IMPORTS') {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
//
|
|
22
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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);
|
package/dist/analysis/diff.d.ts
CHANGED
|
@@ -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[];
|
package/dist/analysis/diff.js
CHANGED
|
@@ -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[];
|
package/dist/analysis/enrich.js
CHANGED
|
@@ -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
|
|
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) =>
|
|
25
|
-
!n.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
n.
|
|
29
|
-
|
|
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
|
|
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
|
};
|
|
@@ -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,166 +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
|
-
|
|
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
|
-
|
|
11
|
-
lines.push('');
|
|
12
|
-
// Changed functions
|
|
21
|
+
// ── Changed functions ──
|
|
13
22
|
if (analysis.changed_functions.length > 0) {
|
|
14
|
-
lines.push('
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
lines.push(`
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
lines.push(
|
|
27
|
-
}
|
|
28
|
-
// 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
|
|
29
39
|
if (fn.callers.length > 0) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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`);
|
|
34
50
|
}
|
|
35
51
|
}
|
|
36
|
-
|
|
37
|
-
lines.push('Callers: none');
|
|
38
|
-
}
|
|
39
|
-
// Callees
|
|
52
|
+
// Callees (→ compact chain)
|
|
40
53
|
if (fn.callees.length > 0) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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}`;
|
|
44
59
|
}
|
|
60
|
+
lines.push(calleeLine);
|
|
45
61
|
}
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|
|
48
68
|
}
|
|
49
|
-
//
|
|
50
|
-
lines.push(`Test coverage: ${fn.has_test_coverage ? 'yes' : 'no'}`);
|
|
51
|
-
// Affected flows
|
|
69
|
+
// Affected flows inline
|
|
52
70
|
if (fn.in_flows.length > 0) {
|
|
53
|
-
|
|
71
|
+
const MAX_FLOWS = 3;
|
|
72
|
+
let flowCount = 0;
|
|
54
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
|
+
}
|
|
55
78
|
const flow = analysis.affected_flows.find((f) => f.entry_point === ep);
|
|
56
79
|
if (flow) {
|
|
57
80
|
const prefix = flow.type === 'http' ? 'HTTP' : 'TEST';
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
else {
|
|
61
|
-
lines.push(` - ${ep.split('::').pop()}`);
|
|
81
|
+
const path = flow.path.map((q) => shortName(q)).join(' → ');
|
|
82
|
+
lines.push(` flow: ${prefix} ${path}`);
|
|
62
83
|
}
|
|
84
|
+
flowCount++;
|
|
63
85
|
}
|
|
64
86
|
}
|
|
65
|
-
else {
|
|
66
|
-
lines.push('Affected flows: none');
|
|
67
|
-
}
|
|
68
87
|
lines.push('');
|
|
69
88
|
}
|
|
70
89
|
}
|
|
71
|
-
//
|
|
90
|
+
// ── Hierarchy (compact) ──
|
|
72
91
|
if (analysis.inheritance.length > 0) {
|
|
73
|
-
lines.push('
|
|
74
|
-
lines.push('');
|
|
92
|
+
lines.push('HIERARCHY:');
|
|
75
93
|
for (const entry of analysis.inheritance) {
|
|
76
|
-
const name = entry.qualified_name
|
|
94
|
+
const name = shortName(entry.qualified_name);
|
|
77
95
|
const parts = [];
|
|
78
96
|
if (entry.extends) {
|
|
79
|
-
parts.push(`extends ${entry.extends
|
|
97
|
+
parts.push(`extends ${shortName(entry.extends)}`);
|
|
80
98
|
}
|
|
81
99
|
if (entry.implements.length > 0) {
|
|
82
|
-
parts.push(`
|
|
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(' | ')}`;
|
|
83
105
|
}
|
|
84
|
-
lines.push(`- ${name} ${parts.join(', ')}`);
|
|
85
106
|
if (entry.children.length > 0) {
|
|
86
|
-
|
|
107
|
+
line += ` | children: ${entry.children.map((c) => shortName(c)).join(', ')}`;
|
|
87
108
|
}
|
|
109
|
+
lines.push(line);
|
|
88
110
|
}
|
|
89
111
|
lines.push('');
|
|
90
112
|
}
|
|
91
|
-
// Blast radius by depth
|
|
113
|
+
// ── Blast radius by depth (compact) ──
|
|
92
114
|
const byDepth = analysis.blast_radius.by_depth;
|
|
93
115
|
const depthKeys = Object.keys(byDepth).sort();
|
|
94
116
|
if (depthKeys.length > 0) {
|
|
95
|
-
lines.push('
|
|
96
|
-
lines.push('');
|
|
117
|
+
lines.push('BLAST RADIUS:');
|
|
97
118
|
for (const depth of depthKeys) {
|
|
98
|
-
const
|
|
99
|
-
|
|
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
|
+
}
|
|
100
129
|
}
|
|
101
130
|
lines.push('');
|
|
102
131
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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;
|
|
110
154
|
}
|
|
111
|
-
|
|
155
|
+
const existing = parentToChildren.get(edge.target_qualified) || [];
|
|
156
|
+
existing.push(edge.source_qualified);
|
|
157
|
+
parentToChildren.set(edge.target_qualified, existing);
|
|
112
158
|
}
|
|
113
|
-
//
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
if (diff.summary.added > 0) {
|
|
123
|
-
parts.push(`${diff.summary.added} added`);
|
|
124
|
-
}
|
|
125
|
-
if (diff.summary.removed > 0) {
|
|
126
|
-
parts.push(`${diff.summary.removed} removed`);
|
|
127
|
-
}
|
|
128
|
-
if (diff.summary.modified > 0) {
|
|
129
|
-
parts.push(`${diff.summary.modified} modified`);
|
|
130
|
-
}
|
|
131
|
-
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
|
|
132
168
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
if (diff.nodes.modified.length > 0) {
|
|
142
|
-
lines.push('');
|
|
143
|
-
lines.push('Modified:');
|
|
144
|
-
for (const m of diff.nodes.modified) {
|
|
145
|
-
const name = m.qualified_name.split('::').pop();
|
|
146
|
-
lines.push(` - ${name} (${m.changes.join(', ')})`);
|
|
147
|
-
}
|
|
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;
|
|
148
175
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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);
|
|
161
197
|
}
|
|
162
198
|
}
|
|
163
|
-
lines.push('');
|
|
164
199
|
}
|
|
165
|
-
return
|
|
200
|
+
return result;
|
|
166
201
|
}
|
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
|
package/dist/commands/analyze.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 = {
|
package/dist/commands/diff.js
CHANGED
|
@@ -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
|
|
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));
|