From d2c43ecd1864a4ec8d739109fe048039e0cd8c12 Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Fri, 29 May 2026 07:16:39 +0530 Subject: [PATCH 1/5] feat(visualizer): add zero-latency static analysis suite - Implement circular dependency detection on adjacency list - Add percentile-based file complexity heatmap to graph nodes - Introduce orphan file detection for dead code identification - Update node state interfaces and UI legends --- app/analyze/page.tsx | 3 +- components/ArchitectureMap.tsx | 1259 +++++++++++++++++++++++++++----- 2 files changed, 1071 insertions(+), 191 deletions(-) diff --git a/app/analyze/page.tsx b/app/analyze/page.tsx index a1681ae..d28383c 100644 --- a/app/analyze/page.tsx +++ b/app/analyze/page.tsx @@ -768,6 +768,7 @@ const AnalyzeContent = memo(() => { ) : (
@@ -886,7 +887,7 @@ const AnalyzeContent = memo(() => { exit={{ x: 100, opacity: 0 }} onClick={() => setIsChatOpen(true)} aria-label="Open chat assistant" - className="absolute right-0 top-1/2 -translate-y-1/2 z-40 flex items-center gap-3 bg-[#141414]/90 backdrop-blur-md border border-white/10 border-r-0 px-4 py-4 rounded-l-2xl shadow-[-10px_0_30px_rgba(0,0,0,0.5)] hover:bg-[#1a1a1a] hover:pr-6 transition-all group" + className="absolute right-0 bottom-8 z-40 flex items-center gap-3 bg-[#141414]/90 backdrop-blur-md border border-white/10 border-r-0 px-4 py-4 rounded-l-2xl shadow-[-10px_0_30px_rgba(0,0,0,0.5)] hover:bg-[#1a1a1a] hover:pr-6 transition-all group" >
diff --git a/components/ArchitectureMap.tsx b/components/ArchitectureMap.tsx index 3292548..028d5ff 100644 --- a/components/ArchitectureMap.tsx +++ b/components/ArchitectureMap.tsx @@ -29,9 +29,92 @@ import { forceX, forceY, } from "d3-force"; +import { motion, AnimatePresence } from "framer-motion"; +import { + AlertTriangle, + CheckCircle2, + Ghost, + X, + RefreshCw, + Flame, + ChevronRight, +} from "lucide-react"; + +// ============================================================================ +// ALGORITHMS +// ============================================================================ + +function detectCircularDependencies(graph: Record): { + cycleNodes: Set; + cycleCount: number; +} { + const visited = new Set(); + const inStack = new Set(); + const cycleNodes = new Set(); + let cycleCount = 0; + + const dfs = (node: string, path: string[]) => { + if (inStack.has(node)) { + const cycleStart = path.indexOf(node); + if (cycleStart !== -1) { + cycleCount++; + for (let i = cycleStart; i < path.length; i++) cycleNodes.add(path[i]); + cycleNodes.add(node); + } + return; + } + if (visited.has(node)) return; + visited.add(node); + inStack.add(node); + path.push(node); + for (const dep of graph[node] || []) dfs(dep, path); + path.pop(); + inStack.delete(node); + }; + + for (const node of Object.keys(graph)) { + if (!visited.has(node)) dfs(node, []); + } + + return { cycleNodes, cycleCount }; +} + +function computeHeatmapColors( + fileMetrics: { path: string; size: number }[], +): Map { + const map = new Map(); + if (!fileMetrics || fileMetrics.length === 0) return map; + const sizes = fileMetrics.map((f) => f.size).sort((a, b) => a - b); + const p66 = sizes[Math.floor(sizes.length * 0.66)] ?? 0; + const p90 = sizes[Math.floor(sizes.length * 0.9)] ?? 0; + for (const f of fileMetrics) { + if (f.size >= p90) map.set(f.path, "red"); + else if (f.size >= p66) map.set(f.path, "yellow"); + else map.set(f.path, "green"); + } + return map; +} + +function detectOrphans(graph: Record): string[] { + const hasOutbound = new Set( + Object.keys(graph).filter((k) => (graph[k]?.length ?? 0) > 0), + ); + const hasInbound = new Set(); + for (const deps of Object.values(graph)) { + for (const dep of deps) hasInbound.add(dep); + } + return Object.keys(graph).filter( + (f) => !hasOutbound.has(f) && !hasInbound.has(f), + ); +} // ============================================================================ -// GLASS NODE COMPONENT +// ACTIVE MODE TYPE — single source of truth, replaces ad-hoc if/else chain +// ============================================================================ +type ActiveMode = "blast" | "circular" | "orphan" | null; + +// ============================================================================ +// GLASS NODE // ============================================================================ interface GlassNodeData { @@ -40,28 +123,79 @@ interface GlassNodeData { isEntry?: boolean; fullPath: string; label: string; + isCircular?: boolean; + heatmap?: "green" | "yellow" | "red"; + isOrphan?: boolean; + isOrphanHighlighted?: boolean; } +const HEATMAP_STYLES: Record< + "green" | "yellow" | "red", + { border: string; bg: string; shadow: string; text: string } +> = { + green: { + border: "border-emerald-500/40", + bg: "bg-emerald-500/5", + shadow: "shadow-[0_0_12px_rgba(16,185,129,0.1)]", + text: "text-emerald-200", + }, + yellow: { + border: "border-amber-500/40", + bg: "bg-amber-500/5", + shadow: "shadow-[0_0_12px_rgba(245,158,11,0.1)]", + text: "text-amber-200", + }, + red: { + border: "border-red-500/40", + bg: "bg-red-500/5", + shadow: "shadow-[0_0_12px_rgba(239,68,68,0.1)]", + text: "text-red-200", + }, +}; + const GlassNode = ({ data }: { data: GlassNodeData }) => { - const isBlastRadius = data.isBlastRadius; - const isDimmed = data.isDimmed; + const { isBlastRadius, isDimmed, isCircular, heatmap, isOrphanHighlighted } = + data; + + let containerClass = + "px-4 py-2 shadow-xl rounded-xl backdrop-blur-md min-w-[150px] transition-all duration-300 cursor-pointer"; + let textClass = "text-sm font-mono truncate max-w-[200px] transition-colors"; + let handleColor = "!bg-slate-500"; + + if (isBlastRadius) { + containerClass += + " bg-red-500/10 border border-red-500/50 shadow-[0_0_20px_rgba(239,68,68,0.2)]"; + textClass += " text-red-200"; + handleColor = "!bg-red-500"; + } else if (isDimmed) { + containerClass += " bg-[#141414]/40 border border-white/5 opacity-25"; + textClass += " text-slate-600"; + } else if (isOrphanHighlighted) { + containerClass += + " bg-violet-500/10 border border-violet-400/50 shadow-[0_0_18px_rgba(139,92,246,0.25)]"; + textClass += " text-violet-200"; + handleColor = "!bg-violet-400"; + } else if (isCircular) { + containerClass += + " bg-orange-500/10 border border-orange-500/50 shadow-[0_0_18px_rgba(249,115,22,0.2)]"; + textClass += " text-orange-200"; + handleColor = "!bg-orange-500"; + } else if (heatmap && HEATMAP_STYLES[heatmap]) { + const s = HEATMAP_STYLES[heatmap]; + containerClass += ` ${s.bg} border ${s.border} ${s.shadow}`; + textClass += ` ${s.text}`; + } else { + containerClass += + " bg-[#141414]/90 border border-white/10 hover:border-slate-500"; + textClass += " text-slate-200"; + } return ( -
+
{data.isEntry && ( @@ -69,25 +203,24 @@ const GlassNode = ({ data }: { data: GlassNodeData }) => { Entry Point
)} -
+ {data.isOrphan && !isBlastRadius && ( +
+ Orphan +
+ )} + {isCircular && !isBlastRadius && ( +
+ Circular +
+ )} +
{data.label}
); @@ -96,7 +229,7 @@ const GlassNode = ({ data }: { data: GlassNodeData }) => { const nodeTypes = { glass: GlassNode }; // ============================================================================ -// D3-FORCE LAYOUT +// D3-FORCE LAYOUT (unchanged from original) // ============================================================================ interface ForceNode extends Node { @@ -108,7 +241,7 @@ interface ForceNode extends Node { fy?: number | null; } -interface ForceLink { +interface ForceLinkType { source: string | ForceNode; target: string | ForceNode; } @@ -141,7 +274,6 @@ function getForceLayoutedElements( const forceNodes: ForceNode[] = nodes.map((node, i) => { const isHub = hubNodes.has(node.id); const isEntry = entryPoints.includes(node.id); - let x, y; if (isHub) { const angle = (i / nodes.length) * 2 * Math.PI; @@ -156,33 +288,30 @@ function getForceLayoutedElements( x = centerX + Math.cos(angle) * 150; y = centerY + Math.sin(angle) * 150; } - return { ...node, x, y }; }); - const forceLinks: ForceLink[] = edges.map((edge) => ({ + const forceLinks: ForceLinkType[] = edges.map((edge) => ({ source: edge.source, target: edge.target, })); - // --- PRINCIPAL UPGRADE: STOP THE TIMER INSTANTLY --- const simulation = forceSimulation(forceNodes) .stop() .force( "link", - forceLink(forceLinks) - .id((d: ForceNode) => d.id) - .distance((d: ForceLink) => { - const sourceDegree = nodeDegree.get((d.source as ForceNode).id) || 1; - const targetDegree = nodeDegree.get((d.target as ForceNode).id) || 1; - const avgDegree = (sourceDegree + targetDegree) / 2; - return avgDegree > maxDegree * 0.5 ? 80 : 120; + forceLink(forceLinks) + .id((d) => d.id) + .distance((d) => { + const s = nodeDegree.get((d.source as ForceNode).id) || 1; + const t = nodeDegree.get((d.target as ForceNode).id) || 1; + return (s + t) / 2 > maxDegree * 0.5 ? 80 : 120; }) .strength(1.2), ) .force( "charge", - forceManyBody().strength((d: ForceNode) => { + forceManyBody().strength((d) => { const degree = nodeDegree.get(d.id) || 1; return degree > maxDegree * 0.5 ? -600 : -300; }), @@ -191,7 +320,7 @@ function getForceLayoutedElements( .force( "collision", forceCollide() - .radius((d: ForceNode) => { + .radius((d) => { const degree = nodeDegree.get(d.id) || 1; return Math.max(80, Math.min(120, 60 + degree * 3)); }) @@ -199,23 +328,21 @@ function getForceLayoutedElements( ) .force( "x", - forceX(centerX).strength((d: ForceNode) => + forceX(centerX).strength((d) => (nodeDegree.get(d.id) || 1) > maxDegree * 0.5 ? 0.3 : 0.1, ), ) .force( "y", - forceY(centerY).strength((d: ForceNode) => + forceY(centerY).strength((d) => (nodeDegree.get(d.id) || 1) > maxDegree * 0.5 ? 0.3 : 0.1, ), ) .alphaDecay(0.01) .velocityDecay(0.4); - const numIterations = 500; - for (let i = 0; i < numIterations; i++) { + for (let i = 0; i < 500; i++) { simulation.tick(); - forceNodes.forEach((node) => { const margin = 100; node.x = Math.max(margin, Math.min(width - margin, node.x || centerX)); @@ -223,17 +350,579 @@ function getForceLayoutedElements( }); } - const layoutedNodes = forceNodes.map((node) => ({ - ...node, - position: { - x: node.x || 0, - y: node.y || 0, + return { + nodes: forceNodes.map((node) => ({ + ...node, + position: { x: node.x || 0, y: node.y || 0 }, + sourcePosition: Position.Bottom, + targetPosition: Position.Top, + })), + edges, + }; +} + +// ============================================================================ +// ANALYSIS SIDEBAR +// ============================================================================ + +interface SidebarProps { + // graph meta + nodeCount: number; + // feature 1 + cycleCount: number; + cycleNodes: Set; + // feature 2 + heatmapEnabled: boolean; + onHeatmapToggle: () => void; + // feature 3 + orphans: string[]; + selectedOrphan: string | null; + onOrphanSelect: (path: string | null) => void; + // shared + activeMode: ActiveMode; + onActivateMode: (mode: ActiveMode) => void; + onClearAll: () => void; +} + +function AnalysisSidebar({ + nodeCount, + cycleCount, + cycleNodes, + heatmapEnabled, + onHeatmapToggle, + orphans, + selectedOrphan, + onOrphanSelect, + activeMode, + onActivateMode, + onClearAll, +}: SidebarProps) { + const [expanded, setExpanded] = useState(true); + const [orphanListOpen, setOrphanListOpen] = useState(true); + + const hasCircular = cycleCount > 0; + const hasOrphans = orphans.length > 0; + + // Icon rail icons for collapsed state + const RAIL = [ + { + key: "circular" as const, + icon: , + color: hasCircular ? "text-orange-400" : "text-emerald-400", + active: activeMode === "circular", + dot: hasCircular, + dotColor: "bg-orange-500", }, - sourcePosition: Position.Bottom, - targetPosition: Position.Top, - })); + { + key: "heatmap" as const, + icon: , + color: heatmapEnabled ? "text-amber-300" : "text-slate-400", + active: heatmapEnabled, + dot: false, + dotColor: "", + }, + { + key: "orphan" as const, + icon: , + color: hasOrphans ? "text-violet-400" : "text-slate-500", + active: activeMode === "orphan", + dot: hasOrphans, + dotColor: "bg-violet-500", + }, + ]; + + return ( + + {/* ── Collapsed icon rail ── */} + + {!expanded && ( + + {/* Expand button */} + + +
+ + {/* Rail icons */} + {RAIL.map((item) => ( +
+ + {item.dot && ( + + )} +
+ ))} + + )} + + + {/* ── Expanded sidebar ── */} + + {expanded && ( + + {/* Sidebar header */} +
+
+
+ + Analysis + +
+
+ {activeMode && ( + + )} + +
+
+ + {/* Graph meta */} +
+
+ + Nodes + + + {nodeCount} + +
+
+ + Mode + + + {activeMode === "blast" + ? "Blast Radius" + : activeMode === "circular" + ? "Circular" + : activeMode === "orphan" + ? "Orphan" + : heatmapEnabled + ? "Heatmap" + : "Default"} + +
+
+ + {/* Scrollable sections */} +
+ {/* ── SECTION 1: Blast radius hint ── */} +
+
+
+ + Blast Radius + +
+

+ Click any node on the graph to trace all upstream dependents. +

+ {activeMode === "blast" && ( + +
+
+ Active — click canvas to clear +
+ + )} +
+ + {/* ── SECTION 2: Circular Dependencies ── */} +
+ + + {/* Cycle node list — shown when active */} + + {activeMode === "circular" && cycleNodes.size > 0 && ( + +
+ {Array.from(cycleNodes) + .slice(0, 8) + .map((n) => ( +
+
+ + {n.split("/").pop()} + +
+ ))} + {cycleNodes.size > 8 && ( +

+ +{cycleNodes.size - 8} more +

+ )} +
+ + )} + +
+ + {/* ── SECTION 3: Complexity Heatmap ── */} +
+
+
+
+ + + Complexity + + {/* Toggle */} + +
+ + {/* Legend — always visible so user knows what to expect */} +
+ {( + [ + { + color: "bg-emerald-400", + label: "Small", + sub: "bottom 66%", + }, + { + color: "bg-amber-400", + label: "Medium", + sub: "66–90th pct", + }, + { + color: "bg-red-400", + label: "Large", + sub: "top 10%", + }, + ] as const + ).map(({ color, label, sub }) => ( +
+
+ + {label} + + + {sub} + +
+ ))} +
+
+
+ + {/* ── SECTION 4: Orphaned Files ── */} +
+ + + {/* Orphan file list */} + + {activeMode === "orphan" && hasOrphans && orphanListOpen && ( + +
+ {orphans.map((path) => { + const label = path.split("/").pop() || path; + const dir = path.split("/").slice(-2, -1)[0] ?? ""; + const isSelected = selectedOrphan === path; + return ( + + ); + })} +

+ No imports · no importers +

+
+
+ )} +
+
+
- return { nodes: layoutedNodes, edges }; + {/* Sidebar footer */} +
+

+ 🌀 Force Layout · {nodeCount} nodes +

+
+ + )} + + + ); } // ============================================================================ @@ -243,37 +932,59 @@ function getForceLayoutedElements( export default function ArchitectureMap({ dependencyGraph = {}, entryPoints = [], + fileMetrics = [], }: { dependencyGraph: Record; entryPoints: string[]; + fileMetrics?: { path: string; size: number }[]; }) { + // ── Core state ───────────────────────────────────────────────────────────── const [selectedNode, setSelectedNode] = useState(null); - - // --- PRINCIPAL UPGRADE: State protection --- const previousGraphRef = useRef(""); + // ── Single enum for active highlight mode ────────────────────────────────── + const [activeMode, setActiveMode] = useState(null); + + // ── Feature-specific state ───────────────────────────────────────────────── + const [heatmapEnabled, setHeatmapEnabled] = useState(false); + const [selectedOrphan, setSelectedOrphan] = useState(null); + + // ── Algorithmic computations ─────────────────────────────────────────────── + const { cycleNodes, cycleCount } = useMemo( + () => detectCircularDependencies(dependencyGraph), + [dependencyGraph], + ); + + const heatmapColors = useMemo( + () => computeHeatmapColors(fileMetrics), + [fileMetrics], + ); + + const orphans = useMemo( + () => detectOrphans(dependencyGraph), + [dependencyGraph], + ); + + // ── Reverse graph for blast radius ───────────────────────────────────────── const adjacencyList = useMemo(() => { - const reverseGraph: Record = {}; + const rev: Record = {}; Object.keys(dependencyGraph).forEach((src) => { - const deps = dependencyGraph[src]; - if (Array.isArray(deps)) { - deps.forEach((tgt) => { - if (!reverseGraph[tgt]) reverseGraph[tgt] = []; - reverseGraph[tgt].push(src); - }); - } + (dependencyGraph[src] || []).forEach((tgt) => { + if (!rev[tgt]) rev[tgt] = []; + rev[tgt].push(src); + }); }); - return reverseGraph; + return rev; }, [dependencyGraph]); + // ── Build initial nodes/edges (layout only, no feature data) ─────────────── const { initialNodes, initialEdges, graphHash } = useMemo(() => { + if (!dependencyGraph || typeof dependencyGraph !== "object") + return { initialNodes: [], initialEdges: [], graphHash: "" }; + const nodes: Node[] = []; const edges: Edge[] = []; - if (!dependencyGraph || typeof dependencyGraph !== "object") { - return { initialNodes: [], initialEdges: [], graphHash: "" }; - } - Object.keys(dependencyGraph).forEach((filePath) => { nodes.push({ id: filePath, @@ -284,50 +995,48 @@ export default function ArchitectureMap({ isEntry: entryPoints.includes(filePath), isBlastRadius: false, isDimmed: false, + isCircular: false, + heatmap: undefined, + isOrphan: false, + isOrphanHighlighted: false, }, position: { x: 0, y: 0 }, }); }); Object.keys(dependencyGraph).forEach((filePath) => { - const deps = dependencyGraph[filePath]; - if (Array.isArray(deps)) { - deps.forEach((depPath) => { - edges.push({ - id: `e-${filePath}-${depPath}`, - source: filePath, - target: depPath, - animated: true, - style: { stroke: "#475569", strokeWidth: 2, opacity: 1 }, - markerEnd: { - type: MarkerType.ArrowClosed, - color: "#475569", - }, - }); + (dependencyGraph[filePath] || []).forEach((depPath) => { + edges.push({ + id: `e-${filePath}-${depPath}`, + source: filePath, + target: depPath, + animated: true, + style: { stroke: "#475569", strokeWidth: 2, opacity: 1 }, + markerEnd: { type: MarkerType.ArrowClosed, color: "#475569" }, }); - } + }); }); - const layoutResult = getForceLayoutedElements(nodes, edges, entryPoints); + const { nodes: layoutNodes, edges: layoutEdges } = getForceLayoutedElements( + nodes, + edges, + entryPoints, + ); - // Create a deterministic hash for dependency graph comparison const graphHash = JSON.stringify({ graphKeys: Object.keys(dependencyGraph).sort(), edgesCount: edges.length, entryPoints: entryPoints.sort(), }); - return { - initialNodes: layoutResult.nodes, - initialEdges: layoutResult.edges, - graphHash, - }; + return { initialNodes: layoutNodes, initialEdges: layoutEdges, graphHash }; + }, [dependencyGraph, entryPoints]); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - // --- PRINCIPAL UPGRADE: Deep compare guard --- + // Guard: only re-layout when the graph actually changes useEffect(() => { if (previousGraphRef.current !== graphHash) { setNodes(initialNodes); @@ -336,12 +1045,36 @@ export default function ArchitectureMap({ } }, [initialNodes, initialEdges, graphHash, setNodes, setEdges]); + // ── Apply feature overlay data (does NOT re-run layout) ─────────────────── + useEffect(() => { + setNodes((nds) => + nds.map((n) => ({ + ...n, + data: { + ...n.data, + isCircular: cycleNodes.has(n.id), + heatmap: heatmapEnabled + ? (heatmapColors.get(n.id) ?? "green") + : undefined, + isOrphan: orphans.includes(n.id), + }, + })), + ); + }, [cycleNodes, heatmapColors, heatmapEnabled, orphans, setNodes]); + + // ── Master highlight effect — driven by activeMode enum ─────────────────── useEffect(() => { - if (!selectedNode) { + // Default: clear everything + if (!activeMode && !selectedNode) { setNodes((nds) => nds.map((n) => ({ ...n, - data: { ...n.data, isBlastRadius: false, isDimmed: false }, + data: { + ...n.data, + isBlastRadius: false, + isDimmed: false, + isOrphanHighlighted: false, + }, })), ); setEdges((eds) => @@ -355,62 +1088,161 @@ export default function ArchitectureMap({ return; } - const blastRadiusNodes = new Set(); - const blastRadiusEdges = new Set(); - const queue = [selectedNode]; - blastRadiusNodes.add(selectedNode); - - while (queue.length > 0) { - const current = queue.shift()!; - const dependents = adjacencyList[current] || []; - dependents.forEach((dep) => { - const edgeId = `e-${dep}-${current}`; - blastRadiusEdges.add(edgeId); - if (!blastRadiusNodes.has(dep)) { - blastRadiusNodes.add(dep); - queue.push(dep); - } - }); - } - - setNodes((nds) => - nds.map((n) => ({ - ...n, - data: { - ...n.data, - isBlastRadius: blastRadiusNodes.has(n.id), - isDimmed: !blastRadiusNodes.has(n.id), - }, - })), - ); - - setEdges((eds) => - eds.map((e) => { - const isBlastEdge = blastRadiusEdges.has(e.id); - return { + // ORPHAN mode + if (activeMode === "orphan") { + setNodes((nds) => + nds.map((n) => ({ + ...n, + data: { + ...n.data, + isBlastRadius: false, + isDimmed: selectedOrphan ? n.id !== selectedOrphan : false, + isOrphanHighlighted: selectedOrphan + ? n.id === selectedOrphan + : false, + }, + })), + ); + setEdges((eds) => + eds.map((e) => ({ ...e, style: { - stroke: isBlastEdge ? "#ef4444" : "#475569", - strokeWidth: isBlastEdge ? 3 : 2, - opacity: isBlastEdge ? 1 : 0.2, + stroke: "#475569", + strokeWidth: 2, + opacity: selectedOrphan ? 0.1 : 0.5, + }, + animated: false, + markerEnd: { type: MarkerType.ArrowClosed, color: "#475569" }, + })), + ); + return; + } + + // CIRCULAR mode + if (activeMode === "circular") { + setNodes((nds) => + nds.map((n) => ({ + ...n, + data: { + ...n.data, + isBlastRadius: false, + isDimmed: !cycleNodes.has(n.id), + isOrphanHighlighted: false, }, - animated: isBlastEdge, - markerEnd: { - type: MarkerType.ArrowClosed, - color: isBlastEdge ? "#ef4444" : "#475569", + })), + ); + setEdges((eds) => + eds.map((e) => { + const isCycleEdge = + cycleNodes.has(e.source) && cycleNodes.has(e.target); + return { + ...e, + style: { + stroke: isCycleEdge ? "#f97316" : "#475569", + strokeWidth: isCycleEdge ? 3 : 2, + opacity: isCycleEdge ? 1 : 0.08, + }, + animated: isCycleEdge, + markerEnd: { + type: MarkerType.ArrowClosed, + color: isCycleEdge ? "#f97316" : "#475569", + }, + }; + }), + ); + return; + } + + // BLAST mode (existing logic unchanged) + if (activeMode === "blast" && selectedNode) { + const blastNodes = new Set(); + const blastEdges = new Set(); + const queue = [selectedNode]; + blastNodes.add(selectedNode); + while (queue.length > 0) { + const cur = queue.shift()!; + (adjacencyList[cur] || []).forEach((dep) => { + blastEdges.add(`e-${dep}-${cur}`); + if (!blastNodes.has(dep)) { + blastNodes.add(dep); + queue.push(dep); + } + }); + } + setNodes((nds) => + nds.map((n) => ({ + ...n, + data: { + ...n.data, + isBlastRadius: blastNodes.has(n.id), + isDimmed: !blastNodes.has(n.id), + isOrphanHighlighted: false, }, - }; - }), - ); - }, [selectedNode, adjacencyList, setNodes, setEdges]); + })), + ); + setEdges((eds) => + eds.map((e) => { + const hit = blastEdges.has(e.id); + return { + ...e, + style: { + stroke: hit ? "#ef4444" : "#475569", + strokeWidth: hit ? 3 : 2, + opacity: hit ? 1 : 0.15, + }, + animated: hit, + markerEnd: { + type: MarkerType.ArrowClosed, + color: hit ? "#ef4444" : "#475569", + }, + }; + }), + ); + } + }, [ + activeMode, + selectedNode, + cycleNodes, + selectedOrphan, + adjacencyList, + setNodes, + setEdges, + ]); - const handleNodeClick = useCallback( - (_: React.MouseEvent, node: Node) => setSelectedNode(node.id), - [], - ); + // ── Handlers ────────────────────────────────────────────────────────────── + const handleNodeClick = useCallback((_: React.MouseEvent, node: Node) => { + setSelectedOrphan(null); + setSelectedNode(node.id); + setActiveMode("blast"); + }, []); - const handlePaneClick = useCallback(() => setSelectedNode(null), []); + const handlePaneClick = useCallback(() => { + setSelectedNode(null); + setSelectedOrphan(null); + setActiveMode(null); + }, []); + const handleActivateMode = useCallback((mode: ActiveMode) => { + setActiveMode(mode); + if (mode !== "blast") setSelectedNode(null); + if (mode !== "orphan") setSelectedOrphan(null); + }, []); + + const handleOrphanSelect = useCallback((path: string | null) => { + setSelectedOrphan(path); + }, []); + + const handleClearAll = useCallback(() => { + setActiveMode(null); + setSelectedNode(null); + setSelectedOrphan(null); + }, []); + + const handleHeatmapToggle = useCallback(() => { + setHeatmapEnabled((v) => !v); + }, []); + + // ── Empty state ──────────────────────────────────────────────────────────── if (nodes.length === 0) { return (
@@ -422,53 +1254,100 @@ export default function ArchitectureMap({ } return ( -
-
-

- Interactive Architecture Map -

-
-
- Click nodes to view Blast Radius -
-
- 🌀 Compact Force Layout · {nodes.length} nodes +
+ {/* ── Graph canvas (fills all available space) ── */} +
+ {/* Top-left: minimal graph label only — analysis moved to sidebar */} +
+

+ Interactive Architecture Map +

+
+
+ Click nodes to view Blast Radius +
+ + {/* Active mode pill — floats top-center, always dismissible */} + + {activeMode && ( + + + {activeMode === "blast" + ? "Blast radius active" + : activeMode === "circular" + ? "Circular deps highlighted" + : selectedOrphan + ? `Orphan: ${selectedOrphan.split("/").pop()}` + : "Orphan mode — select a file"} + + + + )} + + + + + +
- - - - + {/* ── Analysis sidebar (right, collapsible) ── */} +
); } From 5f61b5afd6a393978e037358895d564d48a47765 Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Fri, 29 May 2026 08:47:12 +0530 Subject: [PATCH 2/5] refactor(analyze): decompose page.tsx into modular hooks and UI components - Extract state management, LLM streaming, and graph traversal into custom hooks - Modularize UI into dedicated layout components (Visualizer, Doctor, Risk Radar) - Isolate static configurations and types into constants.ts - Strip page.tsx down to act strictly as a layout orchestrator --- app/analyze/page.tsx | 1011 +++---------------- components/ArchitectureMap.tsx | 1 - components/analyze/AnalyzeErrorScreen.tsx | 70 ++ components/analyze/AnalyzeHeader.tsx | 146 +++ components/analyze/AnalyzeLoadingScreen.tsx | 56 + components/analyze/AnalyzeTabBar.tsx | 55 + components/analyze/ChatPanel.tsx | 103 ++ components/analyze/DoctorPanel.tsx | 73 ++ components/analyze/ExitModal.tsx | 109 ++ components/analyze/GitHubAuthModal.tsx | 52 + components/analyze/RiskRadarPanel.tsx | 45 + components/analyze/SkeletonLoader.tsx | 20 + components/analyze/VisualizerPanel.tsx | 102 ++ components/analyze/constants.tsx | 68 ++ hooks/analyze/useAiStream.ts | 128 +++ hooks/analyze/useAnalyzeData.ts | 137 +++ hooks/analyze/useAnalyzeUIState.ts | 129 +++ hooks/analyze/useFeedback.ts | 81 ++ hooks/analyze/useLoadingAnimation.ts | 62 ++ 19 files changed, 1551 insertions(+), 897 deletions(-) create mode 100644 components/analyze/AnalyzeErrorScreen.tsx create mode 100644 components/analyze/AnalyzeHeader.tsx create mode 100644 components/analyze/AnalyzeLoadingScreen.tsx create mode 100644 components/analyze/AnalyzeTabBar.tsx create mode 100644 components/analyze/ChatPanel.tsx create mode 100644 components/analyze/DoctorPanel.tsx create mode 100644 components/analyze/ExitModal.tsx create mode 100644 components/analyze/GitHubAuthModal.tsx create mode 100644 components/analyze/RiskRadarPanel.tsx create mode 100644 components/analyze/SkeletonLoader.tsx create mode 100644 components/analyze/VisualizerPanel.tsx create mode 100644 components/analyze/constants.tsx create mode 100644 hooks/analyze/useAiStream.ts create mode 100644 hooks/analyze/useAnalyzeData.ts create mode 100644 hooks/analyze/useAnalyzeUIState.ts create mode 100644 hooks/analyze/useFeedback.ts create mode 100644 hooks/analyze/useLoadingAnimation.ts diff --git a/app/analyze/page.tsx b/app/analyze/page.tsx index d28383c..dbbdf88 100644 --- a/app/analyze/page.tsx +++ b/app/analyze/page.tsx @@ -1,59 +1,39 @@ "use client"; -import { useSearchParams, useRouter } from "next/navigation"; -import useSWR from "swr"; -import { fetcher } from "@/lib/fetcher"; -import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { useEffect, useState, - Suspense, - useMemo, - useCallback, useRef, + useCallback, + Suspense, memo, } from "react"; -import { motion, AnimatePresence, useAnimationControls } from "framer-motion"; +import { useSearchParams, useRouter } from "next/navigation"; +import { AnimatePresence } from "framer-motion"; import dynamic from "next/dynamic"; -import Link from "next/link"; -import { - ArrowLeft, - FileCode, - Terminal, - Cpu, - Layers, - ThumbsUp, - ThumbsDown, - MessageSquare, - X, - LayoutGrid, - Activity, - GitPullRequest, - Target, - CheckCircle2, -} from "lucide-react"; -import { FaGithub } from "react-icons/fa"; - -// Global Components -import ExportButton from "@/components/ExportButton"; -import ShareButton from "@/components/ShareButton"; +import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { createClient } from "@/lib/supabase-browser"; -import { RepoData } from "@/lib/types/analyze"; - -// --- Skeleton Extracted (No Re-allocation) --- -const SkeletonLoader = memo(() => ( -
-
-
-
-
-
-
-
-)); -SkeletonLoader.displayName = "SkeletonLoader"; -// --- Lazy Load ALL Tab Components --- +// ─── Hooks ──────────────────────────────────────────────────────────────────── +import { useAnalyzeData } from "@/hooks/analyze/useAnalyzeData"; +import { useAnalyzeUIState } from "@/hooks/analyze/useAnalyzeUIState"; +import { useFeedback } from "@/hooks/analyze/useFeedback"; +import { useLoadingAnimation } from "@/hooks/analyze/useLoadingAnimation"; + +// ─── Components ─────────────────────────────────────────────────────────────── +import SkeletonLoader from "@/components/analyze/SkeletonLoader"; +import AnalyzeLoadingScreen from "@/components/analyze/AnalyzeLoadingScreen"; +import AnalyzeErrorScreen from "@/components/analyze/AnalyzeErrorScreen"; +import GitHubAuthModal from "@/components/analyze/GitHubAuthModal"; +import AnalyzeHeader from "@/components/analyze/AnalyzeHeader"; +import AnalyzeTabBar from "@/components/analyze/AnalyzeTabBar"; +import VisualizerPanel from "@/components/analyze/VisualizerPanel"; +import DoctorPanel from "@/components/analyze/DoctorPanel"; +import RiskRadarPanel from "@/components/analyze/RiskRadarPanel"; +import ChatPanel from "@/components/analyze/ChatPanel"; +import ExitModal from "@/components/analyze/ExitModal"; + +// ─── Lazy Tab Panels (unchanged from original) ──────────────────────────────── const OverviewTab = dynamic(() => import("@/components/analyze/OverviewTab"), { loading: () => , ssr: false, @@ -62,338 +42,64 @@ const PrImpactTab = dynamic(() => import("@/components/analyze/PrImpactTab"), { loading: () => , ssr: false, }); -const DebugInterface = dynamic( - () => import("@/components/debug/DebugInterface"), - { loading: () => , ssr: false }, -); -const ArchitectureMap = dynamic(() => import("@/components/ArchitectureMap"), { - loading: () => , - ssr: false, -}); -const TreemapVisualizer = dynamic( - () => import("@/components/TreemapVisualizer"), - { loading: () => , ssr: false }, -); -const DirectoryTreeVisualizer = dynamic( - () => import("@/components/DirectoryTreeVisualizer"), - { loading: () => , ssr: false }, -); -const RiskDashboard = dynamic(() => import("@/components/RiskDashboard"), { - loading: () => , - ssr: false, -}); -const RepoChat = dynamic(() => import("@/components/RepoChat"), { - loading: () => , - ssr: false, -}); -const EXPO_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]; - -// --- Tab & View Config (No Re-allocation) --- -const TAB_CONFIG = [ - { id: "overview" as const, icon: FileCode, label: "Read Docs" }, - { id: "visualizer" as const, icon: Layers, label: "Blueprint Map" }, - { id: "doctor" as const, icon: Activity, label: "Diagnostic Engine" }, - { id: "pr_impact" as const, icon: GitPullRequest, label: "PR Impact" }, - { id: "risk_radar" as const, icon: Target, label: "Risk Radar" }, -] as const; - -const MAP_VIEW_CONFIG = [ - { id: "graph" as const, label: "Dependency Flow" }, - { id: "directory" as const, label: "Folder Structure" }, - { id: "treemap" as const, label: "Codebase Weight" }, -] as const; - -const LOADING_PHRASES = [ - "Cloning repository...", - "Decrypting source tree...", - "Mapping AST nodes...", - "Tracing execution flows...", - "Calculating blast radius...", - "Evaluating test coverage...", - "Querying Groq LLM...", - "Compiling health report...", -] as const; - -// Default skeleton analysis shown while AI stream is starting -const DEFAULT_ANALYSIS = { - architecture_pattern: "Analyzing...", - what_it_does: "Waking up Groq LLM...", - execution_flow: [], - tech_stack: [], - key_modules: [], - onboarding_guide: [], - evidence_paths: [], - blast_radius: [], - health_status: { - grade: "-", - score: 0, - status: "Pending", - refactor_plan: [], - }, -} as const; - -type TabType = (typeof TAB_CONFIG)[number]["id"]; -type MapViewType = (typeof MAP_VIEW_CONFIG)[number]["id"]; - -// --- Memoized Component (Prevents Unnecessary Re-renders) --- +// ───────────────────────────────────────────────────────────────────────────── + const AnalyzeContent = memo(() => { const searchParams = useSearchParams(); const router = useRouter(); + const repoUrl = searchParams.get("url") || searchParams.get("repo"); const source = searchParams.get("source"); - // UI State - const [showGitHubAuthModal, setShowGitHubAuthModal] = useState(false); - const [isChatOpen, setIsChatOpen] = useState(false); - const [feedbackSubmitted, setFeedbackSubmitted] = useState(false); - const [hideFeedback, setHideFeedback] = useState(false); - const [showExitModal, setShowExitModal] = useState(false); - const exitModalRef = useRef(null); - - // AI Streaming State - const [aiAnalysis, setAiAnalysis] = useState | null>( - null, - ); - const [isAiStreaming, setIsAiStreaming] = useState(false); - - // Mount Flag + // ── Mount flag (prevents SSR / sessionStorage mismatches) ────────────────── const [isMounted, setIsMounted] = useState(false); - - // Safe Synchronous Initialization (Only runs in browser) - const [activeTab, setActiveTab] = useState(() => { - if (typeof window === "undefined") return "overview"; - const saved = sessionStorage.getItem("codeautopsy_tab"); - return saved && TAB_CONFIG.some((t) => t.id === saved) - ? (saved as TabType) - : "overview"; - }); - - const [mapView, setMapView] = useState(() => { - if (typeof window === "undefined") return "graph"; - const saved = sessionStorage.getItem("codeautopsy_view"); - return saved && MAP_VIEW_CONFIG.some((v) => v.id === saved) - ? (saved as MapViewType) - : "graph"; - }); - - // Mark as mounted useEffect(() => { - const timeoutId = setTimeout(() => setIsMounted(true), 0); - return () => clearTimeout(timeoutId); + const id = setTimeout(() => setIsMounted(true), 0); + return () => clearTimeout(id); }, []); - // Sync tab/view changes to sessionStorage - useEffect(() => { - if (isMounted) { - sessionStorage.setItem("codeautopsy_tab", activeTab); - sessionStorage.setItem("codeautopsy_view", mapView); - } - }, [activeTab, mapView, isMounted]); - - // --- Local Codebase Handler (Bypasses API) --- - const [localData] = useState(() => { - if (typeof window === "undefined" || source !== "local") return null; - const stored = sessionStorage.getItem("localAnalysisResult"); - return stored ? JSON.parse(stored) : null; - }); + // ── GitHub Auth Modal ─────────────────────────────────────────────────────── + const [showGitHubAuthModal, setShowGitHubAuthModal] = useState(false); - const [localError] = useState(() => { - if (typeof window === "undefined" || source !== "local") return null; - const stored = sessionStorage.getItem("localAnalysisResult"); - return !stored - ? "Local analysis data expired or lost. Please return to home and upload again." - : null; + // ── Data ──────────────────────────────────────────────────────────────────── + const { data, loading, error, isRateLimit, isAiStreaming } = useAnalyzeData({ + repoUrl, + source, + onRequireGitHubAuth: () => setShowGitHubAuthModal(true), }); - // --- Remote API Fetching (SWR Cache) --- + // ── UI State ──────────────────────────────────────────────────────────────── const { - data: swrData, - error: swrError, - isLoading: swrLoading, - } = useSWR( - repoUrl && source !== "local" ? ["/api/analyze", repoUrl] : null, - ([url, repo]) => fetcher(url, repo), - { - revalidateOnFocus: false, - shouldRetryOnError: false, - onError: (err) => { - if (err.message === "REQUIRE_GITHUB_AUTH") setShowGitHubAuthModal(true); - }, - }, - ); - - // --- REAL-TIME AI STREAMING ENGINE --- - useEffect(() => { - if (swrData && swrData.analysis === null && !isAiStreaming && !aiAnalysis) { - setIsAiStreaming(true); - - const fetchAiStream = async () => { - try { - const res = await fetch("/api/ai", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - repoName: `${swrData.owner}/${swrData.repo}`, - description: swrData.description, - entryPoints: swrData.entryPoints, - topFiles: swrData.topFilesForGroq, - fileContents: swrData.fileContents, - blastRadiusTargets: swrData.blastRadiusTargets, - healthMetrics: swrData.healthMetrics, - }), - }); - - if (!res.body) return; - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let jsonText = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - jsonText += decoder.decode(value, { stream: true }); - - // 1. Try to parse the complete JSON first - try { - setAiAnalysis(JSON.parse(jsonText)); - } catch { - // 2. On-the-fly Partial JSON Repair for live UI rendering - try { - let fixed = jsonText.replace(/("[^"]*)$/, '"'); // Close any open string - const openBraces = (fixed.match(/\{/g) || []).length; - const closeBraces = (fixed.match(/\}/g) || []).length; - const openBrackets = (fixed.match(/\[/g) || []).length; - const closeBrackets = (fixed.match(/\]/g) || []).length; - - fixed += - "]".repeat(Math.max(0, openBrackets - closeBrackets)) + - "}".repeat(Math.max(0, openBraces - closeBraces)); - - const partial = JSON.parse(fixed); - if (partial) setAiAnalysis(partial); - } catch { - // Silently wait for the next chunk if still unparseable - } - } - } - } catch (err) { - console.error("AI Stream Failed:", err); - } finally { - setIsAiStreaming(false); - } - }; - - fetchAiStream(); - } - }, [swrData, isAiStreaming, aiAnalysis]); - - // --- Unify Data Sources with Zod Bypass + AI Stream Injection --- - const isLocal = source === "local"; - const loading = isLocal ? !localData && !localError : swrLoading; - const rawError = isLocal - ? localError - : swrError?.message === "REQUIRE_GITHUB_AUTH" - ? null - : swrError?.message; - - const data = useMemo(() => { - if (isLocal) return localData; - if (!swrData || swrError) return null; - - // Bypass strict Zod validation: the incoming AI stream is intentionally incomplete. - // Inject streaming aiAnalysis (or a safe placeholder) directly into the SWR payload. - return { - ...swrData, - analysis: - aiAnalysis ?? - (swrData.analysis === null ? DEFAULT_ANALYSIS : swrData.analysis), - }; - }, [isLocal, localData, swrData, swrError, aiAnalysis]); - - const error = rawError ?? null; - - const isRateLimit = useMemo( - () => - !!error && - (error.includes("RATE_LIMIT_REACHED") || error.includes("Daily limit")), - [error], - ); - - // Loading Animation Loop - const [loadingStep, setLoadingStep] = useState(0); - const loadingControls = useAnimationControls(); - - useEffect(() => { - if (!loading) return; - let isActive = true; - - const animate = async () => { - while (isActive) { - await loadingControls.start({ - opacity: [0, 1], - transition: { duration: 0.4, ease: "easeOut" }, - }); - await new Promise((resolve) => setTimeout(resolve, 800)); - await loadingControls.start({ - opacity: [1, 0], - transition: { duration: 0.4, ease: "easeIn" }, - }); - if (isActive) - setLoadingStep((prev) => (prev + 1) % LOADING_PHRASES.length); - } - }; - - animate(); - return () => { - isActive = false; - }; - }, [loading, loadingControls]); - - // Escape Key & Modal Focus Traps - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key !== "Escape") return; - if (showExitModal) setShowExitModal(false); - else if (showGitHubAuthModal) router.push("/"); - else if (isChatOpen) setIsChatOpen(false); - }; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [showExitModal, showGitHubAuthModal, isChatOpen, router]); + activeTab, + setActiveTab, + mapView, + setMapView, + isChatOpen, + setIsChatOpen, + showExitModal, + setShowExitModal, + } = useAnalyzeUIState({ + onEscapeFromAuthModal: () => router.push("/"), + showGitHubAuthModal, + isMounted, + }); - useEffect(() => { - if (!showExitModal) return; - const modal = exitModalRef.current; - if (!modal) return; + // ── Exit modal focus trap ref ─────────────────────────────────────────────── + const exitModalRef = useRef(null); - const focusable = modal.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ); - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - - const trap = (e: KeyboardEvent) => { - if (e.key !== "Tab") return; - if (e.shiftKey) { - if (document.activeElement === first) { - e.preventDefault(); - last?.focus(); - } - } else if (document.activeElement === last) { - e.preventDefault(); - first?.focus(); - } - }; + // ── Feedback ──────────────────────────────────────────────────────────────── + const { feedbackSubmitted, hideFeedback, handleFeedback } = useFeedback({ + repoName: data?.repo, + repoUrl, + onExit: () => router.back(), + onCloseExitModal: () => setShowExitModal(false), + }); - first?.focus(); - document.addEventListener("keydown", trap); - return () => document.removeEventListener("keydown", trap); - }, [showExitModal]); + // ── Loading animation ─────────────────────────────────────────────────────── + const { loadingStep, loadingControls } = useLoadingAnimation(loading); - // Handlers + // ── GitHub OAuth handler ──────────────────────────────────────────────────── const handleGitHubLogin = useCallback(async () => { const supabase = createClient(); await supabase.auth.signInWithOAuth({ @@ -405,303 +111,51 @@ const AnalyzeContent = memo(() => { }); }, [repoUrl]); - const handleFeedback = useCallback( - async (isHelpful: boolean, exitAfter = false) => { - setFeedbackSubmitted(true); - if (exitAfter) setShowExitModal(false); - else setTimeout(() => setHideFeedback(true), 2000); - - try { - const supabase = createClient(); - await supabase.from("debug_feedback").insert([ - { - debug_id: data?.repo || repoUrl || "local-upload", - is_helpful: isHelpful, - }, - ]); - } catch (err) { - console.error(err); - } - - if (exitAfter) router.back(); - }, - [data, repoUrl, router], - ); - + // ── Back navigation ───────────────────────────────────────────────────────── const handleBackNavigation = useCallback(() => { if (!feedbackSubmitted) setShowExitModal(true); else router.back(); - }, [feedbackSubmitted, router]); + }, [feedbackSubmitted, router, setShowExitModal]); - // --- RENDERING ROUTER --- + // ── Render branches ───────────────────────────────────────────────────────── if (!isMounted || loading) return ( -
-
-
-
-
- -
-
-

