@kodus/kodus-graph 0.2.8 → 0.2.10
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/LICENSE +21 -0
- package/README.md +252 -0
- package/dist/analysis/blast-radius.d.ts +2 -0
- package/dist/analysis/blast-radius.js +55 -0
- package/dist/analysis/communities.d.ts +28 -0
- package/dist/analysis/communities.js +100 -0
- package/dist/analysis/context-builder.d.ts +34 -0
- package/dist/analysis/context-builder.js +92 -0
- package/dist/analysis/diff.d.ts +41 -0
- package/dist/analysis/diff.js +155 -0
- package/dist/analysis/enrich.d.ts +5 -0
- package/dist/analysis/enrich.js +126 -0
- package/dist/analysis/flows.d.ts +27 -0
- package/dist/analysis/flows.js +86 -0
- package/dist/analysis/inheritance.d.ts +3 -0
- package/dist/analysis/inheritance.js +31 -0
- package/dist/analysis/prompt-formatter.d.ts +2 -0
- package/dist/analysis/prompt-formatter.js +173 -0
- package/dist/analysis/risk-score.d.ts +4 -0
- package/dist/analysis/risk-score.js +51 -0
- package/dist/analysis/search.d.ts +11 -0
- package/dist/analysis/search.js +64 -0
- package/dist/analysis/test-gaps.d.ts +2 -0
- package/dist/analysis/test-gaps.js +14 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +210 -0
- package/dist/commands/analyze.d.ts +9 -0
- package/dist/commands/analyze.js +116 -0
- package/dist/commands/communities.d.ts +8 -0
- package/dist/commands/communities.js +9 -0
- package/dist/commands/context.d.ts +12 -0
- package/dist/commands/context.js +130 -0
- package/dist/commands/diff.d.ts +9 -0
- package/dist/commands/diff.js +89 -0
- package/dist/commands/flows.d.ts +8 -0
- package/dist/commands/flows.js +9 -0
- package/dist/commands/parse.d.ts +11 -0
- package/dist/commands/parse.js +101 -0
- package/dist/commands/search.d.ts +12 -0
- package/dist/commands/search.js +27 -0
- package/dist/commands/update.d.ts +7 -0
- package/dist/commands/update.js +154 -0
- package/dist/graph/builder.d.ts +6 -0
- package/dist/graph/builder.js +248 -0
- package/dist/graph/edges.d.ts +23 -0
- package/dist/graph/edges.js +159 -0
- package/dist/graph/json-writer.d.ts +9 -0
- package/dist/graph/json-writer.js +38 -0
- package/dist/graph/loader.d.ts +13 -0
- package/dist/graph/loader.js +101 -0
- package/dist/graph/merger.d.ts +7 -0
- package/dist/graph/merger.js +18 -0
- package/dist/graph/types.d.ts +252 -0
- package/dist/graph/types.js +1 -0
- package/dist/parser/batch.d.ts +5 -0
- package/dist/parser/batch.js +93 -0
- package/dist/parser/discovery.d.ts +7 -0
- package/dist/parser/discovery.js +61 -0
- package/dist/parser/extractor.d.ts +4 -0
- package/dist/parser/extractor.js +33 -0
- package/dist/parser/extractors/generic.d.ts +8 -0
- package/dist/parser/extractors/generic.js +471 -0
- package/dist/parser/extractors/python.d.ts +8 -0
- package/dist/parser/extractors/python.js +133 -0
- package/dist/parser/extractors/ruby.d.ts +8 -0
- package/dist/parser/extractors/ruby.js +153 -0
- package/dist/parser/extractors/typescript.d.ts +10 -0
- package/dist/parser/extractors/typescript.js +365 -0
- package/dist/parser/languages.d.ts +32 -0
- package/dist/parser/languages.js +304 -0
- package/dist/resolver/call-resolver.d.ts +36 -0
- package/dist/resolver/call-resolver.js +178 -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-map.d.ts +12 -0
- package/dist/resolver/import-map.js +21 -0
- package/dist/resolver/import-resolver.d.ts +19 -0
- package/dist/resolver/import-resolver.js +310 -0
- package/dist/resolver/languages/csharp.d.ts +3 -0
- package/dist/resolver/languages/csharp.js +94 -0
- package/dist/resolver/languages/go.d.ts +3 -0
- package/dist/resolver/languages/go.js +197 -0
- package/dist/resolver/languages/java.d.ts +1 -0
- package/dist/resolver/languages/java.js +193 -0
- package/dist/resolver/languages/php.d.ts +3 -0
- package/dist/resolver/languages/php.js +75 -0
- package/dist/resolver/languages/python.d.ts +11 -0
- package/dist/resolver/languages/python.js +127 -0
- package/dist/resolver/languages/ruby.d.ts +24 -0
- package/dist/resolver/languages/ruby.js +110 -0
- package/dist/resolver/languages/rust.d.ts +1 -0
- package/dist/resolver/languages/rust.js +197 -0
- package/dist/resolver/languages/typescript.d.ts +35 -0
- package/dist/resolver/languages/typescript.js +416 -0
- package/dist/resolver/re-export-resolver.d.ts +24 -0
- package/dist/resolver/re-export-resolver.js +57 -0
- package/dist/resolver/symbol-table.d.ts +17 -0
- package/dist/resolver/symbol-table.js +60 -0
- package/dist/shared/extract-calls.d.ts +26 -0
- package/dist/shared/extract-calls.js +57 -0
- package/dist/shared/file-hash.d.ts +3 -0
- package/dist/shared/file-hash.js +10 -0
- package/dist/shared/filters.d.ts +3 -0
- package/dist/shared/filters.js +240 -0
- package/dist/shared/logger.d.ts +6 -0
- package/dist/shared/logger.js +17 -0
- package/dist/shared/qualified-name.d.ts +1 -0
- package/dist/shared/qualified-name.js +9 -0
- package/dist/shared/safe-path.d.ts +6 -0
- package/dist/shared/safe-path.js +29 -0
- package/dist/shared/schemas.d.ts +43 -0
- package/dist/shared/schemas.js +30 -0
- package/dist/shared/temp.d.ts +11 -0
- package/{src/shared/temp.ts → dist/shared/temp.js} +4 -5
- package/package.json +20 -6
- package/src/analysis/blast-radius.ts +0 -54
- package/src/analysis/communities.ts +0 -135
- package/src/analysis/context-builder.ts +0 -130
- package/src/analysis/diff.ts +0 -169
- package/src/analysis/enrich.ts +0 -110
- package/src/analysis/flows.ts +0 -112
- package/src/analysis/inheritance.ts +0 -34
- package/src/analysis/prompt-formatter.ts +0 -175
- package/src/analysis/risk-score.ts +0 -62
- package/src/analysis/search.ts +0 -76
- package/src/analysis/test-gaps.ts +0 -21
- package/src/cli.ts +0 -210
- package/src/commands/analyze.ts +0 -128
- package/src/commands/communities.ts +0 -19
- package/src/commands/context.ts +0 -182
- package/src/commands/diff.ts +0 -96
- package/src/commands/flows.ts +0 -19
- package/src/commands/parse.ts +0 -124
- package/src/commands/search.ts +0 -41
- package/src/commands/update.ts +0 -166
- package/src/graph/builder.ts +0 -209
- package/src/graph/edges.ts +0 -101
- package/src/graph/json-writer.ts +0 -43
- package/src/graph/loader.ts +0 -113
- package/src/graph/merger.ts +0 -25
- package/src/graph/types.ts +0 -283
- package/src/parser/batch.ts +0 -82
- package/src/parser/discovery.ts +0 -75
- package/src/parser/extractor.ts +0 -37
- package/src/parser/extractors/generic.ts +0 -132
- package/src/parser/extractors/python.ts +0 -133
- package/src/parser/extractors/ruby.ts +0 -147
- package/src/parser/extractors/typescript.ts +0 -350
- package/src/parser/languages.ts +0 -122
- package/src/resolver/call-resolver.ts +0 -244
- package/src/resolver/import-map.ts +0 -27
- package/src/resolver/import-resolver.ts +0 -72
- package/src/resolver/languages/csharp.ts +0 -7
- package/src/resolver/languages/go.ts +0 -7
- package/src/resolver/languages/java.ts +0 -7
- package/src/resolver/languages/php.ts +0 -7
- package/src/resolver/languages/python.ts +0 -35
- package/src/resolver/languages/ruby.ts +0 -21
- package/src/resolver/languages/rust.ts +0 -7
- package/src/resolver/languages/typescript.ts +0 -168
- package/src/resolver/re-export-resolver.ts +0 -66
- package/src/resolver/symbol-table.ts +0 -67
- package/src/shared/extract-calls.ts +0 -75
- package/src/shared/file-hash.ts +0 -12
- package/src/shared/filters.ts +0 -243
- package/src/shared/logger.ts +0 -17
- package/src/shared/qualified-name.ts +0 -5
- package/src/shared/safe-path.ts +0 -31
- package/src/shared/schemas.ts +0 -32
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { log } from '../shared/logger';
|
|
2
|
+
export function computeStructuralDiff(oldGraph, newNodes, newEdges, changedFiles) {
|
|
3
|
+
const changedSet = new Set(changedFiles);
|
|
4
|
+
// Old nodes in changed files
|
|
5
|
+
const oldNodesInChanged = new Map();
|
|
6
|
+
for (const n of oldGraph.nodes) {
|
|
7
|
+
if (changedSet.has(n.file_path)) {
|
|
8
|
+
oldNodesInChanged.set(n.qualified_name, n);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
// New nodes in changed files
|
|
12
|
+
const newNodesMap = new Map();
|
|
13
|
+
for (const n of newNodes) {
|
|
14
|
+
if (changedSet.has(n.file_path)) {
|
|
15
|
+
newNodesMap.set(n.qualified_name, n);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
log.debug('diff: input', {
|
|
19
|
+
oldNodesInChanged: oldNodesInChanged.size,
|
|
20
|
+
newNodesInChanged: newNodesMap.size,
|
|
21
|
+
changedFiles,
|
|
22
|
+
});
|
|
23
|
+
// Classify nodes
|
|
24
|
+
const added = [];
|
|
25
|
+
const removed = [];
|
|
26
|
+
const modified = [];
|
|
27
|
+
for (const [qn, n] of newNodesMap) {
|
|
28
|
+
if (!oldNodesInChanged.has(qn)) {
|
|
29
|
+
added.push({
|
|
30
|
+
qualified_name: qn,
|
|
31
|
+
kind: n.kind,
|
|
32
|
+
file_path: n.file_path,
|
|
33
|
+
line_start: n.line_start,
|
|
34
|
+
line_end: n.line_end,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const [qn, n] of oldNodesInChanged) {
|
|
39
|
+
if (!newNodesMap.has(qn)) {
|
|
40
|
+
removed.push({
|
|
41
|
+
qualified_name: qn,
|
|
42
|
+
kind: n.kind,
|
|
43
|
+
file_path: n.file_path,
|
|
44
|
+
line_start: n.line_start,
|
|
45
|
+
line_end: n.line_end,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const newN = newNodesMap.get(qn);
|
|
50
|
+
const changes = [];
|
|
51
|
+
// Detect real content changes vs. pure displacement.
|
|
52
|
+
// content_hash = SHA256 of the node's source text (position-independent).
|
|
53
|
+
if (n.content_hash && newN.content_hash) {
|
|
54
|
+
// Definitive: hash comparison catches ALL content changes,
|
|
55
|
+
// even same-line-count edits (e.g. `return 1` → `return 2`).
|
|
56
|
+
if (n.content_hash !== newN.content_hash) {
|
|
57
|
+
changes.push('body');
|
|
58
|
+
log.debug('diff: body change detected', {
|
|
59
|
+
node: qn,
|
|
60
|
+
oldHash: n.content_hash.substring(0, 8),
|
|
61
|
+
newHash: newN.content_hash.substring(0, 8),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
log.debug('diff: hash match (displacement only)', {
|
|
66
|
+
node: qn,
|
|
67
|
+
oldLines: `${n.line_start}-${n.line_end}`,
|
|
68
|
+
newLines: `${newN.line_start}-${newN.line_end}`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else if (n.line_start !== newN.line_start || n.line_end !== newN.line_end) {
|
|
73
|
+
// Fallback (legacy data without content_hash): size heuristic.
|
|
74
|
+
const oldSize = n.line_end - n.line_start;
|
|
75
|
+
const newSize = newN.line_end - newN.line_start;
|
|
76
|
+
if (oldSize !== newSize) {
|
|
77
|
+
changes.push('line_range');
|
|
78
|
+
log.debug('diff: line_range fallback (no content_hash)', {
|
|
79
|
+
node: qn,
|
|
80
|
+
hasOldHash: !!n.content_hash,
|
|
81
|
+
hasNewHash: !!newN.content_hash,
|
|
82
|
+
oldSize,
|
|
83
|
+
newSize,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const contractDiffs = [];
|
|
88
|
+
if ((n.params || '') !== (newN.params || '')) {
|
|
89
|
+
changes.push('params');
|
|
90
|
+
contractDiffs.push({ field: 'params', old_value: n.params || '()', new_value: newN.params || '()' });
|
|
91
|
+
}
|
|
92
|
+
if ((n.return_type || '') !== (newN.return_type || '')) {
|
|
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
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (changes.length > 0) {
|
|
109
|
+
modified.push({ qualified_name: qn, changes, contract_diffs: contractDiffs });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Classify edges
|
|
114
|
+
const oldEdgesInChanged = oldGraph.edges.filter((e) => changedSet.has(e.file_path));
|
|
115
|
+
const oldEdgeKeys = new Set(oldEdgesInChanged.map((e) => `${e.kind}|${e.source_qualified}|${e.target_qualified}`));
|
|
116
|
+
const newEdgesInChanged = newEdges.filter((e) => changedSet.has(e.file_path));
|
|
117
|
+
const newEdgeKeys = new Set(newEdgesInChanged.map((e) => `${e.kind}|${e.source_qualified}|${e.target_qualified}`));
|
|
118
|
+
const addedEdges = newEdgesInChanged
|
|
119
|
+
.filter((e) => !oldEdgeKeys.has(`${e.kind}|${e.source_qualified}|${e.target_qualified}`))
|
|
120
|
+
.map((e) => ({ kind: e.kind, source_qualified: e.source_qualified, target_qualified: e.target_qualified }));
|
|
121
|
+
const removedEdges = oldEdgesInChanged
|
|
122
|
+
.filter((e) => !newEdgeKeys.has(`${e.kind}|${e.source_qualified}|${e.target_qualified}`))
|
|
123
|
+
.map((e) => ({ kind: e.kind, source_qualified: e.source_qualified, target_qualified: e.target_qualified }));
|
|
124
|
+
// Risk by file: count unique dependents via reverse adjacency
|
|
125
|
+
const riskByFile = {};
|
|
126
|
+
for (const file of changedFiles) {
|
|
127
|
+
const nodesInFile = oldGraph.byFile.get(file) || [];
|
|
128
|
+
const dependents = new Set();
|
|
129
|
+
for (const n of nodesInFile) {
|
|
130
|
+
for (const edge of oldGraph.reverseAdjacency.get(n.qualified_name) || []) {
|
|
131
|
+
if (!changedSet.has(edge.file_path)) {
|
|
132
|
+
dependents.add(edge.source_qualified);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const count = dependents.size;
|
|
137
|
+
const risk = count >= 10 ? 'HIGH' : count >= 3 ? 'MEDIUM' : 'LOW';
|
|
138
|
+
riskByFile[file] = { dependents: count, risk };
|
|
139
|
+
}
|
|
140
|
+
log.info('diff: result', {
|
|
141
|
+
added: added.length,
|
|
142
|
+
removed: removed.length,
|
|
143
|
+
modified: modified.length,
|
|
144
|
+
edgesAdded: addedEdges.length,
|
|
145
|
+
edgesRemoved: removedEdges.length,
|
|
146
|
+
modifiedDetails: modified.map((m) => `${m.qualified_name} [${m.changes.join(',')}]`),
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
changed_files: changedFiles,
|
|
150
|
+
summary: { added: added.length, removed: removed.length, modified: modified.length },
|
|
151
|
+
nodes: { added, removed, modified },
|
|
152
|
+
edges: { added: addedEdges, removed: removedEdges },
|
|
153
|
+
risk_by_file: riskByFile,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { IndexedGraph } from '../graph/loader';
|
|
2
|
+
import type { EnrichedFunction } from '../graph/types';
|
|
3
|
+
import type { DiffResult } from './diff';
|
|
4
|
+
import type { Flow } from './flows';
|
|
5
|
+
export declare function enrichChangedFunctions(graph: IndexedGraph, changedFiles: string[], diff: DiffResult, allFlows: Flow[], minConfidence: number, onlyChanged?: boolean): EnrichedFunction[];
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export function enrichChangedFunctions(graph, changedFiles, diff, allFlows, minConfidence, onlyChanged = false) {
|
|
2
|
+
const changedSet = new Set(changedFiles);
|
|
3
|
+
// Pre-index diff results
|
|
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]));
|
|
6
|
+
// Pre-index TESTED_BY
|
|
7
|
+
const testedFiles = new Set(graph.edges.filter((e) => e.kind === 'TESTED_BY').map((e) => e.source_qualified));
|
|
8
|
+
// Pre-index flows by function
|
|
9
|
+
const flowsByFunction = new Map();
|
|
10
|
+
for (const flow of allFlows) {
|
|
11
|
+
for (const qn of flow.path) {
|
|
12
|
+
const list = flowsByFunction.get(qn);
|
|
13
|
+
if (list) {
|
|
14
|
+
if (!list.includes(flow.entry_point)) {
|
|
15
|
+
list.push(flow.entry_point);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
flowsByFunction.set(qn, [flow.entry_point]);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// Filter functions in changed files
|
|
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
|
+
});
|
|
40
|
+
return changedFunctions
|
|
41
|
+
.sort((a, b) => a.file_path.localeCompare(b.file_path) || a.line_start - b.line_start)
|
|
42
|
+
.map((node) => {
|
|
43
|
+
// Callers
|
|
44
|
+
const callers = [];
|
|
45
|
+
for (const edge of graph.reverseAdjacency.get(node.qualified_name) || []) {
|
|
46
|
+
if (edge.kind !== 'CALLS') {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
// null/undefined confidence = high confidence (edge came from DB or parser without scoring)
|
|
50
|
+
if ((edge.confidence ?? 1.0) < minConfidence) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
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
|
+
}
|
|
62
|
+
// Callees
|
|
63
|
+
const callees = [];
|
|
64
|
+
const seenCallees = new Set();
|
|
65
|
+
for (const edge of graph.adjacency.get(node.qualified_name) || []) {
|
|
66
|
+
if (edge.kind !== 'CALLS') {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (seenCallees.has(edge.target_qualified)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
seenCallees.add(edge.target_qualified);
|
|
73
|
+
const targetNode = graph.byQualified.get(edge.target_qualified);
|
|
74
|
+
const name = targetNode?.name || edge.target_qualified.split('::').pop() || 'unknown';
|
|
75
|
+
const params = targetNode?.params && targetNode.params !== '()' ? targetNode.params : '';
|
|
76
|
+
const ret = targetNode?.return_type ? ` -> ${targetNode.return_type}` : '';
|
|
77
|
+
callees.push({
|
|
78
|
+
qualified_name: edge.target_qualified,
|
|
79
|
+
name,
|
|
80
|
+
file_path: targetNode?.file_path || '',
|
|
81
|
+
signature: `${name}${params}${ret}`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// Signature
|
|
85
|
+
const shortName = node.name.includes('.') ? node.name.split('.').pop() : node.name;
|
|
86
|
+
const params = node.params && node.params !== '()' ? node.params : '';
|
|
87
|
+
const ret = node.return_type ? ` -> ${node.return_type}` : '';
|
|
88
|
+
const signature = `${shortName}${params}${ret}`;
|
|
89
|
+
// Diff
|
|
90
|
+
const isNew = addedSet.has(node.qualified_name);
|
|
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
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
qualified_name: node.qualified_name,
|
|
110
|
+
name: node.name,
|
|
111
|
+
kind: node.kind,
|
|
112
|
+
signature,
|
|
113
|
+
file_path: node.file_path,
|
|
114
|
+
line_start: node.line_start,
|
|
115
|
+
line_end: node.line_end,
|
|
116
|
+
callers,
|
|
117
|
+
callees,
|
|
118
|
+
has_test_coverage: testedFiles.has(node.file_path),
|
|
119
|
+
diff_changes: diffChanges,
|
|
120
|
+
contract_diffs: contractDiffs,
|
|
121
|
+
caller_impact: callerImpact,
|
|
122
|
+
is_new: isNew,
|
|
123
|
+
in_flows: flowsByFunction.get(node.qualified_name) || [],
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { IndexedGraph } from '../graph/loader';
|
|
2
|
+
export interface FlowOptions {
|
|
3
|
+
maxDepth: number;
|
|
4
|
+
type: 'test' | 'http' | 'all';
|
|
5
|
+
}
|
|
6
|
+
export interface Flow {
|
|
7
|
+
entry_point: string;
|
|
8
|
+
type: 'test' | 'http';
|
|
9
|
+
depth: number;
|
|
10
|
+
node_count: number;
|
|
11
|
+
file_count: number;
|
|
12
|
+
criticality: number;
|
|
13
|
+
path: string[];
|
|
14
|
+
}
|
|
15
|
+
export interface FlowsResult {
|
|
16
|
+
flows: Flow[];
|
|
17
|
+
summary: {
|
|
18
|
+
total_flows: number;
|
|
19
|
+
by_type: {
|
|
20
|
+
test: number;
|
|
21
|
+
http: number;
|
|
22
|
+
};
|
|
23
|
+
avg_depth: number;
|
|
24
|
+
max_criticality: number;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export declare function detectFlows(graph: IndexedGraph, opts: FlowOptions): FlowsResult;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const HTTP_METHOD_NAMES = new Set(['get', 'post', 'put', 'delete', 'patch', 'handle', 'handler']);
|
|
2
|
+
function isHttpHandler(_qualifiedName, name, parentName) {
|
|
3
|
+
if (HTTP_METHOD_NAMES.has(name.toLowerCase())) {
|
|
4
|
+
return true;
|
|
5
|
+
}
|
|
6
|
+
if (parentName?.toLowerCase().endsWith('controller')) {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
export function detectFlows(graph, opts) {
|
|
12
|
+
const { maxDepth, type } = opts;
|
|
13
|
+
// Find entry points
|
|
14
|
+
const entryPoints = [];
|
|
15
|
+
for (const node of graph.nodes) {
|
|
16
|
+
if (type !== 'http' && node.kind === 'Test') {
|
|
17
|
+
entryPoints.push({ qualified: node.qualified_name, type: 'test' });
|
|
18
|
+
}
|
|
19
|
+
if (type !== 'test' && (node.kind === 'Method' || node.kind === 'Function')) {
|
|
20
|
+
if (isHttpHandler(node.qualified_name, node.name, node.parent_name)) {
|
|
21
|
+
entryPoints.push({ qualified: node.qualified_name, type: 'http' });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// BFS for each entry point
|
|
26
|
+
const flows = [];
|
|
27
|
+
for (const ep of entryPoints) {
|
|
28
|
+
const path = [ep.qualified];
|
|
29
|
+
const visited = new Set([ep.qualified]);
|
|
30
|
+
const files = new Set();
|
|
31
|
+
const startNode = graph.byQualified.get(ep.qualified);
|
|
32
|
+
if (startNode) {
|
|
33
|
+
files.add(startNode.file_path);
|
|
34
|
+
}
|
|
35
|
+
let frontier = [ep.qualified];
|
|
36
|
+
let depth = 0;
|
|
37
|
+
while (frontier.length > 0 && depth < maxDepth) {
|
|
38
|
+
const next = [];
|
|
39
|
+
for (const q of frontier) {
|
|
40
|
+
for (const edge of graph.adjacency.get(q) || []) {
|
|
41
|
+
if (edge.kind !== 'CALLS') {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (visited.has(edge.target_qualified)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
visited.add(edge.target_qualified);
|
|
48
|
+
next.push(edge.target_qualified);
|
|
49
|
+
path.push(edge.target_qualified);
|
|
50
|
+
const targetNode = graph.byQualified.get(edge.target_qualified);
|
|
51
|
+
if (targetNode) {
|
|
52
|
+
files.add(targetNode.file_path);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (next.length === 0) {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
frontier = next;
|
|
60
|
+
depth++;
|
|
61
|
+
}
|
|
62
|
+
flows.push({
|
|
63
|
+
entry_point: ep.qualified,
|
|
64
|
+
type: ep.type,
|
|
65
|
+
depth,
|
|
66
|
+
node_count: visited.size,
|
|
67
|
+
file_count: files.size,
|
|
68
|
+
criticality: visited.size * files.size,
|
|
69
|
+
path,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
flows.sort((a, b) => b.criticality - a.criticality);
|
|
73
|
+
const testFlows = flows.filter((f) => f.type === 'test').length;
|
|
74
|
+
const httpFlows = flows.filter((f) => f.type === 'http').length;
|
|
75
|
+
const avgDepth = flows.length > 0 ? Math.round((flows.reduce((s, f) => s + f.depth, 0) / flows.length) * 10) / 10 : 0;
|
|
76
|
+
const maxCriticality = flows.length > 0 ? flows[0].criticality : 0;
|
|
77
|
+
return {
|
|
78
|
+
flows,
|
|
79
|
+
summary: {
|
|
80
|
+
total_flows: flows.length,
|
|
81
|
+
by_type: { test: testFlows, http: httpFlows },
|
|
82
|
+
avg_depth: avgDepth,
|
|
83
|
+
max_criticality: maxCriticality,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function extractInheritance(graph, changedFiles) {
|
|
2
|
+
const changedSet = new Set(changedFiles);
|
|
3
|
+
const entries = [];
|
|
4
|
+
const changedClasses = graph.nodes.filter((n) => changedSet.has(n.file_path) && n.kind === 'Class');
|
|
5
|
+
for (const cls of changedClasses) {
|
|
6
|
+
let extendsClass;
|
|
7
|
+
const implementsList = [];
|
|
8
|
+
const children = [];
|
|
9
|
+
for (const edge of graph.adjacency.get(cls.qualified_name) || []) {
|
|
10
|
+
if (edge.kind === 'INHERITS') {
|
|
11
|
+
extendsClass = edge.target_qualified;
|
|
12
|
+
}
|
|
13
|
+
if (edge.kind === 'IMPLEMENTS') {
|
|
14
|
+
implementsList.push(edge.target_qualified);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
for (const edge of graph.reverseAdjacency.get(cls.qualified_name) || []) {
|
|
18
|
+
if (edge.kind === 'INHERITS') {
|
|
19
|
+
children.push(edge.source_qualified);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
entries.push({
|
|
23
|
+
qualified_name: cls.qualified_name,
|
|
24
|
+
file_path: cls.file_path,
|
|
25
|
+
extends: extendsClass,
|
|
26
|
+
implements: implementsList,
|
|
27
|
+
children,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return entries;
|
|
31
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
export function formatPrompt(output) {
|
|
2
|
+
const { analysis } = output;
|
|
3
|
+
const lines = [];
|
|
4
|
+
// Header
|
|
5
|
+
const risk = analysis.risk;
|
|
6
|
+
const br = analysis.blast_radius;
|
|
7
|
+
const meta = analysis.metadata;
|
|
8
|
+
lines.push('# Code Review Context');
|
|
9
|
+
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
|
|
13
|
+
if (analysis.changed_functions.length > 0) {
|
|
14
|
+
lines.push('## Changed Functions');
|
|
15
|
+
lines.push('');
|
|
16
|
+
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
|
|
36
|
+
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}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
lines.push('Callers: none');
|
|
45
|
+
}
|
|
46
|
+
// Callees
|
|
47
|
+
if (fn.callees.length > 0) {
|
|
48
|
+
lines.push('Callees:');
|
|
49
|
+
for (const c of fn.callees) {
|
|
50
|
+
lines.push(` - ${c.signature} [${c.file_path}]`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
lines.push('Callees: none');
|
|
55
|
+
}
|
|
56
|
+
// Test coverage
|
|
57
|
+
lines.push(`Test coverage: ${fn.has_test_coverage ? 'yes' : 'no'}`);
|
|
58
|
+
// Affected flows
|
|
59
|
+
if (fn.in_flows.length > 0) {
|
|
60
|
+
lines.push('Affected flows:');
|
|
61
|
+
for (const ep of fn.in_flows) {
|
|
62
|
+
const flow = analysis.affected_flows.find((f) => f.entry_point === ep);
|
|
63
|
+
if (flow) {
|
|
64
|
+
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()}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
lines.push('Affected flows: none');
|
|
74
|
+
}
|
|
75
|
+
lines.push('');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Inheritance
|
|
79
|
+
if (analysis.inheritance.length > 0) {
|
|
80
|
+
lines.push('## Inheritance');
|
|
81
|
+
lines.push('');
|
|
82
|
+
for (const entry of analysis.inheritance) {
|
|
83
|
+
const name = entry.qualified_name.split('::').pop();
|
|
84
|
+
const parts = [];
|
|
85
|
+
if (entry.extends) {
|
|
86
|
+
parts.push(`extends ${entry.extends.split('::').pop()}`);
|
|
87
|
+
}
|
|
88
|
+
if (entry.implements.length > 0) {
|
|
89
|
+
parts.push(`implements ${entry.implements.map((i) => i.split('::').pop()).join(', ')}`);
|
|
90
|
+
}
|
|
91
|
+
lines.push(`- ${name} ${parts.join(', ')}`);
|
|
92
|
+
if (entry.children.length > 0) {
|
|
93
|
+
lines.push(` Children: ${entry.children.map((c) => c.split('::').pop()).join(', ')}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
lines.push('');
|
|
97
|
+
}
|
|
98
|
+
// Blast radius by depth
|
|
99
|
+
const byDepth = analysis.blast_radius.by_depth;
|
|
100
|
+
const depthKeys = Object.keys(byDepth).sort();
|
|
101
|
+
if (depthKeys.length > 0) {
|
|
102
|
+
lines.push('## Blast Radius');
|
|
103
|
+
lines.push('');
|
|
104
|
+
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)`);
|
|
107
|
+
}
|
|
108
|
+
lines.push('');
|
|
109
|
+
}
|
|
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}]`);
|
|
117
|
+
}
|
|
118
|
+
lines.push('');
|
|
119
|
+
}
|
|
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(', '));
|
|
139
|
+
}
|
|
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
|
+
}
|
|
155
|
+
}
|
|
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}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
lines.push('');
|
|
171
|
+
}
|
|
172
|
+
return lines.join('\n');
|
|
173
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function computeRiskScore(graph, changedFiles, blastRadius, options) {
|
|
2
|
+
const changedSet = new Set(changedFiles);
|
|
3
|
+
const changedNodes = graph.nodes.filter((n) => changedSet.has(n.file_path) && !n.is_test);
|
|
4
|
+
// Factor 1: Blast radius (0.35)
|
|
5
|
+
const brValue = Math.min(blastRadius.total_functions / 20, 1); // cap at 20
|
|
6
|
+
// Factor 2: Test gaps (0.30) — skipped when test detection is disabled
|
|
7
|
+
let tgValue = 0;
|
|
8
|
+
let untestedCount = 0;
|
|
9
|
+
const changedFunctions = changedNodes.filter((n) => n.kind === 'Function' || n.kind === 'Method');
|
|
10
|
+
if (!options?.skipTests) {
|
|
11
|
+
const testedFiles = new Set(graph.edges.filter((e) => e.kind === 'TESTED_BY').map((e) => e.source_qualified));
|
|
12
|
+
untestedCount = changedFunctions.filter((n) => !testedFiles.has(n.file_path)).length;
|
|
13
|
+
tgValue = changedFunctions.length > 0 ? untestedCount / changedFunctions.length : 0;
|
|
14
|
+
}
|
|
15
|
+
// Factor 3: Complexity (0.20)
|
|
16
|
+
const avgSize = changedNodes.length > 0
|
|
17
|
+
? changedNodes.reduce((s, n) => s + (n.line_end - n.line_start), 0) / changedNodes.length
|
|
18
|
+
: 0;
|
|
19
|
+
const cxValue = Math.min(avgSize / 50, 1); // cap at 50 lines
|
|
20
|
+
// Factor 4: Inheritance (0.15)
|
|
21
|
+
const hasInheritance = graph.edges.some((e) => (e.kind === 'INHERITS' || e.kind === 'IMPLEMENTS') && changedSet.has(e.file_path));
|
|
22
|
+
const ihValue = hasInheritance ? 1 : 0;
|
|
23
|
+
const score = brValue * 0.35 + tgValue * 0.3 + cxValue * 0.2 + ihValue * 0.15;
|
|
24
|
+
const level = score >= 0.6 ? 'HIGH' : score >= 0.3 ? 'MEDIUM' : 'LOW';
|
|
25
|
+
return {
|
|
26
|
+
level,
|
|
27
|
+
score: Math.round(score * 100) / 100,
|
|
28
|
+
factors: {
|
|
29
|
+
blast_radius: {
|
|
30
|
+
weight: 0.35,
|
|
31
|
+
value: Math.round(brValue * 100) / 100,
|
|
32
|
+
detail: `${blastRadius.total_functions} functions, ${blastRadius.total_files} files`,
|
|
33
|
+
},
|
|
34
|
+
test_gaps: {
|
|
35
|
+
weight: 0.3,
|
|
36
|
+
value: Math.round(tgValue * 100) / 100,
|
|
37
|
+
detail: `${untestedCount}/${changedFunctions.length} untested`,
|
|
38
|
+
},
|
|
39
|
+
complexity: {
|
|
40
|
+
weight: 0.2,
|
|
41
|
+
value: Math.round(cxValue * 100) / 100,
|
|
42
|
+
detail: `avg ${Math.round(avgSize)} lines`,
|
|
43
|
+
},
|
|
44
|
+
inheritance: {
|
|
45
|
+
weight: 0.15,
|
|
46
|
+
value: ihValue,
|
|
47
|
+
detail: hasInheritance ? 'has inheritance' : 'no inheritance',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|