@abhinav2203/codeflow-analysis 0.1.0
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/bin/cli.js +43 -0
- package/package.json +33 -0
- package/src/app/api/analysis/cycles/route.test.ts +124 -0
- package/src/app/api/analysis/metrics/route.test.ts +122 -0
- package/src/app/api/analysis/smells/route.test.ts +134 -0
- package/src/app/api/conflicts/route.test.ts +72 -0
- package/src/app/api/refactor/detect/route.test.ts +74 -0
- package/src/app/api/refactor/heal/route.test.ts +85 -0
- package/src/conflicts.test.ts +134 -0
- package/src/conflicts.ts +106 -0
- package/src/cycles.test.ts +145 -0
- package/src/cycles.ts +161 -0
- package/src/handlers/conflicts.ts +29 -0
- package/src/handlers/cycles.ts +29 -0
- package/src/handlers/metrics.ts +28 -0
- package/src/handlers/refactor-detect.ts +28 -0
- package/src/handlers/refactor-heal.ts +32 -0
- package/src/handlers/smells.ts +29 -0
- package/src/index.ts +18 -0
- package/src/invoke.ts +176 -0
- package/src/metrics.test.ts +169 -0
- package/src/metrics.ts +184 -0
- package/src/refactor.test.ts +362 -0
- package/src/refactor.ts +290 -0
- package/src/smells.test.ts +196 -0
- package/src/smells.ts +221 -0
- package/test-fixtures/sample-repo/src/app/api/tasks/route.ts +9 -0
- package/test-fixtures/sample-repo/src/app/page.tsx +7 -0
- package/test-fixtures/sample-repo/src/lib/auth.ts +13 -0
- package/test-fixtures/sample-repo/src/services/base-service.ts +5 -0
- package/test-fixtures/sample-repo/src/services/task-service.ts +22 -0
- package/test-fixtures/sample-repo/tsconfig.json +10 -0
- package/tsconfig.json +23 -0
- package/vitest.config.ts +22 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { detectDrift, healGraph } from "./refactor";
|
|
4
|
+
import type { BlueprintEdge, BlueprintGraph } from "@/lib/blueprint/schema";
|
|
5
|
+
import { emptyContract } from "@/lib/blueprint/schema";
|
|
6
|
+
|
|
7
|
+
// ── Fixtures ───────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const makeNode = (
|
|
10
|
+
id: string,
|
|
11
|
+
overrides: Partial<{
|
|
12
|
+
contractCalls: { target: string; kind?: string }[];
|
|
13
|
+
signature: string;
|
|
14
|
+
firstMethodSignature: string;
|
|
15
|
+
}> = {}
|
|
16
|
+
): BlueprintGraph["nodes"][number] => {
|
|
17
|
+
const calls = overrides.contractCalls?.map((c) => ({
|
|
18
|
+
target: c.target,
|
|
19
|
+
kind: c.kind as "calls" | "imports" | "reads-state" | "writes-state" | "inherits" | "renders" | "emits" | "consumes",
|
|
20
|
+
description: undefined,
|
|
21
|
+
})) ?? [];
|
|
22
|
+
|
|
23
|
+
const methods = overrides.firstMethodSignature
|
|
24
|
+
? [
|
|
25
|
+
{
|
|
26
|
+
name: id,
|
|
27
|
+
signature: overrides.firstMethodSignature,
|
|
28
|
+
summary: "Method.",
|
|
29
|
+
inputs: [] as { name: string; type: string; description?: string }[],
|
|
30
|
+
outputs: [] as { name: string; type: string; description?: string }[],
|
|
31
|
+
sideEffects: [] as string[],
|
|
32
|
+
calls: [] as { target: string; kind?: string; description?: string }[],
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
: [];
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
id,
|
|
39
|
+
kind: "function",
|
|
40
|
+
name: id,
|
|
41
|
+
summary: `${id} summary.`,
|
|
42
|
+
signature: overrides.signature,
|
|
43
|
+
contract: {
|
|
44
|
+
...emptyContract(),
|
|
45
|
+
...(calls.length > 0 ? { calls } : {}),
|
|
46
|
+
...(methods.length > 0 ? { methods } : {}),
|
|
47
|
+
},
|
|
48
|
+
sourceRefs: [],
|
|
49
|
+
generatedRefs: [],
|
|
50
|
+
traceRefs: [],
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const edge = (from: string, to: string, kind = "calls"): BlueprintEdge => ({
|
|
55
|
+
from,
|
|
56
|
+
to,
|
|
57
|
+
kind,
|
|
58
|
+
required: false,
|
|
59
|
+
confidence: 1,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const makeGraph = (
|
|
63
|
+
overrides: Partial<{
|
|
64
|
+
nodes: BlueprintGraph["nodes"];
|
|
65
|
+
edges: BlueprintGraph["edges"];
|
|
66
|
+
}> = {}
|
|
67
|
+
): BlueprintGraph => ({
|
|
68
|
+
projectName: "TestApp",
|
|
69
|
+
mode: "essential",
|
|
70
|
+
phase: "spec",
|
|
71
|
+
generatedAt: "2026-03-14T00:00:00.000Z",
|
|
72
|
+
warnings: [],
|
|
73
|
+
workflows: [],
|
|
74
|
+
edges: [],
|
|
75
|
+
nodes: [
|
|
76
|
+
makeNode("function:auth", {
|
|
77
|
+
contractCalls: [],
|
|
78
|
+
}),
|
|
79
|
+
makeNode("api:users", { contractCalls: [] }),
|
|
80
|
+
makeNode("function:checkout", { contractCalls: [] }),
|
|
81
|
+
],
|
|
82
|
+
...overrides,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── detectDrift ─────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe("detectDrift", () => {
|
|
88
|
+
it("reports a healthy graph with no issues", () => {
|
|
89
|
+
const report = detectDrift(makeGraph());
|
|
90
|
+
|
|
91
|
+
expect(report.isHealthy).toBe(true);
|
|
92
|
+
expect(report.issues).toHaveLength(0);
|
|
93
|
+
expect(report.totalIssues).toBe(0);
|
|
94
|
+
expect(report.driftedNodeIds).toHaveLength(0);
|
|
95
|
+
expect(report.provenance).toBe("deterministic");
|
|
96
|
+
expect(report.maturity).toBe("preview");
|
|
97
|
+
expect(report.scope).toBe("graph");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("detects a broken edge whose source node does not exist", () => {
|
|
101
|
+
const graph = makeGraph({
|
|
102
|
+
edges: [edge("node:ghost", "function:auth")],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const report = detectDrift(graph);
|
|
106
|
+
|
|
107
|
+
expect(report.isHealthy).toBe(false);
|
|
108
|
+
const brokenIssues = report.issues.filter((i) => i.kind === "broken-edge");
|
|
109
|
+
expect(brokenIssues.length).toBeGreaterThanOrEqual(1);
|
|
110
|
+
// Anchored on the existing endpoint.
|
|
111
|
+
expect(brokenIssues[0].nodeId).toBe("function:auth");
|
|
112
|
+
expect(brokenIssues[0].missingNodeId).toBe("node:ghost");
|
|
113
|
+
// driftedNodeIds only contains real node IDs.
|
|
114
|
+
expect(report.driftedNodeIds).toContain("function:auth");
|
|
115
|
+
expect(report.driftedNodeIds).not.toContain("node:ghost");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("detects a broken edge whose target node does not exist", () => {
|
|
119
|
+
const graph = makeGraph({
|
|
120
|
+
edges: [edge("function:auth", "node:deleted")],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const report = detectDrift(graph);
|
|
124
|
+
const brokenIssues = report.issues.filter((i) => i.kind === "broken-edge");
|
|
125
|
+
expect(brokenIssues.length).toBeGreaterThanOrEqual(1);
|
|
126
|
+
expect(brokenIssues[0].nodeId).toBe("function:auth");
|
|
127
|
+
expect(brokenIssues[0].missingNodeId).toBe("node:deleted");
|
|
128
|
+
expect(report.driftedNodeIds).toContain("function:auth");
|
|
129
|
+
expect(report.driftedNodeIds).not.toContain("node:deleted");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("detects a missing edge when a contract call has no graph edge", () => {
|
|
133
|
+
const graph = makeGraph({
|
|
134
|
+
nodes: makeGraph().nodes.map((n) =>
|
|
135
|
+
n.id === "function:auth"
|
|
136
|
+
? makeNode("function:auth", { contractCalls: [{ target: "api:users", kind: "calls" }] })
|
|
137
|
+
: n
|
|
138
|
+
),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const report = detectDrift(graph);
|
|
142
|
+
const missingIssues = report.issues.filter((i) => i.kind === "missing-edge");
|
|
143
|
+
expect(missingIssues.length).toBeGreaterThanOrEqual(1);
|
|
144
|
+
expect(missingIssues[0].edgeFrom).toBe("function:auth");
|
|
145
|
+
expect(missingIssues[0].edgeTo).toBe("api:users");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("does NOT report a missing-edge when the edge already exists", () => {
|
|
149
|
+
const graph = makeGraph({
|
|
150
|
+
nodes: makeGraph().nodes.map((n) =>
|
|
151
|
+
n.id === "function:auth"
|
|
152
|
+
? makeNode("function:auth", { contractCalls: [{ target: "api:users", kind: "calls" }] })
|
|
153
|
+
: n
|
|
154
|
+
),
|
|
155
|
+
edges: [edge("function:auth", "api:users", "calls")],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const report = detectDrift(graph);
|
|
159
|
+
expect(report.issues.filter((i) => i.kind === "missing-edge")).toHaveLength(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("detects signature drift when node signature does not match first method", () => {
|
|
163
|
+
const graph = makeGraph({
|
|
164
|
+
nodes: makeGraph().nodes.map((n) =>
|
|
165
|
+
n.id === "function:auth"
|
|
166
|
+
? makeNode("function:auth", {
|
|
167
|
+
signature: "authenticate(token: string): void",
|
|
168
|
+
firstMethodSignature: "authenticate(token: string, opts?: Options): string",
|
|
169
|
+
})
|
|
170
|
+
: n
|
|
171
|
+
),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const report = detectDrift(graph);
|
|
175
|
+
const driftIssues = report.issues.filter((i) => i.kind === "signature-drift");
|
|
176
|
+
expect(driftIssues.length).toBeGreaterThanOrEqual(1);
|
|
177
|
+
expect(driftIssues[0].nodeId).toBe("function:auth");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("does NOT report signature drift when signatures match", () => {
|
|
181
|
+
const sig = "authenticate(token: string): string";
|
|
182
|
+
const graph = makeGraph({
|
|
183
|
+
nodes: makeGraph().nodes.map((n) =>
|
|
184
|
+
n.id === "function:auth"
|
|
185
|
+
? makeNode("function:auth", {
|
|
186
|
+
signature: sig,
|
|
187
|
+
firstMethodSignature: sig,
|
|
188
|
+
})
|
|
189
|
+
: n
|
|
190
|
+
),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const report = detectDrift(graph);
|
|
194
|
+
expect(report.issues.filter((i) => i.kind === "signature-drift")).toHaveLength(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("populates driftedNodeIds with unique node IDs", () => {
|
|
198
|
+
const graph = makeGraph({
|
|
199
|
+
edges: [edge("node:ghost", "function:auth")],
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const report = detectDrift(graph);
|
|
203
|
+
expect(report.driftedNodeIds).toContain("function:auth");
|
|
204
|
+
expect(report.driftedNodeIds).not.toContain("node:ghost");
|
|
205
|
+
expect(
|
|
206
|
+
report.driftedNodeIds.filter((id) => id === "function:auth")
|
|
207
|
+
).toHaveLength(1);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("includes projectName and detectedAt in the report", () => {
|
|
211
|
+
const report = detectDrift(makeGraph());
|
|
212
|
+
expect(report.projectName).toBe("TestApp");
|
|
213
|
+
expect(report.detectedAt).toBeTruthy();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── healGraph ────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
describe("healGraph", () => {
|
|
220
|
+
it("returns unchanged graph when the report is healthy", () => {
|
|
221
|
+
const graph = makeGraph();
|
|
222
|
+
const report = detectDrift(graph);
|
|
223
|
+
const result = healGraph(graph, report);
|
|
224
|
+
|
|
225
|
+
expect(result.issuesFixed).toBe(0);
|
|
226
|
+
expect(result.graph.edges).toHaveLength(0);
|
|
227
|
+
expect(result.graph.nodes).toHaveLength(graph.nodes.length);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("removes broken edges", () => {
|
|
231
|
+
const graph = makeGraph({
|
|
232
|
+
edges: [edge("node:ghost", "function:auth"), edge("function:auth", "api:users")],
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const report = detectDrift(graph);
|
|
236
|
+
const result = healGraph(graph, report);
|
|
237
|
+
|
|
238
|
+
expect(result.graph.edges.some((e) => e.from === "node:ghost")).toBe(false);
|
|
239
|
+
expect(
|
|
240
|
+
result.graph.edges.some((e) => e.from === "function:auth" && e.to === "api:users")
|
|
241
|
+
).toBe(true);
|
|
242
|
+
expect(result.issuesFixed).toBeGreaterThanOrEqual(1);
|
|
243
|
+
expect(result.summary.some((s) => s.includes("Removed broken edge"))).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("adds missing edges from contract calls", () => {
|
|
247
|
+
const graph = makeGraph({
|
|
248
|
+
nodes: makeGraph().nodes.map((n) =>
|
|
249
|
+
n.id === "function:auth"
|
|
250
|
+
? makeNode("function:auth", { contractCalls: [{ target: "api:users", kind: "calls" }] })
|
|
251
|
+
: n
|
|
252
|
+
),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const report = detectDrift(graph);
|
|
256
|
+
const result = healGraph(graph, report);
|
|
257
|
+
|
|
258
|
+
expect(
|
|
259
|
+
result.graph.edges.some(
|
|
260
|
+
(e) => e.from === "function:auth" && e.to === "api:users" && e.kind === "calls"
|
|
261
|
+
)
|
|
262
|
+
).toBe(true);
|
|
263
|
+
expect(result.issuesFixed).toBeGreaterThanOrEqual(1);
|
|
264
|
+
expect(result.summary.some((s) => s.includes("Added missing edge"))).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("does not duplicate edges when healing the same missing edge twice", () => {
|
|
268
|
+
const graph = makeGraph({
|
|
269
|
+
nodes: makeGraph().nodes.map((n) =>
|
|
270
|
+
n.id === "function:auth"
|
|
271
|
+
? makeNode("function:auth", { contractCalls: [{ target: "api:users", kind: "calls" }] })
|
|
272
|
+
: n
|
|
273
|
+
),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const report = detectDrift(graph);
|
|
277
|
+
const result = healGraph(graph, report);
|
|
278
|
+
|
|
279
|
+
const edgesFromAuth = result.graph.edges.filter(
|
|
280
|
+
(e) => e.from === "function:auth" && e.to === "api:users"
|
|
281
|
+
);
|
|
282
|
+
expect(edgesFromAuth).toHaveLength(1);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("syncs signature drift to the first contract method signature", () => {
|
|
286
|
+
const correctedSig = "authenticate(token: string, opts?: Options): string";
|
|
287
|
+
const graph = makeGraph({
|
|
288
|
+
nodes: makeGraph().nodes.map((n) =>
|
|
289
|
+
n.id === "function:auth"
|
|
290
|
+
? makeNode("function:auth", {
|
|
291
|
+
signature: "authenticate(token: string): void",
|
|
292
|
+
firstMethodSignature: correctedSig,
|
|
293
|
+
})
|
|
294
|
+
: n
|
|
295
|
+
),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const report = detectDrift(graph);
|
|
299
|
+
const result = healGraph(graph, report);
|
|
300
|
+
|
|
301
|
+
const authNode = result.graph.nodes.find((n) => n.id === "function:auth");
|
|
302
|
+
expect(authNode?.signature).toBe(correctedSig);
|
|
303
|
+
expect(result.issuesFixed).toBeGreaterThanOrEqual(1);
|
|
304
|
+
expect(result.summary.some((s) => s.includes("Synced signature"))).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("does not mutate the original graph", () => {
|
|
308
|
+
const graph = makeGraph({
|
|
309
|
+
edges: [edge("node:ghost", "function:auth")],
|
|
310
|
+
});
|
|
311
|
+
const originalEdgeCount = graph.edges.length;
|
|
312
|
+
|
|
313
|
+
const report = detectDrift(graph);
|
|
314
|
+
healGraph(graph, report);
|
|
315
|
+
|
|
316
|
+
expect(graph.edges).toHaveLength(originalEdgeCount);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("includes provenance and maturity in the result", () => {
|
|
320
|
+
const graph = makeGraph();
|
|
321
|
+
const report = detectDrift(graph);
|
|
322
|
+
const result = healGraph(graph, report);
|
|
323
|
+
|
|
324
|
+
expect(result.projectName).toBe("TestApp");
|
|
325
|
+
expect(result.healedAt).toBeTruthy();
|
|
326
|
+
expect(result.provenance).toBe("deterministic");
|
|
327
|
+
expect(result.maturity).toBe("preview");
|
|
328
|
+
expect(result.scope).toBe("graph");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("synthesises one edge per distinct (from, to, kind) when multiple calls have different kinds", () => {
|
|
332
|
+
const graph = makeGraph({
|
|
333
|
+
nodes: makeGraph().nodes.map((n) =>
|
|
334
|
+
n.id === "function:auth"
|
|
335
|
+
? makeNode("function:auth", {
|
|
336
|
+
contractCalls: [
|
|
337
|
+
{ target: "api:users", kind: "calls" },
|
|
338
|
+
{ target: "api:users", kind: "reads-state" },
|
|
339
|
+
],
|
|
340
|
+
})
|
|
341
|
+
: n
|
|
342
|
+
),
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const report = detectDrift(graph);
|
|
346
|
+
const missingIssues = report.issues.filter((i) => i.kind === "missing-edge");
|
|
347
|
+
// Two distinct kinds → two distinct missing-edge issues.
|
|
348
|
+
expect(missingIssues).toHaveLength(2);
|
|
349
|
+
|
|
350
|
+
const result = healGraph(graph, report);
|
|
351
|
+
|
|
352
|
+
const callsEdges = result.graph.edges.filter(
|
|
353
|
+
(e) => e.from === "function:auth" && e.to === "api:users" && e.kind === "calls"
|
|
354
|
+
);
|
|
355
|
+
const readsEdges = result.graph.edges.filter(
|
|
356
|
+
(e) => e.from === "function:auth" && e.to === "api:users" && e.kind === "reads-state"
|
|
357
|
+
);
|
|
358
|
+
expect(callsEdges).toHaveLength(1);
|
|
359
|
+
expect(readsEdges).toHaveLength(1);
|
|
360
|
+
expect(result.issuesFixed).toBe(2);
|
|
361
|
+
});
|
|
362
|
+
});
|
package/src/refactor.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BlueprintEdge,
|
|
3
|
+
BlueprintEdgeKind,
|
|
4
|
+
BlueprintGraph,
|
|
5
|
+
BlueprintNode,
|
|
6
|
+
FeatureMaturity,
|
|
7
|
+
OutputProvenance,
|
|
8
|
+
} from "@/lib/blueprint/schema";
|
|
9
|
+
|
|
10
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/** The category of architectural drift that was detected. */
|
|
13
|
+
export type DriftKind = "broken-edge" | "missing-edge" | "signature-drift";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A single detected drift issue in the architecture graph.
|
|
17
|
+
*
|
|
18
|
+
* - `broken-edge` – An edge references a node ID that no longer exists.
|
|
19
|
+
* - `missing-edge` – A node's contract `calls` entry has no corresponding
|
|
20
|
+
* graph edge to the resolved target node.
|
|
21
|
+
* - `signature-drift` – The node's top-level `signature` field doesn't match
|
|
22
|
+
* the `signature` of its first contract method.
|
|
23
|
+
*/
|
|
24
|
+
export interface DriftIssue {
|
|
25
|
+
kind: DriftKind;
|
|
26
|
+
/** ID of the existing node most closely associated with this issue. */
|
|
27
|
+
nodeId: string;
|
|
28
|
+
nodeName: string;
|
|
29
|
+
description: string;
|
|
30
|
+
/** Source node ID of the affected edge (present for edge-related issues). */
|
|
31
|
+
edgeFrom?: string;
|
|
32
|
+
/** Target node ID of the affected edge (present for edge-related issues). */
|
|
33
|
+
edgeTo?: string;
|
|
34
|
+
/**
|
|
35
|
+
* The node ID referenced by the edge that no longer exists in the graph
|
|
36
|
+
* (only set for `broken-edge` issues where the missing ID differs from `nodeId`).
|
|
37
|
+
*/
|
|
38
|
+
missingNodeId?: string;
|
|
39
|
+
/**
|
|
40
|
+
* For `missing-edge` issues: the edge `kind` declared in the contract call.
|
|
41
|
+
* Used during healing to distinguish multiple calls between the same pair of
|
|
42
|
+
* nodes with different relationship kinds (e.g. `calls` vs `reads-state`).
|
|
43
|
+
*/
|
|
44
|
+
edgeKind?: BlueprintEdgeKind;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Summary of all drift issues detected in a graph. */
|
|
48
|
+
export interface RefactorReport {
|
|
49
|
+
projectName: string;
|
|
50
|
+
detectedAt: string;
|
|
51
|
+
provenance: OutputProvenance;
|
|
52
|
+
maturity: FeatureMaturity;
|
|
53
|
+
scope: "graph";
|
|
54
|
+
issues: DriftIssue[];
|
|
55
|
+
/** IDs of nodes that have at least one drift issue. */
|
|
56
|
+
driftedNodeIds: string[];
|
|
57
|
+
totalIssues: number;
|
|
58
|
+
/** `true` when no drift was found. */
|
|
59
|
+
isHealthy: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Result of a heal operation that auto-fixed drift issues. */
|
|
63
|
+
export interface HealResult {
|
|
64
|
+
projectName: string;
|
|
65
|
+
healedAt: string;
|
|
66
|
+
provenance: OutputProvenance;
|
|
67
|
+
maturity: FeatureMaturity;
|
|
68
|
+
scope: "graph";
|
|
69
|
+
issuesFixed: number;
|
|
70
|
+
graph: BlueprintGraph;
|
|
71
|
+
summary: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
const buildNodeIndex = (graph: BlueprintGraph): Map<string, BlueprintNode> =>
|
|
77
|
+
new Map(graph.nodes.map((n) => [n.id, n]));
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a contract call `target` (which may be a node ID or node name) to
|
|
81
|
+
* the matching blueprint node.
|
|
82
|
+
*/
|
|
83
|
+
const resolveCallTarget = (
|
|
84
|
+
graph: BlueprintGraph,
|
|
85
|
+
target: string
|
|
86
|
+
): BlueprintNode | undefined => {
|
|
87
|
+
const byId = graph.nodes.find((n) => n.id === target);
|
|
88
|
+
if (byId) return byId;
|
|
89
|
+
return graph.nodes.find((n) => n.name === target);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Detect architectural drift in a blueprint graph.
|
|
96
|
+
*
|
|
97
|
+
* Three kinds of drift are checked:
|
|
98
|
+
* 1. **Broken edges** – an edge's `from` or `to` points to a node ID that no
|
|
99
|
+
* longer exists in the graph.
|
|
100
|
+
* 2. **Missing edges** – a node's contract `calls` entry references a target
|
|
101
|
+
* that exists in the graph but has no corresponding edge.
|
|
102
|
+
* 3. **Signature drift** – the node's top-level `signature` field doesn't
|
|
103
|
+
* match the `signature` of its first contract method.
|
|
104
|
+
*/
|
|
105
|
+
export const detectDrift = (graph: BlueprintGraph): RefactorReport => {
|
|
106
|
+
const issues: DriftIssue[] = [];
|
|
107
|
+
const index = buildNodeIndex(graph);
|
|
108
|
+
|
|
109
|
+
// ── 1. Broken edges ────────────────────────────────────────────────────────
|
|
110
|
+
for (const edge of graph.edges) {
|
|
111
|
+
if (!index.has(edge.from)) {
|
|
112
|
+
const existingNode = index.get(edge.to);
|
|
113
|
+
issues.push({
|
|
114
|
+
kind: "broken-edge",
|
|
115
|
+
nodeId: existingNode?.id ?? edge.to,
|
|
116
|
+
nodeName: existingNode?.name ?? edge.to,
|
|
117
|
+
description: `Edge "${edge.from}" → "${edge.to}" references a non-existent source node.`,
|
|
118
|
+
edgeFrom: edge.from,
|
|
119
|
+
edgeTo: edge.to,
|
|
120
|
+
missingNodeId: edge.from,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!index.has(edge.to)) {
|
|
125
|
+
const existingNode = index.get(edge.from);
|
|
126
|
+
issues.push({
|
|
127
|
+
kind: "broken-edge",
|
|
128
|
+
nodeId: existingNode?.id ?? edge.from,
|
|
129
|
+
nodeName: existingNode?.name ?? edge.from,
|
|
130
|
+
description: `Edge "${edge.from}" → "${edge.to}" references a non-existent target node.`,
|
|
131
|
+
edgeFrom: edge.from,
|
|
132
|
+
edgeTo: edge.to,
|
|
133
|
+
missingNodeId: edge.to,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── 2. Missing edges + signature drift ────────────────────────────────────
|
|
139
|
+
for (const node of graph.nodes) {
|
|
140
|
+
// Signature drift: top-level signature doesn't match the first method's.
|
|
141
|
+
const firstMethod = node.contract.methods?.[0];
|
|
142
|
+
if (
|
|
143
|
+
node.signature &&
|
|
144
|
+
firstMethod?.signature &&
|
|
145
|
+
node.signature !== firstMethod.signature
|
|
146
|
+
) {
|
|
147
|
+
issues.push({
|
|
148
|
+
kind: "signature-drift",
|
|
149
|
+
nodeId: node.id,
|
|
150
|
+
nodeName: node.name,
|
|
151
|
+
description: `Node "${node.name}" signature "${node.signature}" does not match contract method "${firstMethod.signature}".`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Missing edges: contract calls with no corresponding graph edge.
|
|
156
|
+
for (const call of node.contract.calls ?? []) {
|
|
157
|
+
const targetNode = resolveCallTarget(graph, call.target);
|
|
158
|
+
if (!targetNode) continue; // target not in graph – not our responsibility here
|
|
159
|
+
|
|
160
|
+
const edgeKind = call.kind ?? "calls";
|
|
161
|
+
const edgeExists = graph.edges.some(
|
|
162
|
+
(e) => e.from === node.id && e.to === targetNode.id && e.kind === edgeKind
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (!edgeExists) {
|
|
166
|
+
issues.push({
|
|
167
|
+
kind: "missing-edge",
|
|
168
|
+
nodeId: node.id,
|
|
169
|
+
nodeName: node.name,
|
|
170
|
+
description: `Node "${node.name}" declares a "${edgeKind}" call to "${call.target}" in its contract but no graph edge exists.`,
|
|
171
|
+
edgeFrom: node.id,
|
|
172
|
+
edgeTo: targetNode.id,
|
|
173
|
+
edgeKind,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const driftedNodeIds = [...new Set(issues.map((i) => i.nodeId))];
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
projectName: graph.projectName,
|
|
183
|
+
detectedAt: new Date().toISOString(),
|
|
184
|
+
provenance: "deterministic",
|
|
185
|
+
maturity: "preview",
|
|
186
|
+
scope: "graph",
|
|
187
|
+
issues,
|
|
188
|
+
driftedNodeIds,
|
|
189
|
+
totalIssues: issues.length,
|
|
190
|
+
isHealthy: issues.length === 0,
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Auto-heal a blueprint graph based on a previously computed {@link RefactorReport}.
|
|
196
|
+
*
|
|
197
|
+
* Healing actions:
|
|
198
|
+
* - **Broken edges** are removed.
|
|
199
|
+
* - **Missing edges** are synthesised from the contract call definitions.
|
|
200
|
+
* - **Signature drift** is resolved by syncing the node's top-level
|
|
201
|
+
* `signature` to match its first contract method.
|
|
202
|
+
*
|
|
203
|
+
* The original graph is not mutated; a new graph object is returned.
|
|
204
|
+
*/
|
|
205
|
+
export const healGraph = (graph: BlueprintGraph, report: RefactorReport): HealResult => {
|
|
206
|
+
const index = buildNodeIndex(graph);
|
|
207
|
+
const summary: string[] = [];
|
|
208
|
+
let issuesFixed = 0;
|
|
209
|
+
|
|
210
|
+
// ── Remove broken edges ─────────────────────────────────────────────────────
|
|
211
|
+
const healedEdges = graph.edges.filter((edge) => {
|
|
212
|
+
if (!index.has(edge.from) || !index.has(edge.to)) {
|
|
213
|
+
summary.push(`Removed broken edge: ${edge.from} → ${edge.to}`);
|
|
214
|
+
issuesFixed++;
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ── Synthesise missing edges ────────────────────────────────────────────────
|
|
221
|
+
const newEdges: BlueprintEdge[] = [];
|
|
222
|
+
|
|
223
|
+
const missingEdgeIssues = report.issues.filter((i) => i.kind === "missing-edge");
|
|
224
|
+
|
|
225
|
+
for (const issue of missingEdgeIssues) {
|
|
226
|
+
if (!issue.edgeFrom || !issue.edgeTo) continue;
|
|
227
|
+
|
|
228
|
+
const issueEdgeKind = issue.edgeKind ?? "calls";
|
|
229
|
+
const alreadyAdded = newEdges.some(
|
|
230
|
+
(e) => e.from === issue.edgeFrom && e.to === issue.edgeTo && e.kind === issueEdgeKind
|
|
231
|
+
);
|
|
232
|
+
if (alreadyAdded) continue;
|
|
233
|
+
|
|
234
|
+
// Find the original contract call to preserve kind/label. Match on both
|
|
235
|
+
// target node ID and kind so that multiple calls between the same pair with
|
|
236
|
+
// different kinds each resolve to their own contract entry.
|
|
237
|
+
const fromNode = index.get(issue.edgeFrom);
|
|
238
|
+
const call = fromNode?.contract.calls?.find((c) => {
|
|
239
|
+
const target = resolveCallTarget(graph, c.target);
|
|
240
|
+
return target?.id === issue.edgeTo && (c.kind ?? "calls") === issueEdgeKind;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
newEdges.push({
|
|
244
|
+
from: issue.edgeFrom,
|
|
245
|
+
to: issue.edgeTo,
|
|
246
|
+
kind: issueEdgeKind,
|
|
247
|
+
required: false,
|
|
248
|
+
confidence: 0.8,
|
|
249
|
+
label: call?.description,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const fromName = fromNode?.name ?? issue.edgeFrom;
|
|
253
|
+
const toName = index.get(issue.edgeTo)?.name ?? issue.edgeTo;
|
|
254
|
+
summary.push(`Added missing edge: ${fromName} → ${toName}`);
|
|
255
|
+
issuesFixed++;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Fix signature drift ────────────────────────────────────────────────────
|
|
259
|
+
const healedNodes = graph.nodes.map((node) => {
|
|
260
|
+
const hasDrift = report.issues.some(
|
|
261
|
+
(i) => i.kind === "signature-drift" && i.nodeId === node.id
|
|
262
|
+
);
|
|
263
|
+
if (!hasDrift) return node;
|
|
264
|
+
|
|
265
|
+
const firstMethod = node.contract.methods?.[0];
|
|
266
|
+
if (!firstMethod?.signature) return node;
|
|
267
|
+
|
|
268
|
+
summary.push(
|
|
269
|
+
`Synced signature for "${node.name}": "${node.signature}" → "${firstMethod.signature}"`
|
|
270
|
+
);
|
|
271
|
+
issuesFixed++;
|
|
272
|
+
|
|
273
|
+
return { ...node, signature: firstMethod.signature };
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
projectName: graph.projectName,
|
|
278
|
+
healedAt: new Date().toISOString(),
|
|
279
|
+
provenance: "deterministic",
|
|
280
|
+
maturity: "preview",
|
|
281
|
+
scope: "graph",
|
|
282
|
+
issuesFixed,
|
|
283
|
+
graph: {
|
|
284
|
+
...graph,
|
|
285
|
+
nodes: healedNodes,
|
|
286
|
+
edges: [...healedEdges, ...newEdges],
|
|
287
|
+
},
|
|
288
|
+
summary,
|
|
289
|
+
};
|
|
290
|
+
};
|