- Performing Autopsy -

-

- {source === "local" - ? "Local.zip Upload" - : repoUrl?.split("/").slice(-2).join("/") || "Repository"} -

-
-
-
-
-
-
-
- - {LOADING_PHRASES[loadingStep]} - -
-
-
-
+ ); if (error) - return ( -
-
-
- {isRateLimit ? ( - - ) : ( - - )} -
-

- {isRateLimit ? "Daily Limit Reached" : "Autopsy Failed"} -

-
-

- {error} -

-
-
- {isRateLimit && ( - router.push("/pricing")} - className="w-full px-6 py-3 rounded-xl bg-white text-black font-bold text-sm hover:scale-[1.02] transition-transform" - > - View Upgrade Options - - )} - router.back()} - className="w-full px-6 py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-sm font-bold text-white transition-all flex items-center justify-center gap-2" - > - Go Back - -
-
-
- ); + return ; if (!data) return null; if (showGitHubAuthModal) - return ( -
-
- -
- -
-

- Private Repository -

-

- To analyze private code, you need to authenticate with GitHub. -

-
- - Connect GitHub Account - - -
-
-
- ); + return ; + + // ── Main layout ───────────────────────────────────────────────────────────── return (
- {/* HEADER */} -
-
- -
- -

- {source === "local" ? ( - "Local Codebase" - ) : ( -
- - {data.owner} - - / - {data.repo} -
- )} -

-
- - - {data.language} - -
- {/* Streaming indicator badge */} - {isAiStreaming && ( -
-
- - AI Live - -
- )} -
-
- -
- - - Dashboard - - - {!hideFeedback && ( - - {!feedbackSubmitted ? ( -
- - Helpful? - -
- - -
-
- ) : ( -
- - - Thanks! - -
- )} -
- )} -
- {source !== "local" && ( - - )} - -
-
+ handleFeedback(isHelpful)} + /> - {/* MAIN CONTENT AREA */}
- {/* TAB LIST */} -
-
- {TAB_CONFIG.map((tab) => { - const isActive = activeTab === tab.id; - return ( - - ); - })} -
-
+ - {/* TAB PANELS */} + {/* Tab panels */}
{activeTab === "overview" && ( @@ -722,284 +176,49 @@ const AnalyzeContent = memo(() => { )} - {activeTab === "visualizer" && data && ( - -
-
-

- Blueprint Map -

- - VISUAL LAYOUT - -
-
- {MAP_VIEW_CONFIG.map((view) => ( - - ))} -
-
-
- - {mapView === "graph" ? ( - data.dependencyGraph && - Object.keys(data.dependencyGraph).length > 0 ? ( - - ) : ( -
- -

- No blueprint data parsed. -

-
- ) - ) : mapView === "directory" ? ( - - ) : ( - - )} -
-
-
+ {activeTab === "visualizer" && ( + )} {activeTab === "doctor" && ( - -
- {data.mermaidDiagram ? ( - <> -
- - - -
- setIsChatOpen(true)} - className="w-full flex-shrink-0 py-4 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all flex items-center justify-center gap-3 group shadow-lg" - > - - - Discuss this diagnosis in Copilot → - - - - ) : ( -
- -

- Diagnostic core offline. -

-
- )} -
-
+ setIsChatOpen(true)} + /> )} - {activeTab === "risk_radar" && ( - - {data.coverageGaps && data.fileContents ? ( - - - - ) : ( -
- No risk data available for this codebase. -
- )} -
- )} + {activeTab === "risk_radar" && }
- {/* CHAT / COPILOT PANEL */} - - {!isChatOpen && ( - setIsChatOpen(true)} - aria-label="Open chat assistant" - className="absolute right-0 bottom-8 z-40 flex items-center gap-3 bg-[#141414]/90 backdrop-blur-md border border-white/10 border-r-0 px-4 py-4 rounded-l-2xl shadow-[-10px_0_30px_rgba(0,0,0,0.5)] hover:bg-[#1a1a1a] hover:pr-6 transition-all group" - > -
- - -
-
- Need Help? - - Ask Copilot - -
-
- )} -
- - - {isChatOpen && ( - -
-
- -

- Autopsy Copilot -

-
- -
-
- - - -
-
- )} -
+ setIsChatOpen(true)} + onClose={() => setIsChatOpen(false)} + />
- {/* EXIT MODAL */} - - {showExitModal && ( -
- router.back()} - /> - -

- Leaving so soon? -

-

- Quick check: Was this codebase analysis helpful to you? -

-
-
- - -
- -
-
-
- )} -
+ router.back()} + />
); }); + AnalyzeContent.displayName = "AnalyzeContent"; +// ───────────────────────────────────────────────────────────────────────────── + export default function AnalyzePage() { return ( +
+
+ {isRateLimit ? ( + + ) : ( + + )} +
+ +

+ {isRateLimit ? "Daily Limit Reached" : "Autopsy Failed"} +

+ +
+

+ {error} +

