@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,134 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { detectGraphConflicts } from "./conflicts";
|
|
6
|
+
import type { BlueprintGraph } from "@/lib/blueprint/schema";
|
|
7
|
+
import { emptyContract } from "@/lib/blueprint/schema";
|
|
8
|
+
|
|
9
|
+
const fixturePath = path.resolve(process.cwd(), "test-fixtures/sample-repo");
|
|
10
|
+
|
|
11
|
+
const node = (
|
|
12
|
+
id: string,
|
|
13
|
+
overrides: Partial<{
|
|
14
|
+
kind: BlueprintGraph["nodes"][number]["kind"];
|
|
15
|
+
path: string;
|
|
16
|
+
name: string;
|
|
17
|
+
summary: string;
|
|
18
|
+
signature: string;
|
|
19
|
+
sourceRefsPath: string;
|
|
20
|
+
}> = {}
|
|
21
|
+
): BlueprintGraph["nodes"][number] => ({
|
|
22
|
+
id,
|
|
23
|
+
kind: overrides.kind ?? "function",
|
|
24
|
+
name: overrides.name ?? id,
|
|
25
|
+
path: overrides.path,
|
|
26
|
+
summary: overrides.summary ?? id,
|
|
27
|
+
signature: overrides.signature,
|
|
28
|
+
contract: { ...emptyContract(), summary: overrides.summary ?? id },
|
|
29
|
+
sourceRefs: overrides.sourceRefsPath
|
|
30
|
+
? [{ kind: "repo" as const, path: overrides.sourceRefsPath, symbol: overrides.name ?? id }]
|
|
31
|
+
: [],
|
|
32
|
+
generatedRefs: [],
|
|
33
|
+
traceRefs: [],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("detectGraphConflicts", () => {
|
|
37
|
+
it("finds a signature-mismatch when blueprint signature diverges from repo", async () => {
|
|
38
|
+
const graph: BlueprintGraph = {
|
|
39
|
+
projectName: "Conflicts",
|
|
40
|
+
mode: "essential",
|
|
41
|
+
generatedAt: "2026-03-14T00:00:00.000Z",
|
|
42
|
+
warnings: [],
|
|
43
|
+
workflows: [],
|
|
44
|
+
edges: [],
|
|
45
|
+
nodes: [
|
|
46
|
+
node("function:normalize", {
|
|
47
|
+
kind: "function",
|
|
48
|
+
path: "src/services/task-service.ts",
|
|
49
|
+
name: "normalizeTask",
|
|
50
|
+
summary: "Wrong summary.",
|
|
51
|
+
signature: "normalizeTask(input: string): string",
|
|
52
|
+
sourceRefsPath: "src/services/task-service.ts",
|
|
53
|
+
}),
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const report = await detectGraphConflicts(graph, fixturePath);
|
|
58
|
+
|
|
59
|
+
expect(report.conflicts.some((c) => c.kind === "signature-mismatch")).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("finds missing-in-blueprint when repo has a symbol not in the blueprint", async () => {
|
|
63
|
+
const graph: BlueprintGraph = {
|
|
64
|
+
projectName: "MissingInBlueprint",
|
|
65
|
+
mode: "essential",
|
|
66
|
+
generatedAt: "2026-03-14T00:00:00.000Z",
|
|
67
|
+
warnings: [],
|
|
68
|
+
workflows: [],
|
|
69
|
+
edges: [],
|
|
70
|
+
nodes: [], // empty — all repo symbols should be reported missing
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const report = await detectGraphConflicts(graph, fixturePath);
|
|
74
|
+
|
|
75
|
+
expect(report.conflicts.some((c) => c.kind === "missing-in-blueprint")).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns empty conflicts for an empty graph and empty repo", async () => {
|
|
79
|
+
// Using a path that exists but has no matching symbols
|
|
80
|
+
const report = await detectGraphConflicts(
|
|
81
|
+
{
|
|
82
|
+
projectName: "Empty",
|
|
83
|
+
mode: "essential",
|
|
84
|
+
generatedAt: "2026-03-14T00:00:00.000Z",
|
|
85
|
+
warnings: [],
|
|
86
|
+
workflows: [],
|
|
87
|
+
edges: [],
|
|
88
|
+
nodes: [],
|
|
89
|
+
},
|
|
90
|
+
fixturePath
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// sample-repo has symbols, so missing-in-blueprint will fire
|
|
94
|
+
// but there should be no signature-mismatch
|
|
95
|
+
expect(report.conflicts.every((c) => c.kind === "missing-in-blueprint")).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns a valid checkedAt timestamp", async () => {
|
|
99
|
+
const report = await detectGraphConflicts(
|
|
100
|
+
{
|
|
101
|
+
projectName: "Timestamp",
|
|
102
|
+
mode: "essential",
|
|
103
|
+
generatedAt: "2026-03-14T00:00:00.000Z",
|
|
104
|
+
warnings: [],
|
|
105
|
+
workflows: [],
|
|
106
|
+
edges: [],
|
|
107
|
+
nodes: [],
|
|
108
|
+
},
|
|
109
|
+
fixturePath
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(() => new Date(report.checkedAt)).not.toThrow();
|
|
113
|
+
expect(report.repoPath).toBe(path.resolve(fixturePath));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("includes suggestedAction on every conflict", async () => {
|
|
117
|
+
const report = await detectGraphConflicts(
|
|
118
|
+
{
|
|
119
|
+
projectName: "Suggestions",
|
|
120
|
+
mode: "essential",
|
|
121
|
+
generatedAt: "2026-03-14T00:00:00.000Z",
|
|
122
|
+
warnings: [],
|
|
123
|
+
workflows: [],
|
|
124
|
+
edges: [],
|
|
125
|
+
nodes: [],
|
|
126
|
+
},
|
|
127
|
+
fixturePath
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
for (const conflict of report.conflicts) {
|
|
131
|
+
expect(conflict.suggestedAction.length).toBeGreaterThan(0);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
package/src/conflicts.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { analyzeTypeScriptRepo } from "@/lib/blueprint/repo";
|
|
4
|
+
import type {
|
|
5
|
+
BlueprintGraph,
|
|
6
|
+
BlueprintNode,
|
|
7
|
+
ConflictRecord,
|
|
8
|
+
ConflictReport,
|
|
9
|
+
} from "@/lib/blueprint/schema";
|
|
10
|
+
|
|
11
|
+
const repoKeyForNode = (node: BlueprintNode): string =>
|
|
12
|
+
`${node.kind}:${node.path ?? ""}:${node.name}`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Detect structural conflicts between a blueprint graph and a live TypeScript repository.
|
|
16
|
+
*
|
|
17
|
+
* Conflicts detected:
|
|
18
|
+
* - `missing-in-repo` – blueprint node has no corresponding symbol in the repo snapshot.
|
|
19
|
+
* - `missing-in-blueprint` – repo has a symbol not represented in the blueprint.
|
|
20
|
+
* - `signature-mismatch` – blueprint node `signature` differs from the repo-derived signature.
|
|
21
|
+
* - `summary-mismatch` – blueprint node `summary` differs from the repo-derived summary.
|
|
22
|
+
*/
|
|
23
|
+
export const detectGraphConflicts = async (
|
|
24
|
+
graph: BlueprintGraph,
|
|
25
|
+
repoPath: string
|
|
26
|
+
): Promise<ConflictReport> => {
|
|
27
|
+
const repoGraph = await analyzeTypeScriptRepo(path.resolve(repoPath));
|
|
28
|
+
const conflicts: ConflictRecord[] = [];
|
|
29
|
+
|
|
30
|
+
// Only consider code-bearing nodes (not modules, which are structural containers).
|
|
31
|
+
const repoNodes = repoGraph.nodes.filter((node) => node.kind !== "module");
|
|
32
|
+
const blueprintRepoNodes = graph.nodes.filter((node) =>
|
|
33
|
+
node.sourceRefs.some((ref) => ref.kind === "repo")
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const repoMap = new Map(repoNodes.map((node) => [repoKeyForNode(node), node]));
|
|
37
|
+
const blueprintMap = new Map(blueprintRepoNodes.map((node) => [repoKeyForNode(node), node]));
|
|
38
|
+
|
|
39
|
+
// Check each blueprint node against the repo snapshot.
|
|
40
|
+
for (const blueprintNode of blueprintRepoNodes) {
|
|
41
|
+
const repoNode = repoMap.get(repoKeyForNode(blueprintNode));
|
|
42
|
+
|
|
43
|
+
if (!repoNode) {
|
|
44
|
+
conflicts.push({
|
|
45
|
+
kind: "missing-in-repo",
|
|
46
|
+
nodeId: blueprintNode.id,
|
|
47
|
+
path: blueprintNode.path,
|
|
48
|
+
blueprintValue: blueprintNode.name,
|
|
49
|
+
message: `${blueprintNode.name} is in the blueprint but not in the repo snapshot.`,
|
|
50
|
+
suggestedAction:
|
|
51
|
+
"Remove the node from the blueprint or recreate it in the repo.",
|
|
52
|
+
});
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if ((blueprintNode.signature ?? "") !== (repoNode.signature ?? "")) {
|
|
57
|
+
conflicts.push({
|
|
58
|
+
kind: "signature-mismatch",
|
|
59
|
+
nodeId: blueprintNode.id,
|
|
60
|
+
path: blueprintNode.path,
|
|
61
|
+
blueprintValue: blueprintNode.signature,
|
|
62
|
+
repoValue: repoNode.signature,
|
|
63
|
+
message: `${blueprintNode.name} has a different signature in the repo.`,
|
|
64
|
+
suggestedAction:
|
|
65
|
+
"Refresh the blueprint contract from the repo or update the implementation.",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (
|
|
70
|
+
blueprintNode.summary &&
|
|
71
|
+
repoNode.summary &&
|
|
72
|
+
blueprintNode.summary !== repoNode.summary
|
|
73
|
+
) {
|
|
74
|
+
conflicts.push({
|
|
75
|
+
kind: "summary-mismatch",
|
|
76
|
+
nodeId: blueprintNode.id,
|
|
77
|
+
path: blueprintNode.path,
|
|
78
|
+
blueprintValue: blueprintNode.summary,
|
|
79
|
+
repoValue: repoNode.summary,
|
|
80
|
+
message: `${blueprintNode.name} summary diverges from the repo-derived description.`,
|
|
81
|
+
suggestedAction:
|
|
82
|
+
"Review the contract summary and align it with current behavior.",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Detect repo symbols missing from the blueprint.
|
|
88
|
+
for (const repoNode of repoNodes) {
|
|
89
|
+
if (!blueprintMap.has(repoKeyForNode(repoNode))) {
|
|
90
|
+
conflicts.push({
|
|
91
|
+
kind: "missing-in-blueprint",
|
|
92
|
+
path: repoNode.path,
|
|
93
|
+
repoValue: repoNode.name,
|
|
94
|
+
message: `${repoNode.name} exists in the repo but is not represented in the blueprint.`,
|
|
95
|
+
suggestedAction:
|
|
96
|
+
"Add the node to the blueprint or mark it intentionally out of scope.",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
checkedAt: new Date().toISOString(),
|
|
103
|
+
repoPath: path.resolve(repoPath),
|
|
104
|
+
conflicts,
|
|
105
|
+
};
|
|
106
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { detectCycles, hasCycles } from "./cycles";
|
|
4
|
+
import type { BlueprintGraph } from "@/lib/blueprint/schema";
|
|
5
|
+
import { emptyContract } from "@/lib/blueprint/schema";
|
|
6
|
+
|
|
7
|
+
const node = (id: string): BlueprintGraph["nodes"][number] => ({
|
|
8
|
+
id,
|
|
9
|
+
kind: "module",
|
|
10
|
+
name: id,
|
|
11
|
+
summary: id,
|
|
12
|
+
contract: emptyContract(),
|
|
13
|
+
sourceRefs: [],
|
|
14
|
+
generatedRefs: [],
|
|
15
|
+
traceRefs: [],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const edge = (from: string, to: string): BlueprintGraph["edges"][number] => ({
|
|
19
|
+
from,
|
|
20
|
+
to,
|
|
21
|
+
kind: "calls",
|
|
22
|
+
required: true,
|
|
23
|
+
confidence: 1,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const graph = (
|
|
27
|
+
projectName: string,
|
|
28
|
+
nodes: BlueprintGraph["nodes"],
|
|
29
|
+
edges: BlueprintGraph["edges"]
|
|
30
|
+
): BlueprintGraph => ({
|
|
31
|
+
projectName,
|
|
32
|
+
mode: "essential",
|
|
33
|
+
generatedAt: "2026-03-14T00:00:00.000Z",
|
|
34
|
+
warnings: [],
|
|
35
|
+
workflows: [],
|
|
36
|
+
nodes,
|
|
37
|
+
edges,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("detectCycles", () => {
|
|
41
|
+
it("returns no cycles for a DAG", () => {
|
|
42
|
+
const report = detectCycles(
|
|
43
|
+
graph(
|
|
44
|
+
"DAG",
|
|
45
|
+
[node("A"), node("B"), node("C")],
|
|
46
|
+
[edge("A", "B"), edge("B", "C")]
|
|
47
|
+
)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(report.totalCycles).toBe(0);
|
|
51
|
+
expect(report.affectedNodeIds).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("detects a simple two-node cycle", () => {
|
|
55
|
+
const report = detectCycles(
|
|
56
|
+
graph("TwoNodeCycle", [node("A"), node("B")], [edge("A", "B"), edge("B", "A")])
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(report.totalCycles).toBe(1);
|
|
60
|
+
expect(report.affectedNodeIds).toContain("A");
|
|
61
|
+
expect(report.affectedNodeIds).toContain("B");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("detects multiple independent cycles", () => {
|
|
65
|
+
const report = detectCycles(
|
|
66
|
+
graph(
|
|
67
|
+
"MultiCycle",
|
|
68
|
+
[node("A"), node("B"), node("C"), node("D")],
|
|
69
|
+
[edge("A", "B"), edge("B", "A"), edge("C", "D"), edge("D", "C")]
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(report.totalCycles).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("handles empty graph", () => {
|
|
77
|
+
const report = detectCycles(graph("Empty", [], []));
|
|
78
|
+
|
|
79
|
+
expect(report.totalCycles).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("detects a self-loop edge as a cycle", () => {
|
|
83
|
+
const report = detectCycles(
|
|
84
|
+
graph("SelfLoop", [node("A")], [{ from: "A", to: "A", kind: "calls", required: true, confidence: 1 }])
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(report.totalCycles).toBe(1);
|
|
88
|
+
expect(report.affectedNodeIds).toContain("A");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns maxCycleLength correctly", () => {
|
|
92
|
+
const report = detectCycles(
|
|
93
|
+
graph(
|
|
94
|
+
"ThreeCycle",
|
|
95
|
+
[node("A"), node("B"), node("C")],
|
|
96
|
+
[edge("A", "B"), edge("B", "C"), edge("C", "A")]
|
|
97
|
+
)
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(report.totalCycles).toBe(1);
|
|
101
|
+
expect(report.maxCycleLength).toBe(3);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("cycles array contains edges belonging to the SCC", () => {
|
|
105
|
+
const report = detectCycles(
|
|
106
|
+
graph("EdgeCycle", [node("X"), node("Y")], [edge("X", "Y"), edge("Y", "X")])
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const cycle = report.cycles[0];
|
|
110
|
+
expect(cycle.nodeIds).toContain("X");
|
|
111
|
+
expect(cycle.nodeIds).toContain("Y");
|
|
112
|
+
expect(cycle.edges).toHaveLength(2);
|
|
113
|
+
expect(cycle.edges.map((e) => `${e.from}→${e.to}`)).toEqual(expect.arrayContaining(["X→Y", "Y→X"]));
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("hasCycles", () => {
|
|
118
|
+
it("returns false for a DAG", () => {
|
|
119
|
+
expect(
|
|
120
|
+
hasCycles(
|
|
121
|
+
graph("DAG", [node("A"), node("B")], [edge("A", "B")])
|
|
122
|
+
)
|
|
123
|
+
).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns true when a two-node cycle exists", () => {
|
|
127
|
+
expect(
|
|
128
|
+
hasCycles(
|
|
129
|
+
graph("Cyclic", [node("A"), node("B")], [edge("A", "B"), edge("B", "A")])
|
|
130
|
+
)
|
|
131
|
+
).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns true for a self-loop", () => {
|
|
135
|
+
expect(
|
|
136
|
+
hasCycles(
|
|
137
|
+
graph("SelfLoop", [node("A")], [{ from: "A", to: "A", kind: "calls", required: true, confidence: 1 }])
|
|
138
|
+
)
|
|
139
|
+
).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns false for empty graph", () => {
|
|
143
|
+
expect(hasCycles(graph("Empty", [], []))).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
});
|
package/src/cycles.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import type { BlueprintGraph } from "@/lib/blueprint/schema";
|
|
4
|
+
|
|
5
|
+
export const cycleSchema = z.object({
|
|
6
|
+
nodeIds: z.array(z.string()),
|
|
7
|
+
edges: z.array(
|
|
8
|
+
z.object({
|
|
9
|
+
from: z.string(),
|
|
10
|
+
to: z.string(),
|
|
11
|
+
kind: z.string(),
|
|
12
|
+
})
|
|
13
|
+
),
|
|
14
|
+
});
|
|
15
|
+
export type Cycle = z.infer<typeof cycleSchema>;
|
|
16
|
+
|
|
17
|
+
export const cycleReportSchema = z.object({
|
|
18
|
+
analyzedAt: z.string(),
|
|
19
|
+
totalCycles: z.number(),
|
|
20
|
+
maxCycleLength: z.number(),
|
|
21
|
+
cycles: z.array(cycleSchema),
|
|
22
|
+
affectedNodeIds: z.array(z.string()),
|
|
23
|
+
});
|
|
24
|
+
export type CycleReport = z.infer<typeof cycleReportSchema>;
|
|
25
|
+
|
|
26
|
+
const tarjanIterative = (nodeIds: string[], adjacency: Map<string, string[]>): string[][] => {
|
|
27
|
+
const indices = new Map<string, number>();
|
|
28
|
+
const lowlinks = new Map<string, number>();
|
|
29
|
+
const onStack = new Set<string>();
|
|
30
|
+
const stack: string[] = [];
|
|
31
|
+
const sccs: string[][] = [];
|
|
32
|
+
let index = 0;
|
|
33
|
+
|
|
34
|
+
type Frame = {
|
|
35
|
+
node: string;
|
|
36
|
+
neighborIndex: number;
|
|
37
|
+
neighbors: string[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
for (const root of nodeIds) {
|
|
41
|
+
if (indices.has(root)) continue;
|
|
42
|
+
|
|
43
|
+
const callStack: Frame[] = [{ node: root, neighborIndex: 0, neighbors: adjacency.get(root) ?? [] }];
|
|
44
|
+
indices.set(root, index);
|
|
45
|
+
lowlinks.set(root, index);
|
|
46
|
+
index++;
|
|
47
|
+
stack.push(root);
|
|
48
|
+
onStack.add(root);
|
|
49
|
+
|
|
50
|
+
while (callStack.length > 0) {
|
|
51
|
+
const frame = callStack[callStack.length - 1];
|
|
52
|
+
|
|
53
|
+
if (frame.neighborIndex < frame.neighbors.length) {
|
|
54
|
+
const neighbor = frame.neighbors[frame.neighborIndex];
|
|
55
|
+
frame.neighborIndex++;
|
|
56
|
+
|
|
57
|
+
if (!indices.has(neighbor)) {
|
|
58
|
+
indices.set(neighbor, index);
|
|
59
|
+
lowlinks.set(neighbor, index);
|
|
60
|
+
index++;
|
|
61
|
+
stack.push(neighbor);
|
|
62
|
+
onStack.add(neighbor);
|
|
63
|
+
callStack.push({ node: neighbor, neighborIndex: 0, neighbors: adjacency.get(neighbor) ?? [] });
|
|
64
|
+
} else if (onStack.has(neighbor)) {
|
|
65
|
+
lowlinks.set(frame.node, Math.min(lowlinks.get(frame.node)!, lowlinks.get(neighbor)!));
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
if (lowlinks.get(frame.node) === indices.get(frame.node)) {
|
|
69
|
+
const scc: string[] = [];
|
|
70
|
+
let w: string;
|
|
71
|
+
do {
|
|
72
|
+
w = stack.pop()!;
|
|
73
|
+
onStack.delete(w);
|
|
74
|
+
scc.push(w);
|
|
75
|
+
} while (w !== frame.node);
|
|
76
|
+
sccs.push(scc);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
callStack.pop();
|
|
80
|
+
if (callStack.length > 0) {
|
|
81
|
+
const parent = callStack[callStack.length - 1];
|
|
82
|
+
lowlinks.set(parent.node, Math.min(lowlinks.get(parent.node)!, lowlinks.get(frame.node)!));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return sccs;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Detect all directed cycles in a blueprint graph using Tarjan's strongly-connected
|
|
93
|
+
* components algorithm (iterative, stack-safe).
|
|
94
|
+
*
|
|
95
|
+
* Self-loop edges (from === to) are detected separately and treated as single-node cycles.
|
|
96
|
+
*/
|
|
97
|
+
export const detectCycles = (graph: BlueprintGraph): CycleReport => {
|
|
98
|
+
const nodeIds = graph.nodes.map((n) => n.id);
|
|
99
|
+
const adjacency = new Map<string, string[]>();
|
|
100
|
+
|
|
101
|
+
for (const id of nodeIds) {
|
|
102
|
+
adjacency.set(id, []);
|
|
103
|
+
}
|
|
104
|
+
for (const edge of graph.edges) {
|
|
105
|
+
adjacency.get(edge.from)?.push(edge.to);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const sccs = tarjanIterative(nodeIds, adjacency);
|
|
109
|
+
|
|
110
|
+
// A self-loop (from === to) is a genuine cycle but Tarjan's SCC returns it as
|
|
111
|
+
// a size-1 SCC. Detect them separately and treat them as single-node cycles.
|
|
112
|
+
const selfLoopNodeIds = new Set(
|
|
113
|
+
graph.edges.filter((e) => e.from === e.to).map((e) => e.from)
|
|
114
|
+
);
|
|
115
|
+
const selfLoopSccs: string[][] = [...selfLoopNodeIds].map((id) => [id]);
|
|
116
|
+
|
|
117
|
+
const sccSet = [
|
|
118
|
+
...sccs.filter((scc) => scc.length >= 2),
|
|
119
|
+
...selfLoopSccs
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const cycles: Cycle[] = sccSet.map((scc) => {
|
|
123
|
+
const memberSet = new Set(scc);
|
|
124
|
+
const edges = graph.edges
|
|
125
|
+
.filter((e) => memberSet.has(e.from) && memberSet.has(e.to))
|
|
126
|
+
.map((e) => ({ from: e.from, to: e.to, kind: e.kind }));
|
|
127
|
+
return { nodeIds: scc, edges };
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const affectedNodeIds = [...new Set(cycles.flatMap((c) => c.nodeIds))];
|
|
131
|
+
const maxCycleLength = cycles.reduce((max, c) => Math.max(max, c.nodeIds.length), 0);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
analyzedAt: new Date().toISOString(),
|
|
135
|
+
totalCycles: cycles.length,
|
|
136
|
+
maxCycleLength,
|
|
137
|
+
cycles,
|
|
138
|
+
affectedNodeIds,
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Returns true if the graph contains at least one directed cycle.
|
|
144
|
+
* Faster than detectCycles — stops early on the first cycle found.
|
|
145
|
+
*/
|
|
146
|
+
export const hasCycles = (graph: BlueprintGraph): boolean => {
|
|
147
|
+
if (graph.edges.some((e) => e.from === e.to)) return true;
|
|
148
|
+
|
|
149
|
+
const nodeIds = graph.nodes.map((n) => n.id);
|
|
150
|
+
const adjacency = new Map<string, string[]>();
|
|
151
|
+
|
|
152
|
+
for (const id of nodeIds) {
|
|
153
|
+
adjacency.set(id, []);
|
|
154
|
+
}
|
|
155
|
+
for (const edge of graph.edges) {
|
|
156
|
+
adjacency.get(edge.from)?.push(edge.to);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const sccs = tarjanIterative(nodeIds, adjacency);
|
|
160
|
+
return sccs.some((scc) => scc.length >= 2);
|
|
161
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
import { detectGraphConflicts } from "../conflicts";
|
|
4
|
+
import { conflictCheckRequestSchema } from "@/lib/blueprint/schema";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /api/conflicts
|
|
8
|
+
*
|
|
9
|
+
* Body: { graph: BlueprintGraph, repoPath: string }
|
|
10
|
+
*
|
|
11
|
+
* Compares a blueprint graph against a live TypeScript repository,
|
|
12
|
+
* detecting signature mismatches, summary mismatches, missing-in-repo
|
|
13
|
+
* nodes, and missing-in-blueprint symbols.
|
|
14
|
+
*/
|
|
15
|
+
export async function POST(request: Request) {
|
|
16
|
+
try {
|
|
17
|
+
const payload = conflictCheckRequestSchema.parse(await request.json());
|
|
18
|
+
const report = await detectGraphConflicts(payload.graph, payload.repoPath);
|
|
19
|
+
|
|
20
|
+
return NextResponse.json({ report });
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return NextResponse.json(
|
|
23
|
+
{
|
|
24
|
+
error: error instanceof Error ? error.message : "Failed to analyze graph conflicts.",
|
|
25
|
+
},
|
|
26
|
+
{ status: 400 }
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
import { detectCycles, hasCycles } from "../cycles";
|
|
4
|
+
import { blueprintGraphSchema } from "@/lib/blueprint/schema";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /api/analysis/cycles
|
|
8
|
+
*
|
|
9
|
+
* Body: {@link BlueprintGraph}
|
|
10
|
+
*
|
|
11
|
+
* Returns a cycle detection report for the submitted blueprint graph.
|
|
12
|
+
* Includes total cycle count, affected node IDs, per-cycle edge details,
|
|
13
|
+
* and a convenience `hasCycles` boolean.
|
|
14
|
+
*/
|
|
15
|
+
export async function POST(request: Request) {
|
|
16
|
+
try {
|
|
17
|
+
const payload = blueprintGraphSchema.parse(await request.json());
|
|
18
|
+
const report = detectCycles(payload);
|
|
19
|
+
|
|
20
|
+
return NextResponse.json({ report: { ...report, hasCycles: hasCycles(payload) } });
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return NextResponse.json(
|
|
23
|
+
{
|
|
24
|
+
error: error instanceof Error ? error.message : "Failed to detect dependency cycles.",
|
|
25
|
+
},
|
|
26
|
+
{ status: 400 }
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
import { computeGraphMetrics } from "../metrics";
|
|
4
|
+
import { blueprintGraphSchema } from "@/lib/blueprint/schema";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /api/analysis/metrics
|
|
8
|
+
*
|
|
9
|
+
* Body: {@link BlueprintGraph}
|
|
10
|
+
*
|
|
11
|
+
* Returns structural graph metrics: node/edge counts, degree statistics,
|
|
12
|
+
* density, connected components, and contract-level averages.
|
|
13
|
+
*/
|
|
14
|
+
export async function POST(request: Request) {
|
|
15
|
+
try {
|
|
16
|
+
const payload = blueprintGraphSchema.parse(await request.json());
|
|
17
|
+
const metrics = computeGraphMetrics(payload);
|
|
18
|
+
|
|
19
|
+
return NextResponse.json({ metrics });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return NextResponse.json(
|
|
22
|
+
{
|
|
23
|
+
error: error instanceof Error ? error.message : "Failed to compute graph metrics.",
|
|
24
|
+
},
|
|
25
|
+
{ status: 400 }
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
import { detectDrift } from "../refactor";
|
|
4
|
+
import { blueprintGraphSchema } from "@/lib/blueprint/schema";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /api/refactor/detect
|
|
8
|
+
*
|
|
9
|
+
* Body: {@link BlueprintGraph}
|
|
10
|
+
*
|
|
11
|
+
* Returns a {@link RefactorReport} describing all detected drift issues:
|
|
12
|
+
* broken edges, missing edges, and signature drift.
|
|
13
|
+
*/
|
|
14
|
+
export async function POST(request: Request) {
|
|
15
|
+
try {
|
|
16
|
+
const graph = blueprintGraphSchema.parse(await request.json());
|
|
17
|
+
const report = detectDrift(graph);
|
|
18
|
+
|
|
19
|
+
return NextResponse.json({ report });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return NextResponse.json(
|
|
22
|
+
{
|
|
23
|
+
error: error instanceof Error ? error.message : "Failed to detect architectural drift.",
|
|
24
|
+
},
|
|
25
|
+
{ status: 400 }
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
import { detectDrift, healGraph } from "../refactor";
|
|
4
|
+
import { blueprintGraphSchema } from "@/lib/blueprint/schema";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /api/refactor/heal
|
|
8
|
+
*
|
|
9
|
+
* Body: {@link BlueprintGraph}
|
|
10
|
+
*
|
|
11
|
+
* Detects all drift issues, then auto-heals the graph:
|
|
12
|
+
* removes broken edges, synthesises missing edges from contract calls,
|
|
13
|
+
* and syncs node signatures to match their first contract method.
|
|
14
|
+
*
|
|
15
|
+
* Returns both the detection report and the healed graph.
|
|
16
|
+
*/
|
|
17
|
+
export async function POST(request: Request) {
|
|
18
|
+
try {
|
|
19
|
+
const graph = blueprintGraphSchema.parse(await request.json());
|
|
20
|
+
const report = detectDrift(graph);
|
|
21
|
+
const result = healGraph(graph, report);
|
|
22
|
+
|
|
23
|
+
return NextResponse.json({ report, result });
|
|
24
|
+
} catch (error) {
|
|
25
|
+
return NextResponse.json(
|
|
26
|
+
{
|
|
27
|
+
error: error instanceof Error ? error.message : "Failed to heal architectural drift.",
|
|
28
|
+
},
|
|
29
|
+
{ status: 400 }
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|