Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/components/ui/CodeWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Diagram from "./Diagram";
import { saveGraph } from "@/lib/graphs";
import type { Graph } from "@/lib/analysis/types";
import { graphSchema } from "@/lib/validation";

const LANGUAGES = ["python", "javascript", "typescript", "go", "rust", "sql"];

Expand Down Expand Up @@ -297,7 +298,12 @@ export default function CodeWorkspace({
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Error");
setGraph(data as Graph);

const parsed = graphSchema.safeParse(data);
if (!parsed.success) {
throw new Error("Invalid graph data returned by the API.");
}
setGraph(parsed.data as Graph);
Comment on lines +302 to +306

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

The new validation still lets malformed graph shapes cross the API boundary.

graphSchema only checks a minimal subset of node/edge fields and allows everything else through with .loose(), so invalid parent/layout fields can still reach Diagram. Line 306 then hides that drift again with as Graph. Please align the schema with the full GraphNode/GraphEdge contract and let parsed.data type-check without a cast.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/CodeWorkspace.tsx` around lines 302 - 306, The graph
validation in CodeWorkspace is still too permissive because graphSchema uses a
loose shape and the result is force-cast to Graph, allowing invalid node/edge
fields like parent and layout metadata to slip through. Tighten graphSchema so
it fully matches the GraphNode and GraphEdge contract used by Diagram, remove
the .loose() behavior for fields that must be validated, and update the
setGraph(parsed.data ...) path so parsed.data is accepted by TypeScript without
any cast.

} catch (err) {
setError(err instanceof Error ? err.message : "Error");
setGraph(null);
Expand Down
37 changes: 37 additions & 0 deletions src/components/ui/Diagram.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, test } from "vitest";
import { layout } from "./Diagram";
import type { Graph } from "@/lib/analysis/types";

describe("layout", () => {
test("handles basic linear parent hierarchy without crashing", () => {
const graph: Graph = {
nodes: [
{ id: "root", label: "root", type: "module" },
{ id: "child", label: "child", type: "function", parent: "root" },
],
edges: [],
};
const result = layout(graph, false);
expect(result.nodes.length).toBe(2);
});

test("breaks out of parent reference cycles to avoid infinite loops and stack overflow", () => {
// Node A's parent is B, and Node B's parent is A
const graph: Graph = {
nodes: [
{ id: "A", label: "Node A", type: "module", parent: "B" },
{ id: "B", label: "Node B", type: "module", parent: "A" },
],
edges: [],
};

// This should run quickly and complete without throwing "Maximum call stack size exceeded"
// or entering an infinite loop.
const startTime = Date.now();
const result = layout(graph, false);
const duration = Date.now() - startTime;

expect(duration).toBeLessThan(1000); // Must complete almost instantly
expect(result.nodes.length).toBe(2);
});
});
46 changes: 39 additions & 7 deletions src/components/ui/Diagram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { toPng, toJpeg, toSvg } from "html-to-image";
import dagre from "@dagrejs/dagre";
import "reactflow/dist/style.css";
import type { Graph, GraphNode, TableColumn } from "@/lib/analysis/types";
import { ErrorBoundary } from "./ErrorBoundary";

const NODE_W = 156;
const NODE_H = 40;
Expand Down Expand Up @@ -140,7 +141,7 @@ const isContainer = (n: GraphNode) => n.type === "module" || n.type === "class";

type Layout = { nodes: Node[]; edges: Edge[] };

function layout(graph: Graph, dark: boolean): Layout {
export function layout(graph: Graph, dark: boolean): Layout {
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
const childrenOf = new Map<string, GraphNode[]>();
const roots: GraphNode[] = [];
Expand All @@ -160,7 +161,12 @@ function layout(graph: Graph, dark: boolean): Layout {
// Direct child of `container` that is, or contains, `nodeId`.
function ancestorIn(nodeId: string, container: string): string | null {
let cur: string | undefined = nodeId;
const visited = new Set<string>();
while (cur) {
if (visited.has(cur)) {
return null;
}
visited.add(cur);
const n = byId.get(cur);
if (!n) return null;
if (n.parent === container) return cur;
Expand All @@ -169,12 +175,25 @@ function layout(graph: Graph, dark: boolean): Layout {
return null;
}

const visitedSizeOf = new Set<string>();

// Lay out a container's direct children (recursively) and return its size.
function sizeOf(id: string): { w: number; h: number } {
const node = byId.get(id)!;
if (!isContainer(node)) return { w: NODE_W, h: NODE_H };
if (visitedSizeOf.has(id)) {
return { w: NODE_W, h: NODE_H };
}
visitedSizeOf.add(id);

const node = byId.get(id);
if (!node || !isContainer(node)) {
visitedSizeOf.delete(id);
return { w: NODE_W, h: NODE_H };
}
const kids = childrenOf.get(id) ?? [];
if (kids.length === 0) return { w: 200, h: HEADER + 14 };
if (kids.length === 0) {
visitedSizeOf.delete(id);
return { w: 200, h: HEADER + 14 };
}

const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: "TB", nodesep: 24, ranksep: 40 });
Expand Down Expand Up @@ -207,6 +226,7 @@ function layout(graph: Graph, dark: boolean): Layout {
maxX = Math.max(maxX, rx + s.w);
maxY = Math.max(maxY, ry + s.h);
}
visitedSizeOf.delete(id);
return { w: maxX + PAD, h: maxY + PAD };
}

Expand All @@ -222,7 +242,12 @@ function layout(graph: Graph, dark: boolean): Layout {
// Top-level layout of modules (imports + cross-module calls/extends).
const topAncestor = (nodeId: string): string | null => {
let cur: string | undefined = nodeId;
const visited = new Set<string>();
while (cur) {
if (visited.has(cur)) {
return null;
}
visited.add(cur);
const n = byId.get(cur);
if (!n) return null;
if (!n.parent) return cur;
Expand Down Expand Up @@ -252,7 +277,12 @@ function layout(graph: Graph, dark: boolean): Layout {
const depthOf = (n: GraphNode): number => {
let d = 0;
let cur = n.parent;
const visited = new Set<string>();
while (cur) {
if (visited.has(cur)) {
break;
}
visited.add(cur);
d++;
cur = byId.get(cur)?.parent;
}
Expand Down Expand Up @@ -647,8 +677,10 @@ function DiagramFlow({

export default function Diagram(props: { graph: Graph; emptyLabel: string }) {
return (
<ReactFlowProvider>
<DiagramFlow {...props} />
</ReactFlowProvider>
<ErrorBoundary>
<ReactFlowProvider>
<DiagramFlow {...props} />
</ReactFlowProvider>
</ErrorBoundary>
Comment on lines +680 to +684

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Tie the boundary to graph so it recovers on new input.

Because ErrorBoundary does not reset on prop changes (see ErrorBoundary.tsx), a render failure for one graph will keep showing the fallback even after CodeWorkspace passes a different valid graph. Adding a key derived from the graph forces a remount (and fresh boundary state) whenever the input changes — a minimal fix until/unless resetKeys is added.

Proposed fix
 export default function Diagram(props: { graph: Graph; emptyLabel: string }) {
   return (
-    <ErrorBoundary>
+    <ErrorBoundary key={props.graph.nodes.length + ":" + props.graph.edges.length}>
       <ReactFlowProvider>
         <DiagramFlow {...props} />
       </ReactFlowProvider>
     </ErrorBoundary>
   );
 }

A more robust key would be a stable hash/id of the graph if one is available.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<ErrorBoundary>
<ReactFlowProvider>
<DiagramFlow {...props} />
</ReactFlowProvider>
</ErrorBoundary>
<ErrorBoundary key={props.graph.nodes.length + ":" + props.graph.edges.length}>
<ReactFlowProvider>
<DiagramFlow {...props} />
</ReactFlowProvider>
</ErrorBoundary>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/Diagram.tsx` around lines 680 - 684, The Diagram error
boundary currently keeps its fallback state across different inputs, so a
failure in DiagramFlow can persist even when CodeWorkspace passes a new valid
graph. Update the ErrorBoundary usage in Diagram by tying it to the current
graph input with a key derived from graph (or a stable graph id/hash if
available) so the boundary remounts and resets when the graph changes.

);
}
72 changes: 72 additions & 0 deletions src/components/ui/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use client";