+
+ +
+ {isRateLimit && ( + router.push("/pricing")} + className="w-full px-6 py-3 rounded-xl bg-white text-black font-bold text-sm hover:scale-[1.02] transition-transform" + > + View Upgrade Options + + )} + router.back()} + className="w-full px-6 py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-sm font-bold text-white transition-all flex items-center justify-center gap-2" + > + Go Back + +
+
+
+ ); +} diff --git a/components/analyze/AnalyzeHeader.tsx b/components/analyze/AnalyzeHeader.tsx new file mode 100644 index 0000000..cce0f99 --- /dev/null +++ b/components/analyze/AnalyzeHeader.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { motion, AnimatePresence } from "framer-motion"; +import { + ArrowLeft, + Terminal, + LayoutGrid, + ThumbsUp, + ThumbsDown, + CheckCircle2, +} from "lucide-react"; +import { FaGithub } from "react-icons/fa"; +import Link from "next/link"; +import ExportButton from "@/components/ExportButton"; +import ShareButton from "@/components/ShareButton"; +import { RepoData } from "@/lib/types/analyze"; + +interface AnalyzeHeaderProps { + data: RepoData; + source: string | null; + isAiStreaming: boolean; + feedbackSubmitted: boolean; + hideFeedback: boolean; + onBack: () => void; + onFeedback: (isHelpful: boolean) => void; +} + +export default function AnalyzeHeader({ + data, + source, + isAiStreaming, + feedbackSubmitted, + hideFeedback, + onBack, + onFeedback, +}: AnalyzeHeaderProps) { + return ( +
+ {/* ── Left: Back + Repo Identity ── */} +
+ + +
+ + +

+ {source === "local" ? ( + "Local Codebase" + ) : ( +
+ + {data.owner} + + / + {data.repo} +
+ )} +

+ +
+ + + {data.language} + +
+ + {/* AI streaming indicator */} + {isAiStreaming && ( +
+
+ + AI Live + +
+ )} +
+
+ + {/* ── Right: Actions ── */} +
+ + + Dashboard + + + {/* Inline feedback widget */} + + {!hideFeedback && ( + + {!feedbackSubmitted ? ( +
+ + Helpful? + +
+ + +
+
+ ) : ( +
+ + + Thanks! + +
+ )} +
+ )} +
+ + {source !== "local" && ( + + )} + +
+
+ ); +} diff --git a/components/analyze/AnalyzeLoadingScreen.tsx b/components/analyze/AnalyzeLoadingScreen.tsx new file mode 100644 index 0000000..df76f63 --- /dev/null +++ b/components/analyze/AnalyzeLoadingScreen.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Cpu } from "lucide-react"; +import { LOADING_PHRASES } from "@/components/analyze/constants"; +import type { LoadingControls } from "@/hooks/analyze/useLoadingAnimation"; + +interface AnalyzeLoadingScreenProps { + repoUrl: string | null; + source: string | null; + loadingStep: number; + loadingControls: LoadingControls; +} + +export default function AnalyzeLoadingScreen({ + repoUrl, + source, + loadingStep, + loadingControls, +}: AnalyzeLoadingScreenProps) { + return ( +
+
+
+
+
+ +
+
+

+ Performing Autopsy +

+

+ {source === "local" + ? "Local.zip Upload" + : repoUrl?.split("/").slice(-2).join("/") || "Repository"} +

+
+
+
+
+
+
+
+ + {LOADING_PHRASES[loadingStep]} + +
+
+
+
+ ); +} diff --git a/components/analyze/AnalyzeTabBar.tsx b/components/analyze/AnalyzeTabBar.tsx new file mode 100644 index 0000000..fc911f6 --- /dev/null +++ b/components/analyze/AnalyzeTabBar.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { motion } from "framer-motion"; +import { TAB_CONFIG, TabType } from "@/components/analyze/constants"; + +interface AnalyzeTabBarProps { + activeTab: TabType; + onTabChange: (tab: TabType) => void; +} + +export default function AnalyzeTabBar({ + activeTab, + onTabChange, +}: AnalyzeTabBarProps) { + return ( +
+
+ {TAB_CONFIG.map((tab) => { + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/components/analyze/ChatPanel.tsx b/components/analyze/ChatPanel.tsx new file mode 100644 index 0000000..a4df451 --- /dev/null +++ b/components/analyze/ChatPanel.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { motion, AnimatePresence } from "framer-motion"; +import { Terminal, MessageSquare, X } from "lucide-react"; +import dynamic from "next/dynamic"; +import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; +import { EXPO_OUT } from "@/components/analyze/constants"; +import { RepoData } from "@/lib/types/analyze"; +import SkeletonLoader from "@/components/analyze/SkeletonLoader"; + +const RepoChat = dynamic(() => import("@/components/RepoChat"), { + loading: () => , + ssr: false, +}); + +interface ChatPanelProps { + data: RepoData; + isOpen: boolean; + onOpen: () => void; + onClose: () => void; +} + +export default function ChatPanel({ + data, + isOpen, + onOpen, + onClose, +}: ChatPanelProps) { + return ( + <> + {/* Floating toggle button — visible only when panel is closed */} + + {!isOpen && ( + +
+ + +
+
+ Need Help? + + Ask Copilot + +
+
+ )} +
+ + {/* Slide-in panel */} + + {isOpen && ( + + {/* Panel header */} +
+
+ +

+ Autopsy Copilot +

+
+ +
+ + {/* Chat content */} +
+ + + +
+
+ )} +
+ + ); +} diff --git a/components/analyze/DoctorPanel.tsx b/components/analyze/DoctorPanel.tsx new file mode 100644 index 0000000..4a9c756 --- /dev/null +++ b/components/analyze/DoctorPanel.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Terminal, MessageSquare } from "lucide-react"; +import dynamic from "next/dynamic"; +import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; +import { RepoData } from "@/lib/types/analyze"; +import SkeletonLoader from "@/components/analyze/SkeletonLoader"; + +const DebugInterface = dynamic( + () => import("@/components/debug/DebugInterface"), + { loading: () => , ssr: false }, +); + +interface DoctorPanelProps { + data: RepoData; + source: string | null; + onOpenChat: () => void; +} + +export default function DoctorPanel({ + data, + source, + onOpenChat, +}: DoctorPanelProps) { + const repoUrl = + source === "local" + ? "Local.zip Codebase" + : `https://github.com/${data.owner}/${data.repo}`; + + return ( + +
+ {data.mermaidDiagram ? ( + <> +
+ + + +
+ + + + Discuss this diagnosis in Copilot → + + + + ) : ( +
+ +

+ Diagnostic core offline. +

+
+ )} +
+
+ ); +} diff --git a/components/analyze/ExitModal.tsx b/components/analyze/ExitModal.tsx new file mode 100644 index 0000000..4224e76 --- /dev/null +++ b/components/analyze/ExitModal.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect } from "react"; +import type { RefObject } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { ThumbsUp, ThumbsDown } from "lucide-react"; +import { useRouter } from "next/navigation"; + +interface ExitModalProps { + isOpen: boolean; + modalRef: RefObject; + onFeedback: (isHelpful: boolean, exitAfter: boolean) => void; + onSkip: () => void; +} + +export default function ExitModal({ + isOpen, + modalRef, + onFeedback, + onSkip, +}: ExitModalProps) { + const router = useRouter(); + + // Focus trap: keeps keyboard focus inside the modal while it is open. + useEffect(() => { + if (!isOpen) return; + const modal = modalRef.current; + if (!modal) return; + + const focusable = modal.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + const trap = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + }; + + first?.focus(); + document.addEventListener("keydown", trap); + return () => document.removeEventListener("keydown", trap); + }, [isOpen, modalRef]); + + return ( + + {isOpen && ( +
+ {/* Backdrop */} + router.back()} + /> + + {/* Modal card */} + } + initial={{ opacity: 0, scale: 0.95, y: 10 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + exit={{ opacity: 0, scale: 0.95, y: 10 }} + className="relative z-10 glass-card p-6 rounded-2xl border border-white/10 max-w-sm w-full shadow-2xl bg-[#0e0e0e]" + > +

+ Leaving so soon? +

+

+ Quick check: Was this codebase analysis helpful to you? +

+ +
+
+ + +
+ +
+
+
+ )} +
+ ); +} diff --git a/components/analyze/GitHubAuthModal.tsx b/components/analyze/GitHubAuthModal.tsx new file mode 100644 index 0000000..3e9ce50 --- /dev/null +++ b/components/analyze/GitHubAuthModal.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ArrowLeft } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { FaGithub } from "react-icons/fa"; + +interface GitHubAuthModalProps { + onLogin: () => Promise; +} + +export default function GitHubAuthModal({ onLogin }: GitHubAuthModalProps) { + const router = useRouter(); + + return ( +
+
+ +
+ +
+ +

+ Private Repository +

+

+ To analyze private code, you need to authenticate with GitHub. +

+ +
+ + Connect GitHub Account + + +
+
+
+ ); +} diff --git a/components/analyze/RiskRadarPanel.tsx b/components/analyze/RiskRadarPanel.tsx new file mode 100644 index 0000000..cf98844 --- /dev/null +++ b/components/analyze/RiskRadarPanel.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { motion } from "framer-motion"; +import dynamic from "next/dynamic"; +import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; +import { RepoData } from "@/lib/types/analyze"; +import SkeletonLoader from "@/components/analyze/SkeletonLoader"; + +const RiskDashboard = dynamic(() => import("@/components/RiskDashboard"), { + loading: () => , + ssr: false, +}); + +interface RiskRadarPanelProps { + data: RepoData; +} + +export default function RiskRadarPanel({ data }: RiskRadarPanelProps) { + return ( + + {data.coverageGaps && data.fileContents ? ( + + + + ) : ( +
+ No risk data available for this codebase. +
+ )} +
+ ); +} diff --git a/components/analyze/SkeletonLoader.tsx b/components/analyze/SkeletonLoader.tsx new file mode 100644 index 0000000..a3619e8 --- /dev/null +++ b/components/analyze/SkeletonLoader.tsx @@ -0,0 +1,20 @@ +import { memo } from "react"; + +/** + * Generic pulsing skeleton shown while a lazy-loaded tab component is + * being fetched or while the page is in its initial loading state. + */ +const SkeletonLoader = memo(() => ( +
+
+
+
+
+
+
+
+)); + +SkeletonLoader.displayName = "SkeletonLoader"; + +export default SkeletonLoader; diff --git a/components/analyze/VisualizerPanel.tsx b/components/analyze/VisualizerPanel.tsx new file mode 100644 index 0000000..20a6092 --- /dev/null +++ b/components/analyze/VisualizerPanel.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Layers } from "lucide-react"; +import dynamic from "next/dynamic"; +import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; +import { MAP_VIEW_CONFIG, MapViewType } from "@/components/analyze/constants"; +import { RepoData } from "@/lib/types/analyze"; +import SkeletonLoader from "@/components/analyze/SkeletonLoader"; + +const ArchitectureMap = dynamic(() => import("@/components/ArchitectureMap"), { + loading: () => , + ssr: false, +}); +const TreemapVisualizer = dynamic( + () => import("@/components/TreemapVisualizer"), + { loading: () => , ssr: false }, +); +const DirectoryTreeVisualizer = dynamic( + () => import("@/components/DirectoryTreeVisualizer"), + { loading: () => , ssr: false }, +); + +interface VisualizerPanelProps { + data: RepoData; + mapView: MapViewType; + onMapViewChange: (view: MapViewType) => void; +} + +export default function VisualizerPanel({ + data, + mapView, + onMapViewChange, +}: VisualizerPanelProps) { + return ( + + {/* Sub-view switcher */} +
+
+

+ Blueprint Map +

+ + VISUAL LAYOUT + +
+
+ {MAP_VIEW_CONFIG.map((view) => ( + + ))} +
+
+ + {/* Map canvas */} +
+ + {mapView === "graph" ? ( + data.dependencyGraph && + Object.keys(data.dependencyGraph).length > 0 ? ( + + ) : ( +
+ +

+ No blueprint data parsed. +

+
+ ) + ) : mapView === "directory" ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/components/analyze/constants.tsx b/components/analyze/constants.tsx new file mode 100644 index 0000000..4a02318 --- /dev/null +++ b/components/analyze/constants.tsx @@ -0,0 +1,68 @@ +import { + FileCode, + Layers, + Activity, + GitPullRequest, + Target, +} from "lucide-react"; + +// ─── Framer Motion ──────────────────────────────────────────────────────────── + +export const EXPO_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]; + +// ─── Tab Configuration ──────────────────────────────────────────────────────── + +export const TAB_CONFIG = [ + { id: "overview" as const, icon: FileCode, label: "Read Docs" }, + { id: "visualizer" as const, icon: Layers, label: "Blueprint Map" }, + { id: "doctor" as const, icon: Activity, label: "Diagnostic Engine" }, + { id: "pr_impact" as const, icon: GitPullRequest, label: "PR Impact" }, + { id: "risk_radar" as const, icon: Target, label: "Risk Radar" }, +] as const; + +export type TabType = (typeof TAB_CONFIG)[number]["id"]; + +// ─── Map View Configuration ─────────────────────────────────────────────────── + +export const MAP_VIEW_CONFIG = [ + { id: "graph" as const, label: "Dependency Flow" }, + { id: "directory" as const, label: "Folder Structure" }, + { id: "treemap" as const, label: "Codebase Weight" }, +] as const; + +export type MapViewType = (typeof MAP_VIEW_CONFIG)[number]["id"]; + +// ─── Loading Screen Phrases ─────────────────────────────────────────────────── + +export const LOADING_PHRASES = [ + "Cloning repository...", + "Decrypting source tree...", + "Mapping AST nodes...", + "Tracing execution flows...", + "Calculating blast radius...", + "Evaluating test coverage...", + "Querying Groq LLM...", + "Compiling health report...", +] as const; + +// ─── Default Analysis Skeleton ──────────────────────────────────────────────── +// Shown in the UI while the AI stream is starting up. +// Intentionally does NOT satisfy the full Analysis interface — the streaming +// engine progressively replaces these placeholder values. + +export const DEFAULT_ANALYSIS = { + architecture_pattern: "Analyzing...", + what_it_does: "Waking up Groq LLM...", + execution_flow: [], + tech_stack: [], + key_modules: [], + onboarding_guide: [], + evidence_paths: [], + blast_radius: [], + health_status: { + grade: "-", + score: 0, + status: "Pending", + refactor_plan: [], + }, +} as const; diff --git a/hooks/analyze/useAiStream.ts b/hooks/analyze/useAiStream.ts new file mode 100644 index 0000000..395ced5 --- /dev/null +++ b/hooks/analyze/useAiStream.ts @@ -0,0 +1,128 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { RepoData } from "@/lib/types/analyze"; + +// The subset of swrData fields that the AI endpoint consumes. +// Keeps the hook signature explicit and avoids a full RepoData dependency. +interface AiStreamInput { + owner: string; + repo: string; + description: string; + entryPoints: string[]; + topFilesForGroq?: unknown; + fileContents?: { path: string; content: string }[]; + blastRadiusTargets?: unknown; + healthMetrics?: unknown; + // analysis is null when the server deferred AI generation to the client stream + analysis: RepoData["analysis"] | null; +} + +interface UseAiStreamReturn { + aiAnalysis: Record | null; + isAiStreaming: boolean; +} + +/** + * Owns the real-time AI streaming engine. + * + * When `swrData.analysis` comes back as `null`, the server has signalled that + * the AI generation should be streamed client-side. This hook opens a POST + * stream to `/api/ai`, reads NDJSON chunks, and applies on-the-fly partial + * JSON repair so the UI can render progressive updates before the stream + * completes. + * + * The hook is intentionally idempotent: once streaming has started (or + * completed), it will not re-trigger for the same `swrData` reference. + */ +export function useAiStream( + swrData: AiStreamInput | null | undefined, +): UseAiStreamReturn { + const [aiAnalysis, setAiAnalysis] = useState | null>( + null, + ); + const [isAiStreaming, setIsAiStreaming] = useState(false); + + useEffect(() => { + // Only start the stream when: + // 1. swrData has arrived + // 2. The server explicitly deferred analysis (analysis === null) + // 3. We are not already streaming + // 4. We have not already received a completed result + if (!swrData || swrData.analysis !== null || isAiStreaming || aiAnalysis) { + return; + } + + setIsAiStreaming(true); + + const fetchAiStream = async () => { + try { + const res = await fetch("/api/ai", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + repoName: `${swrData.owner}/${swrData.repo}`, + description: swrData.description, + entryPoints: swrData.entryPoints, + topFiles: swrData.topFilesForGroq, + fileContents: swrData.fileContents, + blastRadiusTargets: swrData.blastRadiusTargets, + healthMetrics: swrData.healthMetrics, + }), + }); + + if (!res.body) return; + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let jsonText = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + jsonText += decoder.decode(value, { stream: true }); + + // 1. Attempt to parse the complete accumulated JSON first. + try { + setAiAnalysis(JSON.parse(jsonText)); + } catch { + // 2. On-the-fly partial JSON repair for progressive UI rendering. + // The incoming stream is valid JSON that may be mid-write, so we + // close any open string literals and balance braces/brackets. + try { + let fixed = jsonText.replace(/("[^"]*)$/, '"'); + + const openBraces = (fixed.match(/\{/g) || []).length; + const closeBraces = (fixed.match(/\}/g) || []).length; + const openBrackets = (fixed.match(/\[/g) || []).length; + const closeBrackets = (fixed.match(/\]/g) || []).length; + + fixed += + "]".repeat(Math.max(0, openBrackets - closeBrackets)) + + "}".repeat(Math.max(0, openBraces - closeBraces)); + + const partial = JSON.parse(fixed); + if (partial) setAiAnalysis(partial); + } catch { + // Silently wait for the next chunk if the fragment is still + // unparseable after repair attempts. + } + } + } + } catch (err) { + console.error("AI Stream Failed:", err); + } finally { + setIsAiStreaming(false); + } + }; + + fetchAiStream(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [swrData]); + // Intentionally omitting `isAiStreaming` and `aiAnalysis` from deps: + // the guard conditions at the top of the effect body are sufficient, and + // including them would risk re-triggering the stream on state updates. + + return { aiAnalysis, isAiStreaming }; +} \ No newline at end of file diff --git a/hooks/analyze/useAnalyzeData.ts b/hooks/analyze/useAnalyzeData.ts new file mode 100644 index 0000000..027458b --- /dev/null +++ b/hooks/analyze/useAnalyzeData.ts @@ -0,0 +1,137 @@ +"use client"; + +import { useState, useMemo } from "react"; +import useSWR from "swr"; +import { fetcher } from "@/lib/fetcher"; +import { RepoData, Analysis } from "@/lib/types/analyze"; +import { useAiStream } from "./useAiStream"; +import { DEFAULT_ANALYSIS } from "@/components/analyze/constants"; + +interface UseAnalyzeDataOptions { + repoUrl: string | null; + source: string | null; + onRequireGitHubAuth: () => void; +} + +interface UseAnalyzeDataReturn { + /** Fully resolved data, with AI analysis injected (streaming or cached). */ + data: RepoData | null; + loading: boolean; + error: string | null; + isRateLimit: boolean; + isAiStreaming: boolean; +} + +/** + * Owns all data-fetching concerns for the analyze page. + * + * Responsibilities: + * - SWR-cached remote fetch via `/api/analyze` + * - Local-upload fallback from `sessionStorage` + * - AI stream injection via `useAiStream` + * - Unified `data`, `loading`, and `error` surface for consumers + * + * The `onRequireGitHubAuth` callback is fired instead of surfacing an error + * string when the API returns `REQUIRE_GITHUB_AUTH`, so the caller can show + * the auth modal without coupling this hook to UI state. + */ +export function useAnalyzeData({ + repoUrl, + source, + onRequireGitHubAuth, +}: UseAnalyzeDataOptions): UseAnalyzeDataReturn { + const isLocal = source === "local"; + + // ─── Local Upload Path ────────────────────────────────────────────────────── + // Read once on mount. We use plain `useState` initializers (not effects) so + // the value is available synchronously on the first render. + + const [localData] = useState(() => { + if (typeof window === "undefined" || !isLocal) return null; + const stored = sessionStorage.getItem("localAnalysisResult"); + return stored ? (JSON.parse(stored) as RepoData) : null; + }); + + const [localError] = useState(() => { + if (typeof window === "undefined" || !isLocal) return null; + const stored = sessionStorage.getItem("localAnalysisResult"); + return !stored + ? "Local analysis data expired or lost. Please return to home and upload again." + : null; + }); + + // ─── Remote Fetch Path (SWR) ──────────────────────────────────────────────── + + const { + data: swrData, + error: swrError, + isLoading: swrLoading, + } = useSWR( + repoUrl && !isLocal ? ["/api/analyze", repoUrl] : null, + ([url, repo]) => fetcher(url, repo), + { + revalidateOnFocus: false, + shouldRetryOnError: false, + onError: (err: Error) => { + if (err.message === "REQUIRE_GITHUB_AUTH") onRequireGitHubAuth(); + }, + }, + ); + + // ─── AI Stream ────────────────────────────────────────────────────────────── + // Pass swrData to the stream hook. It self-guards and will only open a + // stream when `swrData.analysis === null`. + + const { aiAnalysis, isAiStreaming } = useAiStream( + isLocal ? null : swrData, + ); + + // ─── Unified Data Surface ─────────────────────────────────────────────────── + + const data = useMemo(() => { + if (isLocal) return localData; + if (!swrData || swrError) return null; + + // The server may return `analysis: null` to signal client-side streaming. + // We inject the live aiAnalysis (or a safe placeholder skeleton) so the + // rest of the UI never needs to handle a null analysis field. + const resolvedAnalysis: Analysis = + aiAnalysis != null + ? (aiAnalysis as unknown as Analysis) + : swrData.analysis !== null + ? swrData.analysis + : (DEFAULT_ANALYSIS as unknown as Analysis); + + return { + ...swrData, + analysis: resolvedAnalysis, + } as RepoData; + }, [isLocal, localData, swrData, swrError, aiAnalysis]); + + // ─── Error Derivation ─────────────────────────────────────────────────────── + // Strip the REQUIRE_GITHUB_AUTH sentinel — that path is handled via the + // callback above and should not surface as a visible error string. + + const rawErrorMessage = + swrError?.message === "REQUIRE_GITHUB_AUTH" ? null : swrError?.message; + + const error: string | null = isLocal + ? localError + : (rawErrorMessage ?? null); + + const isRateLimit = + !!error && + (error.includes("RATE_LIMIT_REACHED") || error.includes("Daily limit")); + + // ─── Loading ───────────────────────────────────────────────────────────────── + + const loading = isLocal ? !localData && !localError : swrLoading; + + return { + data, + loading, + error, + isRateLimit, + isAiStreaming, + }; +} \ No newline at end of file diff --git a/hooks/analyze/useAnalyzeUIState.ts b/hooks/analyze/useAnalyzeUIState.ts new file mode 100644 index 0000000..df2b5e7 --- /dev/null +++ b/hooks/analyze/useAnalyzeUIState.ts @@ -0,0 +1,129 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + TAB_CONFIG, + MAP_VIEW_CONFIG, + TabType, + MapViewType, +} from "@/components/analyze/constants"; + +interface UseAnalyzeUIStateOptions { + /** + * Called when Escape is pressed and the GitHub auth modal is the active + * layer. The caller owns that modal's visibility state, so we delegate the + * navigation action rather than importing the router here. + */ + onEscapeFromAuthModal: () => void; + /** Whether the GitHub auth modal is currently visible. */ + showGitHubAuthModal: boolean; + /** Whether the mount flag has been set (prevents SSR sessionStorage reads). */ + isMounted: boolean; +} + +interface UseAnalyzeUIStateReturn { + activeTab: TabType; + setActiveTab: (tab: TabType) => void; + mapView: MapViewType; + setMapView: (view: MapViewType) => void; + isChatOpen: boolean; + setIsChatOpen: (open: boolean) => void; + showExitModal: boolean; + setShowExitModal: (open: boolean) => void; +} + +/** + * Owns all ephemeral UI state for the analyze page. + * + * Responsibilities: + * - `activeTab` and `mapView` with `sessionStorage` persistence + * - `isChatOpen` and `showExitModal` panel visibility + * - Global Escape key handler (dispatches to the correct layer) + */ +export function useAnalyzeUIState({ + onEscapeFromAuthModal, + showGitHubAuthModal, + isMounted, +}: UseAnalyzeUIStateOptions): UseAnalyzeUIStateReturn { + // ─── Tab & View State ──────────────────────────────────────────────────────── + // Safe synchronous initialization: sessionStorage is only read in the browser. + + const [activeTab, setActiveTabRaw] = useState(() => { + if (typeof window === "undefined") return "overview"; + const saved = sessionStorage.getItem("codeautopsy_tab"); + return saved && TAB_CONFIG.some((t) => t.id === saved) + ? (saved as TabType) + : "overview"; + }); + + const [mapView, setMapViewRaw] = useState(() => { + if (typeof window === "undefined") return "graph"; + const saved = sessionStorage.getItem("codeautopsy_view"); + return saved && MAP_VIEW_CONFIG.some((v) => v.id === saved) + ? (saved as MapViewType) + : "graph"; + }); + + // ─── Panel Visibility ──────────────────────────────────────────────────────── + + const [isChatOpen, setIsChatOpen] = useState(false); + const [showExitModal, setShowExitModal] = useState(false); + + // ─── sessionStorage Sync ───────────────────────────────────────────────────── + // Only write after the component has mounted to avoid SSR mismatches. + + useEffect(() => { + if (!isMounted) return; + sessionStorage.setItem("codeautopsy_tab", activeTab); + }, [activeTab, isMounted]); + + useEffect(() => { + if (!isMounted) return; + sessionStorage.setItem("codeautopsy_view", mapView); + }, [mapView, isMounted]); + + // ─── Escape Key Handler ─────────────────────────────────────────────────────── + // Layers (highest priority first): + // 1. Exit modal + // 2. GitHub auth modal → delegate navigation to caller + // 3. Chat panel + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (showExitModal) { + setShowExitModal(false); + } else if (showGitHubAuthModal) { + onEscapeFromAuthModal(); + } else if (isChatOpen) { + setIsChatOpen(false); + } + }; + + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [showExitModal, showGitHubAuthModal, isChatOpen, onEscapeFromAuthModal]); + + // ─── Stable Setters ─────────────────────────────────────────────────────────── + // Wrapped in useCallback so consumers can use them in their own dependency + // arrays without risking stale-closure issues. + + const setActiveTab = useCallback((tab: TabType) => { + setActiveTabRaw(tab); + }, []); + + const setMapView = useCallback((view: MapViewType) => { + setMapViewRaw(view); + }, []); + + return { + activeTab, + setActiveTab, + mapView, + setMapView, + isChatOpen, + setIsChatOpen, + showExitModal, + setShowExitModal, + }; +} \ No newline at end of file diff --git a/hooks/analyze/useFeedback.ts b/hooks/analyze/useFeedback.ts new file mode 100644 index 0000000..23a961b --- /dev/null +++ b/hooks/analyze/useFeedback.ts @@ -0,0 +1,81 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { createClient } from "@/lib/supabase-browser"; + +interface UseFeedbackOptions { + /** + * The repo name or identifier used as the `debug_id` in Supabase. + * Falls back to `repoUrl`, then `"local-upload"` if neither is available. + */ + repoName: string | null | undefined; + repoUrl: string | null | undefined; + /** Called after feedback is submitted when `exitAfter` is true. */ + onExit: () => void; + /** Called to close the exit modal regardless of exit intent. */ + onCloseExitModal: () => void; +} + +interface UseFeedbackReturn { + feedbackSubmitted: boolean; + hideFeedback: boolean; + /** + * @param isHelpful - Whether the user found the analysis helpful. + * @param exitAfter - If true, closes the exit modal and calls `onExit` after + * the Supabase insert completes (fire-and-forget; errors are swallowed). + */ + handleFeedback: (isHelpful: boolean, exitAfter?: boolean) => Promise; +} + +/** + * Owns the feedback UI state and the Supabase persistence side-effect. + * + * The Supabase insert is fire-and-forget: errors are logged but never surfaced + * to the user, matching the original behaviour in page.tsx. + */ +export function useFeedback({ + repoName, + repoUrl, + onExit, + onCloseExitModal, +}: UseFeedbackOptions): UseFeedbackReturn { + const [feedbackSubmitted, setFeedbackSubmitted] = useState(false); + const [hideFeedback, setHideFeedback] = useState(false); + + const handleFeedback = useCallback( + async (isHelpful: boolean, exitAfter = false) => { + setFeedbackSubmitted(true); + + if (exitAfter) { + onCloseExitModal(); + } else { + // Auto-hide the inline feedback widget after the success animation + // completes (2 seconds), matching the original behaviour. + setTimeout(() => setHideFeedback(true), 2000); + } + + try { + const supabase = createClient(); + await supabase.from("debug_feedback").insert([ + { + debug_id: repoName ?? repoUrl ?? "local-upload", + is_helpful: isHelpful, + }, + ]); + } catch (err) { + console.error("Feedback insert failed:", err); + } + + if (exitAfter) { + onExit(); + } + }, + [repoName, repoUrl, onExit, onCloseExitModal], + ); + + return { + feedbackSubmitted, + hideFeedback, + handleFeedback, + }; +} \ No newline at end of file diff --git a/hooks/analyze/useLoadingAnimation.ts b/hooks/analyze/useLoadingAnimation.ts new file mode 100644 index 0000000..ad8dcc4 --- /dev/null +++ b/hooks/analyze/useLoadingAnimation.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useAnimationControls } from "framer-motion"; +import { LOADING_PHRASES } from "@/components/analyze/constants"; + +export type LoadingControls = ReturnType; + +interface UseLoadingAnimationReturn { + loadingStep: number; + loadingControls: LoadingControls; +} + +/** + * Drives the loading screen phrase animation. + * + * Cycles through `LOADING_PHRASES` with a fade-out / increment / fade-in + * loop for as long as `loading` is `true`. The loop is cancelled cleanly + * via the `isActive` flag when the effect tears down. + * + * @param loading - Should be `true` while data is still being fetched. + */ +export function useLoadingAnimation( + loading: boolean, +): UseLoadingAnimationReturn { + const [loadingStep, setLoadingStep] = useState(0); + const loadingControls = useAnimationControls(); + + useEffect(() => { + if (!loading) return; + + let isActive = true; + + const animate = async () => { + while (isActive) { + await loadingControls.start({ + opacity: [0, 1], + transition: { duration: 0.4, ease: "easeOut" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 800)); + + await loadingControls.start({ + opacity: [1, 0], + transition: { duration: 0.4, ease: "easeIn" }, + }); + + if (isActive) { + setLoadingStep((prev) => (prev + 1) % LOADING_PHRASES.length); + } + } + }; + + animate(); + + return () => { + isActive = false; + }; + }, [loading, loadingControls]); + + return { loadingStep, loadingControls }; +} \ No newline at end of file From eaf7813d662f0b1a47f184c93c22726b17cfd1e4 Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Fri, 29 May 2026 09:29:26 +0530 Subject: [PATCH 3/5] fix: restore clean refactored page.tsx, remove merge pollution --- app/analyze/page.tsx | 142 ++++--------------------------------------- 1 file changed, 12 insertions(+), 130 deletions(-) diff --git a/app/analyze/page.tsx b/app/analyze/page.tsx index 09b6445..dbbdf88 100644 --- a/app/analyze/page.tsx +++ b/app/analyze/page.tsx @@ -176,70 +176,12 @@ const AnalyzeContent = memo(() => { )} - {activeTab === "visualizer" && data && ( - -
-
-

- Blueprint Map -

- - VISUAL LAYOUT - -
-
- {MAP_VIEW_CONFIG.map((view) => ( - - ))} -
-
-
- - {mapView === "graph" ? ( - data.dependencyGraph && - Object.keys(data.dependencyGraph).length > 0 ? ( - - ) : ( -
- -

- No blueprint data parsed. -

-
- ) - ) : mapView === "directory" ? ( - - ) : ( - - )} -
-
-
+ {activeTab === "visualizer" && ( + )} {activeTab === "doctor" && ( @@ -255,72 +197,12 @@ const AnalyzeContent = memo(() => {
- {/* CHAT / COPILOT PANEL */} - - {!isChatOpen && ( - setIsChatOpen(true)} - aria-label="Open chat assistant" - className="absolute right-0 bottom-8 z-40 flex items-center gap-3 bg-[#141414]/90 backdrop-blur-md border border-white/10 border-r-0 px-4 py-4 rounded-l-2xl shadow-[-10px_0_30px_rgba(0,0,0,0.5)] hover:bg-[#1a1a1a] hover:pr-6 transition-all group" - > -
- - -
-
- Need Help? - - Ask Copilot - -
-
- )} -
- - - {isChatOpen && ( - -
-
- -

- Autopsy Copilot -

-
- -
-
- - - -
-
- )} -
+ setIsChatOpen(true)} + onClose={() => setIsChatOpen(false)} + />
Date: Fri, 29 May 2026 21:34:25 +0530 Subject: [PATCH 4/5] feat(visualizer): implement multi-source reverse BFS for PR blast radius - Extract changed files from existing PR analysis route - Add 'pr-blast' highlight mode to React Flow ArchitectureMap - Implement automatic tab switching and state lifting for seamless UX --- app/analyze/page.tsx | 52 ++- app/api/ai/route.ts | 92 ++++- app/api/analyze-pr/route.ts | 120 ++++--- components/ArchitectureMap.tsx | 469 ++++++++++++++++++++----- components/analyze/AiGate.tsx | 134 +++++++ components/analyze/ChatPanel.tsx | 2 +- components/analyze/DoctorPanel.tsx | 24 ++ components/analyze/PrImpactTab.tsx | 178 ++++++---- components/analyze/VisualizerPanel.tsx | 6 + hooks/analyze/useAiStream.ts | 63 ++-- hooks/analyze/useAnalyzeData.ts | 8 +- lib/ai-usage.ts | 56 +++ lib/types/analyze.ts | 4 + next.config.ts | 4 + package-lock.json | 14 + package.json | 1 + 16 files changed, 949 insertions(+), 278 deletions(-) create mode 100644 components/analyze/AiGate.tsx create mode 100644 lib/ai-usage.ts diff --git a/app/analyze/page.tsx b/app/analyze/page.tsx index dbbdf88..a7864e1 100644 --- a/app/analyze/page.tsx +++ b/app/analyze/page.tsx @@ -33,15 +33,15 @@ import RiskRadarPanel from "@/components/analyze/RiskRadarPanel"; import ChatPanel from "@/components/analyze/ChatPanel"; import ExitModal from "@/components/analyze/ExitModal"; -// ─── Lazy Tab Panels (unchanged from original) ──────────────────────────────── +// ─── Lazy Tab Panels ────────────────────────────────────────────────────────── const OverviewTab = dynamic(() => import("@/components/analyze/OverviewTab"), { loading: () => , ssr: false, }); -const PrImpactTab = dynamic(() => import("@/components/analyze/PrImpactTab"), { - loading: () => , - ssr: false, -}); + +// PrImpactTab is NOT lazy-loaded here because we need to pass callbacks to it. +// It is imported directly so TypeScript can see the prop types at compile time. +import PrImpactTab from "@/components/analyze/PrImpactTab"; // ───────────────────────────────────────────────────────────────────────────── @@ -52,7 +52,7 @@ const AnalyzeContent = memo(() => { const repoUrl = searchParams.get("url") || searchParams.get("repo"); const source = searchParams.get("source"); - // ── Mount flag (prevents SSR / sessionStorage mismatches) ────────────────── + // ── Mount flag ────────────────────────────────────────────────────────────── const [isMounted, setIsMounted] = useState(false); useEffect(() => { const id = setTimeout(() => setIsMounted(true), 0); @@ -63,11 +63,12 @@ const AnalyzeContent = memo(() => { const [showGitHubAuthModal, setShowGitHubAuthModal] = useState(false); // ── Data ──────────────────────────────────────────────────────────────────── - const { data, loading, error, isRateLimit, isAiStreaming } = useAnalyzeData({ - repoUrl, - source, - onRequireGitHubAuth: () => setShowGitHubAuthModal(true), - }); + const { data, loading, error, isRateLimit, isAiStreaming, aiGateState } = + useAnalyzeData({ + repoUrl, + source, + onRequireGitHubAuth: () => setShowGitHubAuthModal(true), + }); // ── UI State ──────────────────────────────────────────────────────────────── const { @@ -85,7 +86,23 @@ const AnalyzeContent = memo(() => { isMounted, }); - // ── Exit modal focus trap ref ─────────────────────────────────────────────── + // ── PR Blast Radius state — lifted from PrImpactTab ───────────────────────── + // Holds the raw list of files changed in the most recently analyzed PR. + // Empty array = no PR has been analyzed yet (ArchitectureMap shows default). + const [prChangedFiles, setPrChangedFiles] = useState([]); + + // Called by PrImpactTab after a successful PR analysis. + // Stores the file list AND switches to the visualizer tab so the user + // immediately sees the graph with pr-blast mode activated. + const handlePrAnalyzed = useCallback( + (files: string[]) => { + setPrChangedFiles(files); + setActiveTab("visualizer"); + }, + [setActiveTab], + ); + + // ── Exit modal ref ────────────────────────────────────────────────────────── const exitModalRef = useRef(null); // ── Feedback ──────────────────────────────────────────────────────────────── @@ -99,7 +116,7 @@ const AnalyzeContent = memo(() => { // ── Loading animation ─────────────────────────────────────────────────────── const { loadingStep, loadingControls } = useLoadingAnimation(loading); - // ── GitHub OAuth handler ──────────────────────────────────────────────────── + // ── GitHub OAuth ──────────────────────────────────────────────────────────── const handleGitHubLogin = useCallback(async () => { const supabase = createClient(); await supabase.auth.signInWithOAuth({ @@ -155,7 +172,6 @@ const AnalyzeContent = memo(() => {
- {/* Tab panels */}
{activeTab === "overview" && ( @@ -172,7 +188,11 @@ const AnalyzeContent = memo(() => { key="pr-boundary" fallbackMessage="Failed to load PR impact analyzer." > - + setActiveTab("visualizer")} + /> )} @@ -181,6 +201,7 @@ const AnalyzeContent = memo(() => { data={data} mapView={mapView} onMapViewChange={setMapView} + prChangedFiles={prChangedFiles} /> )} @@ -189,6 +210,7 @@ const AnalyzeContent = memo(() => { data={data} source={source} onOpenChat={() => setIsChatOpen(true)} + aiGateState={aiGateState} /> )} diff --git a/app/api/ai/route.ts b/app/api/ai/route.ts index ef8eee0..c7791d8 100644 --- a/app/api/ai/route.ts +++ b/app/api/ai/route.ts @@ -1,22 +1,74 @@ import { streamAnalyzeWithGemini } from "@/lib/gemini"; -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { createServerClient } from "@supabase/ssr"; +import { checkAiUsageLimit, insertAiUsage } from "@/lib/ai-usage"; -export const runtime = 'nodejs'; +export const runtime = "nodejs"; export async function POST(req: NextRequest) { try { + // ── 1. Auth ────────────────────────────────────────────────────────────── + const cookieStore = await cookies(); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options), + ); + } catch {} + }, + }, + }, + ); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + // ── 2. Gate: not logged in ──────────────────────────────────────────────── + if (!user) { + return NextResponse.json( + { error: "AI_AUTH_REQUIRED" }, + { status: 401 }, + ); + } + + // ── 3. Gate: usage limit ────────────────────────────────────────────────── + const { allowed, used, limit } = await checkAiUsageLimit( + supabase, + user.id, + user.email, + ); + + if (!allowed) { + return NextResponse.json( + { error: "AI_LIMIT_REACHED", used, limit }, + { status: 402 }, + ); + } + + // ── 4. Parse body ───────────────────────────────────────────────────────── const body = await req.json(); - const { - repoName, - description, - entryPoints, - topFiles, - fileContents, - blastRadiusTargets, - healthMetrics + const { + repoName, + description, + entryPoints, + topFiles, + fileContents, + blastRadiusTargets, + healthMetrics, } = body; - // 1. Kick off the streaming response from the AI SDK + // ── 5. Stream AI response ───────────────────────────────────────────────── const responseStream = await streamAnalyzeWithGemini( repoName, description, @@ -24,17 +76,21 @@ export async function POST(req: NextRequest) { topFiles, fileContents, blastRadiusTargets, - healthMetrics + healthMetrics, ); - // 2. Return the stream directly to the frontend - return responseStream; + // ── 6. Record usage after stream is kicked off ──────────────────────────── + // Non-blocking — do not await, never fail the response + insertAiUsage(supabase, user.id, repoName ?? "unknown").catch((err) => + console.error("[ai] Usage insert failed:", err), + ); + return responseStream; } catch (error) { console.error("AI Streaming Error:", error); - return new Response(JSON.stringify({ error: "Failed to generate AI analysis stream" }), { - status: 500, - headers: { "Content-Type": "application/json" } - }); + return NextResponse.json( + { error: "Failed to generate AI analysis stream" }, + { status: 500 }, + ); } } \ No newline at end of file diff --git a/app/api/analyze-pr/route.ts b/app/api/analyze-pr/route.ts index 9bb4b7c..7959fb2 100644 --- a/app/api/analyze-pr/route.ts +++ b/app/api/analyze-pr/route.ts @@ -1,22 +1,17 @@ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; -import { createServerClient } from "@supabase/ssr"; -import { checkUsageLimit } from "@/lib/usage"; +import { createServerClient } from "@supabase/ssr"; +import { checkUsageLimit } from "@/lib/usage"; import { getFileContributors } from "@/lib/github/pr-fetcher"; function scanForSecrets(text: string): string[] { const alerts: string[] = []; - - if (/(AKIA|A3T[A-Z0-9]|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}/.test(text)) { + if (/(AKIA|A3T[A-Z0-9]|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}/.test(text)) alerts.push("Exposed AWS Access Key"); - } - if (/(?:api_key|apikey|secret|password|token)(?:["'\s:=]+)(?:["']?)([a-zA-Z0-9\-_]{20,})(?:["']?)/i.test(text)) { + if (/(?:api_key|apikey|secret|password|token)(?:["'\s:=]+)(?:["']?)([a-zA-Z0-9\-_]{20,})(?:["']?)/i.test(text)) alerts.push("Potential hardcoded Secret/Token"); - } - if (/-----BEGIN (RSA|EC|DSA|OPENSSH|PGP) PRIVATE KEY-----/.test(text)) { + if (/-----BEGIN (RSA|EC|DSA|OPENSSH|PGP) PRIVATE KEY-----/.test(text)) alerts.push("Exposed Private Key"); - } - return alerts; } @@ -25,64 +20,69 @@ export async function POST(req: Request) { const body = await req.json(); const { owner, repo, prNumber } = body; - if (!owner || !repo || !prNumber) { + if (!owner || !repo || !prNumber) return NextResponse.json({ error: "Missing parameters." }, { status: 400 }); - } const cookieStore = await cookies(); const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { cookies: { get(name: string) { return cookieStore.get(name)?.value; } } } + { cookies: { get(name: string) { return cookieStore.get(name)?.value; } } }, ); - const { data: { user }, error: authError } = await supabase.auth.getUser(); - if (authError || !user) { + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) return NextResponse.json({ error: "Unauthorized. Please log in." }, { status: 401 }); - } const isUnderLimit = await checkUsageLimit(supabase, user.id, user.email); - if (!isUnderLimit) { + if (!isUnderLimit) return NextResponse.json( - { - error: "RATE_LIMIT_REACHED", - message: "Daily limit of 10 scans reached. Please upgrade to the Architect tier to continue." - }, - { status: 429 } + { error: "RATE_LIMIT_REACHED", message: "Daily limit of 10 scans reached. Please upgrade to the Architect tier to continue." }, + { status: 429 }, ); - } - const prMetaRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`, { - headers: { Accept: "application/vnd.github.v3+json" } - }); - + // ── PR metadata ─────────────────────────────────────────────────────────── + const prMetaRes = await fetch( + `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`, + { headers: { Accept: "application/vnd.github.v3+json" } }, + ); if (!prMetaRes.ok) throw new Error("Pull Request not found."); const prMeta = await prMetaRes.json(); - const prFilesRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/files`, { - headers: { Accept: "application/vnd.github.v3+json" } - }); + // ── PR files ────────────────────────────────────────────────────────────── + const prFilesRes = await fetch( + `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/files`, + { headers: { Accept: "application/vnd.github.v3+json" } }, + ); const prFiles = await prFilesRes.json(); + // ── Extract changed filenames for the client-side graph BFS ────────────── + // This is the only addition to the original route: we collect the raw + // filename list and attach it to the final response so ArchitectureMap + // can run the reverse BFS entirely on the client without a second API call. + const changedFiles: string[] = Array.isArray(prFiles) + ? prFiles.map((f: { filename: string }) => f.filename) + : []; + + // ── Secret scanning + author fetch (unchanged) ──────────────────────────── const securityAlerts: string[] = []; - const filePromises = prFiles.slice(0, 15).map(async (f: { patch?: string; filename: string; status: string }) => { - const patch = f.patch ? f.patch.substring(0, 1500) : "Binary/large file."; - - const secretsFound = scanForSecrets(patch); - if (secretsFound.length > 0) { - securityAlerts.push(`CRITICAL ALERT in ${f.filename}: ${secretsFound.join(', ')}`); - } - - const authors = await getFileContributors(owner, repo, f.filename, process.env.GITHUB_TOKEN); - const authorText = authors.length > 0 ? authors.join(", ") : "Unknown/New File"; - - return `=== FILE: ${f.filename} (Status: ${f.status}) ===\nHistorical Authors: ${authorText}\n${patch}`; - }); + const filePromises = prFiles.slice(0, 15).map( + async (f: { patch?: string; filename: string; status: string }) => { + const patch = f.patch ? f.patch.substring(0, 1500) : "Binary/large file."; + const secretsFound = scanForSecrets(patch); + if (secretsFound.length > 0) + securityAlerts.push(`CRITICAL ALERT in ${f.filename}: ${secretsFound.join(", ")}`); + const authors = await getFileContributors(owner, repo, f.filename, process.env.GITHUB_TOKEN); + const authorText = authors.length > 0 ? authors.join(", ") : "Unknown/New File"; + return `=== FILE: ${f.filename} (Status: ${f.status}) ===\nHistorical Authors: ${authorText}\n${patch}`; + }, + ); const fileChangesArray = await Promise.all(filePromises); const fileChangesText = fileChangesArray.join("\n\n"); + // ── Groq prompt (unchanged) ─────────────────────────────────────────────── const systemPrompt = `You are a senior software engineer conducting a strict code review on a Pull Request. Repository: ${owner}/${repo} PR Title: ${prMeta.title} @@ -110,12 +110,15 @@ Analyze these code changes and return ONLY a valid JSON object with EXACTLY this const apiKey = process.env.GROQ_API_KEY; const groqRes = await fetch("https://api.groq.com/openai/v1/chat/completions", { method: "POST", - headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ model: "llama-3.3-70b-versatile", - messages: [{ role: "system", content: systemPrompt }, { role: "user", content: "Analyze this PR and return ONLY the required JSON." }], + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: "Analyze this PR and return ONLY the required JSON." }, + ], temperature: 0.1, - response_format: { type: "json_object" } + response_format: { type: "json_object" }, }), }); @@ -124,34 +127,35 @@ Analyze these code changes and return ONLY a valid JSON object with EXACTLY this const finalResult = JSON.parse(data.choices[0].message.content); console.log("🤖 RAW AI PAYLOAD:", JSON.stringify(finalResult.suggestedReviewers, null, 2)); + // ── Security alert injection (unchanged) ────────────────────────────────── if (securityAlerts.length > 0) { finalResult.riskLevel = "high"; - if (!finalResult.blastRadius) finalResult.blastRadius = []; - - securityAlerts.forEach(alert => { - finalResult.blastRadius.unshift({ - file: "SECURITY BREACH", - impact: alert - }); + securityAlerts.forEach((alert) => { + finalResult.blastRadius.unshift({ file: "SECURITY BREACH", impact: alert }); }); } - const { error: dbError } = await supabase.from('pr_analyses').insert({ + // ── Attach raw changed files for client-side graph BFS ──────────────────── + finalResult.changedFiles = changedFiles; + + // ── Persist to Supabase (unchanged) ─────────────────────────────────────── + const { error: dbError } = await supabase.from("pr_analyses").insert({ user_id: user.id, repo_name: `${owner}/${repo}`, pr_number: prNumber, title: finalResult.title, risk_level: finalResult.riskLevel, - analysis_data: finalResult + analysis_data: finalResult, }); - if (dbError) console.error("Failed to save PR analysis to Supabase:", dbError); return NextResponse.json(finalResult); - } catch (error: unknown) { console.error("PR Analysis Error:", error); - return NextResponse.json({ error: error instanceof Error ? error.message : "Failed to analyze Pull Request." }, { status: 500 }); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to analyze Pull Request." }, + { status: 500 }, + ); } } \ No newline at end of file diff --git a/components/ArchitectureMap.tsx b/components/ArchitectureMap.tsx index 028d5ff..fa2e322 100644 --- a/components/ArchitectureMap.tsx +++ b/components/ArchitectureMap.tsx @@ -31,13 +31,13 @@ import { } from "d3-force"; import { motion, AnimatePresence } from "framer-motion"; import { - AlertTriangle, CheckCircle2, Ghost, X, RefreshCw, Flame, ChevronRight, + GitPullRequest, } from "lucide-react"; // ============================================================================ @@ -108,10 +108,52 @@ function detectOrphans(graph: Record): string[] { ); } +/** + * Multi-source Reverse BFS for PR Blast Radius. + * + * Takes an array of PR-changed files as starting nodes and walks the + * REVERSE dependency graph (adjacencyList = who imports X) to find every + * file that could break if one of the changed files changes. + * + * Returns: + * modifiedNodes — the files directly changed in the PR (yellow) + * blastNodes — files that import (transitively) a modified file (red) + * + * A file in modifiedNodes that is ALSO reachable via the reverse graph stays + * in modifiedNodes (yellow takes priority over red in the renderer). + */ +function computePrBlastRadius( + changedFiles: string[], + adjacencyList: Record, // reverse graph: file → who imports it +): { modifiedNodes: Set; blastNodes: Set } { + const modifiedNodes = new Set(changedFiles); + const blastNodes = new Set(); + + const queue = [...changedFiles]; + const visited = new Set(changedFiles); + + while (queue.length > 0) { + const current = queue.shift()!; + const importers = adjacencyList[current] || []; + for (const importer of importers) { + if (!visited.has(importer)) { + visited.add(importer); + // Only add to blastNodes if not one of the directly modified files + if (!modifiedNodes.has(importer)) { + blastNodes.add(importer); + } + queue.push(importer); + } + } + } + + return { modifiedNodes, blastNodes }; +} + // ============================================================================ -// ACTIVE MODE TYPE — single source of truth, replaces ad-hoc if/else chain +// ACTIVE MODE TYPE // ============================================================================ -type ActiveMode = "blast" | "circular" | "orphan" | null; +type ActiveMode = "blast" | "circular" | "orphan" | "pr-blast" | null; // ============================================================================ // GLASS NODE @@ -127,6 +169,9 @@ interface GlassNodeData { heatmap?: "green" | "yellow" | "red"; isOrphan?: boolean; isOrphanHighlighted?: boolean; + // PR blast fields + isPrModified?: boolean; // changed in the PR → yellow + isPrBlast?: boolean; // breaks because of the PR → red } const HEATMAP_STYLES: Record< @@ -154,15 +199,33 @@ const HEATMAP_STYLES: Record< }; const GlassNode = ({ data }: { data: GlassNodeData }) => { - const { isBlastRadius, isDimmed, isCircular, heatmap, isOrphanHighlighted } = - data; + const { + isBlastRadius, + isDimmed, + isCircular, + heatmap, + isOrphanHighlighted, + isPrModified, + isPrBlast, + } = data; let containerClass = "px-4 py-2 shadow-xl rounded-xl backdrop-blur-md min-w-[150px] transition-all duration-300 cursor-pointer"; let textClass = "text-sm font-mono truncate max-w-[200px] transition-colors"; let handleColor = "!bg-slate-500"; - if (isBlastRadius) { + // Priority: pr-modified > pr-blast > blast > dimmed > orphan > circular > heatmap > default + if (isPrModified) { + containerClass += + " bg-yellow-500/15 border border-yellow-400/60 shadow-[0_0_20px_rgba(234,179,8,0.25)]"; + textClass += " text-yellow-200"; + handleColor = "!bg-yellow-400"; + } else if (isPrBlast) { + containerClass += + " bg-red-500/10 border border-red-500/50 shadow-[0_0_20px_rgba(239,68,68,0.2)]"; + textClass += " text-red-200"; + handleColor = "!bg-red-500"; + } else if (isBlastRadius) { containerClass += " bg-red-500/10 border border-red-500/50 shadow-[0_0_20px_rgba(239,68,68,0.2)]"; textClass += " text-red-200"; @@ -203,12 +266,22 @@ const GlassNode = ({ data }: { data: GlassNodeData }) => { Entry Point
)} - {data.isOrphan && !isBlastRadius && ( + {isPrModified && ( +
+ PR Changed +
+ )} + {isPrBlast && !isPrModified && ( +
+ Breaks +
+ )} + {data.isOrphan && !isBlastRadius && !isPrModified && !isPrBlast && (
Orphan
)} - {isCircular && !isBlastRadius && ( + {isCircular && !isBlastRadius && !isPrModified && !isPrBlast && (
Circular
@@ -229,7 +302,7 @@ const GlassNode = ({ data }: { data: GlassNodeData }) => { const nodeTypes = { glass: GlassNode }; // ============================================================================ -// D3-FORCE LAYOUT (unchanged from original) +// D3-FORCE LAYOUT (unchanged) // ============================================================================ interface ForceNode extends Node { @@ -366,18 +439,18 @@ function getForceLayoutedElements( // ============================================================================ interface SidebarProps { - // graph meta nodeCount: number; - // feature 1 cycleCount: number; cycleNodes: Set; - // feature 2 heatmapEnabled: boolean; onHeatmapToggle: () => void; - // feature 3 orphans: string[]; selectedOrphan: string | null; onOrphanSelect: (path: string | null) => void; + // PR blast + prChangedFiles: string[]; + prModifiedNodes: Set; + prBlastNodes: Set; // shared activeMode: ActiveMode; onActivateMode: (mode: ActiveMode) => void; @@ -393,6 +466,9 @@ function AnalysisSidebar({ orphans, selectedOrphan, onOrphanSelect, + prChangedFiles, + prModifiedNodes, + prBlastNodes, activeMode, onActivateMode, onClearAll, @@ -402,9 +478,17 @@ function AnalysisSidebar({ const hasCircular = cycleCount > 0; const hasOrphans = orphans.length > 0; + const hasPrData = prChangedFiles.length > 0; - // Icon rail icons for collapsed state const RAIL = [ + { + key: "pr-blast" as const, + icon: , + color: hasPrData ? "text-cyan-400" : "text-slate-600", + active: activeMode === "pr-blast", + dot: hasPrData, + dotColor: "bg-cyan-500", + }, { key: "circular" as const, icon: , @@ -448,7 +532,6 @@ function AnalysisSidebar({ exit={{ opacity: 0 }} className="w-10 h-full flex flex-col items-center py-4 gap-1 border-l border-white/5 bg-[#0e0e0e]" > - {/* Expand button */} -
- - {/* Rail icons */} {RAIL.map((item) => (
+ + {/* PR blast legend + stats when active */} + + {activeMode === "pr-blast" && hasPrData && ( + +
+ {/* Legend */} +
+
+
+ + Changed in PR + + + {prModifiedNodes.size} + +
+
+
+ + Downstream impact + + + {prBlastNodes.size} + +
+
+
+ + Unaffected + +
+
+ + {/* Changed file list — truncated to 6 */} +
+

+ Changed files +

+ {Array.from(prModifiedNodes) + .slice(0, 6) + .map((f) => ( +
+
+ + {f.split("/").pop()} + +
+ ))} + {prModifiedNodes.size > 6 && ( +

+ +{prModifiedNodes.size - 6} more +

+ )} +
+
+ + )} + +
+ + {/* ── SECTION 1: Blast Radius hint ── */}
Blast Radius @@ -654,7 +874,6 @@ function AnalysisSidebar({
- {/* Cycle node list — shown when active */} {activeMode === "circular" && cycleNodes.size > 0 && ( Complexity - {/* Toggle */}
- - {/* Legend — always visible so user knows what to expect */}
{( [ @@ -758,11 +969,7 @@ function AnalysisSidebar({ label: "Medium", sub: "66–90th pct", }, - { - color: "bg-red-400", - label: "Large", - sub: "top 10%", - }, + { color: "bg-red-400", label: "Large", sub: "top 10%" }, ] as const ).map(({ color, label, sub }) => (
@@ -850,7 +1057,6 @@ function AnalysisSidebar({
- {/* Orphan file list */} {activeMode === "orphan" && hasOrphans && orphanListOpen && (
- {/* Sidebar footer */} + {/* Footer */}

🌀 Force Layout · {nodeCount} nodes @@ -933,19 +1139,18 @@ export default function ArchitectureMap({ dependencyGraph = {}, entryPoints = [], fileMetrics = [], + prChangedFiles = [], }: { dependencyGraph: Record; entryPoints: string[]; fileMetrics?: { path: string; size: number }[]; + prChangedFiles?: string[]; }) { - // ── Core state ───────────────────────────────────────────────────────────── const [selectedNode, setSelectedNode] = useState(null); const previousGraphRef = useRef(""); - - // ── Single enum for active highlight mode ────────────────────────────────── - const [activeMode, setActiveMode] = useState(null); - - // ── Feature-specific state ───────────────────────────────────────────────── + const [activeMode, setActiveMode] = useState( + prChangedFiles && prChangedFiles.length > 0 ? ("pr-blast" as ActiveMode) : ("default" as ActiveMode) + ); const [heatmapEnabled, setHeatmapEnabled] = useState(false); const [selectedOrphan, setSelectedOrphan] = useState(null); @@ -965,7 +1170,7 @@ export default function ArchitectureMap({ [dependencyGraph], ); - // ── Reverse graph for blast radius ───────────────────────────────────────── + // Reverse graph — used by both single-node blast radius AND multi-source PR BFS const adjacencyList = useMemo(() => { const rev: Record = {}; Object.keys(dependencyGraph).forEach((src) => { @@ -977,7 +1182,31 @@ export default function ArchitectureMap({ return rev; }, [dependencyGraph]); - // ── Build initial nodes/edges (layout only, no feature data) ─────────────── + // Multi-source reverse BFS — recomputed whenever prChangedFiles changes. + // Returns empty sets when no PR data is present (zero-cost default). + const { prModifiedNodes, prBlastNodes } = useMemo(() => { + if (!prChangedFiles || prChangedFiles.length === 0) + return { + prModifiedNodes: new Set(), + prBlastNodes: new Set(), + }; + const { modifiedNodes, blastNodes } = computePrBlastRadius( + prChangedFiles, + adjacencyList, + ); + return { prModifiedNodes: modifiedNodes, prBlastNodes: blastNodes }; + }, [prChangedFiles, adjacencyList]); + + // Auto-activate pr-blast mode when new PR data arrives + const prevPrFilesRef = useRef(""); + useEffect(() => { + const key = prChangedFiles.join(","); + if (key && key !== prevPrFilesRef.current) { + prevPrFilesRef.current = key; + } + }, [prChangedFiles]); + + // ── Build initial nodes/edges (layout only) ──────────────────────────────── const { initialNodes, initialEdges, graphHash } = useMemo(() => { if (!dependencyGraph || typeof dependencyGraph !== "object") return { initialNodes: [], initialEdges: [], graphHash: "" }; @@ -999,6 +1228,8 @@ export default function ArchitectureMap({ heatmap: undefined, isOrphan: false, isOrphanHighlighted: false, + isPrModified: false, + isPrBlast: false, }, position: { x: 0, y: 0 }, }); @@ -1036,7 +1267,6 @@ export default function ArchitectureMap({ const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - // Guard: only re-layout when the graph actually changes useEffect(() => { if (previousGraphRef.current !== graphHash) { setNodes(initialNodes); @@ -1045,7 +1275,8 @@ export default function ArchitectureMap({ } }, [initialNodes, initialEdges, graphHash, setNodes, setEdges]); - // ── Apply feature overlay data (does NOT re-run layout) ─────────────────── + // ── Apply static overlay data (heatmap, circular, orphan flags) ─────────── + // This never re-runs the force layout. useEffect(() => { setNodes((nds) => nds.map((n) => ({ @@ -1062,9 +1293,9 @@ export default function ArchitectureMap({ ); }, [cycleNodes, heatmapColors, heatmapEnabled, orphans, setNodes]); - // ── Master highlight effect — driven by activeMode enum ─────────────────── + // ── Master highlight effect ──────────────────────────────────────────────── useEffect(() => { - // Default: clear everything + // Default: clear all highlight state if (!activeMode && !selectedNode) { setNodes((nds) => nds.map((n) => ({ @@ -1074,6 +1305,8 @@ export default function ArchitectureMap({ isBlastRadius: false, isDimmed: false, isOrphanHighlighted: false, + isPrModified: false, + isPrBlast: false, }, })), ); @@ -1088,6 +1321,58 @@ export default function ArchitectureMap({ return; } + // PR-BLAST mode — multi-source reverse BFS result applied to graph + if (activeMode === "pr-blast") { + setNodes((nds) => + nds.map((n) => ({ + ...n, + data: { + ...n.data, + isBlastRadius: false, + isOrphanHighlighted: false, + isPrModified: prModifiedNodes.has(n.id), + isPrBlast: prBlastNodes.has(n.id), + // Dim only nodes that are neither modified nor in the blast set + isDimmed: !prModifiedNodes.has(n.id) && !prBlastNodes.has(n.id), + }, + })), + ); + setEdges((eds) => + eds.map((e) => { + const sourceHit = + prModifiedNodes.has(e.source) || prBlastNodes.has(e.source); + const targetHit = + prModifiedNodes.has(e.target) || prBlastNodes.has(e.target); + const isHotEdge = sourceHit && targetHit; + // Yellow edge between modified nodes, red edge from modified to blast + const isModifiedEdge = + prModifiedNodes.has(e.source) && prModifiedNodes.has(e.target); + return { + ...e, + style: { + stroke: isModifiedEdge + ? "#eab308" + : isHotEdge + ? "#ef4444" + : "#475569", + strokeWidth: isHotEdge ? 3 : 2, + opacity: isHotEdge ? 1 : 0.12, + }, + animated: isHotEdge, + markerEnd: { + type: MarkerType.ArrowClosed, + color: isModifiedEdge + ? "#eab308" + : isHotEdge + ? "#ef4444" + : "#475569", + }, + }; + }), + ); + return; + } + // ORPHAN mode if (activeMode === "orphan") { setNodes((nds) => @@ -1096,6 +1381,8 @@ export default function ArchitectureMap({ data: { ...n.data, isBlastRadius: false, + isPrModified: false, + isPrBlast: false, isDimmed: selectedOrphan ? n.id !== selectedOrphan : false, isOrphanHighlighted: selectedOrphan ? n.id === selectedOrphan @@ -1126,6 +1413,8 @@ export default function ArchitectureMap({ data: { ...n.data, isBlastRadius: false, + isPrModified: false, + isPrBlast: false, isDimmed: !cycleNodes.has(n.id), isOrphanHighlighted: false, }, @@ -1153,7 +1442,7 @@ export default function ArchitectureMap({ return; } - // BLAST mode (existing logic unchanged) + // BLAST mode — single node, existing behaviour unchanged if (activeMode === "blast" && selectedNode) { const blastNodes = new Set(); const blastEdges = new Set(); @@ -1177,6 +1466,8 @@ export default function ArchitectureMap({ isBlastRadius: blastNodes.has(n.id), isDimmed: !blastNodes.has(n.id), isOrphanHighlighted: false, + isPrModified: false, + isPrBlast: false, }, })), ); @@ -1204,6 +1495,8 @@ export default function ArchitectureMap({ selectedNode, cycleNodes, selectedOrphan, + prModifiedNodes, + prBlastNodes, adjacencyList, setNodes, setEdges, @@ -1255,9 +1548,8 @@ export default function ArchitectureMap({ return (

- {/* ── Graph canvas (fills all available space) ── */} + {/* Graph canvas */}
- {/* Top-left: minimal graph label only — analysis moved to sidebar */}

Interactive Architecture Map @@ -1268,7 +1560,7 @@ export default function ArchitectureMap({

- {/* Active mode pill — floats top-center, always dismissible */} + {/* Active mode pill */} {activeMode && ( - {activeMode === "blast" - ? "Blast radius active" - : activeMode === "circular" - ? "Circular deps highlighted" - : selectedOrphan - ? `Orphan: ${selectedOrphan.split("/").pop()}` - : "Orphan mode — select a file"} + {activeMode === "pr-blast" + ? `PR · ${prModifiedNodes.size} changed · ${prBlastNodes.size} impacted` + : activeMode === "blast" + ? "Blast radius active" + : activeMode === "circular" + ? "Circular deps highlighted" + : selectedOrphan + ? `Orphan: ${selectedOrphan.split("/").pop()}` + : "Orphan mode — select a file"} @@ -1334,7 +1630,7 @@ export default function ArchitectureMap({
- {/* ── Analysis sidebar (right, collapsible) ── */} + {/* Analysis sidebar */} { + const supabase = createClient(); + await supabase.auth.signInWithOAuth({ + provider: "github", + options: { + scopes: "user:email", + redirectTo: `${window.location.origin}/analyze?repo=${encodeURIComponent(repoUrl ?? "")}`, + }, + }); + }, [repoUrl]); + + return ( + + {/* Blurred backdrop */} +
+ + {/* Card */} + + {state === "auth_required" ? ( + <> + {/* Icon */} +
+ +
+ + {/* Copy */} +
+

+ Unlock AI Insights +

+

+ Get {AI_FREE_LIMIT} free AI architectural analyses. Sign in with + GitHub — no credit card needed. +

+
+ + {/* Feature pills */} +
+ {[ + "Architecture Summary", + "Risk Detection", + "Code Health Score", + ].map((f) => ( + + {f} + + ))} +
+ + {/* CTA */} + + +

+ Free forever · No spam · Open source +

+ + ) : ( + <> + {/* Icon */} +
+ +
+ + {/* Copy */} +
+

+ You've used your {AI_FREE_LIMIT} free analyses +

+

+ Upgrade to Pro for unlimited AI insights, or plug in your own + Gemini API key to keep going for free. +

+
+ + {/* CTA — Pro (placeholder) */} + + + {/* CTA — Own key (placeholder) */} + + + )} +
+ + ); +} diff --git a/components/analyze/ChatPanel.tsx b/components/analyze/ChatPanel.tsx index a4df451..ef80edf 100644 --- a/components/analyze/ChatPanel.tsx +++ b/components/analyze/ChatPanel.tsx @@ -38,7 +38,7 @@ export default function ChatPanel({ exit={{ x: 100, opacity: 0 }} onClick={onOpen} aria-label="Open chat assistant" - className="absolute right-0 bottom-8 z-40 flex items-center gap-3 bg-[#141414]/90 backdrop-blur-md border border-white/10 border-r-0 px-4 py-4 rounded-l-2xl shadow-[-10px_0_30px_rgba(0,0,0,0.5)] hover:bg-[#1a1a1a] hover:pr-6 transition-all group" + className="absolute right-0 top-4 z-40 flex items-center gap-3 bg-[#141414]/90 backdrop-blur-md border border-white/10 border-r-0 px-4 py-4 rounded-l-2xl shadow-[-10px_0_30px_rgba(0,0,0,0.5)] hover:bg-[#1a1a1a] transition-all group" >
diff --git a/components/analyze/DoctorPanel.tsx b/components/analyze/DoctorPanel.tsx index 4a9c756..a880163 100644 --- a/components/analyze/DoctorPanel.tsx +++ b/components/analyze/DoctorPanel.tsx @@ -6,6 +6,7 @@ import dynamic from "next/dynamic"; import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { RepoData } from "@/lib/types/analyze"; import SkeletonLoader from "@/components/analyze/SkeletonLoader"; +import AiGate from "@/components/analyze/AiGate"; const DebugInterface = dynamic( () => import("@/components/debug/DebugInterface"), @@ -16,18 +17,25 @@ interface DoctorPanelProps { data: RepoData; source: string | null; onOpenChat: () => void; + aiGateState: "free" | "login-required" | "limit-reached" | null; } export default function DoctorPanel({ data, source, onOpenChat, + aiGateState, }: DoctorPanelProps) { const repoUrl = source === "local" ? "Local.zip Codebase" : `https://github.com/${data.owner}/${data.repo}`; + const githubRepoUrl = + source === "local" + ? undefined + : `https://github.com/${data.owner}/${data.repo}`; + return ( {data.mermaidDiagram ? ( <> + {/* Wrap the AI panel in a relative container so AiGate + can position itself absolutely over it */}
+ + {/* Gate overlay — renders on top when active */} + {(aiGateState === "login-required" || + aiGateState === "limit-reached") && ( + + )}
+ void; + // Callback to switch to the visualizer tab — triggered by "View on Graph" + onViewOnGraph: () => void; +} + +export default function PrImpactTab({ + data, + onPrAnalyzed, + onViewOnGraph, +}: PrImpactTabProps) { const [prInput, setPrInput] = useState(""); const [isAnalyzingPR, setIsAnalyzingPR] = useState(false); const [prResult, setPrResult] = useState(null); @@ -43,15 +58,22 @@ export default function PrImpactTab({ data }: { data: RepoData }) { prNumber: extractedPrNumber, }), }); - const json = await res.json(); - if (!res.ok) throw new Error(json.error || "Failed to analyze PR"); + const json: PRAnalysisResult = await res.json(); + if (!res.ok) throw new Error((json as unknown as { error: string }).error || "Failed to analyze PR"); + setPrResult(json); - } catch (err: Error | unknown) { + + // Lift the changed files up to AnalyzeContent so the visualizer can + // consume them. Guard against missing field (e.g. older cached results). + if (json.changedFiles && json.changedFiles.length > 0) { + onPrAnalyzed(json.changedFiles); + } + } catch (err: unknown) { setPrError(err instanceof Error ? err.message : "Unknown error"); } finally { setIsAnalyzingPR(false); } - }, [prInput, data.owner, data.repo]); + }, [prInput, data.owner, data.repo, onPrAnalyzed]); return (
@@ -101,7 +127,7 @@ export default function PrImpactTab({ data }: { data: RepoData }) { > {isAnalyzingPR ? ( <> -
{" "} +
Parsing ) : ( @@ -122,11 +148,9 @@ export default function PrImpactTab({ data }: { data: RepoData }) { variants={{ visible: { transition: { staggerChildren: 0.1 } } }} className="w-full text-left" > + {/* PR header */}
@@ -146,7 +170,11 @@ export default function PrImpactTab({ data }: { data: RepoData }) {

{prResult.riskLevel === "high" ? ( @@ -157,11 +185,40 @@ export default function PrImpactTab({ data }: { data: RepoData }) {
+ {/* View on Graph CTA — shown only when changedFiles were returned */} + {prResult.changedFiles && prResult.changedFiles.length > 0 && ( + + + + )} + + {/* Blast radius + architectural changes */}
@@ -233,54 +290,49 @@ export default function PrImpactTab({ data }: { data: RepoData }) {
- {prResult.suggestedReviewers && - prResult.suggestedReviewers.length > 0 && ( - -

- Context-Aware Reviewers -

-
- {prResult.suggestedReviewers.map((reviewer, i) => ( -
- {reviewer.username} { - e.currentTarget.src = - "https://github.com/ghost.png"; - }} - /> -
- - @{reviewer.username} - -

- {reviewer.reason} -

-
+ {/* Suggested reviewers */} + {prResult.suggestedReviewers && prResult.suggestedReviewers.length > 0 && ( + +

+ Context-Aware Reviewers +

+
+ {prResult.suggestedReviewers.map((reviewer, i) => ( +
+ {reviewer.username} { + e.currentTarget.src = "https://github.com/ghost.png"; + }} + /> +
+ + @{reviewer.username} + +

+ {reviewer.reason} +

- ))} -
- - )} +
+ ))} +
+ + )} + + {/* Analyze another PR */}
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index b9c6167..ddcaf36 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,17 +1,11 @@ +// app/dashboard/page.tsx import { createClient } from "@/utils/supabase/server"; import { redirect } from "next/navigation"; import BackButton from "@/components/BackButton"; import Link from "next/link"; import Image from "next/image"; -import { - Database, - Clock, - GitBranch, - ArrowRight, - LayoutGrid, - Plus, - GitPullRequest, -} from "lucide-react"; +import { LayoutGrid, Plus, GitPullRequest } from "lucide-react"; +import DashboardGrid from "@/components/dashboard/DashboardGrid"; export const metadata = { title: "Dashboard | CodeAutopsy", @@ -36,9 +30,9 @@ export default async function DashboardPage() { return (
- {/* ======================================================================== - DASHBOARD NAVBAR -======================================================================== */} + {/* ================================================================ + DASHBOARD NAVBAR + ================================================================ */} + + {/* Background effects */}
+ {/* ================================================================ + HEADER + ================================================================ */}
@@ -85,6 +84,9 @@ export default async function DashboardPage() {
+ {/* ================================================================ + WORKSPACE TOOLS + ================================================================ */}

Workspace Tools @@ -114,86 +116,10 @@ export default async function DashboardPage() {

-
-

- Historical Scans -

- - {error ? ( -
-

- Database Error -

-

{error.message}

-
- ) : !analyses || analyses.length === 0 ? ( -
- -

- No Autopsies Found -

-

- You haven't analyzed any repositories yet. Start your first - scan to generate an architecture blueprint and diagnostic - report. -

- - Initiate Scan - -
- ) : ( -
- {analyses.map((analysis) => ( -
-
-
- -
- - - {new Date(analysis.created_at).toLocaleDateString( - undefined, - { - month: "short", - day: "numeric", - year: "numeric", - }, - )} - -
- -
-

- {analysis.repo_name} -

-
- - - {analysis.commit_sha.substring(0, 7)} - -
-
- - - Load Report - -
- ))} -
- )} -
+ {/* ================================================================ + DASHBOARD GRID — Client Component + ================================================================ */} +
); diff --git a/components/ArchitectureMap.tsx b/components/ArchitectureMap.tsx index fa2e322..97fbf0e 100644 --- a/components/ArchitectureMap.tsx +++ b/components/ArchitectureMap.tsx @@ -1,5 +1,9 @@ +// components/ArchitectureMap.tsx + "use client"; +import InfoTooltip from "@/components/InfoTooltip"; + import React, { useMemo, useState, @@ -38,12 +42,9 @@ import { Flame, ChevronRight, GitPullRequest, + Zap, } from "lucide-react"; -// ============================================================================ -// ALGORITHMS -// ============================================================================ - function detectCircularDependencies(graph: Record): { cycleNodes: Set; cycleCount: number; @@ -108,27 +109,12 @@ function detectOrphans(graph: Record): string[] { ); } -/** - * Multi-source Reverse BFS for PR Blast Radius. - * - * Takes an array of PR-changed files as starting nodes and walks the - * REVERSE dependency graph (adjacencyList = who imports X) to find every - * file that could break if one of the changed files changes. - * - * Returns: - * modifiedNodes — the files directly changed in the PR (yellow) - * blastNodes — files that import (transitively) a modified file (red) - * - * A file in modifiedNodes that is ALSO reachable via the reverse graph stays - * in modifiedNodes (yellow takes priority over red in the renderer). - */ function computePrBlastRadius( changedFiles: string[], - adjacencyList: Record, // reverse graph: file → who imports it + adjacencyList: Record, ): { modifiedNodes: Set; blastNodes: Set } { const modifiedNodes = new Set(changedFiles); const blastNodes = new Set(); - const queue = [...changedFiles]; const visited = new Set(changedFiles); @@ -138,10 +124,7 @@ function computePrBlastRadius( for (const importer of importers) { if (!visited.has(importer)) { visited.add(importer); - // Only add to blastNodes if not one of the directly modified files - if (!modifiedNodes.has(importer)) { - blastNodes.add(importer); - } + if (!modifiedNodes.has(importer)) blastNodes.add(importer); queue.push(importer); } } @@ -150,14 +133,22 @@ function computePrBlastRadius( return { modifiedNodes, blastNodes }; } -// ============================================================================ -// ACTIVE MODE TYPE -// ============================================================================ -type ActiveMode = "blast" | "circular" | "orphan" | "pr-blast" | null; +type PageRankTier = 0 | 1 | 2 | 3; -// ============================================================================ -// GLASS NODE -// ============================================================================ +function getPageRankTier(score: number): PageRankTier { + if (score >= 70) return 3; + if (score >= 40) return 2; + if (score >= 15) return 1; + return 0; +} + +type ActiveMode = + | "blast" + | "circular" + | "orphan" + | "pr-blast" + | "pagerank" + | null; interface GlassNodeData { isBlastRadius: boolean; @@ -169,11 +160,47 @@ interface GlassNodeData { heatmap?: "green" | "yellow" | "red"; isOrphan?: boolean; isOrphanHighlighted?: boolean; - // PR blast fields - isPrModified?: boolean; // changed in the PR → yellow - isPrBlast?: boolean; // breaks because of the PR → red + isPrModified?: boolean; + isPrBlast?: boolean; + pageRankScore?: number; + pageRankTier?: PageRankTier; + pageRankActive?: boolean; } +const PR_TIER_STYLES: Record< + PageRankTier, + { border: string; bg: string; shadow: string; text: string; badge: string } +> = { + 3: { + border: "border-cyan-400/70", + bg: "bg-cyan-500/10", + shadow: "shadow-[0_0_22px_rgba(34,211,238,0.28)]", + text: "text-cyan-200", + badge: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30", + }, + 2: { + border: "border-blue-400/60", + bg: "bg-blue-500/8", + shadow: "shadow-[0_0_16px_rgba(59,130,246,0.2)]", + text: "text-blue-200", + badge: "bg-blue-500/15 text-blue-300 border-blue-500/25", + }, + 1: { + border: "border-indigo-400/40", + bg: "bg-indigo-500/5", + shadow: "shadow-[0_0_10px_rgba(99,102,241,0.12)]", + text: "text-indigo-200", + badge: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20", + }, + 0: { + border: "border-white/5", + bg: "bg-[#141414]/40", + shadow: "", + text: "text-slate-600", + badge: "", + }, +}; + const HEATMAP_STYLES: Record< "green" | "yellow" | "red", { border: string; bg: string; shadow: string; text: string } @@ -207,14 +234,71 @@ const GlassNode = ({ data }: { data: GlassNodeData }) => { isOrphanHighlighted, isPrModified, isPrBlast, + pageRankTier, + pageRankActive, + pageRankScore, } = data; + if ( + pageRankActive && + pageRankTier !== undefined && + !isBlastRadius && + !isPrModified && + !isPrBlast && + !isCircular && + !isOrphanHighlighted + ) { + const s = PR_TIER_STYLES[pageRankTier]; + const dimmed = isDimmed || pageRankTier === 0; + + return ( +
+ = 2 ? "!bg-cyan-400" : "!bg-slate-500"}`} + /> +
+ {pageRankTier === 3 && ( +
+ Nerve Center +
+ )} + {pageRankTier === 2 && ( +
+ High Influence +
+ )} +
+ {data.label} +
+ {pageRankTier >= 1 && pageRankScore !== undefined && ( +
+ PR {pageRankScore} +
+ )} +
+ = 2 ? "!bg-cyan-400" : "!bg-slate-500"}`} + /> +
+ ); + } + let containerClass = "px-4 py-2 shadow-xl rounded-xl backdrop-blur-md min-w-[150px] transition-all duration-300 cursor-pointer"; let textClass = "text-sm font-mono truncate max-w-[200px] transition-colors"; let handleColor = "!bg-slate-500"; - // Priority: pr-modified > pr-blast > blast > dimmed > orphan > circular > heatmap > default if (isPrModified) { containerClass += " bg-yellow-500/15 border border-yellow-400/60 shadow-[0_0_20px_rgba(234,179,8,0.25)]"; @@ -301,10 +385,6 @@ const GlassNode = ({ data }: { data: GlassNodeData }) => { const nodeTypes = { glass: GlassNode }; -// ============================================================================ -// D3-FORCE LAYOUT (unchanged) -// ============================================================================ - interface ForceNode extends Node { x?: number; y?: number; @@ -434,10 +514,6 @@ function getForceLayoutedElements( }; } -// ============================================================================ -// ANALYSIS SIDEBAR -// ============================================================================ - interface SidebarProps { nodeCount: number; cycleCount: number; @@ -447,14 +523,14 @@ interface SidebarProps { orphans: string[]; selectedOrphan: string | null; onOrphanSelect: (path: string | null) => void; - // PR blast prChangedFiles: string[]; prModifiedNodes: Set; prBlastNodes: Set; - // shared activeMode: ActiveMode; onActivateMode: (mode: ActiveMode) => void; onClearAll: () => void; + pageRankScores: Record; + pageRankTopFiles: Array<{ path: string; score: number }>; } function AnalysisSidebar({ @@ -472,6 +548,8 @@ function AnalysisSidebar({ activeMode, onActivateMode, onClearAll, + pageRankScores, + pageRankTopFiles, }: SidebarProps) { const [expanded, setExpanded] = useState(true); const [orphanListOpen, setOrphanListOpen] = useState(true); @@ -479,6 +557,7 @@ function AnalysisSidebar({ const hasCircular = cycleCount > 0; const hasOrphans = orphans.length > 0; const hasPrData = prChangedFiles.length > 0; + const hasPageRank = Object.keys(pageRankScores).length > 0; const RAIL = [ { @@ -513,6 +592,14 @@ function AnalysisSidebar({ dot: hasOrphans, dotColor: "bg-violet-500", }, + { + key: "pagerank" as const, + icon: , + color: hasPageRank ? "text-cyan-400" : "text-slate-600", + active: activeMode === "pagerank", + dot: false, + dotColor: "", + }, ]; return ( @@ -523,7 +610,6 @@ function AnalysisSidebar({ className="h-full flex-shrink-0 relative z-20 flex" style={{ overflow: "visible" }} > - {/* ── Collapsed icon rail ── */} {!expanded && ( - {/* ── Expanded sidebar ── */} {expanded && ( - {/* Header */}
@@ -601,7 +685,6 @@ function AnalysisSidebar({
- {/* Graph meta */}
@@ -625,7 +708,9 @@ function AnalysisSidebar({ ? "text-orange-400" : activeMode === "orphan" ? "text-violet-400" - : "text-slate-600" + : activeMode === "pagerank" + ? "text-cyan-400" + : "text-slate-600" }`} > {activeMode === "pr-blast" @@ -636,20 +721,18 @@ function AnalysisSidebar({ ? "Circular" : activeMode === "orphan" ? "Orphan" - : heatmapEnabled - ? "Heatmap" - : "Default"} + : activeMode === "pagerank" + ? "PageRank" + : heatmapEnabled + ? "Heatmap" + : "Default"}
- {/* Scrollable sections */}
- {/* ── SECTION 0: PR Blast Radius ── */}
- {/* PR blast legend + stats when active */} {activeMode === "pr-blast" && hasPrData && (
- {/* Legend */}
@@ -747,8 +812,6 @@ function AnalysisSidebar({
- - {/* Changed file list — truncated to 6 */}

Changed files @@ -781,16 +844,17 @@ function AnalysisSidebar({

- {/* ── SECTION 1: Blast Radius hint ── */}
Blast Radius +

@@ -810,11 +874,8 @@ function AnalysisSidebar({ )}

- {/* ── SECTION 2: Circular Dependencies ── */}
@@ -974,14 +1014,10 @@ function AnalysisSidebar({ ).map(({ color, label, sub }) => (
{label} @@ -994,11 +1030,8 @@ function AnalysisSidebar({
- {/* ── SECTION 4: Orphaned Files ── */}
+ + + {activeMode === "pagerank" && hasPageRank && ( + +
+
+ {( + [ + { + tier: 3, + label: "Nerve Center", + color: "bg-cyan-400/80", + sub: "score ≥ 70", + }, + { + tier: 2, + label: "High Influence", + color: "bg-blue-400/80", + sub: "score ≥ 40", + }, + { + tier: 1, + label: "Notable", + color: "bg-indigo-400/80", + sub: "score ≥ 15", + }, + { + tier: 0, + label: "Low Rank", + color: "bg-slate-700", + sub: "score < 15", + }, + ] as const + ).map(({ label, color, sub }) => ( +
+
+ + {label} + + + {sub} + +
+ ))} +
+
+

+ Top architectural hubs +

+ {pageRankTopFiles + .slice(0, 8) + .map(({ path, score }) => { + const tier = getPageRankTier(score); + const s = PR_TIER_STYLES[tier]; + return ( +
+
+ + {path.split("/").pop()} + + + {score} + +
+ ); + })} + {pageRankTopFiles.length > 8 && ( +

+ +{pageRankTopFiles.length - 8} more +

+ )} +
+
+ + )} + +
- {/* Footer */}

🌀 Force Layout · {nodeCount} nodes @@ -1131,30 +1284,27 @@ function AnalysisSidebar({ ); } -// ============================================================================ -// MAIN COMPONENT -// ============================================================================ - export default function ArchitectureMap({ dependencyGraph = {}, entryPoints = [], fileMetrics = [], prChangedFiles = [], + pageRankScores = {}, }: { dependencyGraph: Record; entryPoints: string[]; fileMetrics?: { path: string; size: number }[]; prChangedFiles?: string[]; + pageRankScores?: Record; }) { const [selectedNode, setSelectedNode] = useState(null); const previousGraphRef = useRef(""); const [activeMode, setActiveMode] = useState( - prChangedFiles && prChangedFiles.length > 0 ? ("pr-blast" as ActiveMode) : ("default" as ActiveMode) + prChangedFiles && prChangedFiles.length > 0 ? "pr-blast" : null, ); const [heatmapEnabled, setHeatmapEnabled] = useState(false); const [selectedOrphan, setSelectedOrphan] = useState(null); - // ── Algorithmic computations ─────────────────────────────────────────────── const { cycleNodes, cycleCount } = useMemo( () => detectCircularDependencies(dependencyGraph), [dependencyGraph], @@ -1170,7 +1320,6 @@ export default function ArchitectureMap({ [dependencyGraph], ); - // Reverse graph — used by both single-node blast radius AND multi-source PR BFS const adjacencyList = useMemo(() => { const rev: Record = {}; Object.keys(dependencyGraph).forEach((src) => { @@ -1182,8 +1331,6 @@ export default function ArchitectureMap({ return rev; }, [dependencyGraph]); - // Multi-source reverse BFS — recomputed whenever prChangedFiles changes. - // Returns empty sets when no PR data is present (zero-cost default). const { prModifiedNodes, prBlastNodes } = useMemo(() => { if (!prChangedFiles || prChangedFiles.length === 0) return { @@ -1197,7 +1344,23 @@ export default function ArchitectureMap({ return { prModifiedNodes: modifiedNodes, prBlastNodes: blastNodes }; }, [prChangedFiles, adjacencyList]); - // Auto-activate pr-blast mode when new PR data arrives + const pageRankTopFiles = useMemo( + () => + Object.entries(pageRankScores) + .sort(([, a], [, b]) => b - a) + .slice(0, 20) + .map(([path, score]) => ({ path, score })), + [pageRankScores], + ); + + const pageRankTierMap = useMemo(() => { + const map = new Map(); + for (const [path, score] of Object.entries(pageRankScores)) { + map.set(path, getPageRankTier(score)); + } + return map; + }, [pageRankScores]); + const prevPrFilesRef = useRef(""); useEffect(() => { const key = prChangedFiles.join(","); @@ -1206,7 +1369,6 @@ export default function ArchitectureMap({ } }, [prChangedFiles]); - // ── Build initial nodes/edges (layout only) ──────────────────────────────── const { initialNodes, initialEdges, graphHash } = useMemo(() => { if (!dependencyGraph || typeof dependencyGraph !== "object") return { initialNodes: [], initialEdges: [], graphHash: "" }; @@ -1230,6 +1392,9 @@ export default function ArchitectureMap({ isOrphanHighlighted: false, isPrModified: false, isPrBlast: false, + pageRankScore: pageRankScores[filePath] ?? 0, + pageRankTier: getPageRankTier(pageRankScores[filePath] ?? 0), + pageRankActive: false, }, position: { x: 0, y: 0 }, }); @@ -1261,8 +1426,7 @@ export default function ArchitectureMap({ }); return { initialNodes: layoutNodes, initialEdges: layoutEdges, graphHash }; - - }, [dependencyGraph, entryPoints]); + }, [dependencyGraph, entryPoints, pageRankScores]); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -1275,8 +1439,6 @@ export default function ArchitectureMap({ } }, [initialNodes, initialEdges, graphHash, setNodes, setEdges]); - // ── Apply static overlay data (heatmap, circular, orphan flags) ─────────── - // This never re-runs the force layout. useEffect(() => { setNodes((nds) => nds.map((n) => ({ @@ -1293,9 +1455,7 @@ export default function ArchitectureMap({ ); }, [cycleNodes, heatmapColors, heatmapEnabled, orphans, setNodes]); - // ── Master highlight effect ──────────────────────────────────────────────── useEffect(() => { - // Default: clear all highlight state if (!activeMode && !selectedNode) { setNodes((nds) => nds.map((n) => ({ @@ -1307,6 +1467,7 @@ export default function ArchitectureMap({ isOrphanHighlighted: false, isPrModified: false, isPrBlast: false, + pageRankActive: false, }, })), ); @@ -1321,18 +1482,69 @@ export default function ArchitectureMap({ return; } - // PR-BLAST mode — multi-source reverse BFS result applied to graph + if (activeMode === "pagerank") { + setNodes((nds) => + nds.map((n) => { + const tier = pageRankTierMap.get(n.id) ?? 0; + return { + ...n, + data: { + ...n.data, + pageRankActive: true, + pageRankTier: tier, + pageRankScore: pageRankScores[n.id] ?? 0, + isBlastRadius: false, + isPrModified: false, + isPrBlast: false, + isOrphanHighlighted: false, + isDimmed: tier === 0, + }, + }; + }), + ); + setEdges((eds) => + eds.map((e) => { + const srcTier = pageRankTierMap.get(e.source) ?? 0; + const tgtTier = pageRankTierMap.get(e.target) ?? 0; + const isHotEdge = srcTier >= 2 || tgtTier >= 2; + const isTier3Edge = srcTier === 3 || tgtTier === 3; + return { + ...e, + style: { + stroke: isTier3Edge + ? "#22d3ee" + : isHotEdge + ? "#3b82f6" + : "#475569", + strokeWidth: isTier3Edge ? 3 : isHotEdge ? 2 : 1, + opacity: isHotEdge ? 1 : 0.1, + }, + animated: isTier3Edge, + markerEnd: { + type: MarkerType.ArrowClosed, + color: isTier3Edge + ? "#22d3ee" + : isHotEdge + ? "#3b82f6" + : "#475569", + }, + }; + }), + ); + return; + } + if (activeMode === "pr-blast") { setNodes((nds) => nds.map((n) => ({ ...n, data: { ...n.data, + pageRankActive: false, isBlastRadius: false, isOrphanHighlighted: false, isPrModified: prModifiedNodes.has(n.id), isPrBlast: prBlastNodes.has(n.id), - // Dim only nodes that are neither modified nor in the blast set isDimmed: !prModifiedNodes.has(n.id) && !prBlastNodes.has(n.id), }, })), @@ -1344,7 +1556,6 @@ export default function ArchitectureMap({ const targetHit = prModifiedNodes.has(e.target) || prBlastNodes.has(e.target); const isHotEdge = sourceHit && targetHit; - // Yellow edge between modified nodes, red edge from modified to blast const isModifiedEdge = prModifiedNodes.has(e.source) && prModifiedNodes.has(e.target); return { @@ -1373,13 +1584,13 @@ export default function ArchitectureMap({ return; } - // ORPHAN mode if (activeMode === "orphan") { setNodes((nds) => nds.map((n) => ({ ...n, data: { ...n.data, + pageRankActive: false, isBlastRadius: false, isPrModified: false, isPrBlast: false, @@ -1405,13 +1616,13 @@ export default function ArchitectureMap({ return; } - // CIRCULAR mode if (activeMode === "circular") { setNodes((nds) => nds.map((n) => ({ ...n, data: { ...n.data, + pageRankActive: false, isBlastRadius: false, isPrModified: false, isPrBlast: false, @@ -1442,7 +1653,6 @@ export default function ArchitectureMap({ return; } - // BLAST mode — single node, existing behaviour unchanged if (activeMode === "blast" && selectedNode) { const blastNodes = new Set(); const blastEdges = new Set(); @@ -1463,6 +1673,7 @@ export default function ArchitectureMap({ ...n, data: { ...n.data, + pageRankActive: false, isBlastRadius: blastNodes.has(n.id), isDimmed: !blastNodes.has(n.id), isOrphanHighlighted: false, @@ -1497,12 +1708,13 @@ export default function ArchitectureMap({ selectedOrphan, prModifiedNodes, prBlastNodes, + pageRankTierMap, + pageRankScores, adjacencyList, setNodes, setEdges, ]); - // ── Handlers ────────────────────────────────────────────────────────────── const handleNodeClick = useCallback((_: React.MouseEvent, node: Node) => { setSelectedOrphan(null); setSelectedNode(node.id); @@ -1535,7 +1747,6 @@ export default function ArchitectureMap({ setHeatmapEnabled((v) => !v); }, []); - // ── Empty state ──────────────────────────────────────────────────────────── if (nodes.length === 0) { return (

@@ -1548,7 +1759,6 @@ export default function ArchitectureMap({ return (
- {/* Graph canvas */}

@@ -1560,7 +1770,6 @@ export default function ArchitectureMap({

- {/* Active mode pill */} {activeMode && ( @@ -1587,9 +1798,11 @@ export default function ArchitectureMap({ ? "Blast radius active" : activeMode === "circular" ? "Circular deps highlighted" - : selectedOrphan - ? `Orphan: ${selectedOrphan.split("/").pop()}` - : "Orphan mode — select a file"} + : activeMode === "pagerank" + ? `PageRank · ${pageRankTopFiles.filter((f) => getPageRankTier(f.score) >= 2).length} hubs` + : selectedOrphan + ? `Orphan: ${selectedOrphan.split("/").pop()}` + : "Orphan mode — select a file"} @@ -1630,7 +1843,6 @@ export default function ArchitectureMap({
- {/* Analysis sidebar */}
); diff --git a/components/InfoTooltip.tsx b/components/InfoTooltip.tsx new file mode 100644 index 0000000..0a9d578 --- /dev/null +++ b/components/InfoTooltip.tsx @@ -0,0 +1,193 @@ +// components/InfoTooltip.tsx +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { HelpCircle } from "lucide-react"; + +interface InfoTooltipProps { + content: string; + side?: "top" | "bottom" | "left" | "right"; +} + +interface TooltipPos { + top: number; + left: number; + resolvedSide: "top" | "bottom" | "left" | "right"; +} + +export default function InfoTooltip({ + content, + side = "bottom", +}: InfoTooltipProps) { + const [visible, setVisible] = useState(false); + const [pos, setPos] = useState(null); + const triggerRef = useRef(null); + + const GAP = 8; + const MARGIN = 8; + + const computePosition = useCallback( + (tooltipWidth: number, tooltipHeight: number): TooltipPos => { + if (!triggerRef.current) { + return { top: 0, left: 0, resolvedSide: side }; + } + + const btn = triggerRef.current.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + const tryOrder: Array<"top" | "bottom" | "left" | "right"> = [ + side, + side === "top" + ? "bottom" + : side === "bottom" + ? "top" + : side === "left" + ? "right" + : "left", + ]; + + for (const s of tryOrder) { + let top = 0; + let left = 0; + + if (s === "top") { + top = btn.top - tooltipHeight - GAP; + left = btn.left + btn.width / 2 - tooltipWidth / 2; + } else if (s === "bottom") { + top = btn.bottom + GAP; + left = btn.left + btn.width / 2 - tooltipWidth / 2; + } else if (s === "left") { + top = btn.top + btn.height / 2 - tooltipHeight / 2; + left = btn.left - tooltipWidth - GAP; + } else { + top = btn.top + btn.height / 2 - tooltipHeight / 2; + left = btn.right + GAP; + } + + const clampedLeft = Math.max( + MARGIN, + Math.min(left, vw - tooltipWidth - MARGIN), + ); + const clampedTop = Math.max( + MARGIN, + Math.min(top, vh - tooltipHeight - MARGIN), + ); + + const fitsHorizontally = + left >= MARGIN && left + tooltipWidth <= vw - MARGIN; + const fitsVertically = + top >= MARGIN && top + tooltipHeight <= vh - MARGIN; + + if (fitsHorizontally && fitsVertically) { + return { top: clampedTop, left: clampedLeft, resolvedSide: s }; + } + + if (s === tryOrder[tryOrder.length - 1]) { + return { top: clampedTop, left: clampedLeft, resolvedSide: s }; + } + } + + return { top: 0, left: 0, resolvedSide: side }; + }, + [side], + ); + + const tooltipRefCallback = useCallback( + (node: HTMLDivElement | null) => { + if (!node) return; + const { width, height } = node.getBoundingClientRect(); + const computed = computePosition(width, height); + setPos(computed); + }, + [computePosition], + ); + + useEffect(() => { + if (!visible) return; + const handler = (e: MouseEvent | TouchEvent) => { + if (triggerRef.current?.contains(e.target as Node)) return; + setVisible(false); + }; + document.addEventListener("mousedown", handler); + document.addEventListener("touchstart", handler); + return () => { + document.removeEventListener("mousedown", handler); + document.removeEventListener("touchstart", handler); + }; + }, [visible]); + + const handleShow = useCallback(() => { + setPos(null); + setVisible(true); + }, []); + + const arrowClasses: Record = { + top: "top-full left-1/2 -translate-x-1/2 border-l-transparent border-r-transparent border-b-transparent border-t-[#1e1e1e]", + bottom: + "bottom-full left-1/2 -translate-x-1/2 border-l-transparent border-r-transparent border-t-transparent border-b-[#1e1e1e]", + left: "left-full top-1/2 -translate-y-1/2 border-t-transparent border-b-transparent border-r-transparent border-l-[#1e1e1e]", + right: + "right-full top-1/2 -translate-y-1/2 border-t-transparent border-b-transparent border-l-transparent border-r-[#1e1e1e]", + }; + + const resolvedSide = pos?.resolvedSide ?? side; + + return ( + + setVisible(false)} + onFocus={handleShow} + onBlur={() => setVisible(false)} + onClick={(e) => { + e.stopPropagation(); + if (visible) { + setVisible(false); + } else { + handleShow(); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (visible) setVisible(false); + else handleShow(); + } + }} + className="w-3.5 h-3.5 flex items-center justify-center text-slate-600 hover:text-slate-300 focus:text-slate-300 transition-colors cursor-default focus:outline-none focus-visible:ring-1 focus-visible:ring-slate-500 rounded-full" + > + + + + {visible && + typeof window !== "undefined" && + createPortal( +
+ +
+

+ {content} +

+
+
, + document.body, + )} +
+ ); +} diff --git a/components/analyze/AnalyzeTabBar.tsx b/components/analyze/AnalyzeTabBar.tsx index fc911f6..480bcf0 100644 --- a/components/analyze/AnalyzeTabBar.tsx +++ b/components/analyze/AnalyzeTabBar.tsx @@ -1,7 +1,22 @@ +// components/analyze/AnalyzeTabBar.tsx "use client"; import { motion } from "framer-motion"; import { TAB_CONFIG, TabType } from "@/components/analyze/constants"; +import InfoTooltip from "@/components/InfoTooltip"; + +const TAB_TOOLTIPS: Record = { + overview: "High-level summary of repo structure, stats, and AI diagnostics.", + visualizer: + "Interactive dependency graph. See how files connect and trace impact.", + doctor: + "AI-powered code health report. Flags bugs, smells, and improvements.", + pr_impact: "Paste a PR to see exactly which files it breaks downstream.", + risk_radar: + "Scores your codebase for security, complexity, and maintenance risk.", + arch_insights: + "Graph algorithms reveal critical files, stability zones, and dead code.", +}; interface AnalyzeTabBarProps { activeTab: TabType; @@ -21,16 +36,17 @@ export default function AnalyzeTabBar({
{TAB_CONFIG.map((tab) => { const isActive = activeTab === tab.id; + const tooltip = TAB_TOOLTIPS[tab.id]; return ( ); })} diff --git a/components/analyze/ArchInsightsPanel.tsx b/components/analyze/ArchInsightsPanel.tsx new file mode 100644 index 0000000..db1d59c --- /dev/null +++ b/components/analyze/ArchInsightsPanel.tsx @@ -0,0 +1,995 @@ +// components/analyze/ArchInsightsPanel.tsx +"use client"; + +import { useMemo, useState, memo, useCallback } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { + ShieldAlert, + TrendingUp, + Ghost, + ChevronDown, + ChevronRight, + CheckCircle2, + AlertTriangle, +} from "lucide-react"; +import { RepoData } from "@/lib/types/analyze"; +import { computeArticulationPoints } from "@/lib/algorithms/articulationPoints"; +import { computeStability } from "@/lib/algorithms/stability"; +import { computeDeadCode } from "@/lib/algorithms/deadCode"; +import InfoTooltip from "@/components/InfoTooltip"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const MAX_BRIDGES_SHOWN = 8; +const MAX_FILES_SHOWN = 30; + +const TAB_CONTENT_TRANSITION = { duration: 0.15 }; + +// ─── Sub-tab config ─────────────────────────────────────────────────────────── + +const SUB_TABS = [ + { + id: "ap" as const, + icon: ShieldAlert, + label: "Critical Files", + accentColor: "#ef4444", + color: "text-red-400", + activeText: "text-red-300", + activeBg: "bg-red-500/10", + activeBorder: "border-red-500/30", + tooltip: + "Files that if removed would split the codebase into disconnected pieces.", + }, + { + id: "stability" as const, + icon: TrendingUp, + label: "Stability", + accentColor: "#3b82f6", + color: "text-blue-400", + activeText: "text-blue-300", + activeBg: "bg-blue-500/10", + activeBorder: "border-blue-500/30", + tooltip: + "How stable each file is based on how many files import it vs how many it imports.", + }, + { + id: "dead" as const, + icon: Ghost, + label: "Dead Code", + accentColor: "#8b5cf6", + color: "text-violet-400", + activeText: "text-violet-300", + activeBg: "bg-violet-500/10", + activeBorder: "border-violet-500/30", + tooltip: + "Files that can never be reached from your entry points at runtime.", + }, +] as const; + +type SubTabId = (typeof SUB_TABS)[number]["id"]; + +const SUB_TAB_MAP = new Map(SUB_TABS.map((t) => [t.id, t])); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function fileName(path: string) { + return path.split("/").pop() ?? path; +} + +function dirName(path: string) { + const idx = path.lastIndexOf("/"); + return idx > -1 ? path.slice(0, idx) : ""; +} + +// Severity is rank-based: top file = critical, second = high, rest = medium. +// This ensures visual differentiation even in small repos where absolute +// disconnect counts are all small numbers. +type Severity = "critical" | "high" | "medium"; + +function getSeverityByRank(index: number): Severity { + if (index === 0) return "critical"; + if (index === 1) return "high"; + return "medium"; +} + +const SEVERITY_STYLES: Record< + Severity, + { border: string; dot: string; text: string; badge: string } +> = { + critical: { + border: "border-red-500/25 bg-red-500/[0.06]", + dot: "bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.8)]", + text: "text-red-300", + badge: "bg-red-500/15 text-red-400 border-red-500/25", + }, + high: { + border: "border-orange-500/25 bg-orange-500/[0.06]", + dot: "bg-orange-400 shadow-[0_0_6px_rgba(251,146,60,0.7)]", + text: "text-orange-300", + badge: "bg-orange-500/15 text-orange-400 border-orange-500/25", + }, + medium: { + border: "border-yellow-500/20 bg-yellow-500/[0.04]", + dot: "bg-yellow-500", + text: "text-yellow-200", + badge: "bg-yellow-500/15 text-yellow-400 border-yellow-500/20", + }, +}; + +const SEVERITY_LABEL: Record = { + critical: "CRITICAL", + high: "HIGH", + medium: "MEDIUM", +}; + +type ZoneTag = { label: string; style: string } | null; + +function getZoneTag(zone: string | undefined): ZoneTag { + if (zone === "pain") + return { + label: "PAIN", + style: "bg-red-500/15 text-red-400 border-red-500/25", + }; + if (zone === "uselessness") + return { + label: "UNUSED", + style: "bg-yellow-500/15 text-yellow-400 border-yellow-500/20", + }; + return null; +} + +// ─── Shared empty-state ─────────────────────────────────────────────────────── + +function AllClear({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( +
+ +

{title}

+

{description}

+
+ ); +} + +// ─── Stat Card ──────────────────────────────────────────────────────────────── + +function StatCard({ + label, + value, + valueColor, + sub, + borderColor, + bgColor, +}: { + label: string; + value: string | number; + valueColor: string; + sub: string; + borderColor: string; + bgColor: string; +}) { + return ( +
+

+ {label} +

+

{value}

+

{sub}

+
+ ); +} + +// ─── Articulation Points Tab ────────────────────────────────────────────────── + +function APTab({ data }: { data: RepoData }) { + const result = useMemo( + () => computeArticulationPoints(data.dependencyGraph ?? {}), + [data.dependencyGraph], + ); + + const sorted = useMemo( + () => + Array.from(result.articulationPoints).sort( + (a, b) => + (result.componentSizes.get(b) ?? 0) - + (result.componentSizes.get(a) ?? 0), + ), + [result], + ); + + if (sorted.length === 0) { + return ( + + ); + } + + const topDisconnects = result.componentSizes.get(sorted[0]) ?? 0; + const visibleBridges = result.bridges.slice(0, MAX_BRIDGES_SHOWN); + const extraBridges = result.bridges.length - MAX_BRIDGES_SHOWN; + + return ( +
+ {/* Summary cards */} +
+ + 0 + ? "fragile dependency edges" + : "no fragile edges" + } + borderColor="border-orange-500/20" + bgColor="bg-orange-500/[0.05]" + /> +
+ + {/* Risk callout if top file is severe */} + {topDisconnects >= 3 && ( +
+ +

+ + {fileName(sorted[0])} + {" "} + is your highest-risk file — removing it would disconnect{" "} + {topDisconnects} parts of your codebase. +

+
+ )} + + {/* File list */} +
+

+ Files — sorted by impact +

+ {sorted.map((ap, idx) => { + const disconnects = result.componentSizes.get(ap) ?? 0; + const severity = getSeverityByRank(idx); + const { border, dot, text, badge } = SEVERITY_STYLES[severity]; + + return ( +
+ {/* Glowing dot */} +
+ +
+
+

+ {fileName(ap)} +

+ + {SEVERITY_LABEL[severity]} + +
+

+ {dirName(ap)} +

+
+ +
+

+ {disconnects} +

+

+ {disconnects === 1 ? "component" : "components"} +

+
+
+ ); + })} +
+ + {/* Bridges section */} + {result.bridges.length > 0 && ( +
+

+ Dependency Bridges +

+
+ {visibleBridges.map(([a, b]) => ( +
+ + {fileName(a)} + + + ──→ + + + {fileName(b)} + +
+ ))} +
+ {extraBridges > 0 && ( +

+ +{extraBridges} more bridges +

+ )} +
+ )} +
+ ); +} + +// ─── Scatter Plot ───────────────────────────────────────────────────────────── + +const ScatterPlot = memo(function ScatterPlot({ + files, +}: { + files: ReturnType["files"]; +}) { + const W = 320; + const H = 160; + const PAD = 24; + const innerW = W - PAD * 2; + const innerH = H - PAD * 2; + + const maxCa = useMemo( + () => Math.max(...files.map((f) => f.afferent), 1), + [files], + ); + + const dots = useMemo( + () => + files.map((f) => ({ + cx: PAD + f.instability * innerW, + cy: H - PAD - (f.afferent / maxCa) * innerH, + fill: + f.zone === "pain" + ? "#ef4444" + : f.zone === "uselessness" + ? "#eab308" + : f.instability > 0.5 + ? "#f97316" + : "#22d3ee", + label: fileName(f.path), + instability: f.instability, + ca: f.afferent, + })), + [files, maxCa, innerW, innerH], + ); + + return ( + + {/* Grid lines */} + {[0, 0.25, 0.5, 0.75, 1].map((v) => ( + + ))} + + {/* Zone of Pain — edge marker only */} + + + + pain + + + {/* Zone of Uselessness — edge marker only */} + + + + unused + + + {/* Axis labels */} + + Stable + + + Unstable + + + Ca↑ + + + {/* Dots */} + {dots.map((d, i) => ( + + + {d.label} — I:{Math.round(d.instability * 100)}% Ca:{d.ca} + + + ))} + + ); +}); + +// ─── Stability Tab ──────────────────────────────────────────────────────────── + +type StabilityFilter = "all" | "pain" | "uselessness" | "unstable"; + +function StabilityTab({ data }: { data: RepoData }) { + const result = useMemo( + () => computeStability(data.dependencyGraph ?? {}), + [data.dependencyGraph], + ); + const [filter, setFilter] = useState("all"); + + const toggleFilter = useCallback( + (value: Exclude) => + setFilter((prev) => (prev === value ? "all" : value)), + [], + ); + + const { sortedFiles, painCount, uselessCount, unstableCount } = + useMemo(() => { + const sorted = [...result.files].sort( + (a, b) => b.instability - a.instability, + ); + return { + sortedFiles: sorted, + painCount: sorted.filter((f) => f.zone === "pain").length, + uselessCount: sorted.filter((f) => f.zone === "uselessness").length, + unstableCount: sorted.filter((f) => f.instability > 0.7).length, + }; + }, [result.files]); + + const filtered = useMemo(() => { + if (filter === "pain") return sortedFiles.filter((f) => f.zone === "pain"); + if (filter === "uselessness") + return sortedFiles.filter((f) => f.zone === "uselessness"); + if (filter === "unstable") + return sortedFiles.filter((f) => f.instability > 0.7); + return sortedFiles; + }, [sortedFiles, filter]); + + // Entry points never need an UNUSED badge — they have Ca:0 by definition + // (nothing imports the root), but that's expected, not a smell. + const entryPointSet = useMemo( + () => new Set(data.entryPoints), + [data.entryPoints], + ); + + if (result.files.length === 0) { + return ( +
+ No dependency data available. +
+ ); + } + + const visibleFiles = filtered.slice(0, MAX_FILES_SHOWN); + const extraFiles = filtered.length - MAX_FILES_SHOWN; + + return ( +
+ {/* Scatter plot */} +
+
+

+ Instability Distribution +

+ +
+ + {/* Legend */} +
+ {[ + { color: "#ef4444", label: "Zone of Pain" }, + { color: "#f97316", label: "Unstable" }, + { color: "#22d3ee", label: "Healthy" }, + { color: "#eab308", label: "Unused" }, + ].map(({ color, label }) => ( +
+
+ + {label} + +
+ ))} +
+
+ + {/* Filter buttons */} +
+ {( + [ + { + key: "pain" as const, + label: "Zone of Pain", + count: painCount, + countColor: "text-red-400", + sub: "rigid & hard to change", + activeBorder: "border-red-500/30", + activeBg: "bg-red-500/8", + }, + { + key: "uselessness" as const, + label: "Uselessness", + count: uselessCount, + countColor: "text-yellow-400", + sub: "nobody imports them", + activeBorder: "border-yellow-500/30", + activeBg: "bg-yellow-500/8", + }, + { + key: "unstable" as const, + label: "Unstable", + count: unstableCount, + countColor: "text-orange-400", + sub: "instability > 70%", + activeBorder: "border-orange-500/30", + activeBg: "bg-orange-500/8", + }, + ] as const + ).map( + ({ key, label, count, countColor, sub, activeBorder, activeBg }) => ( + + ), + )} +
+ + {/* File list */} +
+

+ {filter === "all" + ? `All ${sortedFiles.length} files — sorted by instability` + : `${filtered.length} files — filtered: ${filter}`} +

+ {visibleFiles.map((f) => { + const instPct = Math.round(f.instability * 100); + const barColor = + f.instability > 0.7 + ? "bg-red-500" + : f.instability > 0.4 + ? "bg-orange-400" + : "bg-emerald-500"; + + // Suppress UNUSED badge for entry points — Ca:0 is expected for + // roots, not a sign of uselessness. + const zoneTag = entryPointSet.has(f.path) ? null : getZoneTag(f.zone); + + return ( +
+
+
+ + {fileName(f.path)} + + {zoneTag && ( + + {zoneTag.label} + + )} +
+
+
+
+
+ + {instPct}% + +
+
+
+

+ Ca:{f.afferent} Ce:{f.efferent} +

+
+
+ ); + })} + {extraFiles > 0 && ( +

+ +{extraFiles} more files +

+ )} +
+
+ ); +} + +// ─── Dead Code Tab ──────────────────────────────────────────────────────────── + +function DeadCodeTab({ data }: { data: RepoData }) { + const result = useMemo( + () => computeDeadCode(data.dependencyGraph ?? {}, data.entryPoints), + [data.dependencyGraph, data.entryPoints], + ); + + const dirs = useMemo( + () => Object.entries(result.unreachableByDirectory), + [result.unreachableByDirectory], + ); + const [openDir, setOpenDir] = useState( + () => dirs[0]?.[0] ?? null, + ); + + const toggleDir = useCallback( + (dir: string) => setOpenDir((prev) => (prev === dir ? null : dir)), + [], + ); + + if (result.unreachable.length === 0) { + return ( + + ); + } + + const deadPct = 100 - Math.round(result.reachabilityScore); + + return ( +
+ {/* Summary */} +
+ + + 80 + ? "text-emerald-400" + : result.reachabilityScore > 60 + ? "text-yellow-400" + : "text-red-400" + } + sub={deadPct > 0 ? `${deadPct}% unreachable` : "fully covered"} + borderColor="border-white/8" + bgColor="bg-white/[0.02]" + /> +
+ + {/* Reachability bar */} +
+
+ +
+
+ + {result.reachable.size} reachable + + + {result.unreachable.length} dead + +
+
+ + {/* Entry points */} +
+

+ Entry Points Traced +

+
+ {data.entryPoints.map((ep) => ( + + {fileName(ep)} + + ))} +
+
+ + {/* Dead files by directory */} +
+

+ Unreachable files by directory +

+ {dirs.map(([dir, files]) => ( +
+ + + {openDir === dir && ( + +
+ {files.map((f) => ( +
+
+ + {fileName(f)} + +
+ ))} +
+ + )} + +
+ ))} +
+
+ ); +} + +// ─── Main Panel ─────────────────────────────────────────────────────────────── + +export default function ArchInsightsPanel({ data }: { data: RepoData }) { + const [activeTab, setActiveTab] = useState("ap"); + const current = SUB_TAB_MAP.get(activeTab)!; + + return ( + +
+ {/* Header */} +
+

+ Architecture Insights +

+

+ Pure graph algorithms — no AI, no guessing. +

+
+ + {/* Sub-tab strip */} +
+ {SUB_TABS.map((tab) => { + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+ + {/* Active tab label */} +
+ + + {current.label} + + +
+ + {/* Tab content */} + + + {activeTab === "ap" && } + {activeTab === "stability" && } + {activeTab === "dead" && } + + +
+
+ ); +} diff --git a/components/analyze/VisualizerPanel.tsx b/components/analyze/VisualizerPanel.tsx index 139c32c..2500cd8 100644 --- a/components/analyze/VisualizerPanel.tsx +++ b/components/analyze/VisualizerPanel.tsx @@ -1,3 +1,4 @@ +// components/analyze/VisualizerPanel.tsx "use client"; import { motion } from "framer-motion"; @@ -7,6 +8,7 @@ import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { MAP_VIEW_CONFIG, MapViewType } from "@/components/analyze/constants"; import { RepoData } from "@/lib/types/analyze"; import SkeletonLoader from "@/components/analyze/SkeletonLoader"; +import InfoTooltip from "@/components/InfoTooltip"; const ArchitectureMap = dynamic(() => import("@/components/ArchitectureMap"), { loading: () => , @@ -21,13 +23,19 @@ const DirectoryTreeVisualizer = dynamic( { loading: () => , ssr: false }, ); +const MAP_VIEW_TOOLTIPS: Record = { + graph: + "Force-directed graph of file imports. Click any node to trace its Blast Radius.", + directory: + "Folder tree of your repo. Shows which directories contain the most files.", + treemap: + "File sizes as colored blocks. Bigger block = larger file. Red = top 10% by size.", +}; + interface VisualizerPanelProps { data: RepoData; mapView: MapViewType; onMapViewChange: (view: MapViewType) => void; - // Lifted from PrImpactTab — the raw list of files changed in the analyzed PR. - // Empty array when no PR has been analyzed yet. Passed through to - // ArchitectureMap which runs the multi-source reverse BFS client-side. prChangedFiles?: string[]; } @@ -49,7 +57,7 @@ export default function VisualizerPanel({ transition={{ type: "spring", stiffness: 380, damping: 30, mass: 0.8 }} className="absolute inset-0 p-4 flex flex-col gap-3" > - {/* Sub-view switcher */} + {/* ── View switcher ─────────────────────────────────────────────── */}

@@ -59,24 +67,33 @@ export default function VisualizerPanel({ VISUAL LAYOUT

+
- {MAP_VIEW_CONFIG.map((view) => ( - - ))} + {MAP_VIEW_CONFIG.map((view) => { + const tooltip = MAP_VIEW_TOOLTIPS[view.id]; + return ( + + ); + })}
- {/* Map canvas */} + {/* ── Map area ──────────────────────────────────────────────────── */}
{mapView === "graph" ? ( @@ -87,6 +104,7 @@ export default function VisualizerPanel({ entryPoints={data.entryPoints} fileMetrics={data.fileMetrics} prChangedFiles={prChangedFiles} + pageRankScores={data.pageRankScores ?? {}} /> ) : (
diff --git a/components/analyze/constants.tsx b/components/analyze/constants.tsx index 4a02318..2aa4d61 100644 --- a/components/analyze/constants.tsx +++ b/components/analyze/constants.tsx @@ -4,6 +4,7 @@ import { Activity, GitPullRequest, Target, + Cpu, } from "lucide-react"; // ─── Framer Motion ──────────────────────────────────────────────────────────── @@ -18,6 +19,7 @@ export const TAB_CONFIG = [ { id: "doctor" as const, icon: Activity, label: "Diagnostic Engine" }, { id: "pr_impact" as const, icon: GitPullRequest, label: "PR Impact" }, { id: "risk_radar" as const, icon: Target, label: "Risk Radar" }, + { id: "arch_insights" as const, icon: Cpu, label: "Arch Insights" }, ] as const; export type TabType = (typeof TAB_CONFIG)[number]["id"]; diff --git a/components/dashboard/DashboardGrid.tsx b/components/dashboard/DashboardGrid.tsx new file mode 100644 index 0000000..911ce6d --- /dev/null +++ b/components/dashboard/DashboardGrid.tsx @@ -0,0 +1,364 @@ +// components/dashboard/DashboardGrid.tsx +"use client"; + +import { useState, useMemo } from "react"; +import Link from "next/link"; +import { + Database, + Clock, + GitBranch, + ArrowRight, + ChevronDown, + Layers, + CalendarClock, +} from "lucide-react"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type Analysis = { + id: string; + repo_url: string; + repo_name: string; + commit_sha: string; + created_at: string; +}; + +type GroupedRepo = { + repo_url: string; + repo_name: string; + latest_commit_sha: string; + latest_created_at: string; + oldest_created_at: string; + analysisCount: number; +}; + +type SortKey = "latest-desc" | "latest-asc" | "count-desc" | "count-asc"; + +// ─── Sort Options Config ────────────────────────────────────────────────────── + +const SORT_OPTIONS: { value: SortKey; label: string }[] = [ + { value: "latest-desc", label: "Latest First" }, + { value: "latest-asc", label: "Oldest First" }, + { value: "count-desc", label: "Most Analyzed" }, + { value: "count-asc", label: "Least Analyzed" }, +]; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Groups a flat analyses array by repo_url. + * The "representative" row for a group is the one with the latest created_at. + */ +function groupAnalysesByRepo(analyses: Analysis[]): GroupedRepo[] { + const map = new Map(); + + for (const analysis of analyses) { + const existing = map.get(analysis.repo_url); + + if (!existing) { + map.set(analysis.repo_url, { + repo_url: analysis.repo_url, + repo_name: analysis.repo_name, + latest_commit_sha: analysis.commit_sha, + latest_created_at: analysis.created_at, + oldest_created_at: analysis.created_at, + analysisCount: 1, + }); + } else { + existing.analysisCount += 1; + + // Track the most recent entry + if ( + new Date(analysis.created_at) > new Date(existing.latest_created_at) + ) { + existing.latest_created_at = analysis.created_at; + existing.latest_commit_sha = analysis.commit_sha; + } + + // Track the oldest entry + if ( + new Date(analysis.created_at) < new Date(existing.oldest_created_at) + ) { + existing.oldest_created_at = analysis.created_at; + } + } + } + + return Array.from(map.values()); +} + +/** + * Formats a date string into "29 MAY 2026 • 2:30 PM" + */ +function formatDateTime(dateStr: string): string { + const date = new Date(dateStr); + const day = date.getDate().toString().padStart(2, "0"); + const month = date.toLocaleString("en-US", { month: "short" }).toUpperCase(); + const year = date.getFullYear(); + const time = date.toLocaleString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + return `${day} ${month} ${year} • ${time}`; +} + +// ─── Sort Dropdown ──────────────────────────────────────────────────────────── + +function SortDropdown({ + value, + onChange, +}: { + value: SortKey; + onChange: (v: SortKey) => void; +}) { + const [open, setOpen] = useState(false); + const current = SORT_OPTIONS.find((o) => o.value === value)!; + + // Split the label to show an accent on the qualifier ("First", "Analyzed") + const [base, qualifier] = current.label.split(" ").reduce( + (acc, word, i, arr) => { + if (i < arr.length - 1) acc[0] = (acc[0] ? acc[0] + " " : "") + word; + else acc[1] = word; + return acc; + }, + ["", ""] as [string, string], + ); + + return ( +
+ + + {open && ( + <> + {/* Backdrop */} +
setOpen(false)} /> + {/* Menu */} +
+ {/* Group: Time */} +
+ + Time + +
+ {SORT_OPTIONS.slice(0, 2).map((opt) => ( + + ))} + + {/* Divider */} +
+ + {/* Group: Count */} +
+ + Scans + +
+ {SORT_OPTIONS.slice(2).map((opt) => ( + + ))} +
+
+ + )} +
+ ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +export default function DashboardGrid({ + analyses, + error, +}: { + analyses: Analysis[]; + error?: string; +}) { + const [sortKey, setSortKey] = useState("latest-desc"); + + const sortedRepos = useMemo(() => { + const grouped = groupAnalysesByRepo(analyses); + + return grouped.sort((a, b) => { + switch (sortKey) { + case "latest-desc": + return ( + new Date(b.latest_created_at).getTime() - + new Date(a.latest_created_at).getTime() + ); + case "latest-asc": + return ( + new Date(a.latest_created_at).getTime() - + new Date(b.latest_created_at).getTime() + ); + case "count-desc": + return b.analysisCount - a.analysisCount; + case "count-asc": + return a.analysisCount - b.analysisCount; + } + }); + }, [analyses, sortKey]); + + if (error) { + return ( +
+

+ Database Error +

+

{error}

+
+ ); + } + + return ( +
+ {/* ── Section Header ─────────────────────────────────────────────── */} +
+

+ Historical Scans + {sortedRepos.length > 0 && ( + + ({sortedRepos.length} repo{sortedRepos.length !== 1 ? "s" : ""}) + + )} +

+ + {/* Sort control — only visible when there's data */} + {sortedRepos.length > 0 && ( +
+ + Sort + + +
+ )} +
+ + {/* ── Empty State ────────────────────────────────────────────────── */} + {sortedRepos.length === 0 ? ( +
+ +

+ No Autopsies Found +

+

+ You haven't analyzed any repositories yet. Start your first + scan to generate an architecture blueprint and diagnostic report. +

+ + Initiate Scan + +
+ ) : ( + /* ── Grid ────────────────────────────────────────────────────── */ +
+ {sortedRepos.map((repo) => ( +
+ {/* ── Card Header ─────────────────────────────────── */} +
+
+ {/* DB icon */} +
+ +
+ {/* Scans badge */} + = 5 + ? "bg-indigo-500/15 border-indigo-500/30 text-indigo-300" + : repo.analysisCount >= 2 + ? "bg-slate-200/10 border-slate-200/20 text-slate-300" + : "bg-white/5 border-white/10 text-slate-500" + }`} + > + {repo.analysisCount} Scan + {repo.analysisCount !== 1 ? "s" : ""} + +
+ + {/* Timestamp */} + + + + {formatDateTime(repo.latest_created_at)} + + +
+ + {/* ── Card Body ───────────────────────────────────── */} +
+

+ {repo.repo_name} +

+
+ + + {repo.latest_commit_sha.substring(0, 7)} + +
+
+ + {/* ── CTA ─────────────────────────────────────────── */} + + Load Report + +
+ ))} +
+ )} +
+ ); +} diff --git a/lib/algorithms/articulationPoints.ts b/lib/algorithms/articulationPoints.ts new file mode 100644 index 0000000..d9083a7 --- /dev/null +++ b/lib/algorithms/articulationPoints.ts @@ -0,0 +1,112 @@ +// lib/algorithms/articulationPoints.ts + +export interface ArticulationPointResult { + articulationPoints: Set; + bridges: Array<[string, string]>; + componentSizes: Map; +} + +export function computeArticulationPoints( + graph: Record, +): ArticulationPointResult { + const nodes = Object.keys(graph); + if (nodes.length === 0) { + return { + articulationPoints: new Set(), + bridges: [], + componentSizes: new Map(), + }; + } + + // Build undirected adjacency for AP detection + const adj = new Map>(); + for (const node of nodes) { + if (!adj.has(node)) adj.set(node, new Set()); + for (const dep of graph[node] || []) { + if (!adj.has(dep)) adj.set(dep, new Set()); + adj.get(node)!.add(dep); + adj.get(dep)!.add(node); + } + } + + const allNodes = Array.from(adj.keys()); + const visited = new Set(); + const disc = new Map(); // discovery time + const low = new Map(); // lowest discovery reachable + const parent = new Map(); + const aps = new Set(); + const bridges: Array<[string, string]> = []; + let timer = 0; + + const dfs = (u: string) => { + visited.add(u); + disc.set(u, timer); + low.set(u, timer); + timer++; + + let childCount = 0; + + for (const v of adj.get(u) || []) { + if (!visited.has(v)) { + childCount++; + parent.set(v, u); + dfs(v); + + low.set(u, Math.min(low.get(u)!, low.get(v)!)); + + // AP condition 1: u is root with 2+ children + if (parent.get(u) === null && childCount > 1) aps.add(u); + + // AP condition 2: u is not root and low[v] >= disc[u] + if (parent.get(u) !== null && low.get(v)! >= disc.get(u)!) aps.add(u); + + // Bridge condition + if (low.get(v)! > disc.get(u)!) bridges.push([u, v]); + } else if (v !== parent.get(u)) { + low.set(u, Math.min(low.get(u)!, disc.get(v)!)); + } + } + }; + + for (const node of allNodes) { + if (!visited.has(node)) { + parent.set(node, null); + dfs(node); + } + } + + // For each AP, estimate how many nodes become disconnected if removed + const componentSizes = new Map(); + for (const ap of aps) { + // BFS without the AP node to count disconnected components + const remaining = new Set(allNodes.filter((n) => n !== ap)); + const neighbors = Array.from(adj.get(ap) || []).filter((n) => + remaining.has(n), + ); + + let maxDisconnected = 0; + const seen = new Set(); + + for (const start of neighbors) { + if (seen.has(start)) continue; + const queue = [start]; + seen.add(start); + let count = 0; + while (queue.length > 0) { + const cur = queue.shift()!; + count++; + for (const nb of adj.get(cur) || []) { + if (!seen.has(nb) && remaining.has(nb)) { + seen.add(nb); + queue.push(nb); + } + } + } + maxDisconnected = Math.max(maxDisconnected, count); + } + + componentSizes.set(ap, maxDisconnected); + } + + return { articulationPoints: aps, bridges, componentSizes }; +} \ No newline at end of file diff --git a/lib/algorithms/deadCode.ts b/lib/algorithms/deadCode.ts new file mode 100644 index 0000000..733173e --- /dev/null +++ b/lib/algorithms/deadCode.ts @@ -0,0 +1,62 @@ +// lib/algorithms/deadCode.ts + +export interface DeadCodeResult { + reachable: Set; + unreachable: string[]; + unreachableByDirectory: Record; + reachabilityScore: number; // % of codebase reachable +} + +export function computeDeadCode( + graph: Record, + entryPoints: string[], +): DeadCodeResult { + const allNodes = Object.keys(graph); + + if (allNodes.length === 0 || entryPoints.length === 0) { + return { + reachable: new Set(), + unreachable: allNodes, + unreachableByDirectory: groupByDirectory(allNodes), + reachabilityScore: 0, + }; + } + + // BFS from all entry points through directed dependency graph + const reachable = new Set(); + const queue = [...entryPoints]; + + for (const ep of entryPoints) reachable.add(ep); + + while (queue.length > 0) { + const cur = queue.shift()!; + for (const dep of graph[cur] || []) { + if (!reachable.has(dep)) { + reachable.add(dep); + queue.push(dep); + } + } + } + + const unreachable = allNodes.filter((n) => !reachable.has(n)); + const reachabilityScore = + allNodes.length > 0 ? (reachable.size / allNodes.length) * 100 : 100; + + return { + reachable, + unreachable, + unreachableByDirectory: groupByDirectory(unreachable), + reachabilityScore, + }; +} + +function groupByDirectory(paths: string[]): Record { + const map: Record = {}; + for (const p of paths) { + const parts = p.split("/"); + const dir = parts.length > 1 ? parts.slice(0, -1).join("/") : "(root)"; + if (!map[dir]) map[dir] = []; + map[dir].push(p); + } + return map; +} \ No newline at end of file diff --git a/lib/algorithms/pagerank.ts b/lib/algorithms/pagerank.ts new file mode 100644 index 0000000..78350eb --- /dev/null +++ b/lib/algorithms/pagerank.ts @@ -0,0 +1,159 @@ +// lib/algorithms/pagerank.ts + +export interface PageRankOptions { + dampingFactor?: number; + iterations?: number; + epsilon?: number; + weighted?: boolean; +} + +export interface PageRankResult { + scores: Map; + iterations: number; + converged: boolean; + dangling: Set; + topK: (k: number) => [string, number][]; +} + + +export type AdjacencyList = Map>; + + +export function computePageRank( + graph: AdjacencyList, + options: PageRankOptions = {} +): PageRankResult { + const { + dampingFactor = 0.85, + iterations = 25, + epsilon = 1e-6, + weighted = true, + } = options; + + const nodes = Array.from(graph.keys()); + const N = nodes.length; + + if (N === 0) { + return { + scores: new Map(), + iterations: 0, + converged: true, + dangling: new Set(), + topK: () => [], + }; + } + + // --- Build reverse adjacency (who imports ME?) ------------------------- + // reverseGraph[v] = Map means u → v exists with weight w + const reverseGraph = new Map>(); + const outWeightSum = new Map(); // Σ weights of outgoing edges + + for (const node of nodes) { + if (!reverseGraph.has(node)) reverseGraph.set(node, new Map()); + const edges = graph.get(node)!; + let total = 0; + for (const [target, w] of edges) { + total += weighted ? w : 1; + if (!reverseGraph.has(target)) reverseGraph.set(target, new Map()); + reverseGraph.get(target)!.set(node, weighted ? w : 1); + } + outWeightSum.set(node, total); + } + + // Ensure every node from reverseGraph is in our nodes set + // (handles targets that were never listed as sources) + const allNodes = Array.from(reverseGraph.keys()); + const nodeSet = new Set(allNodes); + const n = nodeSet.size; + + // Dangling nodes: no outgoing edges + const dangling = new Set(); + for (const node of allNodes) { + if ((outWeightSum.get(node) ?? 0) === 0) dangling.add(node); + } + + // --- Initialise rank vector ------------------------------------------- + let rank = new Map(); + const init = 1 / n; + for (const node of allNodes) rank.set(node, init); + + const teleport = (1 - dampingFactor) / n; + let actualIterations = 0; + let converged = false; + + // --- Power iteration --------------------------------------------------- + for (let iter = 0; iter < iterations; iter++) { + actualIterations++; + + // Dangling node mass → distribute uniformly + let danglingMass = 0; + for (const d of dangling) danglingMass += rank.get(d)!; + const danglingContrib = (dampingFactor * danglingMass) / n; + + const next = new Map(); + + for (const node of allNodes) { + let sum = 0; + const inbound = reverseGraph.get(node)!; + for (const [src, w] of inbound) { + const srcOut = outWeightSum.get(src) ?? 0; + if (srcOut > 0) sum += (rank.get(src)! * w) / srcOut; + } + next.set(node, teleport + danglingContrib + dampingFactor * sum); + } + + // L1 norm convergence check + let delta = 0; + for (const node of allNodes) { + delta += Math.abs(next.get(node)! - rank.get(node)!); + } + + rank = next; + + if (delta < epsilon) { + converged = true; + break; + } + } + + // --- Normalise to [0, 1] against the max rank ------------------------- + const maxRank = Math.max(...rank.values()); + const scores = new Map(); + for (const [node, r] of rank) { + scores.set(node, maxRank > 0 ? r / maxRank : 0); + } + + const topK = (k: number): [string, number][] => + Array.from(scores.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, k); + + return { scores, iterations: actualIterations, converged, dangling, topK }; +} + +/** + * Maps a normalised PageRank score ∈ [0, 1] to an OKLCH heatmap colour. + * Cold (low rank) → blue. Hot (high rank) → red, via cyan → green → yellow. + * OKLCH gives perceptually uniform lightness across the hue sweep. + */ +export function rankToOklch(score: number): string { + // Hue sweep: 264° (blue) → 0°/360° (red) over the score range + const hue = 264 - score * 264; + const chroma = 0.12 + score * 0.18; // saturate as rank increases + const lightness = 0.55 + score * 0.2; // brighten hot nodes + return `oklch(${lightness.toFixed(3)} ${chroma.toFixed(3)} ${hue.toFixed(1)})`; +} + +/** + * Derive a border-glow intensity string for React Flow node styles. + * High-rank nodes get a pronounced box-shadow; low-rank nodes get none. + */ +export function rankToGlowStyle(score: number, color: string): React.CSSProperties { + if (score < 0.1) return {}; + const blur = Math.round(4 + score * 28); + const spread = Math.round(score * 6); + return { + boxShadow: `0 0 ${blur}px ${spread}px ${color}`, + borderColor: color, + }; +} \ No newline at end of file diff --git a/lib/algorithms/stability.ts b/lib/algorithms/stability.ts new file mode 100644 index 0000000..50833bc --- /dev/null +++ b/lib/algorithms/stability.ts @@ -0,0 +1,86 @@ +// lib/algorithms/stability.ts + +export interface FileStability { + path: string; + afferent: number; // Ca: how many files import this file + efferent: number; // Ce: how many files this file imports + instability: number; // I = Ce / (Ca + Ce), range [0, 1] + label: string; // "stable" | "unstable" | "balanced" + zone: "pain" | "uselessness" | "main-sequence" | "balanced"; +} + +export interface StabilityResult { + files: FileStability[]; + avgInstability: number; + mostUnstable: FileStability[]; + mostStable: FileStability[]; +} + +export function computeStability( + graph: Record, +): StabilityResult { + const nodes = Object.keys(graph); + if (nodes.length === 0) { + return { files: [], avgInstability: 0, mostUnstable: [], mostStable: [] }; + } + + const afferentMap = new Map(); + const efferentMap = new Map(); + + // Initialise all known nodes + for (const node of nodes) { + if (!afferentMap.has(node)) afferentMap.set(node, 0); + if (!efferentMap.has(node)) efferentMap.set(node, 0); + } + + // Count Ce (efferent) = outgoing imports + // Count Ca (afferent) = incoming imports + for (const src of nodes) { + const deps = graph[src] || []; + efferentMap.set(src, deps.length); + for (const dep of deps) { + if (!afferentMap.has(dep)) afferentMap.set(dep, 0); + if (!efferentMap.has(dep)) efferentMap.set(dep, 0); + afferentMap.set(dep, afferentMap.get(dep)! + 1); + } + } + + const allNodes = Array.from( + new Set([...afferentMap.keys(), ...efferentMap.keys()]), + ); + + const files: FileStability[] = allNodes.map((path) => { + const ca = afferentMap.get(path) ?? 0; + const ce = efferentMap.get(path) ?? 0; + const total = ca + ce; + const instability = total === 0 ? 0.5 : ce / total; + + // Martin's zones + // Abstractness (A) we approximate as 0 (no AST) so: + // Zone of Pain = low instability + low abstraction (I < 0.3) + // Zone of Uselessness = high instability + high abstraction (I > 0.7, Ca ≈ 0) + // Main sequence = balanced + let zone: FileStability["zone"]; + if (instability < 0.3 && ce > 3) zone = "pain"; + else if (instability > 0.7 && ca === 0) zone = "uselessness"; + else if (instability >= 0.3 && instability <= 0.7) zone = "main-sequence"; + else zone = "balanced"; + + const label = + instability < 0.3 ? "stable" : instability > 0.7 ? "unstable" : "balanced"; + + return { path, afferent: ca, efferent: ce, instability, label, zone }; + }); + + const avgInstability = + files.reduce((s, f) => s + f.instability, 0) / files.length; + + const sorted = [...files].sort((a, b) => b.instability - a.instability); + + return { + files, + avgInstability, + mostUnstable: sorted.slice(0, 10), + mostStable: sorted.slice(-10).reverse(), + }; +} \ No newline at end of file diff --git a/lib/pipeline/ast-pipeline.ts b/lib/pipeline/ast-pipeline.ts index bd837ee..5ee605a 100644 --- a/lib/pipeline/ast-pipeline.ts +++ b/lib/pipeline/ast-pipeline.ts @@ -1,12 +1,5 @@ -/** - * lib/pipeline/ast-pipeline.ts - * - * Pure, framework-agnostic AST extraction pipeline. - * Zero Next.js / Vercel / Supabase imports — safe to run from a CLI, - * a cron job, or any future headless API route. - */ - -// ── Static imports only — dynamic imports removed ──────────────────────────── +// lib/pipeline/ast-pipeline.ts + import { parseRepoUrl, fetchRepoMeta, @@ -18,11 +11,10 @@ import { buildDependencyGraph, computeFanIn, graphToMermaid, - getBlastRadiusTargets, // was a dynamic import in the original — static is fine + getBlastRadiusTargets, } from "@/lib/dependency-graph"; import { calculateHealthGrade } from "@/lib/analyzer/health"; -// ── Constants ───────────────────────────────────────────────────────────────── const IGNORE_PATTERNS = [ "node_modules", "dist", "build", ".next", ".git", "coverage", "__pycache__", ".yarn", "vendor", "package-lock.json", "yarn.lock", @@ -34,16 +26,12 @@ const IGNORE_EXTENSIONS = [ ".lock", ".min.js", ".map", ".woff", ".woff2", ] as const; -const CONFIG_FILE_PATTERN = /\.(json|md|ya?ml|config\.(js|mjs|ts|cjs))$/i; -const TEST_FILE_PATTERN = /\.(test|spec)\.[jt]sx?$/i; -const TEST_DIR_PATTERN = /__(tests|mocks)__\//i; +const CONFIG_FILE_PATTERN = /\.(json|md|ya?ml|config\.(js|mjs|ts|cjs))$/i; +const TEST_FILE_PATTERN = /\.(test|spec)\.[jt]sx?$/i; +const TEST_DIR_PATTERN = /__(tests|mocks)__\//i; const MAX_LOCAL_FILES = 300; -const MAX_LOCAL_SIZE_BYTES = 2_000_000; // 2 MB -/** - * GitHub's secondary rate limit kicks in above ~10 concurrent authenticated - * requests. 6 gives comfortable headroom while still being fast. - */ +const MAX_LOCAL_SIZE_BYTES = 2_000_000; const MAX_FETCH_CONCURRENCY = 6; const FETCH_TIMEOUT_MS = 8_000; const MAX_FILE_LINES = 500; @@ -54,7 +42,11 @@ const BLAST_RADIUS_TOP_N = 3; const COVERAGE_GAP_TOP_N = 10; const TOP_FILES_FOR_GROQ = 20; -// ── Typed Errors ────────────────────────────────────────────────────────────── +const PAGERANK_ITERATIONS = 20; +const PAGERANK_DAMPING_FACTOR = 0.85; +const PAGERANK_WEIGHT = 0.7; +const FANIN_WEIGHT = 0.3; + export type PipelineErrorCode = | "INVALID_REPO_URL" | "TOO_MANY_FILES" @@ -66,14 +58,12 @@ export class PipelineError extends Error { public readonly code: PipelineErrorCode; constructor(code: PipelineErrorCode, message: string) { super(message); - this.name = "PipelineError"; - this.code = code; - // Maintains proper prototype chain for `instanceof` checks + this.name = "PipelineError"; + this.code = code; Object.setPrototypeOf(this, PipelineError.prototype); } } -// ── Public Types ────────────────────────────────────────────────────────────── export interface FileContent { path: string; content: string; @@ -90,13 +80,10 @@ export interface CoverageGap { isTested: boolean; testFiles: string[]; riskScore: number; + pageRankScore: number; } -export type TestCoverageMap = Record< - string, - { isTested: boolean; testFiles: string[] } ->; - +export type TestCoverageMap = Record; export interface PipelineResult { owner: string; repo: string; @@ -116,8 +103,8 @@ export interface PipelineResult { coverageGaps: CoverageGap[]; topFilesForGroq: Array<{ path: string; role: string }>; blastRadiusTargets: string[]; + pageRankScores: Record; analysis: null; - /** Present only when the result was served from the DB cache. */ cached?: true; } @@ -126,16 +113,9 @@ export interface PipelineParams { githubToken: string; isLocal?: boolean; localFiles?: unknown[]; - /** - * Injected by the caller so the pure pipeline can check the DB without - * importing Supabase. Returning null/undefined means "cache miss". - * Any error thrown by this callback is caught and treated as a cache miss — - * the pipeline continues with a fresh analysis. - */ checkCache?: (commitSha: string) => Promise; } -// ── Concurrency Semaphore ───────────────────────────────────────────────────── class Semaphore { private permits: number; private readonly queue: Array<() => void> = []; @@ -156,8 +136,6 @@ class Semaphore { } release(): void { - // If a waiter is queued, pass the permit directly — don't increment and - // then decrement, which would allow a race window where permits > max. const next = this.queue.shift(); if (next) { next(); @@ -167,16 +145,6 @@ class Semaphore { } } -// ── Utilities ───────────────────────────────────────────────────────────────── - -/** - * Races `promise` against a hard deadline. - * - * The critical correctness detail: the timeout's `clearTimeout` fires in - * `.finally()` whether the promise wins or the timeout wins. Without this, - * the Node timer keeps the event loop alive (and Vercel's function budget - * ticking) even after the race has already settled. - */ function withTimeout(promise: Promise, ms: number): Promise { let timerId: ReturnType | undefined; @@ -217,7 +185,7 @@ function boostEntryPointScores( ): void { for (const file of files) { if (file.path.endsWith("index.html") || file.path.endsWith(".html")) { - file.role = "entry"; + file.role = "entry"; file.score += 500; } } @@ -231,14 +199,11 @@ function sanitizeDependencyGraph( for (const [source, targets] of Object.entries(graph)) { if (isConfigFile(source)) continue; const validTargets = targets.filter((t) => !isConfigFile(t)); - // Keep the source even if it has no valid targets (important for isolated files) if (validTargets.length > 0 || targets.length === 0) { sanitized[source] = validTargets; } } - // Every referenced target must have an entry so downstream consumers never - // encounter missing keys when walking the graph. const allTargets = new Set(Object.values(sanitized).flat()); for (const target of allTargets) { if (!sanitized[target]) sanitized[target] = []; @@ -271,10 +236,83 @@ function generateTestCoverageMap(allPaths: string[]): TestCoverageMap { return coverageMap; } +function truncateToLines(text: string, maxLines: number): string { + let lineCount = 0; + for (let i = 0; i < text.length; i++) { + if (text[i] === "\n") { + lineCount++; + if (lineCount === maxLines) { + return text.substring(0, i + 1); + } + } + } + return text; +} + +function computePageRank( + graph: Record, + iterations = PAGERANK_ITERATIONS, + dampingFactor = PAGERANK_DAMPING_FACTOR, +): Record { + const nodes = Object.keys(graph); + const N = nodes.length; + if (N === 0) return {}; + + const reverseGraph: Record = {}; + for (const node of nodes) reverseGraph[node] = []; + + for (const [src, targets] of Object.entries(graph)) { + for (const target of targets) { + if (!reverseGraph[target]) reverseGraph[target] = []; + reverseGraph[target].push(src); + } + } + + let rank: Record = {}; + for (const node of nodes) rank[node] = 1 / N; + + for (let i = 0; i < iterations; i++) { + let danglingMass = 0; + for (const node of nodes) { + if ((graph[node]?.length ?? 0) === 0) { + danglingMass += rank[node]; + } + } + + const next: Record = {}; + for (const node of nodes) { + let incoming = 0; + for (const src of reverseGraph[node] ?? []) { + const outDegree = graph[src]?.length; + if (outDegree) { + incoming += rank[src] / outDegree; + } + } + next[node] = + (1 - dampingFactor) / N + + dampingFactor * (incoming + danglingMass / N); + } + rank = next; + } + + const values = Object.values(rank); + const max = Math.max(...values); + const min = Math.min(...values); + const range = max - min || 1; + + const normalized: Record = {}; + for (const [node, r] of Object.entries(rank)) { + normalized[node] = Math.round(((r - min) / range) * 100); + } + + return normalized; +} + function computeCoverageGaps( fanIn: Record, testCoverageMap: TestCoverageMap, topN: number, + pageRank: Record, ): CoverageGap[] { return Object.entries(fanIn) .map(([filePath, fanInScore]) => { @@ -282,12 +320,18 @@ function computeCoverageGaps( isTested: false, testFiles: [], }; + + const pr = pageRank[filePath] ?? 0; + const rawScore = pr * PAGERANK_WEIGHT + fanInScore * FANIN_WEIGHT; + const riskScore = coverage.isTested ? 0 : Math.round(rawScore); + return { file: filePath, fanIn: fanInScore, isTested: coverage.isTested, testFiles: coverage.testFiles, - riskScore: coverage.isTested ? 0 : fanInScore, + riskScore, + pageRankScore: pr, }; }) .filter((gap) => gap.riskScore > 0) @@ -295,13 +339,6 @@ function computeCoverageGaps( .slice(0, topN); } -/** - * Validates and coerces the raw `localFiles` payload. - * - * Security: strips path-traversal sequences (`../`, `..\`) and leading - * separators to prevent directory escape attacks on callers that later - * use these paths for disk operations. - */ function coerceLocalFiles(raw: unknown[]): FileContent[] { return raw .filter( @@ -313,22 +350,13 @@ function coerceLocalFiles(raw: unknown[]): FileContent[] { ) .map((f) => ({ path: f.path - .replace(/\.\.[/\\]/g, "") // strip traversal - .replace(/^[/\\]+/, ""), // strip leading separator + .replace(/\.\.[/\\]/g, "") + .replace(/^[/\\]+/, ""), content: f.content, })) - .filter((f) => f.path.length > 0); // discard empty paths after sanitisation + .filter((f) => f.path.length > 0); } -// ── Bounded parallel file fetcher ───────────────────────────────────────────── -/** - * Fetches up to `MAX_FETCH_CONCURRENCY` files in parallel, each with a hard - * timeout. Failed or timed-out fetches resolve to `null` and are filtered out - * so a single flaky file never blocks the whole analysis. - * - * `Promise.allSettled` is used deliberately — a rejection from one promise - * must not cancel the others. - */ async function fetchFilesWithConcurrencyLimit( files: Array<{ path: string; role: string }>, owner: string, @@ -347,11 +375,9 @@ async function fetchFilesWithConcurrencyLimit( ); return { path: file.path, - content: raw.split("\n").slice(0, MAX_FILE_LINES).join("\n"), + content: truncateToLines(raw, MAX_FILE_LINES), }; } finally { - // Always release — even if withTimeout throws, so the semaphore - // doesn't deadlock the remaining promises. semaphore.release(); } }), @@ -364,23 +390,21 @@ async function fetchFilesWithConcurrencyLimit( .map((r) => r.value); } -// ── Main Pipeline ───────────────────────────────────────────────────────────── export async function runAstPipeline( params: PipelineParams, ): Promise { const { repoUrl, githubToken, isLocal, localFiles, checkCache } = params; - let allFileContents: FileContent[] = []; + let allFileContents: FileContent[] = []; let owner = "Local"; let repo = "Project"; let commitSha = "local-upload"; let description = "Locally uploaded codebase"; let stars = 0; let language = "Mixed"; - let filteredPaths: string[] = []; - let fileMetrics: FileMetric[] = []; + let filteredPaths: string[] = []; + let fileMetrics: FileMetric[] = []; - // ── Branch: local upload ─────────────────────────────────────────────────── if (isLocal && localFiles != null) { if (localFiles.length > MAX_LOCAL_FILES) { throw new PipelineError( @@ -389,7 +413,7 @@ export async function runAstPipeline( ); } - const coerced = coerceLocalFiles(localFiles); + const coerced = coerceLocalFiles(localFiles); const totalSize = coerced.reduce((acc, f) => acc + f.content.length, 0); if (totalSize > MAX_LOCAL_SIZE_BYTES) { @@ -403,7 +427,6 @@ export async function runAstPipeline( filteredPaths = coerced.map((f) => f.path); fileMetrics = coerced.map((f) => ({ path: f.path, size: f.content.length })); - // ── Branch: GitHub remote ────────────────────────────────────────────────── } else { const parsed = parseRepoUrl(repoUrl); if (!parsed) { @@ -413,14 +436,12 @@ export async function runAstPipeline( owner = parsed.owner; repo = parsed.repo; - const meta = await fetchRepoMeta(owner, repo, githubToken); + const meta = await fetchRepoMeta(owner, repo, githubToken); commitSha = String(meta.default_branch); - description = String(meta.description ?? ""); - stars = Number(meta.stargazers_count ?? 0); - language = String(meta.language ?? ""); + description = String(meta.description ?? ""); + stars = Number(meta.stargazers_count ?? 0); + language = String(meta.language ?? ""); - // Cache check — errors here are intentionally swallowed so a cold DB or - // transient timeout never prevents a fresh analysis from running. if (checkCache) { try { const cached = await checkCache(commitSha); @@ -442,7 +463,6 @@ export async function runAstPipeline( boostEntryPointScores(scoredFiles); const topFiles = getTopFiles(scoredFiles, TOP_FILES_FOR_GROQ); - // Ensure tsconfig is always present for accurate path-alias resolution const tsconfigEntry = scoredFiles.find( (f) => f.path === "tsconfig.json" || f.path === "src/tsconfig.json", ); @@ -462,10 +482,6 @@ export async function runAstPipeline( throw new PipelineError("NO_FILES_FOUND", "No readable code files found."); } - // ── Analysis ─────────────────────────────────────────────────────────────── - // Score the full path list (not just fetched files) for accurate role data. - // Note: this is a second pass over `filteredPaths` — cheap CPU vs the - // alternative of threading `scoredFiles` through both branches above. const scoredAllFiles = classifyAndScoreFiles(filteredPaths); boostEntryPointScores(scoredAllFiles); @@ -481,9 +497,8 @@ export async function runAstPipeline( const mermaidDiagram = graphToMermaid(dependencyGraph, entryPoints); const blastRadiusTargets = getBlastRadiusTargets(fanIn, BLAST_RADIUS_TOP_N); - const fanInValues = Object.values(fanIn); - // Guard: Math.max(...[]) returns -Infinity, which would corrupt the health score - const maxFanIn = fanInValues.length > 0 ? Math.max(...fanInValues) : 0; + const fanInValues = Object.values(fanIn); + const maxFanIn = fanInValues.length > 0 ? Math.max(...fanInValues) : 0; const largeFilesCount = fileMetrics.filter((f) => f.size > LARGE_FILE_BYTES).length; const healthMetrics = calculateHealthGrade({ @@ -494,7 +509,13 @@ export async function runAstPipeline( }); const testCoverageMap = generateTestCoverageMap(filteredPaths); - const coverageGaps = computeCoverageGaps(fanIn, testCoverageMap, COVERAGE_GAP_TOP_N); + const pageRankScores = computePageRank(dependencyGraph); + const coverageGaps = computeCoverageGaps( + fanIn, + testCoverageMap, + COVERAGE_GAP_TOP_N, + pageRankScores, + ); return { owner, @@ -515,6 +536,7 @@ export async function runAstPipeline( coverageGaps, topFilesForGroq: topFilesForGroq.map((f) => ({ path: f.path, role: f.role })), blastRadiusTargets: blastRadiusTargets.map((t) => t.file), + pageRankScores, analysis: null, }; } \ No newline at end of file diff --git a/lib/types/analyze.ts b/lib/types/analyze.ts index 984680d..40b77cc 100644 --- a/lib/types/analyze.ts +++ b/lib/types/analyze.ts @@ -1,3 +1,5 @@ +// lib/types/analyze.ts + export interface Analysis { architecture_pattern: string; what_it_does: string; @@ -39,6 +41,7 @@ export interface RepoData { testFiles: string[]; }[]; fileContents?: { path: string; content: string }[]; + pageRankScores?: Record; } export interface PRBlastRadiusItem { @@ -55,8 +58,50 @@ export interface PRAnalysisResult { breakingDependencies: string[]; riskLevel: "low" | "medium" | "high"; suggestedReviewers?: { username: string; reason: string }[]; - // ── Added: raw list of files changed in the PR, used by ArchitectureMap - // for the client-side multi-source reverse BFS (pr-blast mode). - // Populated by /api/analyze-pr from the GitHub /pulls/{pr}/files endpoint. changedFiles?: string[]; +} + +export interface BlastRadiusResult { + targetFile: string; + affectedDownstream: string[]; + riskScore: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"; +} + +export function calculateBlastRadius( + modifiedFiles: string[], + reverseDependencyGraph: Record +): BlastRadiusResult[] { + const results: BlastRadiusResult[] = []; + + for (const file of modifiedFiles) { + const affected = new Set(); + const queue = [file]; + + while (queue.length > 0) { + const current = queue.shift()!; + const dependents = reverseDependencyGraph[current] || []; + + for (const dep of dependents) { + if (!affected.has(dep)) { + affected.add(dep); + queue.push(dep); + } + } + } + + const affectedArray = Array.from(affected); + let risk: BlastRadiusResult["riskScore"] = "LOW"; + + if (affectedArray.length > 20) risk = "CRITICAL"; + else if (affectedArray.length > 10) risk = "HIGH"; + else if (affectedArray.length > 3) risk = "MEDIUM"; + + results.push({ + targetFile: file, + affectedDownstream: affectedArray, + riskScore: risk, + }); + } + + return results.sort((a, b) => b.affectedDownstream.length - a.affectedDownstream.length); } \ No newline at end of file