import React, { Component, type ErrorInfo, type ReactNode } from "react";

interface Props {
children: ReactNode;
fallback?: ReactNode;
}

interface State {
hasError: boolean;
error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
};

public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}

public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
}

public render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex h-full w-full flex-col items-center justify-center p-6 text-center bg-[#f6f7f9] dark:bg-[#0b0d12]">
<div className="max-w-md rounded-xl border border-red-100 dark:border-red-950 bg-white dark:bg-[#12151c] p-6 shadow-sm">
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-red-100 dark:bg-red-950 text-red-600 dark:text-red-400">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<h3 className="text-md font-semibold text-[#0f172a] dark:text-[#e6e9ef] mb-1.5">
Failed to render diagram
</h3>
Comment on lines +53 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Tailwind CSS v4 include a text-md font-size utility by default?

💡 Result:

No, Tailwind CSS v4 does not include a text-md font-size utility class by default [1][2]. In Tailwind CSS, the standard utility for the base (medium) font size is text-base [1][3]. While other utilities follow an -md suffix convention (such as rounded-md or shadow-md), the font-size utility uses text-base to represent the base, default text size [3][4]. You can add a text-md utility if desired by defining it in your project's CSS using the @theme directive [5][6]: @theme { --text-md: 1rem; --text-md--line-height: 1.5rem; }

Citations:


Use text-base instead of text-md. Tailwind doesn’t define text-md by default, so this heading won’t get the intended font size unless a custom token is added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/ErrorBoundary.tsx` around lines 53 - 55, The heading in
ErrorBoundary’s render fallback is using a nonstandard Tailwind class, so update
the h3 styling from text-md to text-base in the ErrorBoundary component to match
the default Tailwind scale and ensure the intended font size is applied.

<p className="text-[13px] leading-relaxed text-[#475569] dark:text-[#9aa6b8] mb-4">
The diagram contains malformed or cyclic data and could not be displayed correctly.
</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="rounded-lg bg-[#4f46e5] px-3.5 py-2 text-xs font-medium text-white hover:bg-[#4338ca] transition-colors"
>
Try Again
</button>
</div>
</div>
);
}

return this.props.children;
}
Comment on lines +29 to +71

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Boundary never resets when the failing input changes.

hasError only clears via the "Try Again" button. There is no getDerivedStateFromProps/resetKeys mechanism, so:

  • After an error, supplying a new (valid) graph to the wrapped Diagram keeps the fallback UI shown until the user clicks "Try Again".
  • "Try Again" merely re-renders the same children; if the underlying error is deterministic, it immediately re-throws and returns to the fallback.

Consider accepting a resetKeys prop and clearing error state in componentDidUpdate when a key changes, so the boundary recovers automatically when the offending data is replaced. The wrap site in Diagram.tsx (Lines 680-684) is the consumer affected by this.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/ErrorBoundary.tsx` around lines 29 - 71, The ErrorBoundary
fallback in ErrorBoundary should reset when the wrapped diagram data changes,
not only when the user clicks Try Again. Add a reset mechanism such as a
resetKeys prop and clear hasError/error when those keys change in
componentDidUpdate or via getDerivedStateFromProps, so new valid input can
recover automatically. Update the ErrorBoundary usage in Diagram to pass a
stable key derived from the graph/input data, and keep the existing fallback
render path and render() behavior intact.

}
1 change: 1 addition & 0 deletions src/test/mocks/styleMock.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/* Mock CSS file for tests */
2 changes: 2 additions & 0 deletions src/test/mocks/styleMock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Mock module for CSS files
export default {};
5 changes: 4 additions & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export default defineConfig({
include: ["src/**/*.test.ts"],
},
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
"reactflow/dist/style.css": fileURLToPath(new URL("./src/test/mocks/styleMock.js", import.meta.url)),
},
},
});
Loading