From d2c43ecd1864a4ec8d739109fe048039e0cd8c12 Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Fri, 29 May 2026 07:16:39 +0530 Subject: [PATCH 01/21] 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 02/21] 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 03/21] 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 04/21] 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 */} - {/* 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 From ada8623394455a72093a0a9d05515464ec67e60d Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Sat, 30 May 2026 09:13:27 +0530 Subject: [PATCH 07/21] feat: add Lemon Squeezy payment integration - Add checkout API route (/api/checkout) - Add webhook handler (/api/webhooks/lemonsqueezy) - Update usage.ts to support pro tier (100/day limit) - Update analyze route to use ratelimitPro for pro users - Update chat route to bypass limit for pro users - Update analyze-pr route to bypass limit for pro users - Add ratelimitPro to ratelimit.ts - Supabase profiles table created with plan_tier column --- app/api/analyze-pr/route.ts | 34 ++++++++----- app/api/analyze/route.ts | 70 +++++++------------------- app/api/chat/route.ts | 26 +++++++--- app/api/checkout/route.ts | 50 ++++++++++++++++++ app/api/webhooks/lemonsqueezy/route.ts | 53 +++++++++++++++++++ lib/ratelimit.ts | 7 +++ lib/usage.ts | 47 ++++++++++------- package-lock.json | 10 ++++ package.json | 1 + 9 files changed, 207 insertions(+), 91 deletions(-) create mode 100644 app/api/checkout/route.ts create mode 100644 app/api/webhooks/lemonsqueezy/route.ts diff --git a/app/api/analyze-pr/route.ts b/app/api/analyze-pr/route.ts index 7959fb2..7b81953 100644 --- a/app/api/analyze-pr/route.ts +++ b/app/api/analyze-pr/route.ts @@ -34,12 +34,23 @@ export async function POST(req: Request) { if (authError || !user) return NextResponse.json({ error: "Unauthorized. Please log in." }, { status: 401 }); - const isUnderLimit = await checkUsageLimit(supabase, user.id, user.email); - 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 }, - ); + // Check pro status — pro users skip the usage limit entirely + const { data: profile } = await supabase + .from("profiles") + .select("plan_tier") + .eq("id", user.id) + .single(); + + const isPro = profile?.plan_tier === "pro"; + + if (!isPro) { + const isUnderLimit = await checkUsageLimit(supabase, user.id, user.email); + if (!isUnderLimit) + return NextResponse.json( + { error: "RATE_LIMIT_REACHED", message: "Daily limit of 10 scans reached. Please upgrade to the Specialist tier to continue." }, + { status: 429 }, + ); + } // ── PR metadata ─────────────────────────────────────────────────────────── const prMetaRes = await fetch( @@ -57,14 +68,11 @@ export async function POST(req: Request) { 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) ──────────────────────────── + // ── Secret scanning + author fetch ──────────────────────────────────────── const securityAlerts: string[] = []; const filePromises = prFiles.slice(0, 15).map( @@ -82,7 +90,7 @@ export async function POST(req: Request) { const fileChangesArray = await Promise.all(filePromises); const fileChangesText = fileChangesArray.join("\n\n"); - // ── Groq prompt (unchanged) ─────────────────────────────────────────────── + // ── Groq prompt ─────────────────────────────────────────────────────────── const systemPrompt = `You are a senior software engineer conducting a strict code review on a Pull Request. Repository: ${owner}/${repo} PR Title: ${prMeta.title} @@ -127,7 +135,7 @@ 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) ────────────────────────────────── + // ── Security alert injection ────────────────────────────────────────────── if (securityAlerts.length > 0) { finalResult.riskLevel = "high"; if (!finalResult.blastRadius) finalResult.blastRadius = []; @@ -139,7 +147,7 @@ Analyze these code changes and return ONLY a valid JSON object with EXACTLY this // ── Attach raw changed files for client-side graph BFS ──────────────────── finalResult.changedFiles = changedFiles; - // ── Persist to Supabase (unchanged) ─────────────────────────────────────── + // ── Persist to Supabase ─────────────────────────────────────────────────── const { error: dbError } = await supabase.from("pr_analyses").insert({ user_id: user.id, repo_name: `${owner}/${repo}`, diff --git a/app/api/analyze/route.ts b/app/api/analyze/route.ts index 75483a9..21375e4 100644 --- a/app/api/analyze/route.ts +++ b/app/api/analyze/route.ts @@ -1,16 +1,13 @@ /** * app/api/analyze/route.ts - * - * Network layer only: Auth, Rate-limiting, DB cache, Streaming. - * All heavy lifting is delegated to `runAstPipeline`. */ export const runtime = "nodejs"; import { NextRequest, NextResponse } from "next/server"; +import { ratelimitAuth, ratelimitFree, ratelimitPro } from "@/lib/ratelimit"; import { cookies, headers } from "next/headers"; import { createServerClient } from "@supabase/ssr"; -import { ratelimitAuth, ratelimitFree } from "@/lib/ratelimit"; import { checkUsageLimit } from "@/lib/usage"; import { processAndStoreCodebase } from "@/lib/rag"; import { @@ -23,16 +20,6 @@ import { GitHubAuthError } from "@/lib/github"; const ANALYSIS_VERSION = 10; // ── SafeStream ──────────────────────────────────────────────────────────────── -/** - * Guards the ReadableStreamDefaultController against two classes of bugs - * that crash Vercel serverless functions in production: - * - * 1. `enqueue()` after `close()` — throws "Controller is already closed". - * 2. `close()` called twice — same error. - * - * The `keepAlive` interval is always cleared through this class, making it - * impossible to forget a clearInterval in any code path. - */ class SafeStream { private closed = false; @@ -42,26 +29,15 @@ class SafeStream { private readonly keepAlive: ReturnType, ) {} - /** - * Serialises `payload` as JSON and enqueues it. - * If the stream is already closed (or the client disconnected), the error is - * absorbed and `close()` is called to clean up the interval immediately. - */ send(payload: object): void { if (this.closed) return; try { this.ctrl.enqueue(this.enc.encode(JSON.stringify(payload))); } catch { - // Client disconnected or stream was cancelled — clean up now rather - // than waiting for the next keep-alive tick. this.close(); } } - /** - * Clears the keep-alive interval and closes the stream. - * Safe to call multiple times. - */ close(): void { if (this.closed) return; this.closed = true; @@ -69,11 +45,9 @@ class SafeStream { try { this.ctrl.close(); } catch { - // Already closed or errored — nothing to do. } } - /** Convenience: send an error envelope and close the stream. */ sendError(error: string, extra?: Record): void { this.send({ error, ...extra }); this.close(); @@ -83,8 +57,6 @@ class SafeStream { // ── Route Handler ───────────────────────────────────────────────────────────── export async function POST(req: NextRequest) { // ── 1. Body parsing ──────────────────────────────────────────────────────── - // `req.json()` throws on malformed JSON or an empty body. Handle it before - // opening the stream so we can return a clean 400 response. let body: { repoUrl?: unknown; isLocal?: unknown; localFiles?: unknown }; try { body = await req.json(); @@ -97,8 +69,6 @@ export async function POST(req: NextRequest) { const localFiles = Array.isArray(body.localFiles) ? body.localFiles : undefined; // ── 2. Input validation ──────────────────────────────────────────────────── - // Validate before touching Supabase / Upstash to avoid wasting quota on - // obviously bad requests. if (!isLocal && !repoUrl) { return NextResponse.json( { error: "repoUrl is required for non-local analysis." }, @@ -150,6 +120,7 @@ export async function POST(req: NextRequest) { let session = null; let authUser = null; let isAuthor = false; + let isPro = false; try { const { data: { session: s } } = await supabase.auth.getSession(); @@ -162,6 +133,15 @@ export async function POST(req: NextRequest) { isAuthor = user.email === process.env.AUTHOR_EMAIL; if (!isAuthor) { + // Check pro status + const { data: profile } = await supabase + .from("profiles") + .select("plan_tier") + .eq("id", user.id) + .single(); + + isPro = profile?.plan_tier === "pro"; + const isUnderLimit = await checkUsageLimit(supabase, user.id, user.email); if (!isUnderLimit) { return NextResponse.json( @@ -173,8 +153,6 @@ export async function POST(req: NextRequest) { } } } catch (err) { - // Auth failure is non-fatal — demote to anonymous. The subsequent - // rate-limit check will use the IP instead. console.error("[analyze] Auth setup error:", err); } @@ -183,7 +161,14 @@ export async function POST(req: NextRequest) { // ── 4. Rate limiting ─────────────────────────────────────────────────────── if (!isAuthor) { const identifier = userId ?? ip; - const limiter = userId ? ratelimitAuth : ratelimitFree; + + // Pro users get a higher Upstash bucket, anon users get IP-based free bucket + const limiter = !userId + ? ratelimitFree + : isPro + ? ratelimitPro + : ratelimitAuth; + const { success, limit, reset, remaining } = await limiter.limit(identifier); if (!success) { @@ -210,10 +195,6 @@ export async function POST(req: NextRequest) { const stream = new ReadableStream({ async start(ctrl) { - // The keep-alive ping enqueues raw whitespace directly on `ctrl`. - // This is intentional: SafeStream JSON-encodes its payloads, which - // would produce `{}` and could confuse single-value JSON parsers. - // If enqueue fails (stream closed/cancelled), the interval clears itself. const keepAlive = setInterval(() => { try { ctrl.enqueue(encoder.encode(" ")); @@ -222,8 +203,6 @@ export async function POST(req: NextRequest) { } }, 2_000); - // All further writes/closes go through SafeStream to prevent - // enqueue-after-close exceptions from crashing the function. const safe = new SafeStream(ctrl, encoder, keepAlive); try { @@ -232,8 +211,6 @@ export async function POST(req: NextRequest) { githubToken, isLocal, localFiles, - // Don't register a cache checker for local uploads — there's nothing - // to cache against (no stable commitSha). checkCache: isLocal ? undefined : async (commitSha) => { @@ -248,7 +225,6 @@ export async function POST(req: NextRequest) { .maybeSingle(); if (cacheError) { - // Supabase returns soft errors — log and treat as miss console.warn("[analyze] Cache query error:", cacheError.message); return null; } @@ -257,10 +233,7 @@ export async function POST(req: NextRequest) { }, }); - // Persist fresh analyses only — cached results are already in the DB. if (!isLocal && !result.cached) { - // DB insert is non-fatal. A failure here must NOT prevent the - // freshly-computed result from reaching the client. try { const { error: insertError } = await supabase.from("analyses").insert({ repo_url: repoUrl, @@ -277,9 +250,6 @@ export async function POST(req: NextRequest) { console.error("[analyze] DB insert threw:", err); } - // RAG indexing is also non-fatal — a failure should never block - // the response. `fileContents` is always an array from the pipeline, - // but guard anyway for safety. try { await processAndStoreCodebase( supabase, @@ -300,8 +270,6 @@ export async function POST(req: NextRequest) { if (err instanceof GitHubAuthError || message === "REQUIRE_GITHUB_AUTH") { safe.sendError("REQUIRE_GITHUB_AUTH", { message: "GitHub auth required." }); } else if (err instanceof PipelineError) { - // Typed pipeline errors include a machine-readable code so the - // client can render specific UI (e.g., "Too many files" banner). safe.sendError(message, { code: err.code }); } else { safe.sendError(message); diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index e933383..6ed5d30 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -22,7 +22,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Messages missing" }, { status: 400 }); } - const cookieStore = await cookies(); const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -32,17 +31,29 @@ export async function POST(req: NextRequest) { const { data: { user } } = await supabase.auth.getUser(); if (user) { - const isUnderLimit = await checkUsageLimit(supabase, user.id, user.email); - if (!isUnderLimit) { - return NextResponse.json({ error: "Daily limit reached." }, { status: 429 }); + // Check pro status — pro users skip the usage limit entirely + const { data: profile } = await supabase + .from("profiles") + .select("plan_tier") + .eq("id", user.id) + .single(); + + const isPro = profile?.plan_tier === "pro"; + + if (!isPro) { + const isUnderLimit = await checkUsageLimit(supabase, user.id, user.email); + if (!isUnderLimit) { + return NextResponse.json( + { error: "RATE_LIMIT_REACHED", message: "Daily limit reached." }, + { status: 429 } + ); + } } } - let contextText = "No repository context provided."; if (repoContext) { - - contextText = JSON.stringify(repoContext).substring(0, 20000); + contextText = JSON.stringify(repoContext).substring(0, 20000); } const systemPrompt = `You are a Senior Systems Architect analyzing a codebase. @@ -51,7 +62,6 @@ Use the provided JSON context about the repository to answer the user's question REPOSITORY CONTEXT (JSON): ${contextText}`; - const res = await fetch(GROQ_URL, { method: "POST", headers: { diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts new file mode 100644 index 0000000..6170236 --- /dev/null +++ b/app/api/checkout/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server' +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs' +import { cookies } from 'next/headers' + +export async function POST() { + const supabase = createRouteHandlerClient({ cookies }) + const { data: { user } } = await supabase.auth.getUser() + + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const response = await fetch('https://api.lemonsqueezy.com/v1/checkouts', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`, + 'Content-Type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + }, + body: JSON.stringify({ + data: { + type: 'checkouts', + attributes: { + checkout_data: { + email: user.email, + custom: { supabase_user_id: user.id } + }, + product_options: { + redirect_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=true`, + } + }, + relationships: { + store: { + data: { type: 'stores', id: process.env.LEMONSQUEEZY_STORE_ID } + }, + variant: { + data: { type: 'variants', id: process.env.LEMONSQUEEZY_VARIANT_ID } + } + } + } + }) + }) + + const data = await response.json() + const checkoutUrl = data.data?.attributes?.url + + if (!checkoutUrl) { + return NextResponse.json({ error: 'Failed to create checkout' }, { status: 500 }) + } + + return NextResponse.json({ url: checkoutUrl }) +} \ No newline at end of file diff --git a/app/api/webhooks/lemonsqueezy/route.ts b/app/api/webhooks/lemonsqueezy/route.ts new file mode 100644 index 0000000..16c6f89 --- /dev/null +++ b/app/api/webhooks/lemonsqueezy/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@supabase/supabase-js' +import crypto from 'crypto' + +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! +) + +function verifySignature(payload: string, signature: string): boolean { + const hmac = crypto.createHmac('sha256', process.env.LEMONSQUEEZY_WEBHOOK_SECRET!) + const digest = hmac.update(payload).digest('hex') + return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature)) +} + +export async function POST(req: NextRequest) { + const rawBody = await req.text() + const signature = req.headers.get('x-signature') ?? '' + + if (!verifySignature(rawBody, signature)) { + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }) + } + + const event = JSON.parse(rawBody) + const eventName = event.meta.event_name + const userId = event.meta.custom_data?.supabase_user_id + const status = event.data.attributes.status + + if (!userId) return NextResponse.json({ error: 'No user ID' }, { status: 400 }) + + if (eventName === 'subscription_created') { + await supabase.from('profiles').update({ + plan_tier: 'pro', + subscription_status: 'active', + }).eq('id', userId) + } + + if (eventName === 'subscription_updated') { + await supabase.from('profiles').update({ + plan_tier: status === 'active' ? 'pro' : 'free', + subscription_status: status, + }).eq('id', userId) + } + + if (eventName === 'subscription_cancelled') { + await supabase.from('profiles').update({ + plan_tier: 'free', + subscription_status: 'cancelled', + }).eq('id', userId) + } + + return NextResponse.json({ received: true }) +} \ No newline at end of file diff --git a/lib/ratelimit.ts b/lib/ratelimit.ts index ad6dd2e..89583ee 100644 --- a/lib/ratelimit.ts +++ b/lib/ratelimit.ts @@ -25,4 +25,11 @@ export const ratelimitAuth = new Ratelimit({ limiter: Ratelimit.slidingWindow(10, "24 h"), analytics: true, prefix: "@upstash/ratelimit/auth", +}); + +export const ratelimitPro = new Ratelimit({ + redis: redis, + limiter: Ratelimit.slidingWindow(100, "24 h"), + analytics: true, + prefix: "@upstash/ratelimit/pro", }); \ No newline at end of file diff --git a/lib/usage.ts b/lib/usage.ts index 399febd..65b2ec6 100644 --- a/lib/usage.ts +++ b/lib/usage.ts @@ -1,13 +1,15 @@ import { SupabaseClient } from "@supabase/supabase-js"; - const ADMIN_EMAILS = [ - "sidhantkumar431@gmail.com", + "sidhantkumar431@gmail.com", "sidhantkumar0707@gmail.com", ]; +const FREE_DAILY_LIMIT = 10; +const PRO_DAILY_LIMIT = 100; + export async function getUsageCount( - supabase: SupabaseClient, + supabase: SupabaseClient, userId: string ): Promise { const today = new Date(); @@ -15,18 +17,18 @@ export async function getUsageCount( const startOfDay = today.toISOString(); const { count: repoCount, error: repoError } = await supabase - .from('analyses') - .select('*', { count: 'exact', head: true }) - .eq('user_id', userId) - .gte('created_at', startOfDay); + .from("analyses") + .select("*", { count: "exact", head: true }) + .eq("user_id", userId) + .gte("created_at", startOfDay); if (repoError) console.error("Error checking repo usage:", repoError); const { count: prCount, error: prError } = await supabase - .from('pr_analyses') - .select('*', { count: 'exact', head: true }) - .eq('user_id', userId) - .gte('created_at', startOfDay); + .from("pr_analyses") + .select("*", { count: "exact", head: true }) + .eq("user_id", userId) + .gte("created_at", startOfDay); if (prError) console.error("Error checking PR usage:", prError); @@ -34,19 +36,26 @@ export async function getUsageCount( } export async function checkUsageLimit( - supabase: SupabaseClient, - userId: string, + supabase: SupabaseClient, + userId: string, userEmail?: string ): Promise { - - + // Admins always bypass if (userEmail && ADMIN_EMAILS.includes(userEmail)) { console.log(`🛡️ Admin bypass active for: ${userEmail}`); - return true; + return true; } - const MAX_DAILY_SCANS = 10; + // Check plan tier + const { data: profile } = await supabase + .from("profiles") + .select("plan_tier") + .eq("id", userId) + .single(); + + const isPro = profile?.plan_tier === "pro"; + const limit = isPro ? PRO_DAILY_LIMIT : FREE_DAILY_LIMIT; + const totalUsage = await getUsageCount(supabase, userId); - - return totalUsage < MAX_DAILY_SCANS; + return totalUsage < limit; } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9a59b40..4dff235 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@ai-sdk/openai": "^3.0.65", "@google/generative-ai": "^0.24.1", "@icons-pack/react-simple-icons": "^13.13.0", + "@lemonsqueezy/lemonsqueezy.js": "^4.0.0", "@supabase/ssr": "^0.10.2", "@supabase/supabase-js": "^2.105.1", "@upstash/ratelimit": "^2.0.8", @@ -1321,6 +1322,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lemonsqueezy/lemonsqueezy.js": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@lemonsqueezy/lemonsqueezy.js/-/lemonsqueezy.js-4.0.0.tgz", + "integrity": "sha512-xcY1/lDrY7CpIF98WKiL1ElsfoVhddP7FT0fw7ssOzrFqQsr44HgolKrQZxd9SywsCPn12OTOUieqDIokI3mFg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@mermaid-js/parser": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", diff --git a/package.json b/package.json index 37167ad..915945a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@ai-sdk/openai": "^3.0.65", "@google/generative-ai": "^0.24.1", "@icons-pack/react-simple-icons": "^13.13.0", + "@lemonsqueezy/lemonsqueezy.js": "^4.0.0", "@supabase/ssr": "^0.10.2", "@supabase/supabase-js": "^2.105.1", "@upstash/ratelimit": "^2.0.8", From 726d72c25dc1ea3aa21e319b853d6cd5ffcec9fc Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Sat, 30 May 2026 09:17:55 +0530 Subject: [PATCH 08/21] fix: replace auth-helpers-nextjs with supabase/ssr in checkout route --- app/api/checkout/route.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index 6170236..13bad2b 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -1,11 +1,17 @@ import { NextResponse } from 'next/server' -import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs' import { cookies } from 'next/headers' +import { createServerClient } from '@supabase/ssr' export async function POST() { - const supabase = createRouteHandlerClient({ cookies }) - const { data: { user } } = await supabase.auth.getUser() + 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 } } } + ) + const { data: { user } } = await supabase.auth.getUser() if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const response = await fetch('https://api.lemonsqueezy.com/v1/checkouts', { From ed52cb4c4180094c31dbbbcc742d5b6c2566b1d4 Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Sat, 30 May 2026 09:24:45 +0530 Subject: [PATCH 09/21] fix: use correct SUPABASE_SERVICE_KEY env var in webhook --- app/api/webhooks/lemonsqueezy/route.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/api/webhooks/lemonsqueezy/route.ts b/app/api/webhooks/lemonsqueezy/route.ts index 16c6f89..7acbc97 100644 --- a/app/api/webhooks/lemonsqueezy/route.ts +++ b/app/api/webhooks/lemonsqueezy/route.ts @@ -2,11 +2,6 @@ import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@supabase/supabase-js' import crypto from 'crypto' -const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY! -) - function verifySignature(payload: string, signature: string): boolean { const hmac = crypto.createHmac('sha256', process.env.LEMONSQUEEZY_WEBHOOK_SECRET!) const digest = hmac.update(payload).digest('hex') @@ -14,6 +9,12 @@ function verifySignature(payload: string, signature: string): boolean { } export async function POST(req: NextRequest) { + // ← moved inside the function, not at module level + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_KEY! + ) + const rawBody = await req.text() const signature = req.headers.get('x-signature') ?? '' From f88ae312d6ccc043267fe97aa091eb4fa5e36b47 Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Sat, 30 May 2026 09:31:44 +0530 Subject: [PATCH 10/21] feat: wire up Specialist tier on pricing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add handleUpgrade function calling /api/checkout - Update tier names: Student→Intern, Architect→Specialist, Surgical→Chief Surgeon - Specialist tier now live with ₹99/mo and amber styling - Add loading state and login redirect for unauthenticated users - Remove Coming Soon badge from Specialist tier --- app/pricing/page.tsx | 91 ++++++++++++++++++++++++++++++++------------ 1 file changed, 66 insertions(+), 25 deletions(-) diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index 65ee3f7..e3cdf68 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useState } from "react"; import Link from "next/link"; import { motion } from "framer-motion"; import { FaCheck, FaLock } from "react-icons/fa"; @@ -11,9 +11,33 @@ const fadeUp = { }; export default function PricingPage() { + const [loading, setLoading] = useState(false); + + async function handleUpgrade() { + setLoading(true); + try { + const res = await fetch("/api/checkout", { method: "POST" }); + const data = await res.json(); + + if (res.status === 401) { + window.location.href = "/login"; + return; + } + + if (data.url) { + window.location.href = data.url; + } else { + alert("Something went wrong. Please try again."); + } + } catch { + alert("Something went wrong. Please try again."); + } finally { + setLoading(false); + } + } + return (
- {}
@@ -38,7 +62,7 @@ export default function PricingPage() {
- {} + {/* Free Tier */}

- Student + Intern

$0 @@ -75,47 +99,61 @@ export default function PricingPage() { - {} + {/* Specialist Tier — LIVE */} -
- - Coming Soon +
+ + Early Access
-

- Architect +

+ Specialist

-
- $19 - /mo +
+ ₹99 + /mo
-
    +

    + Launch price · Lock in forever +

    +
      +
    • + 100 Autopsies / day +
    • - Private Repositories + Private + Repositories
    • - Priority Llama-3.3 Access + Advanced Model + Routing
    • - Export to Markdown + Markdown Exports +
    • +
    • + Priority Analysis + Queue
- + {loading ? "Redirecting..." : "Upgrade to Specialist →"} + - {} + {/* Chief Surgeon Tier */}

- Surgical + Chief Surgeon

@@ -146,6 +184,9 @@ export default function PricingPage() {
  • Custom AI Models
  • +
  • + Workflow Integrations +
  • +
    + ); +} + +// ── APTab (free) ────────────────────────────────────────────────────────────── function APTab({ data }: { data: RepoData }) { const result = useMemo( () => computeArticulationPoints(data.dependencyGraph ?? {}), [data.dependencyGraph], ); - const sorted = useMemo( () => Array.from(result.articulationPoints).sort( @@ -217,7 +240,6 @@ function APTab({ data }: { data: RepoData }) { return (
    - {/* Summary cards */}
    - {/* Risk callout if top file is severe */} {topDisconnects >= 3 && (
    @@ -259,7 +280,6 @@ function APTab({ data }: { data: RepoData }) {
    )} - {/* File list */}

    Files — sorted by impact @@ -268,15 +288,12 @@ function APTab({ data }: { data: RepoData }) { const disconnects = result.componentSizes.get(ap) ?? 0; const severity = getSeverityByRank(idx); const { border, dot, text, badge } = SEVERITY_STYLES[severity]; - return (

    - {/* Glowing dot */}
    -

    -

    - {/* Bridges section */} {result.bridges.length > 0 && (

    @@ -352,7 +367,7 @@ function APTab({ data }: { data: RepoData }) { ); } -// ─── Scatter Plot ───────────────────────────────────────────────────────────── +// ── ScatterPlot ─────────────────────────────────────────────────────────────── const ScatterPlot = memo(function ScatterPlot({ files, @@ -392,7 +407,6 @@ const ScatterPlot = memo(function ScatterPlot({ 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 */} Ca↑ - - {/* Dots */} {dots.map((d, i) => ( new Set(data.entryPoints), [data.entryPoints], @@ -573,7 +577,6 @@ function StabilityTab({ data }: { data: RepoData }) { return (

    - {/* Scatter plot */}

    @@ -585,7 +588,6 @@ function StabilityTab({ data }: { data: RepoData }) { />

    - {/* Legend */}
    {[ { color: "#ef4444", label: "Zone of Pain" }, @@ -606,39 +608,36 @@ function StabilityTab({ data }: { data: RepoData }) {
    - {/* 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: "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", + }, + ].map( ({ key, label, count, countColor, sub, activeBorder, activeBg }) => (
    - {/* Active tab label */}
    + {current.proGated && !isPro && ( + + Pro + + )}
    - {/* Tab content */} {activeTab === "ap" && } - {activeTab === "stability" && } - {activeTab === "dead" && } + + {activeTab === "stability" && ( + <> +
    + +
    + {!isPro && ( + + )} + + )} + + {activeTab === "dead" && ( + <> +
    + +
    + {!isPro && ( + + )} + + )}
    diff --git a/components/analyze/DoctorPanel.tsx b/components/analyze/DoctorPanel.tsx index 68d65f8..c1bccd9 100644 --- a/components/analyze/DoctorPanel.tsx +++ b/components/analyze/DoctorPanel.tsx @@ -1,3 +1,5 @@ +// components/analyze/DoctorPanel.tsx + "use client"; import { motion } from "framer-motion"; @@ -18,6 +20,8 @@ interface DoctorPanelProps { source: string | null; onOpenChat: () => void; aiGateState: "free" | "login-required" | "limit-reached" | null; + isPro: boolean; + diagnosticCount: number; } export default function DoctorPanel({ @@ -25,6 +29,8 @@ export default function DoctorPanel({ source, onOpenChat, aiGateState, + isPro, + diagnosticCount, }: DoctorPanelProps) { const repoUrl = source === "local" @@ -53,7 +59,11 @@ export default function DoctorPanel({ <>
    - + {(aiGateState === "login-required" || diff --git a/components/analyze/PrImpactTab.tsx b/components/analyze/PrImpactTab.tsx index 3fe4b1c..38d0b57 100644 --- a/components/analyze/PrImpactTab.tsx +++ b/components/analyze/PrImpactTab.tsx @@ -1,3 +1,5 @@ +// components/analyze/PrImpactTab.tsx + "use client"; import { useState, useCallback } from "react"; @@ -13,21 +15,80 @@ import { Users, ArrowLeft, GitBranch, + Lock, + Sparkles, } from "lucide-react"; import { RepoData, PRAnalysisResult } from "@/lib/types/analyze"; interface PrImpactTabProps { data: RepoData; - // Called once a PR has been analyzed successfully. - // Passes the raw changed file list up to AnalyzeContent so the - // Visualizer tab can activate pr-blast mode automatically. + isPro: boolean; onPrAnalyzed: (changedFiles: string[]) => void; - // Callback to switch to the visualizer tab — triggered by "View on Graph" onViewOnGraph: () => void; } +function ProGate({ onUpgrade }: { onUpgrade: () => void }) { + return ( + + {/* Blurred ghost preview */} +
    +
    +
    +
    +
    +
    +
    + {[...Array(4)].map((_, i) => ( +
    + ))} +
    +
    + {[...Array(3)].map((_, i) => ( +
    + ))} +
    +
    + + {/* Overlay */} +
    +
    + +
    +
    +

    Pro Feature

    +

    + PR Impact Analysis is only available on the Pro plan. Upgrade to + unlock blast radius, breaking dependencies, and reviewer + suggestions. +

    +
    + +
    +
    + + ); +} + export default function PrImpactTab({ data, + isPro, onPrAnalyzed, onViewOnGraph, }: PrImpactTabProps) { @@ -35,9 +96,16 @@ export default function PrImpactTab({ const [isAnalyzingPR, setIsAnalyzingPR] = useState(false); const [prResult, setPrResult] = useState(null); const [prError, setPrError] = useState(null); + const [showGate, setShowGate] = useState(false); const handleAnalyzePR = useCallback(async () => { if (!prInput.trim()) return; + + if (!isPro) { + setShowGate(true); + return; + } + setIsAnalyzingPR(true); setPrError(null); @@ -59,12 +127,13 @@ export default function PrImpactTab({ }), }); const json: PRAnalysisResult = await res.json(); - if (!res.ok) throw new Error((json as unknown as { error: string }).error || "Failed to analyze PR"); + if (!res.ok) + throw new Error( + (json as unknown as { error: string }).error || + "Failed to analyze PR", + ); setPrResult(json); - - // 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); } @@ -73,7 +142,11 @@ export default function PrImpactTab({ } finally { setIsAnalyzingPR(false); } - }, [prInput, data.owner, data.repo, onPrAnalyzed]); + }, [prInput, isPro, data.owner, data.repo, onPrAnalyzed]); + + const handleUpgrade = useCallback(() => { + window.open("/pricing", "_blank"); + }, []); return (
    + {prError && (

    {prError}

    )} + + {showGate && !isPro && } ) : ( - {/* PR header */}
    @@ -185,10 +263,12 @@ export default function PrImpactTab({
    - {/* View on Graph CTA — shown only when changedFiles were returned */} {prResult.changedFiles && prResult.changedFiles.length > 0 && (
    @@ -216,9 +296,11 @@ export default function PrImpactTab({ )} - {/* Blast radius + architectural changes */}
    @@ -290,49 +372,55 @@ export default function PrImpactTab({
    - {/* 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} -

    + {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 */}
    -
    -
    -
    - +
    +
    +
    + +
    +

    + CodeAutopsy Diagnostic Engine{" "} + + v1.0 + +

    -

    - CodeAutopsy Diagnostic Engine v1.0 -

    + + {/* Usage counter — free users only */} + {!isPro && ( +
    = FREE_DIAGNOSTIC_LIMIT - 1 + ? "border-amber-500/30 bg-amber-500/10 text-amber-400" + : "border-white/10 bg-white/5 text-slate-400" + }`} + > + {usedCount} + / + {FREE_DIAGNOSTIC_LIMIT} + + today + +
    + )}
    +

    - [ SYSTEM STATUS: ONLINE ] Powered by graph traversal + AI reasoning + [ SYSTEM STATUS:{" "} + + ONLINE + {" "} + ] + Powered by graph traversal + AI reasoning

    - {error && (
    - +

    Diagnosis Failed

    {error}

    - +
    )} @@ -93,27 +166,67 @@ export default function DebugInterface({ repoUrl }: { initialChart?: string; rep {isLoading && (
    - 🔍 DEPTH_SEARCH: ACTIVE + + 🔍 DEPTH_SEARCH:{" "} + ACTIVE + | NODES_SCANNED: INCREASING...
    - ANALYZING_FLOW... + + {" "} + ANALYZING_FLOW... +
    )} -
    - {isLoading ? ( - - ) : result ? ( - - ) : ( - - )} -
    + {/* Paywall state — limit hit */} + {hitLimit && !isLoading && !result && ( + +
    + +
    +
    +

    + Daily limit reached +

    +

    + Free users get {FREE_DIAGNOSTIC_LIMIT} diagnoses per day. + Upgrade to Pro for unlimited diagnostics. +

    +
    + +

    + Resets at midnight UTC +

    +
    + )} + {!hitLimit && ( +
    + {isLoading ? ( + + ) : result ? ( + + ) : ( + + )} +
    + )}
    ); -} \ No newline at end of file +} diff --git a/lib/usage.ts b/lib/usage.ts index 613ad7e..0fd23ed 100644 --- a/lib/usage.ts +++ b/lib/usage.ts @@ -1,3 +1,5 @@ +// lib/usage.ts + import { SupabaseClient } from "@supabase/supabase-js"; const ADMIN_EMAILS = [ @@ -5,8 +7,10 @@ const ADMIN_EMAILS = [ "sidhantkumar0707@gmail.com", ]; -const FREE_DAILY_LIMIT = 5; -const PRO_DAILY_LIMIT = 100; +const FREE_DAILY_LIMIT = 5; +const PRO_DAILY_LIMIT = 100; +const FREE_DIAGNOSTIC_LIMIT = 3; +const PRO_DIAGNOSTIC_LIMIT = Infinity; export async function getUsageCount( supabase: SupabaseClient, @@ -40,22 +44,49 @@ export async function checkUsageLimit( userId: string, userEmail?: string ): Promise { - // Admins always bypass - if (userEmail && ADMIN_EMAILS.includes(userEmail)) { - console.log(`🛡️ Admin bypass active for: ${userEmail}`); - return true; - } + if (userEmail && ADMIN_EMAILS.includes(userEmail)) return true; - // Check plan tier const { data: profile } = await supabase .from("profiles") .select("plan_tier") .eq("id", userId) .single(); - const isPro = profile?.plan_tier === "pro"; - const limit = isPro ? PRO_DAILY_LIMIT : FREE_DAILY_LIMIT; + const isPro = profile?.plan_tier === "pro"; + const limit = isPro ? PRO_DAILY_LIMIT : FREE_DAILY_LIMIT; + const total = await getUsageCount(supabase, userId); + return total < limit; +} + +export async function getDiagnosticCount( + supabase: SupabaseClient, + userId: string +): Promise { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const startOfDay = today.toISOString(); + + const { count, error } = await supabase + .from("diagnostic_runs") + .select("*", { count: "exact", head: true }) + .eq("user_id", userId) + .gte("created_at", startOfDay); + + if (error) console.error("Error checking diagnostic usage:", error); + return count || 0; +} + +export async function checkDiagnosticLimit( + supabase: SupabaseClient, + userId: string, + userEmail?: string, + isPro?: boolean +): Promise<{ allowed: boolean; used: number; limit: number }> { + if (userEmail && ADMIN_EMAILS.includes(userEmail)) { + return { allowed: true, used: 0, limit: PRO_DIAGNOSTIC_LIMIT }; + } - const totalUsage = await getUsageCount(supabase, userId); - return totalUsage < limit; + const limit = isPro ? PRO_DIAGNOSTIC_LIMIT : FREE_DIAGNOSTIC_LIMIT; + const used = await getDiagnosticCount(supabase, userId); + return { allowed: used < limit, used, limit }; } \ No newline at end of file From 679503e535d308da5d5776ad17bba682a7d7a1c5 Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Sat, 30 May 2026 18:47:42 +0530 Subject: [PATCH 14/21] fix: add missing isPro and diagnosticCount props to DebugInterface in view page --- app/view/[...slug]/page.tsx | 217 +++++++++++++++++++----------------- 1 file changed, 116 insertions(+), 101 deletions(-) diff --git a/app/view/[...slug]/page.tsx b/app/view/[...slug]/page.tsx index 6c77f82..3e61a61 100644 --- a/app/view/[...slug]/page.tsx +++ b/app/view/[...slug]/page.tsx @@ -9,7 +9,6 @@ type Props = { params: Promise<{ slug: string[] }>; }; - interface TechStackItem { name: string; purpose: string; @@ -42,7 +41,6 @@ export async function generateMetadata({ params }: Props): Promise { export default async function ViewAutopsyPage({ params }: Props) { const { slug } = await params; - if (!slug || slug.length !== 2) notFound(); const owner = slug[0]; @@ -50,7 +48,6 @@ export default async function ViewAutopsyPage({ params }: Props) { const repoName = `${owner}/${repo}`.toLowerCase(); - const { data, error } = await supabase .from("analyses") .select("result_json") @@ -70,9 +67,7 @@ export default async function ViewAutopsyPage({ params }: Props) {

    The repository{" "} - - {repoName} - {" "} + {repoName}{" "} has not been dissected yet.

    @@ -92,102 +87,122 @@ export default async function ViewAutopsyPage({ params }: Props) {
    - {} -
    - -
    - - ← Back to Engine - - -
    - - Archival Report - - -

    - {result?.repo || repo} -

    - -

    - Target: {result?.owner || owner} -

    -
    - -
    -
    -
    - -

    - {result?.analysis?.architecture_pattern || "Unknown Architecture"} -

    -
    - -

    - "{result?.analysis?.what_it_does || "No description available."}" -

    -
    - -
    -

    Execution Flow

    - -
      - {result?.analysis?.execution_flow?.map((step: string, i: number) => ( -
    • - {i + 1}. - {step} -
    • - ))} -
    -
    - -
    -

    Tech Stack

    - -
    - {result?.analysis?.tech_stack?.map((tech: TechStackItem, i: number) => ( - -
    -

    {tech.name}

    -

    {tech.purpose}

    -
    -
    - ))} -
    -
    - -
    -

    Key Modules

    - -
    - {result?.analysis?.key_modules?.map((mod: KeyModuleItem, i: number) => ( -
    -

    {mod.file}

    -

    {mod.why_it_exists}

    -
    - ))} -
    -
    - -
    - -
    - {} -
    +
    + +
    + + ← Back to Engine + + +
    + + Archival Report + + +

    + {result?.repo || repo} +

    + +

    + Target: {result?.owner || owner} +

    +
    + +
    +
    +
    + +

    + {result?.analysis?.architecture_pattern || + "Unknown Architecture"} +

    +
    + +

    + " + {result?.analysis?.what_it_does || "No description available."} + " +

    +
    + +
    +

    + Execution Flow +

    + +
      + {result?.analysis?.execution_flow?.map( + (step: string, i: number) => ( +
    • + {i + 1}. + {step} +
    • + ), + )} +
    +
    + +
    +

    Tech Stack

    + +
    + {result?.analysis?.tech_stack?.map( + (tech: TechStackItem, i: number) => ( + +
    +

    + {tech.name} +

    +

    {tech.purpose}

    +
    +
    + ), + )} +
    +
    + +
    +

    Key Modules

    + +
    + {result?.analysis?.key_modules?.map( + (mod: KeyModuleItem, i: number) => ( +
    +

    {mod.file}

    +

    + {mod.why_it_exists} +

    +
    + ), + )} +
    +
    + +
    + +
    + + {} +
    ); -} \ No newline at end of file +} From a4d341b036969831ac291f84b5bf4253b794d7e2 Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Sat, 30 May 2026 19:03:21 +0530 Subject: [PATCH 15/21] feat: gate Auto-Patch behind pro tier in RiskDashboard --- components/RiskDashboard.tsx | 87 +++++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 27 deletions(-) diff --git a/components/RiskDashboard.tsx b/components/RiskDashboard.tsx index 24df438..5d8e32a 100644 --- a/components/RiskDashboard.tsx +++ b/components/RiskDashboard.tsx @@ -9,6 +9,8 @@ import { Check, Target, AlertCircle, + Lock, + Sparkles, } from "lucide-react"; interface CoverageGap { @@ -25,9 +27,9 @@ interface FileContent { interface RiskDashboardProps { coverageGaps: CoverageGap[]; fileContents: FileContent[]; + isPro: boolean; } -// --- PRINCIPAL UPGRADE: Staggered entry animations --- const containerVariants: Variants = { hidden: { opacity: 0 }, show: { @@ -44,24 +46,24 @@ const itemVariants: Variants = { transition: { type: "spring", stiffness: 400, damping: 30 }, }, }; + export default function RiskDashboard({ coverageGaps, fileContents, + isPro, }: RiskDashboardProps) { const [generating, setGenerating] = useState(null); const [generatedTests, setGeneratedTests] = useState>( {}, ); const [copied, setCopied] = useState(null); - - // --- PRINCIPAL UPGRADE: Inline error state instead of native alerts --- const [errors, setErrors] = useState>({}); - // --- PRINCIPAL UPGRADE: Memoized handlers --- const handleGenerateTest = useCallback( async (fileName: string) => { + if (!isPro) return; setGenerating(fileName); - setErrors((prev) => ({ ...prev, [fileName]: "" })); // Clear previous errors + setErrors((prev) => ({ ...prev, [fileName]: "" })); try { const fileData = fileContents.find((f) => f.path === fileName); @@ -84,7 +86,6 @@ export default function RiskDashboard({ setGeneratedTests((prev) => ({ ...prev, [fileName]: data.test_code })); } catch (error) { console.error(error); - // Beautiful inline error handling setErrors((prev) => ({ ...prev, [fileName]: "AI Generation failed. Please try again.", @@ -93,7 +94,7 @@ export default function RiskDashboard({ setGenerating(null); } }, - [fileContents], + [fileContents, isPro], ); const handleCopy = useCallback((fileName: string, code: string) => { @@ -133,6 +134,30 @@ export default function RiskDashboard({ Critical architectural nodes lacking unit tests. Generate production-ready coverage instantly.

    + + {/* Pro banner for free users */} + {!isPro && ( + + +

    + + Auto-Patch is a Pro feature. + {" "} + Upgrade to generate AI test coverage for your riskiest files. +

    + +
    + )}
    - handleGenerateTest(gap.file)} - disabled={generating === gap.file} - className="w-full sm:w-auto px-5 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-xs font-bold text-slate-200 transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group/btn" - > - {generating === gap.file ? ( - <> -
    - Analyzing... - - ) : ( - <> - - Auto-Patch - - )} - + {isPro ? ( + handleGenerateTest(gap.file)} + disabled={generating === gap.file} + className="w-full sm:w-auto px-5 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-xs font-bold text-slate-200 transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group/btn" + > + {generating === gap.file ? ( + <> +
    + Analyzing... + + ) : ( + <> + + Auto-Patch + + )} + + ) : ( + + )} - {/* --- PRINCIPAL UPGRADE: Beautiful inline error message --- */} {errors[gap.file] && (
    - {/* --- PRINCIPAL UPGRADE: Smooth accordion terminal opening --- */} {generatedTests[gap.file] && ( Date: Sat, 30 May 2026 19:13:31 +0530 Subject: [PATCH 16/21] fix: replace amber pro accents with project-native slate/white palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amber color tokens felt out of place against the dark technical aesthetic. Replaced all amber instances in RiskDashboard with the existing slate/white/transparent vocabulary used throughout the component. - Pro banner: amber border/bg → white/5 glass surface - Upgrade button: amber fills → slate-300/white hover - Locked Auto-Patch button: amber tint → desaturated slate-500 - FileCode icon: amber-400 → slate-300 with white hover - Traffic light middle dot: amber-500 → slate-500 --- components/RiskDashboard.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/components/RiskDashboard.tsx b/components/RiskDashboard.tsx index 5d8e32a..126ee1e 100644 --- a/components/RiskDashboard.tsx +++ b/components/RiskDashboard.tsx @@ -140,18 +140,18 @@ export default function RiskDashboard({ - -

    - + +

    + Auto-Patch is a Pro feature. {" "} Upgrade to generate AI test coverage for your riskiest files.

    )} diff --git a/components/analyze/ArchInsightsPanel.tsx b/components/analyze/ArchInsightsPanel.tsx index 1c981d8..9faf241 100644 --- a/components/analyze/ArchInsightsPanel.tsx +++ b/components/analyze/ArchInsightsPanel.tsx @@ -13,7 +13,6 @@ import { CheckCircle2, AlertTriangle, Lock, - Sparkles, } from "lucide-react"; import { RepoData } from "@/lib/types/analyze"; import { computeArticulationPoints } from "@/lib/algorithms/articulationPoints"; @@ -174,7 +173,7 @@ function StatCard({ ); } -// ── Pro gate overlay — sits on top of blurred content ──────────────────────── +// ── Pro gate overlay ────────────────────────────────────────────────────────── function ProTabGate({ label, @@ -184,25 +183,33 @@ function ProTabGate({ onUpgrade: () => void; }) { return ( -
    -
    - +
    +
    +
    -
    -

    - {label} is a Pro feature +

    +

    + {label} requires Pro

    -

    - Upgrade to Pro to access stability analysis, instability distribution, - and architectural health metrics. +

    + Stability analysis, instability distribution, and architectural health + metrics.

    ); @@ -701,12 +708,10 @@ function StabilityTab({ data }: { data: RepoData }) { )}
    -
    +
    @@ -954,7 +959,7 @@ export default function ArchInsightsPanel({ {tab.label} {isLocked && ( - + )} @@ -973,7 +978,7 @@ export default function ArchInsightsPanel({ {current.proGated && !isPro && ( - + Pro )} From a0b35d4b2dc7bd60954ea0c57827747ab2cccba4 Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Sat, 30 May 2026 23:53:54 +0530 Subject: [PATCH 18/21] refactor(debug): optimise engine pipeline - fix: dynamic analysis_version query instead of hardcoded v3 - fix: separate visited sets for upstream/downstream BFS traversal - fix: node ID collision in mermaid sanitizer (path-aware) - fix: opacity replaced with stroke-width in mermaid styles - fix: JSON.parse wrapped in try/catch in cache and groq-debug - fix: requiresRuntimeCheck narrowed to specific runtime patterns - fix: at async removed from framework noise filter in stack-parser - fix: findMatchingFile disambiguates same-name files by path score - perf: parallel file fetching via Promise.allSettled - perf: reverse graph memoized via WeakMap - perf: BFS queue uses head pointer instead of array.shift() - refactor: rename gemini-debug to groq-debug with backward alias - refactor: confidence removed from AI prompt, heuristics only - remove: dead code generateErrorGraph and extractAllCrashNodes - add: cache invalidation via invalidateRepoCache() - add: server-side rate limiting with 429 response --- app/api/debug/analyze/route.ts | 200 ++++++++++++++++++++++--------- lib/debug/cache.ts | 69 +++++++++-- lib/debug/file-fetcher.ts | 47 +++++--- lib/debug/gemini-debug.ts | 109 ----------------- lib/debug/graph-traversal.ts | 71 ++++++----- lib/debug/groq-debug.ts | 139 +++++++++++++++++++++ lib/debug/heuristics.ts | 82 ++++++------- lib/debug/mermaid-highlighter.ts | 93 ++++---------- lib/debug/stack-parser.ts | 115 +++++++++--------- 9 files changed, 535 insertions(+), 390 deletions(-) delete mode 100644 lib/debug/gemini-debug.ts create mode 100644 lib/debug/groq-debug.ts diff --git a/app/api/debug/analyze/route.ts b/app/api/debug/analyze/route.ts index cb4424c..ea7872c 100644 --- a/app/api/debug/analyze/route.ts +++ b/app/api/debug/analyze/route.ts @@ -1,18 +1,54 @@ +// app/api/debug/analyze/route.ts + import { NextRequest, NextResponse } from "next/server"; import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; import { parseStackTrace, extractErrorInfo } from "@/lib/debug/stack-parser"; import { traverseFromCrash } from "@/lib/debug/graph-traversal"; -import { analyzeDebugWithGemini } from "@/lib/debug/gemini-debug"; +import { analyzeDebugWithGemini } from "@/lib/debug/groq-debug"; import { highlightDebugPath } from "@/lib/debug/mermaid-highlighter"; import { fetchMissingFiles, extractLineContext } from "@/lib/debug/file-fetcher"; import { getCachedDebug, cacheDebug, hashStackTrace } from "@/lib/debug/cache"; import { applyDebugHeuristics, calculateConfidence, requiresRuntimeCheck } from "@/lib/debug/heuristics"; import { parseRepoUrl } from "@/lib/github"; +// ─── Constants ─────────────────────────────────────────────────────────────── + +const DAILY_LIMIT = 3; +const MAX_TRAVERSAL_NODES: Record = { + TypeError: 5, + ReferenceError: 5, + SyntaxError: 3, + RangeError: 4, + default: 10, +}; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function getMaxNodes(errorType: string): number { + return MAX_TRAVERSAL_NODES[errorType] ?? MAX_TRAVERSAL_NODES.default; +} + +function stripComments(content: string): string { + return content + .split("\n") + .map((line) => line.trim()) + .filter( + (line) => + line.length > 0 && + !line.startsWith("//") && + !line.startsWith("/*") && + !line.startsWith("*") + ) + .join("\n"); +} + +// ─── Route Handler ─────────────────────────────────────────────────────────── + export async function POST(req: NextRequest) { try { const cookieStore = await cookies(); + const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, @@ -25,55 +61,100 @@ export async function POST(req: NextRequest) { } ); - let userId = undefined; - let providerToken = undefined; + // ── 1. Auth ─────────────────────────────────────────────────────────────── + let userId: string | undefined; + let providerToken: string | undefined; try { - const { data: { user } } = await supabase.auth.getUser(); + const { + data: { user }, + } = await supabase.auth.getUser(); userId = user?.id; - - const { data: { session } } = await supabase.auth.getSession(); + + const { + data: { session }, + } = await supabase.auth.getSession(); providerToken = session?.provider_token ?? undefined; - } catch { + + if (!providerToken) { + console.warn( + "[debug/analyze] No provider token — GitHub file fetching will use unauthenticated API (60 req/hr limit)." + ); + } + } catch (authErr) { + console.warn( + "[debug/analyze] Auth fetch failed, continuing as guest:", + authErr + ); } + // ── 2. Input validation ─────────────────────────────────────────────────── const body = await req.json(); - const { repoUrl, stackTrace } = body; if (!repoUrl || !stackTrace || stackTrace.trim() === "") { return NextResponse.json( - { error: `Missing data. Received repoUrl: ${!!repoUrl}, stackTrace: ${!!stackTrace}` }, + { + error: `Missing data. Received repoUrl: ${!!repoUrl}, stackTrace: ${!!stackTrace}`, + }, { status: 400 } ); } + // ── 3. Validate repo URL before any DB/cache hits ───────────────────────── + const parsed = parseRepoUrl(repoUrl); + if (!parsed) { + return NextResponse.json( + { error: "Invalid GitHub URL" }, + { status: 400 } + ); + } + + const { owner, repo } = parsed; + + // ── 4. Rate limiting ────────────────────────────────────────────────────── + if (userId) { + const today = new Date().toISOString().split("T")[0]; + + const { count, error: countError } = await supabase + .from("debug_analyses") + .select("*", { count: "exact", head: true }) + .eq("user_id", userId) + .gte("created_at", `${today}T00:00:00.000Z`); + + if (countError) { + console.error("[debug/analyze] Rate limit check failed:", countError); + } else if ((count ?? 0) >= DAILY_LIMIT) { + return NextResponse.json( + { error: `Daily limit of ${DAILY_LIMIT} diagnoses reached. Try again tomorrow.` }, + { status: 429 } + ); + } + } + + // ── 5. Extract error info ───────────────────────────────────────────────── const { error_type, error_message } = extractErrorInfo(stackTrace); + // ── 6. Cache check ──────────────────────────────────────────────────────── const traceHash = hashStackTrace(stackTrace); const cached = await getCachedDebug(repoUrl, traceHash); - + if (cached) { return NextResponse.json({ ...cached, cached: true }); } - const parsed = parseRepoUrl(repoUrl); - if (!parsed) { - return NextResponse.json({ error: "Invalid GitHub URL" }, { status: 400 }); - } - - const { owner, repo } = parsed; - + // ── 7. Fetch latest analysis from DB (dynamic version) ──────────────────── const { data: analysis, error: dbError } = await supabase .from("analyses") .select("*") .eq("repo_url", repoUrl) - .eq("analysis_version", 3) + .order("analysis_version", { ascending: false }) .order("created_at", { ascending: false }) .limit(1) .maybeSingle(); if (dbError) { + console.error("[debug/analyze] DB fetch failed:", dbError); return NextResponse.json( { error: "Failed to fetch analysis from database" }, { status: 500 } @@ -90,28 +171,30 @@ export async function POST(req: NextRequest) { const { result_json } = analysis; const { dependencyGraph, fanIn, mermaidDiagram } = result_json; + // ── 8. Parse stack trace ────────────────────────────────────────────────── const allFiles = Object.keys(dependencyGraph); - const crashNode = parseStackTrace(stackTrace, allFiles); if (!crashNode) { return NextResponse.json( - { error: "Could not extract crash location from stack trace. Make sure the file exists in the analyzed repository." }, + { + error: + "Could not extract crash location from stack trace. Make sure the file exists in the analyzed repository.", + }, { status: 400 } ); } - let traversalPath = traverseFromCrash( - crashNode.file, - dependencyGraph, - fanIn - ); - + // ── 9. Graph traversal + heuristics ─────────────────────────────────────── + let traversalPath = traverseFromCrash(crashNode.file, dependencyGraph, fanIn); traversalPath = applyDebugHeuristics(traversalPath, error_type); - const fileContents = result_json.fileContents || []; + // ── 10. Fetch file contents ─────────────────────────────────────────────── + const fileContents: { path: string; content: string }[] = + result_json.fileContents || []; + const existingContents = new Map( - fileContents.map((f: { path: string; content: string }) => [f.path, f.content]) + fileContents.map((f) => [f.path, f.content]) ); const allContents = await fetchMissingFiles( @@ -122,41 +205,33 @@ export async function POST(req: NextRequest) { providerToken ); - const relevantCode = traversalPath.slice(0, 10).map((node) => { + // ── 11. Build relevant code snapshot ───────────────────────────────────── + const maxNodes = getMaxNodes(error_type); + + const relevantCode = traversalPath.slice(0, maxNodes).map((node) => { const content = allContents.get(node.file) || ""; - const is_crash_site = node.file === crashNode.file; - - let finalContent = ""; - if (is_crash_site) { - finalContent = extractLineContext(content, crashNode.line).snippet; - } else { - finalContent = content - .split("\n") - .map(line => line.trim()) - .filter(line => - line.length > 0 && - !line.startsWith("//") && - !line.startsWith("/*") && - !line.startsWith("*") - ) - .slice(0, 300) - .join("\n"); - } + const isCrashSite = node.file === crashNode.file; - let line_context = undefined; - if (is_crash_site) { - const ctx = extractLineContext(content, crashNode.line); - line_context = { start: ctx.start, end: ctx.end }; - } + const finalContent = isCrashSite + ? extractLineContext(content, crashNode.line).snippet + : stripComments(content).split("\n").slice(0, 300).join("\n"); + + const lineContext = isCrashSite + ? (() => { + const ctx = extractLineContext(content, crashNode.line); + return { start: ctx.start, end: ctx.end }; + })() + : undefined; return { file: node.file, content: finalContent, - is_crash_site, - line_context, + is_crash_site: isCrashSite, + line_context: lineContext, }; }); + // ── 12. AI analysis ─────────────────────────────────────────────────────── const debugInput = { error_type, error_message, @@ -172,10 +247,14 @@ export async function POST(req: NextRequest) { const debugResult = await analyzeDebugWithGemini(debugInput); + // ── 13. Post-processing ─────────────────────────────────────────────────── const confidence = calculateConfidence(traversalPath); const requires_runtime = requiresRuntimeCheck(error_type, error_message); - const suspectedRootCause = traversalPath.find(n => n.relationship === "upstream")?.file; + const suspectedRootCause = traversalPath.find( + (n) => n.relationship === "upstream" + )?.file; + const highlightedMermaid = highlightDebugPath( mermaidDiagram, crashNode.file, @@ -183,7 +262,8 @@ export async function POST(req: NextRequest) { suspectedRootCause ); - const { data: stored } = await supabase + // ── 14. Persist to DB ───────────────────────────────────────────────────── + const { data: stored, error: insertError } = await supabase .from("debug_analyses") .insert({ analysis_id: analysis.id, @@ -201,6 +281,11 @@ export async function POST(req: NextRequest) { .select() .maybeSingle(); + if (insertError) { + console.error("[debug/analyze] Failed to store debug result:", insertError); + } + + // ── 15. Cache + respond ─────────────────────────────────────────────────── const finalResult = { debug_id: stored?.id || "unknown", crash_node: crashNode, @@ -208,7 +293,7 @@ export async function POST(req: NextRequest) { root_cause_hypothesis: debugResult.root_cause_hypothesis, fix_suggestions: debugResult.fix_suggestions, verification_steps: debugResult.verification_steps, - confidence: confidence, + confidence, requires_runtime_check: requires_runtime, highlighted_mermaid: highlightedMermaid, }; @@ -218,6 +303,7 @@ export async function POST(req: NextRequest) { return NextResponse.json(finalResult); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; + console.error("[debug/analyze] Unhandled error:", message); return NextResponse.json({ error: message }, { status: 500 }); } } \ No newline at end of file diff --git a/lib/debug/cache.ts b/lib/debug/cache.ts index f011c62..4e6f88b 100644 --- a/lib/debug/cache.ts +++ b/lib/debug/cache.ts @@ -1,37 +1,92 @@ +// lib/debug/cache.ts import crypto from "crypto"; import { redis } from "@/lib/ratelimit"; import { DebugResult } from "./types"; +const CACHE_TTL_SECONDS = 3600 * 24; // 24 hours + +// ── Key builder ───────────────────────────────────────────────────────────── +// Centralised so all cache operations use identical key format. +function buildCacheKey(repoUrl: string, stackTraceHash: string): string { + return `debug:${repoUrl}:${stackTraceHash}`; +} + +// ── Read ───────────────────────────────────────────────────────────────────── export async function getCachedDebug( repoUrl: string, stackTraceHash: string ): Promise { try { - const key = `debug:${repoUrl}:${stackTraceHash}`; + const key = buildCacheKey(repoUrl, stackTraceHash); const cached = await redis.get(key); - return cached ? JSON.parse(cached as string) : null; + + if (!cached) return null; + + // Previously: no try/catch — corrupted Redis data crashed the request. + try { + return JSON.parse(cached as string) as DebugResult; + } catch { + console.warn( + `[cache] Corrupted cache entry for key ${key} — discarding.` + ); + await redis.del(key); + return null; + } } catch (err) { - console.error("Cache fetch error:", err); + console.error("[cache] Cache fetch error:", err); return null; } } +// ── Write ───────────────────────────────────────────────────────────────────── export async function cacheDebug( repoUrl: string, stackTraceHash: string, result: DebugResult ): Promise { try { - const key = `debug:${repoUrl}:${stackTraceHash}`; - await redis.set(key, JSON.stringify(result), { ex: 3600 * 24 }); + const key = buildCacheKey(repoUrl, stackTraceHash); + await redis.set(key, JSON.stringify(result), { ex: CACHE_TTL_SECONDS }); } catch (err) { - console.error("Cache write error:", err); + console.error("[cache] Cache write error:", err); } } +// ── Invalidate ──────────────────────────────────────────────────────────────── +// Call this whenever a repo is re-analyzed so stale diagnosis +// results don't persist for 24 hours after new analysis is stored. +export async function invalidateRepoCache(repoUrl: string): Promise { + try { + // Redis SCAN to find all keys matching this repo + let cursor = "0"; + const pattern = `debug:${repoUrl}:*`; + + do { + const [nextCursor, keys]: [string, string[]] = await (redis as unknown as { scan: (...args: unknown[]) => Promise<[string, string[]]> }).scan( + cursor, + "MATCH", + pattern, + "COUNT", + 100 + ); + cursor = nextCursor; + + if (keys.length > 0) { + await redis.del(...keys); + console.log( + `[cache] Invalidated ${keys.length} cache entries for ${repoUrl}` + ); + } + } while (cursor !== "0"); + } catch (err) { + console.error("[cache] Cache invalidation error:", err); + } +} +// ── Hash ────────────────────────────────────────────────────────────────────── export function hashStackTrace(trace: string): string { - + // 16 hex chars = 64 bits of SHA-256 — sufficient for cache keys + // where collision risk is acceptable (worst case: a cache miss). return crypto.createHash("sha256").update(trace).digest("hex").slice(0, 16); } \ No newline at end of file diff --git a/lib/debug/file-fetcher.ts b/lib/debug/file-fetcher.ts index da61469..5ce5c88 100644 --- a/lib/debug/file-fetcher.ts +++ b/lib/debug/file-fetcher.ts @@ -1,8 +1,12 @@ - +// lib/debug/file-fetcher.ts import { TraversalNode } from "./types"; import { fetchFileContent } from "@/lib/github"; +// Consistent line limit used for both GitHub-fetched and +// existing file content — prevents the 100 vs 300 mismatch. +const MAX_LINES_PER_FILE = 300; + export async function fetchMissingFiles( traversalPath: TraversalNode[], existingContents: Map, @@ -12,31 +16,42 @@ export async function fetchMissingFiles( ): Promise> { const missing = traversalPath .filter((n) => !existingContents.has(n.file)) - .slice(0, 10); + .slice(0, 10); const fetched = new Map(existingContents); - for (const node of missing) { - try { - const content = await fetchFileContent( - owner, - repo, - node.file, - providerToken + if (missing.length === 0) return fetched; + + // ── Parallel fetch ────────────────────────────────────────────────────────── + // Previously sequential — each file waited for the prior one. + // Promise.allSettled fetches all missing files concurrently and + // handles individual failures without aborting the entire batch. + const results = await Promise.allSettled( + missing.map((node) => + fetchFileContent(owner, repo, node.file, providerToken).then( + (content) => ({ file: node.file, content }) + ) + ) + ); + + for (const result of results) { + if (result.status === "fulfilled") { + const { file, content } = result.value; + fetched.set( + file, + content.split("\n").slice(0, MAX_LINES_PER_FILE).join("\n") ); - - fetched.set(node.file, content.split("\n").slice(0, 100).join("\n")); - } catch (err) { - console.warn(`Failed to fetch ${node.file}:`, err); - - fetched.set(node.file, "// Content not available"); + } else { + // Find the node that failed to log it properly + const failedFile = missing[results.indexOf(result)]?.file ?? "unknown"; + console.warn(`[file-fetcher] Failed to fetch ${failedFile}:`, result.reason); + fetched.set(failedFile, "// Content not available"); } } return fetched; } - export function extractLineContext( content: string, crashLine: number, diff --git a/lib/debug/gemini-debug.ts b/lib/debug/gemini-debug.ts deleted file mode 100644 index 18d2f66..0000000 --- a/lib/debug/gemini-debug.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { DebugContext, DebugResult } from "./types"; - -export async function analyzeDebugWithGemini( - input: DebugContext -): Promise { - - if (process.env.USE_GROQ_FOR_ANALYSIS !== 'true') { - throw new Error("Gemini is currently disabled. Please use Groq. Check Vercel Env Variables."); - } - - const apiKey = process.env.GROQ_API_KEY; - if (!apiKey) { - throw new Error("Missing GROQ_API_KEY environment variable"); - } - - const url = "https://api.groq.com/openai/v1/chat/completions"; - - const codeSnippets = input.relevant_code - .map((file) => { - const marker = file.is_crash_site ? "🔴 CRASH SITE" : ""; - return `=== ${file.file} ${marker} ===\n${file.content}`; - }) - .join("\n\n"); - - const traversalSummary = input.traversal_path - .slice(0, 10) - .map( - (n) => - `- ${n.file} (distance: ${n.distance}, fan-in: ${n.fan_in}, ${n.relationship})` - ) - .join("\n"); - - const systemPrompt = `You are a debugging assistant analyzing a crash in a codebase. - -CRITICAL CONSTRAINTS: -- You ONLY have access to static code dependencies, NOT runtime state. -- If the error suggests missing data (undefined, null), your FIRST suggestion must involve checking: - 1. Database queries - 2. API responses - 3. Environment variables (.env) - 4. Authentication state - -ERROR DETAILS: -Type: ${input.error_type} -Message: ${input.error_message} -Crash Location: ${input.crash_location.file}:${input.crash_location.line} -${input.crash_location.function ? `Function: ${input.crash_location.function}` : ""} - -DEPENDENCY ANALYSIS: -Files analyzed (ordered by relevance): -${traversalSummary} - -CODE SNIPPETS: -${codeSnippets} - -REPOSITORY CONTEXT: -Name: ${input.repo_context.repo_name} -Entry Points: ${input.repo_context.entry_points.join(", ")} -Tech Stack: ${input.repo_context.tech_stack.map((t) => t.name).join(", ")} - -TASK: -Analyze the crash and return ONLY a valid JSON object with this exact structure: -{ - "root_cause_hypothesis": "Your best guess at the root cause (2-3 sentences)", - "fix_suggestions": ["concrete fix 1", "concrete fix 2", "concrete fix 3"], - "verification_steps": ["step to verify fix 1", "step 2", "step 3"], - "confidence": "high | medium | low", - "requires_runtime_check": true | false -}`; - - let res: Response | undefined; - - for (let attempt = 1; attempt <= 3; attempt++) { - res = await fetch(url, { - method: "POST", - 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 crash and return only the JSON." }, - ], - temperature: 0.2, - response_format: { type: "json_object" }, - }), - }); - - if (res.ok) break; - - if ((res.status === 503 || res.status === 429) && attempt < 3) { - await new Promise((r) => setTimeout(r, 5000 * attempt)); - continue; - } - - const error = await res.text(); - throw new Error(`Groq API error ${res.status}: ${error}`); - } - - if (!res || !res.ok) throw new Error("Groq API failed after retries"); - - const data = await res.json(); - const text = data.choices?.[0]?.message?.content; - if (!text) throw new Error("Empty response from Groq"); - - return JSON.parse(text) as DebugResult; -} \ No newline at end of file diff --git a/lib/debug/graph-traversal.ts b/lib/debug/graph-traversal.ts index ca5ae48..f34f3ff 100644 --- a/lib/debug/graph-traversal.ts +++ b/lib/debug/graph-traversal.ts @@ -1,4 +1,4 @@ - +// lib/debug/graph-traversal.ts import { DependencyGraph } from "@/lib/dependency-graph"; import { TraversalNode } from "./types"; @@ -9,6 +9,30 @@ interface TraversalConfig { prioritizeUpstream: boolean; } +// ── Reverse graph cache ─────────────────────────────────────────────────────── +// Previously: buildReverseGraph() was called on every request, +// rebuilding the full reverse graph each time. +// Now: memoized per graph reference — rebuilt only when graph changes. +const reverseGraphCache = new WeakMap(); + +function getOrBuildReverseGraph(graph: DependencyGraph): DependencyGraph { + if (reverseGraphCache.has(graph)) { + return reverseGraphCache.get(graph)!; + } + + const reverse: DependencyGraph = {}; + for (const [file, imports] of Object.entries(graph)) { + for (const imp of imports) { + if (!reverse[imp]) reverse[imp] = []; + reverse[imp].push(file); + } + } + + reverseGraphCache.set(graph, reverse); + return reverse; +} + +// ── Main traversal ──────────────────────────────────────────────────────────── export function traverseFromCrash( crashNode: string, graph: DependencyGraph, @@ -20,47 +44,48 @@ export function traverseFromCrash( } ): TraversalNode[] { const result: TraversalNode[] = []; - const visited = new Set(); - + // Separate visited sets per direction so upstream traversal + // is not blocked by nodes already visited during downstream traversal. + const visitedDownstream = new Set([crashNode]); + const visitedUpstream = new Set([crashNode]); + result.push({ file: crashNode, distance: 0, fan_in: fanIn[crashNode] || 0, relationship: "crash_site", - relevance_score: 1000, + relevance_score: 1000, }); - visited.add(crashNode); - const downstreamNodes = bfsTraversal( crashNode, graph, fanIn, config.maxDepth, "downstream", - visited + visitedDownstream ); - + const reverseGraph = getOrBuildReverseGraph(graph); + const upstreamNodes = bfsTraversal( crashNode, - buildReverseGraph(graph), + reverseGraph, fanIn, config.maxDepth, "upstream", - visited + visitedUpstream ); result.push(...downstreamNodes, ...upstreamNodes); - result.sort((a, b) => b.relevance_score - a.relevance_score); - return result.slice(0, config.maxNodes); } +// ── BFS ─────────────────────────────────────────────────────────────────────── function bfsTraversal( startNode: string, graph: DependencyGraph, @@ -69,13 +94,16 @@ function bfsTraversal( relationship: "upstream" | "downstream", visited: Set ): TraversalNode[] { + // Using an index pointer instead of array.shift() avoids O(n) + // dequeue cost on large graphs. const queue: { node: string; depth: number }[] = [ { node: startNode, depth: 0 }, ]; + let head = 0; const result: TraversalNode[] = []; - while (queue.length > 0) { - const { node, depth } = queue.shift()!; + while (head < queue.length) { + const { node, depth } = queue[head++]; if (depth >= maxDepth) continue; @@ -88,8 +116,6 @@ function bfsTraversal( const distance = depth + 1; const fanInScore = fanIn[neighbor] || 0; - - const relevance_score = (maxDepth - distance) * (1 + Math.log(fanInScore + 1)); @@ -106,17 +132,4 @@ function bfsTraversal( } return result; -} - -function buildReverseGraph(graph: DependencyGraph): DependencyGraph { - const reverse: DependencyGraph = {}; - - for (const [file, imports] of Object.entries(graph)) { - for (const imp of imports) { - if (!reverse[imp]) reverse[imp] = []; - reverse[imp].push(file); - } - } - - return reverse; } \ No newline at end of file diff --git a/lib/debug/groq-debug.ts b/lib/debug/groq-debug.ts new file mode 100644 index 0000000..c65f414 --- /dev/null +++ b/lib/debug/groq-debug.ts @@ -0,0 +1,139 @@ +// lib/debug/groq-debug.ts + +import { DebugContext, DebugResult } from "./types"; + +export async function analyzeDebugWithGroq( + input: DebugContext +): Promise { + if (process.env.USE_GROQ_FOR_ANALYSIS !== "true") { + throw new Error( + "Groq analysis is disabled. Set USE_GROQ_FOR_ANALYSIS=true in environment variables." + ); + } + + const apiKey = process.env.GROQ_API_KEY; + if (!apiKey) { + throw new Error("Missing GROQ_API_KEY environment variable"); + } + + const url = "https://api.groq.com/openai/v1/chat/completions"; + + // ── Build prompt ──────────────────────────────────────────────────────────── + const codeSnippets = input.relevant_code + .map((file) => { + const marker = file.is_crash_site ? "🔴 CRASH SITE" : ""; + return `=== ${file.file} ${marker} ===\n${file.content}`; + }) + .join("\n\n"); + + const traversalSummary = input.traversal_path + .slice(0, 10) + .map( + (n) => + `- ${n.file} (distance: ${n.distance}, fan-in: ${n.fan_in}, ${n.relationship})` + ) + .join("\n"); + + // NOTE: Confidence is intentionally excluded from AI prompt. + // It is calculated deterministically via calculateConfidence() in heuristics.ts + // to avoid inconsistent AI-generated confidence scores. + const systemPrompt = `You are a debugging assistant analyzing a crash in a codebase. + +CRITICAL CONSTRAINTS: +- You ONLY have access to static code dependencies, NOT runtime state. +- If the error suggests missing data (undefined, null), your FIRST suggestion must involve checking: + 1. Database queries + 2. API responses + 3. Environment variables (.env) + 4. Authentication state + +ERROR DETAILS: +Type: ${input.error_type} +Message: ${input.error_message} +Crash Location: ${input.crash_location.file}:${input.crash_location.line} +${input.crash_location.function ? `Function: ${input.crash_location.function}` : ""} + +DEPENDENCY ANALYSIS: +Files analyzed (ordered by relevance): +${traversalSummary} + +CODE SNIPPETS: +${codeSnippets} + +REPOSITORY CONTEXT: +Name: ${input.repo_context.repo_name} +Entry Points: ${input.repo_context.entry_points.join(", ")} +Tech Stack: ${input.repo_context.tech_stack.map((t) => t.name).join(", ")} + +TASK: +Analyze the crash and return ONLY a valid JSON object with this exact structure: +{ + "root_cause_hypothesis": "Your best guess at the root cause (2-3 sentences)", + "fix_suggestions": ["concrete fix 1", "concrete fix 2", "concrete fix 3"], + "verification_steps": ["step to verify fix 1", "step 2", "step 3"], + "requires_runtime_check": true | false +}`; + + // ── Fetch with retry ──────────────────────────────────────────────────────── + let res: Response | undefined; + let lastError: string = ""; + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + res = await fetch(url, { + method: "POST", + 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 crash and return only the JSON.", + }, + ], + temperature: 0.2, + response_format: { type: "json_object" }, + }), + }); + + if (res.ok) break; + + if ((res.status === 503 || res.status === 429) && attempt < 3) { + await new Promise((r) => setTimeout(r, 5000 * attempt)); + continue; + } + + lastError = await res.text(); + throw new Error(`Groq API error ${res.status}: ${lastError}`); + } catch (err) { + if (attempt === 3) throw err; + await new Promise((r) => setTimeout(r, 5000 * attempt)); + } + } + + if (!res || !res.ok) { + throw new Error(`Groq API failed after 3 retries. Last error: ${lastError}`); + } + + // ── Parse response ────────────────────────────────────────────────────────── + const data = await res.json(); + const text = data.choices?.[0]?.message?.content; + + if (!text) { + throw new Error("Empty response from Groq"); + } + + try { + return JSON.parse(text) as DebugResult; + } catch { + throw new Error( + `Groq returned malformed JSON. Raw response: ${text.slice(0, 200)}` + ); + } +} + +export const analyzeDebugWithGemini = analyzeDebugWithGroq; \ No newline at end of file diff --git a/lib/debug/heuristics.ts b/lib/debug/heuristics.ts index 408368d..970ad73 100644 --- a/lib/debug/heuristics.ts +++ b/lib/debug/heuristics.ts @@ -1,7 +1,8 @@ - +// lib/debug/heuristics.ts import { TraversalNode } from "./types"; +// ── Debug heuristics ────────────────────────────────────────────────────────── export function applyDebugHeuristics( nodes: TraversalNode[], errorType: string @@ -9,22 +10,12 @@ export function applyDebugHeuristics( return nodes.map((node) => { let bonus = 0; - - if (node.relationship === "upstream") { - bonus += 2; - } + if (node.relationship === "upstream") bonus += 2; - - if (/config|setup|init|bootstrap|env/i.test(node.file)) { - bonus += 1.5; - } + if (/config|setup|init|bootstrap|env/i.test(node.file)) bonus += 1.5; - - if (node.file.includes("index.") || node.file.includes("main.")) { - bonus += 1; - } + if (node.file.includes("index.") || node.file.includes("main.")) bonus += 1; - if ( (errorType === "TypeError" || errorType === "ReferenceError") && (node.file.includes("api/") || @@ -35,7 +26,6 @@ export function applyDebugHeuristics( bonus += 1.5; } - if ( errorType.toLowerCase().includes("auth") && (node.file.includes("auth") || @@ -45,7 +35,6 @@ export function applyDebugHeuristics( bonus += 2; } - if ( node.file.includes("middleware") || node.file.includes("route") || @@ -54,58 +43,61 @@ export function applyDebugHeuristics( bonus += 1; } - return { - ...node, - relevance_score: node.relevance_score + bonus, - }; + return { ...node, relevance_score: node.relevance_score + bonus }; }); } - +// ── Confidence ──────────────────────────────────────────────────────────────── +// Confidence is derived from traversal quality only — NOT from the AI response, +// which previously generated a confidence value that was silently discarded. export function calculateConfidence( - traversalPath: TraversalNode[], + traversalPath: TraversalNode[] ): "high" | "medium" | "low" { - const upstreamCount = traversalPath.filter( + const total = traversalPath.length; + + // Not enough context to be confident + if (total <= 1) return "low"; + + const upstreamNodes = traversalPath.filter( (n) => n.relationship === "upstream" - ).length; - const highFanInCount = traversalPath.filter((n) => n.fan_in > 5).length; + ); + const upstreamCount = upstreamNodes.length; - - if (upstreamCount >= 3 && highFanInCount >= 2) { - return "high"; - } + const highFanInCount = traversalPath.filter((n) => n.fan_in > 5).length; - - if (upstreamCount >= 1) { - return "medium"; - } + // Score based on proportion, not fixed thresholds + const upstreamRatio = upstreamCount / total; + const fanInRatio = highFanInCount / total; - + if (upstreamRatio >= 0.3 && fanInRatio >= 0.2) return "high"; + if (upstreamCount >= 1) return "medium"; return "low"; } - +// ── Runtime check ───────────────────────────────────────────────────────────── +// Previously matched overly broad patterns like /undefined/ and /null/ which +// appear in nearly every TypeError, making this flag meaningless. +// Now targets patterns that specifically indicate runtime/external state issues. export function requiresRuntimeCheck( errorType: string, errorMessage: string ): boolean { const runtimeIndicators = [ - /undefined/i, - /null/i, - /cannot read/i, - /is not a function/i, - /missing/i, - /not found/i, /failed to fetch/i, - /network/i, + /network error/i, /timeout/i, - /connection/i, + /connection refused/i, + /ECONNREFUSED/, /database/i, - /authentication/i, + /authentication failed/i, /unauthorized/i, + /403/, + /401/, + /500/, + /missing.*env/i, + /env.*missing/i, ]; const fullText = `${errorType} ${errorMessage}`; - return runtimeIndicators.some((pattern) => pattern.test(fullText)); } \ No newline at end of file diff --git a/lib/debug/mermaid-highlighter.ts b/lib/debug/mermaid-highlighter.ts index e65f921..e66b970 100644 --- a/lib/debug/mermaid-highlighter.ts +++ b/lib/debug/mermaid-highlighter.ts @@ -1,100 +1,53 @@ - +// lib/debug/mermaid-highlighter.ts import { TraversalNode } from "./types"; +// ── Node ID sanitizer ───────────────────────────────────────────────────────── +// Previously: all non-alphanumeric chars → "_" +// app/auth.ts and app-auth.ts both produced "app_auth_ts" → node ID collision. +// Now: includes the full depth of path segments to guarantee uniqueness. +function sanitize(filePath: string): string { + // Replace path separators with double underscore to preserve folder context + return filePath.replace(/\//g, "__").replace(/[^a-zA-Z0-9_]/g, "_"); +} + +// ── Highlight crash path on existing mermaid diagram ───────────────────────── export function highlightDebugPath( originalMermaid: string, crashNode: string, traversalPath: TraversalNode[], rootCauseFile?: string ): string { - let enhanced = originalMermaid; - - - const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_]/g, "_"); - const crashId = sanitize(crashNode); const rootCauseId = rootCauseFile ? sanitize(rootCauseFile) : null; - const styles: string[] = []; - + // Crash site — red, thick border styles.push( `style ${crashId} fill:#ff4444,stroke:#cc0000,stroke-width:3px,color:#fff` ); - traversalPath.forEach((node) => { const id = sanitize(node.file); - if (id === crashId) return; + if (id === crashId) return; - const opacity = Math.max(0.3, 1 - node.distance * 0.2); + // Previously used `opacity` which is not a valid Mermaid style property. + // Now uses stroke-width to visually indicate distance instead. + const strokeWidth = Math.max(1, 3 - node.distance); const color = node.relationship === "upstream" ? "#ff9933" : "#3399ff"; - styles.push(`style ${id} fill:${color},opacity:${opacity}`); + + styles.push( + `style ${id} fill:${color},stroke:#fff,stroke-width:${strokeWidth}px,color:#fff` + ); }); - + // Root cause — amber, distinct border if (rootCauseId && rootCauseId !== crashId) { styles.push( - `style ${rootCauseId} fill:#ffaa00,stroke:#ff6600,stroke-width:2px` + `style ${rootCauseId} fill:#ffaa00,stroke:#ff6600,stroke-width:2px,color:#000` ); } - - enhanced += "\n\n" + styles.join("\n"); - - - enhanced += ` - -subgraph Legend - direction LR - crash["🔴 Crash Site"] - upstream["🟠 Upstream Caller"] - downstream["🔵 Downstream Dependency"] -end`; - - return enhanced; -} - - -export function generateErrorGraph( - crashNode: string, - traversalPath: TraversalNode[] -): string { - const lines: string[] = ["graph TD"]; - - const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_]/g, "_"); - - - const crashId = sanitize(crashNode); - const crashName = crashNode.split("/").pop() || crashNode; - lines.push(` ${crashId}["🔴 ${crashName}"]`); - lines.push( - ` style ${crashId} fill:#ff4444,stroke:#cc0000,stroke-width:3px,color:#fff` - ); - - - const topNodes = traversalPath.slice(0, 10); - - topNodes.forEach((node) => { - const id = sanitize(node.file); - const name = node.file.split("/").pop() || node.file; - const icon = node.relationship === "upstream" ? "⬆️" : "⬇️"; - - lines.push(` ${id}["${icon} ${name}"]`); - - - if (node.relationship === "upstream") { - lines.push(` ${id} --> ${crashId}`); - } else { - lines.push(` ${crashId} --> ${id}`); - } - - - const color = node.relationship === "upstream" ? "#ff9933" : "#3399ff"; - lines.push(` style ${id} fill:${color},opacity:0.7`); - }); - - return lines.join("\n"); + return `${originalMermaid}\n\n${styles.join("\n")}`; } \ No newline at end of file diff --git a/lib/debug/stack-parser.ts b/lib/debug/stack-parser.ts index befe0b0..f0ab4e8 100644 --- a/lib/debug/stack-parser.ts +++ b/lib/debug/stack-parser.ts @@ -1,7 +1,10 @@ - +// lib/debug/stack-parser.ts import { CrashNode } from "./types"; +// ── Noise filters ───────────────────────────────────────────────────────────── +// Previously included "at async" which accidentally filtered valid +// user async functions. Now only exact/prefix noise is listed. const FRAMEWORK_NOISE = [ "node_modules/", "webpack:", @@ -11,9 +14,8 @@ const FRAMEWORK_NOISE = [ "next/dist/", "node:internal/", "_middleware", - "at eval", + "at eval (", "at processTicksAndRejections", - "at async", "at Module.", "at Object.", ]; @@ -40,6 +42,7 @@ interface StackFrame { raw: string; } +// ── Stack trace parser ──────────────────────────────────────────────────────── export function parseStackTrace( trace: string, allFiles: string[] @@ -48,12 +51,8 @@ export function parseStackTrace( const frames: StackFrame[] = []; for (const line of lines) { - if (!line.trim() || !line.includes("at ")) continue; - - - const match = line.match( /at\s+(?:(.+?)\s+\()?([^()]+):(\d+):(\d+)\)?/ ); @@ -65,10 +64,7 @@ export function parseStackTrace( const lineNum = parseInt(match[3]); const colNum = parseInt(match[4]); - - if (FRAMEWORK_NOISE.some((noise) => filePath.includes(noise))) { - continue; - } + if (FRAMEWORK_NOISE.some((noise) => filePath.includes(noise))) continue; frames.push({ file: filePath, @@ -79,10 +75,8 @@ export function parseStackTrace( }); } - for (const frame of frames) { const matched = findMatchingFile(frame.file, allFiles); - if (matched) { return { file: matched, @@ -93,50 +87,76 @@ export function parseStackTrace( } } - return null; + return null; } +// ── File matcher ────────────────────────────────────────────────────────────── +// Previously returned the first filename match without disambiguation. +// Two files with the same name in different folders would silently resolve +// to whichever came first in the array. +// Now: prefers the match whose full path contains more segments from framePath. function findMatchingFile(framePath: string, allFiles: string[]): string | null { - + // 1. Exact match if (allFiles.includes(framePath)) return framePath; - - const filename = framePath.split("/").pop(); - if (filename) { - const match = allFiles.find((f) => f.endsWith(filename)); - if (match) return match; - } - - - const segments = framePath.split("/"); - if (segments.length >= 2) { - const partial = segments.slice(-2).join("/"); - const match = allFiles.find((f) => f.endsWith(partial)); - if (match) return match; - } - - + // 2. Repo-root relative path match for (const pattern of REPO_ROOT_PATTERNS) { const idx = framePath.indexOf(pattern); if (idx !== -1) { - const repoPath = framePath.substring(idx + 1); + const repoPath = framePath.substring(idx + 1); if (allFiles.includes(repoPath)) return repoPath; } } + // 3. Partial path match (last 2 segments) with disambiguation: + // score each candidate by how many path segments match the frame + const frameSegments = framePath.split("/"); + + if (frameSegments.length >= 2) { + const partial = frameSegments.slice(-2).join("/"); + const candidates = allFiles.filter((f) => f.endsWith(partial)); + + if (candidates.length === 1) return candidates[0]; + + if (candidates.length > 1) { + // Pick the candidate that shares the most path segments with framePath + let bestMatch = candidates[0]; + let bestScore = 0; + + for (const candidate of candidates) { + const candidateSegments = candidate.split("/"); + const score = frameSegments.filter((seg) => + candidateSegments.includes(seg) + ).length; + if (score > bestScore) { + bestScore = score; + bestMatch = candidate; + } + } + + return bestMatch; + } + } + + // 4. Filename-only fallback (least precise — only used if nothing else matches) + const filename = frameSegments.pop(); + if (filename) { + const candidates = allFiles.filter((f) => f.endsWith(filename)); + if (candidates.length === 1) return candidates[0]; + // Multiple files with same name and no better match — return null + // rather than silently picking the wrong one + if (candidates.length > 1) return null; + } + return null; } - +// ── Error info extractor ────────────────────────────────────────────────────── export function extractErrorInfo(trace: string): { error_type: string; error_message: string; } { - const lines = trace.split("\n"); - const firstLine = lines[0]?.trim() || ""; - - // Try to extract "ErrorType: message" - + const firstLine = trace.split("\n")[0]?.trim() || ""; const errorMatch = firstLine.match(/(?:.*\s)?(\w+Error):\s*(.+)$/); if (errorMatch) { @@ -146,27 +166,8 @@ export function extractErrorInfo(trace: string): { }; } - return { error_type: "UnknownError", error_message: firstLine || "No error message provided", }; -} - - -export function extractAllCrashNodes( - trace: string, - allFiles: string[] -): CrashNode[] { - const lines = trace.split("\n"); - const crashes: CrashNode[] = []; - - for (const line of lines) { - const node = parseStackTrace(line, allFiles); - if (node && !crashes.some((c) => c.file === node.file)) { - crashes.push(node); - } - } - - return crashes; } \ No newline at end of file From 6b7892caa9f621b5c1f546fb772dab2c06885384 Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Sun, 31 May 2026 01:17:29 +0530 Subject: [PATCH 19/21] refactor(arch-insights): fix AP algorithm, types, tests, and Pro gate UI --- components/analyze/PrImpactTab.tsx | 294 +++++++----- .../__tests__/articulationPoints.test.ts | 280 +++++++++++ lib/algorithms/articulationPoints.ts | 439 ++++++++++++++---- 3 files changed, 823 insertions(+), 190 deletions(-) create mode 100644 lib/algorithms/__tests__/articulationPoints.test.ts diff --git a/components/analyze/PrImpactTab.tsx b/components/analyze/PrImpactTab.tsx index 38d0b57..40d0d56 100644 --- a/components/analyze/PrImpactTab.tsx +++ b/components/analyze/PrImpactTab.tsx @@ -16,7 +16,10 @@ import { ArrowLeft, GitBranch, Lock, - Sparkles, + Zap, + ShieldAlert, + UserCheck, + Share2, } from "lucide-react"; import { RepoData, PRAnalysisResult } from "@/lib/types/analyze"; @@ -27,6 +30,8 @@ interface PrImpactTabProps { onViewOnGraph: () => void; } +// ── Pro Gate ────────────────────────────────────────────────────────────────── + function ProGate({ onUpgrade }: { onUpgrade: () => void }) { return ( void }) { transition={{ type: "spring", stiffness: 380, damping: 30 }} className="w-full mt-4" > - {/* Blurred ghost preview */} -
    -
    -
    -
    -
    +
    + {/* Ghost preview */} +
    +
    +
    +
    -
    +
    {[...Array(4)].map((_, i) => (
    ))}
    @@ -54,38 +59,102 @@ function ProGate({ onUpgrade }: { onUpgrade: () => void }) { {[...Array(3)].map((_, i) => (
    ))}
    {/* Overlay */} -
    -
    - +
    + {/* Lock icon */} +
    +
    -
    -

    Pro Feature

    -

    - PR Impact Analysis is only available on the Pro plan. Upgrade to - unlock blast radius, breaking dependencies, and reviewer - suggestions. + + {/* Copy */} +

    +

    + PR Impact requires Pro

    +

    + Upgrade to unlock full blast radius analysis, breaking dependency + detection, and context-aware reviewer suggestions. +

    +
    + + {/* Feature pills */} +
    + {[ + { + label: "Blast Radius", + icon: Zap, + style: "bg-red-500/8 border-red-500/20 text-red-400", + }, + { + label: "Breaking Deps", + icon: ShieldAlert, + style: "bg-amber-500/8 border-amber-500/20 text-amber-400", + }, + { + label: "Reviewer Suggestions", + icon: UserCheck, + style: "bg-blue-500/8 border-blue-500/20 text-blue-400", + }, + { + label: "Graph Highlight", + icon: Share2, + style: "bg-cyan-500/8 border-cyan-500/20 text-cyan-400", + }, + ].map(({ label, icon: Icon, style }) => ( + + + {label} + + ))}
    + + {/* CTA */} + +

    + Already on Pro?{" "} + +

    ); } +// ── Main Tab ────────────────────────────────────────────────────────────────── + export default function PrImpactTab({ data, isPro, @@ -155,48 +224,50 @@ export default function PrImpactTab({ animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ type: "spring", stiffness: 380, damping: 30, mass: 0.8 }} - className="absolute inset-0 p-4 sm:p-6 flex flex-col items-center overflow-y-auto custom-scrollbar" + className="absolute inset-0 p-4 sm:p-6 flex flex-col items-center overflow-y-auto [&::-webkit-scrollbar]:w-px [&::-webkit-scrollbar-thumb]:bg-white/10" > -
    +
    {!prResult ? ( - <> -
    - +
    + {/* Header */} +
    +
    + +
    +
    +

    + PR Impact Analyzer +

    +

    + Blast radius · Breaking deps · Reviewer suggestions +

    +
    -

    - PR Impact Analyzer -

    -

    - Paste a Pull Request number to instantly calculate its blast - radius, preview architectural changes, and identify breaking - dependencies before merging. -

    + {/* Input */}
    -
    - - PR # - - setPrInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleAnalyzePR()} - disabled={isAnalyzingPR} - placeholder="e.g. 142 or paste URL..." - className="flex-1 bg-transparent border-none outline-none text-slate-200 placeholder:text-slate-600 font-mono text-sm px-2 disabled:opacity-50 w-full" - /> -
    + + PR # + + setPrInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAnalyzePR()} + disabled={isAnalyzingPR} + placeholder="e.g. 142 or paste URL..." + className="flex-1 bg-transparent border-none outline-none text-slate-200 placeholder:text-slate-600 font-mono text-[11px] px-3 py-3 disabled:opacity-50 min-w-0" + />
    {prError && ( -

    +

    {prError}

    )} + {/* Pro gate (shown when non-pro user hits Analyze) */} {showGate && !isPro && } - +
    ) : ( + /* ── Result view ───────────────────────────────────────────────── */ + {/* PR header */} -
    -
    - +
    +
    + PR #{prResult.prNumber}

    {prResult.title || "Pull Request Analysis"}

    -

    +

    {prResult.description}

    {prResult.riskLevel === "high" ? ( ) : ( - )}{" "} + )} {prResult.riskLevel} Risk
    + {/* View on graph CTA */} {prResult.changedFiles && prResult.changedFiles.length > 0 && ( )} + {/* Blast radius + architectural changes */} -
    -

    - Blast Radius + {/* Blast radius */} +
    +

    + Blast Radius

    -
    +
    {prResult.blastRadius.map((item, i) => (
    {item.file} -

    +

    {item.impact}

    ))} {prResult.blastRadius.length === 0 && ( -

    +

    No files impacted.

    )}
    -
    -
    -

    - Architectural Changes + + {/* Architectural changes + breaking deps */} +
    +
    +

    + Architectural Changes

    -
      +
        {prResult.architecturalChanges.map((change, i) => (
      • -
        +
        {change}
      • ))}
    -
    -

    - Breaking Dependencies + +
    +

    + Breaking Dependencies

    -
      +
        {(Array.isArray(prResult.breakingDependencies) ? prResult.breakingDependencies : [prResult.breakingDependencies || "None detected"] ).map((dep, i) => (
      • - + {String(dep)}
      • ))} @@ -372,6 +452,7 @@ export default function PrImpactTab({
    + {/* Suggested reviewers */} {prResult.suggestedReviewers && prResult.suggestedReviewers.length > 0 && ( -

    - Context-Aware Reviewers +

    + Context-Aware Reviewers

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

    +

    {reviewer.reason}

    @@ -416,21 +497,22 @@ export default function PrImpactTab({ )} + {/* Back button */} diff --git a/lib/algorithms/__tests__/articulationPoints.test.ts b/lib/algorithms/__tests__/articulationPoints.test.ts new file mode 100644 index 0000000..f0b7bee --- /dev/null +++ b/lib/algorithms/__tests__/articulationPoints.test.ts @@ -0,0 +1,280 @@ +// lib/algorithms/__tests__/articulationPoints.test.ts + +import { + computeArticulationPoints, + getRankedArticulationPoints, + type ArticulationPointResult, +} from "../articulationPoints"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Sorted array of AP paths — stable for snapshot comparisons. */ +function sortedAPs(result: ArticulationPointResult): string[] { + return [...result.articulationPoints].sort(); +} + +/** Sorted array of bridge strings "source->target" — stable for comparisons. */ +function sortedBridges(result: ArticulationPointResult): string[] { + return [...result.bridges] + .map(([s, t]) => `${s}->${t}`) + .sort(); +} + +// ── Edge-case / defensive tests ─────────────────────────────────────────────── + +describe("Edge cases", () => { + it("returns empty result for an empty graph", () => { + const result = computeArticulationPoints({}); + expect(result.articulationPoints.size).toBe(0); + expect(result.bridges).toHaveLength(0); + expect(result.componentSizes.size).toBe(0); + }); + + it("returns empty result for a single node with no edges", () => { + const result = computeArticulationPoints({ "a.ts": [] }); + expect(result.articulationPoints.size).toBe(0); + expect(result.bridges).toHaveLength(0); + }); + + it("handles self-loops without crashing or false positives", () => { + // A self-import is structurally meaningless — should be ignored + const result = computeArticulationPoints({ "a.ts": ["a.ts"] }); + expect(result.articulationPoints.size).toBe(0); + expect(result.bridges).toHaveLength(0); + }); + + it("handles duplicate import entries (same target twice)", () => { + // Duplicate imports must collapse to one undirected edge — not a false bridge + const result = computeArticulationPoints({ + "a.ts": ["b.ts", "b.ts"], + "b.ts": [], + }); + expect(sortedBridges(result)).toEqual(["a.ts->b.ts"]); + // a.ts is NOT an AP — it's a leaf with one child, and the root + // with only 1 DFS child is not an AP + expect(result.articulationPoints.has("a.ts")).toBe(false); + }); + + it("handles nodes that appear only as import targets (no outgoing edges)", () => { + // "leaf.ts" is never a key but is a valid node + const result = computeArticulationPoints({ + "entry.ts": ["middle.ts"], + "middle.ts": ["leaf.ts"], + }); + // middle.ts is on the only path from entry.ts to leaf.ts → AP + expect(result.articulationPoints.has("middle.ts")).toBe(true); + expect(sortedBridges(result).length).toBeGreaterThan(0); + }); + + it("handles empty string targets gracefully", () => { + const result = computeArticulationPoints({ "a.ts": ["", "b.ts", ""] }); + // Empty strings are ignored; a.ts→b.ts is a single bridge + expect(result.articulationPoints.size).toBe(0); + expect(sortedBridges(result)).toEqual(["a.ts->b.ts"]); + }); +}); + +// ── Simple chain (A → B → C) ────────────────────────────────────────────────── + +describe("Linear chain: A → B → C", () => { + const graph = { + "a.ts": ["b.ts"], + "b.ts": ["c.ts"], + "c.ts": [], + }; + + let result: ArticulationPointResult; + beforeAll(() => { result = computeArticulationPoints(graph); }); + + it("identifies b.ts as the only articulation point", () => { + expect(sortedAPs(result)).toEqual(["b.ts"]); + }); + + it("identifies both edges as bridges", () => { + expect(sortedBridges(result)).toEqual(["a.ts->b.ts", "b.ts->c.ts"]); + }); + + it("records a non-zero component size for b.ts", () => { + expect(result.componentSizes.get("b.ts")).toBeGreaterThan(0); + }); +}); + +// ── Triangle (no APs or bridges) ───────────────────────────────────────────── + +describe("Triangle: A → B → C → A", () => { + const graph = { + "a.ts": ["b.ts"], + "b.ts": ["c.ts"], + "c.ts": ["a.ts"], + }; + + let result: ArticulationPointResult; + beforeAll(() => { result = computeArticulationPoints(graph); }); + + it("finds no articulation points", () => { + expect(result.articulationPoints.size).toBe(0); + }); + + it("finds no bridges", () => { + expect(result.bridges).toHaveLength(0); + }); +}); + +// ── Classic AP graph ────────────────────────────────────────────────────────── +// +// 1 ─ 2 ─ 3 +// │ +// 4 ─ 5 ─ 6 +// +// Node 2 connects {1,3} to {4,5,6} — AP. +// Node 5 connects {4} to {6} via 2 — AP. + +describe("Classic AP graph", () => { + const graph: Record = { + "1": ["2"], + "2": ["1", "3", "5"], + "3": ["2"], + "4": ["5"], + "5": ["2", "4", "6"], + "6": ["5"], + }; + + let result: ArticulationPointResult; + beforeAll(() => { result = computeArticulationPoints(graph); }); + + it("identifies nodes 2 and 5 as articulation points", () => { + expect(sortedAPs(result)).toEqual(["2", "5"]); + }); + + it("finds no bridges (all edges have alternative paths via the cycle)", () => { + // The 2–5 edge is a bridge; everything else is in a biconnected component + expect(sortedBridges(result)).toEqual(["2->5"]); + }); + + it("component size for node 2 reflects the larger fragment", () => { + // Removing 2: fragments are {1,3} (size 2) and {4,5,6} (size 3) → largest = 3 + expect(result.componentSizes.get("2")).toBe(3); + }); + + it("component size for node 5 reflects the larger fragment", () => { + // Removing 5: fragments are {4} (size 1) and {1,2,3,6} (size 4) → largest = 4 + expect(result.componentSizes.get("5")).toBe(4); + }); +}); + +// ── Mutual imports (A ↔ B) ──────────────────────────────────────────────────── + +describe("Mutual imports: A ↔ B", () => { + const graph = { + "a.ts": ["b.ts"], + "b.ts": ["a.ts"], + }; + + let result: ArticulationPointResult; + beforeAll(() => { result = computeArticulationPoints(graph); }); + + it("finds no articulation points (the pair forms a cycle)", () => { + expect(result.articulationPoints.size).toBe(0); + }); + + it("finds no bridges (the mutual import provides a redundant path)", () => { + expect(result.bridges).toHaveLength(0); + }); +}); + +// ── Disconnected graph ──────────────────────────────────────────────────────── +// +// Island 1: A → B → C (B is AP) +// Island 2: D → E (bridge D→E) + +describe("Disconnected graph", () => { + const graph = { + "a.ts": ["b.ts"], + "b.ts": ["c.ts"], + "c.ts": [], + "d.ts": ["e.ts"], + "e.ts": [], + }; + + let result: ArticulationPointResult; + beforeAll(() => { result = computeArticulationPoints(graph); }); + + it("finds b.ts as an AP in island 1", () => { + expect(result.articulationPoints.has("b.ts")).toBe(true); + }); + + it("finds bridges in both islands", () => { + const bridges = sortedBridges(result); + expect(bridges).toContain("a.ts->b.ts"); + expect(bridges).toContain("b.ts->c.ts"); + expect(bridges).toContain("d.ts->e.ts"); + }); +}); + +// ── Star topology ───────────────────────────────────────────────────────────── +// +// hub imports 4 leaves — hub is an AP; all edges are bridges. + +describe("Star topology: hub → [l1, l2, l3, l4]", () => { + const graph = { + "hub.ts": ["l1.ts", "l2.ts", "l3.ts", "l4.ts"], + "l1.ts": [], + "l2.ts": [], + "l3.ts": [], + "l4.ts": [], + }; + + let result: ArticulationPointResult; + beforeAll(() => { result = computeArticulationPoints(graph); }); + + it("identifies hub.ts as the only articulation point", () => { + expect(sortedAPs(result)).toEqual(["hub.ts"]); + }); + + it("identifies all 4 edges as bridges", () => { + expect(result.bridges).toHaveLength(4); + }); + + it("records the correct component size for hub.ts", () => { + // Removing hub: 4 isolated leaves → largest fragment = 1 + expect(result.componentSizes.get("hub.ts")).toBe(1); + }); +}); + +// ── getRankedArticulationPoints ─────────────────────────────────────────────── + +describe("getRankedArticulationPoints", () => { + it("returns APs sorted by disconnects descending", () => { + // Chain A→B→C→D: both B and C are APs + // Removing B: {A} and {C,D} → largest fragment = 2 + // Removing C: {A,B} and {D} → largest fragment = 2 + // (tie — order stable if sort is stable, but we only check structure) + const graph = { + "a.ts": ["b.ts"], + "b.ts": ["c.ts"], + "c.ts": ["d.ts"], + "d.ts": [], + }; + const result = computeArticulationPoints(graph); + const ranked = getRankedArticulationPoints(result); + + expect(ranked.length).toBe(2); + // Every entry has both required fields + for (const entry of ranked) { + expect(typeof entry.path).toBe("string"); + expect(typeof entry.disconnects).toBe("number"); + } + // Sorted: first entry has disconnects >= last entry + expect(ranked[0].disconnects).toBeGreaterThanOrEqual( + ranked[ranked.length - 1].disconnects, + ); + }); + + it("returns an empty array when there are no APs", () => { + const result = computeArticulationPoints({ + "a.ts": ["b.ts"], + "b.ts": ["a.ts"], + }); + expect(getRankedArticulationPoints(result)).toEqual([]); + }); +}); \ No newline at end of file diff --git a/lib/algorithms/articulationPoints.ts b/lib/algorithms/articulationPoints.ts index d9083a7..24cf752 100644 --- a/lib/algorithms/articulationPoints.ts +++ b/lib/algorithms/articulationPoints.ts @@ -1,112 +1,383 @@ -// lib/algorithms/articulationPoints.ts +/** + * Articulation Point & Bridge Detection — Single Points of Failure + * + * Identifies structural vulnerabilities in the directed dependency graph by + * analysing the underlying undirected topology. + * + * ── Definitions ────────────────────────────────────────────────────────────── + * + * Articulation Point (AP) + * A vertex whose removal increases the number of connected components. + * CodeAutopsy interpretation: a file that, if deleted or fatally broken, + * splits the codebase into disconnected islands. + * + * Bridge + * An edge whose removal increases the number of connected components. + * CodeAutopsy interpretation: a single import relationship with no + * alternative path around it — severing it severs the graph. + * + * ── Mathematical basis: Tarjan (1972) ──────────────────────────────────────── + * + * disc[u] Discovery time of vertex u in the DFS traversal. + * low[u] Minimum disc value reachable from u's DFS subtree via ≤1 back-edge. + * + * Non-root AP: ∃ DFS child v of u s.t. low[v] >= disc[u] + * Root AP: DFS-tree root with ≥ 2 independent subtree children + * Bridge (u,v): low[v] > disc[u] where v is a DFS-tree child of u + * + * ── Engineering decisions ──────────────────────────────────────────────────── + * + * 1. ITERATIVE DFS + * V8's call stack saturates at ~10k–15k frames. Recursive Tarjan will + * throw "Maximum call stack size exceeded" on any large monorepo. + * An explicit DFSFrame stack is semantically equivalent and safe at + * any graph depth. + * + * 2. EDGE-ID PARENT TRACKING (not node-index tracking) + * The classic "skip the parent node" heuristic silently breaks for mutual + * imports (A→B and B→A both present). Using a per-edge integer ID means + * only the exact arrival edge is skipped; the second undirected edge from + * the mutual import is correctly processed as a back-edge, proving the + * A–B pair is not a bridge and neither node is a false AP. + * + * 3. DIRECTED-EDGE DEDUPLICATION + * Duplicate import statements (same target twice in one file) would create + * a false multi-edge, incorrectly suppressing bridge detection. A packed- + * integer Set (u × N + v) collapses identical directed edges in O(1). + * + * 4. O(1) BFS QUEUE (head-pointer index) + * Array.shift() is O(n) — it copies every remaining element one slot. + * The component-size BFS uses a monotonically advancing head index into a + * plain array, reducing dequeue cost to O(1) amortised. + * + * 5. TYPED-ARRAY SEEN-TRACKING + * Uint8Array over a plain boolean[] or Set for the BFS visited + * state. V8 maps Uint8Array to a contiguous byte buffer; fill(0) compiles + * to a single memset call — reset cost between AP iterations is negligible. + * + * Complexity: O(V + E) — main algorithm (Tarjan) + * O(V + E) — component-size pass (amortised across all APs) + */ +// ── Public types ────────────────────────────────────────────────────────────── + +/** + * The complete result of the articulation-point analysis. + */ export interface ArticulationPointResult { + /** + * File paths that are structural single points of failure. + * + * Stored as a `Set` for O(1) membership checks — the dominant use-case + * when decorating thousands of React Flow nodes in a render pass: + * `if (articulationPoints.has(node.id)) { ... }`. + */ articulationPoints: Set; + + /** + * Directed import edges whose removal disconnects the graph. + * + * Each tuple `[source, target]` preserves the original dependency-graph + * direction: `source` imports `target`, and that specific import is the + * sole structural connection between their respective components. + */ bridges: Array<[string, string]>; - componentSizes: Map; + + /** + * For each articulation point, the node-count of the *largest* component + * that fragments off when it is removed. + * + * Use this to rank AP severity in the UI: an AP that disconnects 800 files + * is categorically more critical than one that disconnects 3. + * + * UI label should read "X files affected" — not "X components" — since + * this value is a file count, not a component count. + */ + componentSizes: Map; +} + +/** + * A single articulation point with its pre-computed severity rank. + * Returned by `getRankedArticulationPoints` for direct UI consumption. + */ +export interface RankedArticulationPoint { + /** Absolute file path of the articulation point. */ + path: string; + /** + * Number of nodes in the largest fragment that splits off on removal. + * Higher = more severe. + */ + disconnects: number; +} + +// ── Internal types ──────────────────────────────────────────────────────────── + +/** Single entry in the undirected adjacency list. */ +interface AdjEntry { + readonly v: number; + /** + * Unique integer ID of the undirected edge. + * Both endpoints share this ID — used for parent-edge exclusion + * instead of node-index comparison. + */ + readonly edgeId: number; } +/** + * One frame on the explicit DFS call stack. + * Mirrors a single recursive invocation of dfs(u). + * `adjIdx` and `children` are mutated in-place as the frame is processed. + */ +interface DFSFrame { + readonly u: number; + /** + * Edge ID on which we arrived at `u`. + * `-1` for the DFS-forest root of each connected component. + */ + readonly parentEdgeId: number; + /** Cursor into `adj[u]` — resumed on re-entry after a child returns. */ + adjIdx: number; + /** + * DFS-tree child count. Only meaningful for component roots + * (root AP check: children >= 2). + */ + children: number; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function emptyResult(): ArticulationPointResult { + return { + articulationPoints: new Set(), + bridges: [], + componentSizes: new Map(), + }; +} + +// ── Core algorithm ──────────────────────────────────────────────────────────── + +/** + * Finds every articulation point, bridge, and per-AP component-size in the + * underlying undirected topology of a directed dependency graph. + * + * @param dependencyGraph `graph[A] = [B, C]` means file A imports files B and C. + * @returns Deduplicated sets of APs, bridges, and severity sizes. + */ export function computeArticulationPoints( - graph: Record, + dependencyGraph: Record, ): ArticulationPointResult { - const nodes = Object.keys(graph); - if (nodes.length === 0) { - return { - articulationPoints: new Set(), - bridges: [], - componentSizes: new Map(), - }; + // ── 0. Fast path ────────────────────────────────────────────────────────── + if (Object.keys(dependencyGraph).length === 0) return emptyResult(); + + // ── 1. Enumerate all vertices ───────────────────────────────────────────── + // Include import targets that are not graph keys (leaf files with no + // outgoing imports) so the undirected graph is topologically complete. + const nodeSet = new Set(Object.keys(dependencyGraph)); + for (const targets of Object.values(dependencyGraph)) { + for (const t of targets) { + if (t) nodeSet.add(t); + } } - // 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 nodes = Array.from(nodeSet); + const nodeId = new Map(nodes.map((n, i) => [n, i] as const)); + const N = nodes.length; + + if (N === 0) return emptyResult(); + + // ── 2. Build undirected adjacency list with edge IDs ────────────────────── + // + // Each unique directed edge A→B becomes ONE undirected edge registered in + // both adj[A] and adj[B] under the same integer edgeId. + // + // Deduplication key: ordered pair (u, v) packed as (u × N + v). + // Duplicate import statements → same key → collapsed to one edge. + // A→B and B→A (mutual imports) → different keys → two separate edges, + // which correctly prevents false bridge / false AP detection. + // + // edgeOrigin[eid] = [directedSrc, directedTgt] preserves the original + // arrow direction for accurate bridge-tuple reporting. + + const adj : AdjEntry[][] = Array.from({ length: N }, () => []); + const edgeOrigin : [number, number][] = []; + const seenEdges = new Set(); + + for (const [source, targets] of Object.entries(dependencyGraph)) { + const u = nodeId.get(source); + if (u === undefined) continue; + + for (const target of targets) { + if (!target) continue; + + const v = nodeId.get(target); + if (v === undefined || u === v) continue; // guard + self-loop elimination + + const packed = u * N + v; + if (seenEdges.has(packed)) continue; + seenEdges.add(packed); + + const eid = edgeOrigin.length; + edgeOrigin.push([u, v]); + adj[u].push({ v, edgeId: eid }); + adj[v].push({ v: u, edgeId: eid }); } } - 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)!)); + // ── 3. Tarjan's AP / Bridge — iterative DFS ─────────────────────────────── + // + // Outer loop restarts from every unvisited node, ensuring full coverage of + // disconnected sub-graphs and isolated files. + + const disc = new Int32Array(N).fill(-1); // -1 ≡ unvisited + const low = new Int32Array(N); + + const apIndices = new Set(); // vertex indices of confirmed APs + const bridgeEids = new Set(); // edge IDs of confirmed bridges + let timer = 0; + + for (let root = 0; root < N; root++) { + if (disc[root] !== -1) continue; + + disc[root] = low[root] = timer++; + + const stack: DFSFrame[] = [ + { u: root, parentEdgeId: -1, adjIdx: 0, children: 0 }, + ]; + + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const { u } = frame; + let pushedChild = false; + + // Scan u's adjacency list from where we last paused. + // Back-edges are processed inline; we pause and descend on tree-edges. + while (frame.adjIdx < adj[u].length) { + const { v, edgeId: eid } = adj[u][frame.adjIdx++]; + + if (eid === frame.parentEdgeId) continue; // skip the arrival edge + + if (disc[v] === -1) { + // Tree edge — descend into child + frame.children++; + disc[v] = low[v] = timer++; + stack.push({ u: v, parentEdgeId: eid, adjIdx: 0, children: 0 }); + pushedChild = true; + break; // adjIdx already advanced; resume here after child returns + } else { + // Back-edge — tighten low[u] + if (disc[v] < low[u]) low[u] = disc[v]; + } } - } - }; - for (const node of allNodes) { - if (!visited.has(node)) { - parent.set(node, null); - dfs(node); + if (pushedChild) continue; + + // u fully explored — simulate recursive return + stack.pop(); + + // Root AP: a DFS-tree root is an AP iff it spawned ≥ 2 children, + // because only then does its removal disconnect those subtrees. + if (frame.parentEdgeId === -1) { + if (frame.children >= 2) apIndices.add(u); + continue; // root has no parent; nothing to propagate + } + + const parentFrame = stack[stack.length - 1]; // always valid here + const pu = parentFrame.u; + + // Propagate low upward + if (low[u] < low[pu]) low[pu] = low[u]; + + // Non-root AP: u's subtree cannot back-reach any ancestor of pu, + // so pu is the sole connector. Guard skips the check when pu is a + // root (its AP status is governed by children count only). + if (parentFrame.parentEdgeId !== -1 && low[u] >= disc[pu]) { + apIndices.add(pu); + } + + // Bridge: u's subtree cannot even back-reach pu — the tree-edge + // (pu → u) is the only structural connection; removing it disconnects. + if (low[u] > disc[pu]) { + bridgeEids.add(frame.parentEdgeId); + } } } - // For each AP, estimate how many nodes become disconnected if removed + // ── 4. Map numeric indices back to file-path strings ───────────────────── + const articulationPoints = new Set( + Array.from(apIndices, (i) => nodes[i]), + ); + + const bridges: Array<[string, string]> = Array.from(bridgeEids, (eid) => { + const [s, t] = edgeOrigin[eid]; + return [nodes[s], nodes[t]]; + }); + + // ── 5. Per-AP largest-fragment estimation ───────────────────────────────── + // + // For each AP, BFS the graph without that node and record the size of + // the largest resulting fragment. Drives severity ranking in the UI. + // + // One Uint8Array allocated outside the loop; fill(0) between iterations + // compiles to a single V8-optimised memset — cheaper than per-AP allocation. + // BFS queue uses a head-pointer index: O(1) dequeue vs O(n) Array.shift(). + 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()!; + const seen = new Uint8Array(N); + + for (const apIdx of apIndices) { + seen.fill(0); + seen[apIdx] = 1; // treat the AP itself as permanently visited + + let maxFragment = 0; + + for (const { v: seed } of adj[apIdx]) { + if (seen[seed]) continue; // already part of a counted fragment + + const queue: number[] = [seed]; + seen[seed] = 1; + let head = 0; + let count = 0; + + while (head < queue.length) { + const cur = queue[head++]; count++; - for (const nb of adj.get(cur) || []) { - if (!seen.has(nb) && remaining.has(nb)) { - seen.add(nb); - queue.push(nb); + for (const { v } of adj[cur]) { + if (!seen[v]) { + seen[v] = 1; + queue.push(v); } } } - maxDisconnected = Math.max(maxDisconnected, count); + + if (count > maxFragment) maxFragment = count; } - componentSizes.set(ap, maxDisconnected); + componentSizes.set(nodes[apIdx], maxFragment); } - return { articulationPoints: aps, bridges, componentSizes }; + return { articulationPoints, bridges, componentSizes }; +} + +// ── Utility ─────────────────────────────────────────────────────────────────── + +/** + * Returns every articulation point sorted by severity (highest first). + * + * Convenience wrapper for direct UI consumption — eliminates the sort + + * map boilerplate from the view layer. + * + * @example + * const result = computeArticulationPoints(dependencyGraph); + * const ranked = getRankedArticulationPoints(result); + * // ranked[0] is the most dangerous single point of failure + */ +export function getRankedArticulationPoints( + result: ArticulationPointResult, +): RankedArticulationPoint[] { + return Array.from(result.articulationPoints) + .map((path): RankedArticulationPoint => ({ + path, + disconnects: result.componentSizes.get(path) ?? 0, + })) + .sort((a, b) => b.disconnects - a.disconnects); } \ No newline at end of file From 3171587a1865b8f1f4e5c4fbfd26ffa1d9a737b9 Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Sun, 31 May 2026 02:08:47 +0530 Subject: [PATCH 20/21] =?UTF-8?q?The=20graph=20computation=20layer=20?= =?UTF-8?q?=E2=80=94=20wherever=20computePageRank=20/=20findSCCs=20live=20?= =?UTF-8?q?The=20React=20Flow=20node/edge=20renderer=20=E2=80=94=20whereve?= =?UTF-8?q?r=20node=20styles=20and=20badges=20are=20applied=20The=20right?= =?UTF-8?q?=20analysis=20panel=20=E2=80=94=20wherever=20CIRCULAR=20DEPS=20?= =?UTF-8?q?/=20ORPHANS=20/=20NERVE=20CENTER=20are=20rendered=20The=20Risk?= =?UTF-8?q?=20Radar=20tab=20=E2=80=94=20its=20current=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/ArchitectureMap.tsx | 269 ++++++++++++++++++++++++-- components/analyze/RiskRadarPanel.tsx | 220 +++++++++++++++++++-- lib/types/analyze.ts | 3 + 3 files changed, 466 insertions(+), 26 deletions(-) diff --git a/components/ArchitectureMap.tsx b/components/ArchitectureMap.tsx index 97fbf0e..948000f 100644 --- a/components/ArchitectureMap.tsx +++ b/components/ArchitectureMap.tsx @@ -1,5 +1,3 @@ -// components/ArchitectureMap.tsx - "use client"; import InfoTooltip from "@/components/InfoTooltip"; @@ -44,6 +42,11 @@ import { GitPullRequest, Zap, } from "lucide-react"; +import { + computeArticulationPoints, + getRankedArticulationPoints, + type RankedArticulationPoint, +} from "@/lib/algorithms/articulationPoints"; function detectCircularDependencies(graph: Record): { cycleNodes: Set; @@ -148,6 +151,7 @@ type ActiveMode = | "orphan" | "pr-blast" | "pagerank" + | "fragile" | null; interface GlassNodeData { @@ -165,6 +169,9 @@ interface GlassNodeData { pageRankScore?: number; pageRankTier?: PageRankTier; pageRankActive?: boolean; + isArticulationPoint?: boolean; + apDisconnects?: number; + isBridge?: boolean; } const PR_TIER_STYLES: Record< @@ -299,7 +306,17 @@ const GlassNode = ({ data }: { data: GlassNodeData }) => { let textClass = "text-sm font-mono truncate max-w-[200px] transition-colors"; let handleColor = "!bg-slate-500"; - if (isPrModified) { + if ( + data.isArticulationPoint && + !isPrModified && + !isPrBlast && + !isBlastRadius + ) { + containerClass += + " bg-red-500/8 border border-red-400/50 shadow-[0_0_18px_rgba(248,113,113,0.3)]"; + textClass += " text-red-200"; + handleColor = "!bg-red-400"; + } else 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"; @@ -370,9 +387,29 @@ const GlassNode = ({ data }: { data: GlassNodeData }) => { Circular
    )} + {data.isArticulationPoint && + !isBlastRadius && + !isPrModified && + !isPrBlast && + !isCircular && ( +
    + + Fragile +
    + )}
    {data.label}
    + {data.isArticulationPoint && + data.apDisconnects !== undefined && + data.apDisconnects > 0 && + !isPrModified && + !isPrBlast && + !isBlastRadius && ( +
    + {data.apDisconnects} files affected +
    + )}
    ; prBlastNodes: Set; + rankedAPs: RankedArticulationPoint[]; + bridges: Array<[string, string]>; activeMode: ActiveMode; onActivateMode: (mode: ActiveMode) => void; onClearAll: () => void; @@ -545,6 +584,8 @@ function AnalysisSidebar({ prChangedFiles, prModifiedNodes, prBlastNodes, + rankedAPs, + bridges, activeMode, onActivateMode, onClearAll, @@ -558,6 +599,7 @@ function AnalysisSidebar({ const hasOrphans = orphans.length > 0; const hasPrData = prChangedFiles.length > 0; const hasPageRank = Object.keys(pageRankScores).length > 0; + const hasAPs = rankedAPs.length > 0; const RAIL = [ { @@ -710,7 +752,9 @@ function AnalysisSidebar({ ? "text-violet-400" : activeMode === "pagerank" ? "text-cyan-400" - : "text-slate-600" + : activeMode === "fragile" + ? "text-red-400" + : "text-slate-600" }`} > {activeMode === "pr-blast" @@ -723,14 +767,17 @@ function AnalysisSidebar({ ? "Orphan" : activeMode === "pagerank" ? "PageRank" - : heatmapEnabled - ? "Heatmap" - : "Default"} + : activeMode === "fragile" + ? "Fragile" + : heatmapEnabled + ? "Heatmap" + : "Default"}
    + {/* ── PR Blast Radius ── */}
    @@ -844,6 +891,7 @@ function AnalysisSidebar({
    + {/* ── Blast Radius ── */}
    + {/* ── Circular Deps ── */}
    @@ -958,6 +1007,125 @@ function AnalysisSidebar({
    + {/* ── Fragile Points ── */} +
    + + + + {activeMode === "fragile" && hasAPs && ( + +
    +
    +
    +
    + + Articulation points + + + {rankedAPs.length} + +
    +
    +
    + + Bridge edges + + + {bridges.length} + +
    +
    +
    +

    + Ranked by severity +

    + {rankedAPs + .slice(0, 8) + .map(({ path, disconnects }) => ( +
    +
    + + {path.split("/").pop()} + + + {disconnects}f + +
    + ))} + {rankedAPs.length > 8 && ( +

    + +{rankedAPs.length - 8} more +

    + )} +
    +
    + + )} + +
    + + {/* ── Complexity Heatmap ── */}
    @@ -1030,6 +1198,7 @@ function AnalysisSidebar({
    + {/* ── Orphans ── */}
    @@ -1126,6 +1295,7 @@ function AnalysisSidebar({
    + {/* ── Nerve Center / PageRank ── */}
    @@ -1361,6 +1531,24 @@ export default function ArchitectureMap({ return map; }, [pageRankScores]); + // ── Articulation point computation ──────────────────────────────────────── + const apResult = useMemo( + () => computeArticulationPoints(dependencyGraph), + [dependencyGraph], + ); + + const rankedAPs = useMemo( + () => getRankedArticulationPoints(apResult), + [apResult], + ); + + const apSet = useMemo(() => apResult.articulationPoints, [apResult]); + + const bridgeSet = useMemo( + () => new Set(apResult.bridges.map(([s, t]) => `e-${s}-${t}`)), + [apResult], + ); + const prevPrFilesRef = useRef(""); useEffect(() => { const key = prChangedFiles.join(","); @@ -1395,6 +1583,9 @@ export default function ArchitectureMap({ pageRankScore: pageRankScores[filePath] ?? 0, pageRankTier: getPageRankTier(pageRankScores[filePath] ?? 0), pageRankActive: false, + isArticulationPoint: false, + apDisconnects: 0, + isBridge: false, }, position: { x: 0, y: 0 }, }); @@ -1468,6 +1659,8 @@ export default function ArchitectureMap({ isPrModified: false, isPrBlast: false, pageRankActive: false, + isArticulationPoint: false, + apDisconnects: 0, }, })), ); @@ -1482,6 +1675,45 @@ export default function ArchitectureMap({ return; } + if (activeMode === "fragile") { + setNodes((nds) => + nds.map((n) => ({ + ...n, + data: { + ...n.data, + pageRankActive: false, + isBlastRadius: false, + isPrModified: false, + isPrBlast: false, + isOrphanHighlighted: false, + isArticulationPoint: apSet.has(n.id), + apDisconnects: apResult.componentSizes.get(n.id) ?? 0, + isDimmed: !apSet.has(n.id), + }, + })), + ); + setEdges((eds) => + eds.map((e) => { + const isBridge = bridgeSet.has(e.id); + return { + ...e, + style: { + stroke: isBridge ? "#f87171" : "#475569", + strokeWidth: isBridge ? 3 : 2, + strokeDasharray: isBridge ? "6 3" : undefined, + opacity: isBridge ? 1 : 0.08, + }, + animated: false, + markerEnd: { + type: MarkerType.ArrowClosed, + color: isBridge ? "#f87171" : "#475569", + }, + }; + }), + ); + return; + } + if (activeMode === "pagerank") { setNodes((nds) => nds.map((n) => { @@ -1497,6 +1729,7 @@ export default function ArchitectureMap({ isPrModified: false, isPrBlast: false, isOrphanHighlighted: false, + isArticulationPoint: false, isDimmed: tier === 0, }, }; @@ -1543,6 +1776,7 @@ export default function ArchitectureMap({ pageRankActive: false, isBlastRadius: false, isOrphanHighlighted: false, + isArticulationPoint: false, isPrModified: prModifiedNodes.has(n.id), isPrBlast: prBlastNodes.has(n.id), isDimmed: !prModifiedNodes.has(n.id) && !prBlastNodes.has(n.id), @@ -1594,6 +1828,7 @@ export default function ArchitectureMap({ isBlastRadius: false, isPrModified: false, isPrBlast: false, + isArticulationPoint: false, isDimmed: selectedOrphan ? n.id !== selectedOrphan : false, isOrphanHighlighted: selectedOrphan ? n.id === selectedOrphan @@ -1626,6 +1861,7 @@ export default function ArchitectureMap({ isBlastRadius: false, isPrModified: false, isPrBlast: false, + isArticulationPoint: false, isDimmed: !cycleNodes.has(n.id), isOrphanHighlighted: false, }, @@ -1679,6 +1915,7 @@ export default function ArchitectureMap({ isOrphanHighlighted: false, isPrModified: false, isPrBlast: false, + isArticulationPoint: false, }, })), ); @@ -1711,6 +1948,10 @@ export default function ArchitectureMap({ pageRankTierMap, pageRankScores, adjacencyList, + apSet, + apResult, + bridgeSet, + rankedAPs, setNodes, setEdges, ]); @@ -1788,7 +2029,9 @@ export default function ArchitectureMap({ ? "bg-orange-500/10 border-orange-500/25 text-orange-400" : activeMode === "pagerank" ? "bg-cyan-500/10 border-cyan-500/25 text-cyan-400" - : "bg-violet-500/10 border-violet-500/25 text-violet-400" + : activeMode === "fragile" + ? "bg-red-500/10 border-red-500/25 text-red-400" + : "bg-violet-500/10 border-violet-500/25 text-violet-400" }`} > @@ -1800,9 +2043,11 @@ export default function ArchitectureMap({ ? "Circular deps highlighted" : activeMode === "pagerank" ? `PageRank · ${pageRankTopFiles.filter((f) => getPageRankTier(f.score) >= 2).length} hubs` - : selectedOrphan - ? `Orphan: ${selectedOrphan.split("/").pop()}` - : "Orphan mode — select a file"} + : activeMode === "fragile" + ? `Fragile · ${rankedAPs.length} APs · ${apResult.bridges.length} bridges` + : selectedOrphan + ? `Orphan: ${selectedOrphan.split("/").pop()}` + : "Orphan mode — select a file"} @@ -1855,6 +2100,8 @@ export default function ArchitectureMap({ prChangedFiles={prChangedFiles} prModifiedNodes={prModifiedNodes} prBlastNodes={prBlastNodes} + rankedAPs={rankedAPs} + bridges={apResult.bridges} activeMode={activeMode} onActivateMode={handleActivateMode} onClearAll={handleClearAll} diff --git a/components/analyze/RiskRadarPanel.tsx b/components/analyze/RiskRadarPanel.tsx index 8be9b85..e3e70c6 100644 --- a/components/analyze/RiskRadarPanel.tsx +++ b/components/analyze/RiskRadarPanel.tsx @@ -1,5 +1,3 @@ -// components/analyze/RiskRadarPanel.tsx - "use client"; import { motion } from "framer-motion"; @@ -7,6 +5,11 @@ import dynamic from "next/dynamic"; import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { RepoData } from "@/lib/types/analyze"; import SkeletonLoader from "@/components/analyze/SkeletonLoader"; +import { + computeArticulationPoints, + getRankedArticulationPoints, +} from "@/lib/algorithms/articulationPoints"; +import { useMemo } from "react"; const RiskDashboard = dynamic(() => import("@/components/RiskDashboard"), { loading: () => , @@ -19,6 +22,26 @@ interface RiskRadarPanelProps { } export default function RiskRadarPanel({ data, isPro }: RiskRadarPanelProps) { + const apResult = useMemo( + () => + data.dependencyGraph + ? computeArticulationPoints(data.dependencyGraph) + : { + articulationPoints: new Set(), + bridges: [] as Array<[string, string]>, + componentSizes: new Map(), + }, + [data.dependencyGraph], + ); + + const rankedAPs = useMemo( + () => getRankedArticulationPoints(apResult), + [apResult], + ); + + const hasAPs = rankedAPs.length > 0; + const hasBridges = apResult.bridges.length > 0; + return ( - {data.coverageGaps && data.fileContents ? ( - - - - ) : ( -
    - No risk data available for this codebase. -
    - )} +
    + {/* ── Fragile Points Panel ── */} + {data.dependencyGraph && ( +
    +
    +
    +
    + + Fragile Points + +
    +
    + + {hasAPs ? `${rankedAPs.length} APs` : "CLEAN"} + + + {hasBridges + ? `${apResult.bridges.length} bridges` + : "NO BRIDGES"} + +
    +
    + + {!hasAPs && !hasBridges ? ( +
    +
    + + + +
    +

    + No structural single points of failure detected +

    +
    + ) : ( +
    + {/* AP ranked list */} + {hasAPs && ( +
    +

    + Articulation points — ranked by severity +

    +
    + {rankedAPs.map(({ path, disconnects }, i) => { + const pct = + rankedAPs[0].disconnects > 0 + ? (disconnects / rankedAPs[0].disconnects) * 100 + : 0; + return ( +
    +
    + + {i + 1} + + + {path.split("/").pop()} + + + {disconnects} files + +
    +
    + +
    +

    + {path} +

    +
    + ); + })} +
    +
    + )} + + {/* Bridge list */} + {hasBridges && ( +
    +

    + Bridge edges — critical import paths +

    +
    + {apResult.bridges.slice(0, 12).map(([src, tgt]) => ( +
    + + {src.split("/").pop()} + + + + + + {tgt.split("/").pop()} + +
    + ))} + {apResult.bridges.length > 12 && ( +

    + +{apResult.bridges.length - 12} more bridges +

    + )} +
    +
    + )} +
    + )} +
    + )} + + {/* ── Existing RiskDashboard ── */} + {data.coverageGaps && data.fileContents ? ( + + + + ) : ( + !data.dependencyGraph && ( +
    + No risk data available for this codebase. +
    + ) + )} +
    ); } diff --git a/lib/types/analyze.ts b/lib/types/analyze.ts index 40b77cc..f6c94c0 100644 --- a/lib/types/analyze.ts +++ b/lib/types/analyze.ts @@ -42,6 +42,9 @@ export interface RepoData { }[]; fileContents?: { path: string; content: string }[]; pageRankScores?: Record; +articulationPoints?: string[]; +bridges?: Array<[string, string]>; +componentSizes?: Record; } export interface PRBlastRadiusItem { From 0001c256f77eaf7205d3b08267d527ed1d59b32d Mon Sep 17 00:00:00 2001 From: Sidhant0707 Date: Sun, 31 May 2026 06:37:06 +0530 Subject: [PATCH 21/21] fix(blueprint-map): compute PageRank internally when scores not passed as prop - Add computePageRank() helper using damped iteration (20 passes, 0.85 factor) - Normalize scores to 0-100 range for consistent tier thresholds - Add computedPageRankScores useMemo that falls back to internal computation - Replace all pageRankScores references with computedPageRankScores - Nerve Center panel now works out of the box without parent wiring --- components/ArchitectureMap.tsx | 60 ++++- lib/github/commits.ts | 413 +++++++++++++++++++++++++++++++++ lib/github/implicitCoupling.ts | 327 ++++++++++++++++++++++++++ lib/github/resolve-aliases.ts | 346 +++++++++++++++++++++++++++ 4 files changed, 1136 insertions(+), 10 deletions(-) create mode 100644 lib/github/commits.ts create mode 100644 lib/github/implicitCoupling.ts diff --git a/components/ArchitectureMap.tsx b/components/ArchitectureMap.tsx index 948000f..b9c8024 100644 --- a/components/ArchitectureMap.tsx +++ b/components/ArchitectureMap.tsx @@ -136,6 +136,39 @@ function computePrBlastRadius( return { modifiedNodes, blastNodes }; } +// ── NEW: Self-contained PageRank computation ────────────────────────────────── +function computePageRank( + graph: Record, + iterations = 20, + dampingFactor = 0.85, +): Record { + const nodes = Object.keys(graph); + if (nodes.length === 0) return {}; + + const scores: Record = {}; + nodes.forEach((n) => (scores[n] = 1)); + + for (let iter = 0; iter < iterations; iter++) { + const next: Record = {}; + nodes.forEach((n) => (next[n] = 1 - dampingFactor)); + nodes.forEach((src) => { + const deps = graph[src] || []; + if (deps.length === 0) return; + deps.forEach((tgt) => { + next[tgt] = + (next[tgt] || 0) + (dampingFactor * scores[src]) / deps.length; + }); + }); + Object.assign(scores, next); + } + + // Normalize to 0–100 + const max = Math.max(...Object.values(scores), 1); + nodes.forEach((n) => (scores[n] = Math.round((scores[n] / max) * 100))); + return scores; +} +// ───────────────────────────────────────────────────────────────────────────── + type PageRankTier = 0 | 1 | 2 | 3; function getPageRankTier(score: number): PageRankTier { @@ -1514,22 +1547,29 @@ export default function ArchitectureMap({ return { prModifiedNodes: modifiedNodes, prBlastNodes: blastNodes }; }, [prChangedFiles, adjacencyList]); + // ── NEW: Compute PageRank internally if not passed in from parent ────────── + const computedPageRankScores = useMemo(() => { + if (Object.keys(pageRankScores).length > 0) return pageRankScores; + return computePageRank(dependencyGraph); + }, [dependencyGraph, pageRankScores]); + // ────────────────────────────────────────────────────────────────────────── + const pageRankTopFiles = useMemo( () => - Object.entries(pageRankScores) + Object.entries(computedPageRankScores) .sort(([, a], [, b]) => b - a) .slice(0, 20) .map(([path, score]) => ({ path, score })), - [pageRankScores], + [computedPageRankScores], ); const pageRankTierMap = useMemo(() => { const map = new Map(); - for (const [path, score] of Object.entries(pageRankScores)) { + for (const [path, score] of Object.entries(computedPageRankScores)) { map.set(path, getPageRankTier(score)); } return map; - }, [pageRankScores]); + }, [computedPageRankScores]); // ── Articulation point computation ──────────────────────────────────────── const apResult = useMemo( @@ -1580,8 +1620,8 @@ export default function ArchitectureMap({ isOrphanHighlighted: false, isPrModified: false, isPrBlast: false, - pageRankScore: pageRankScores[filePath] ?? 0, - pageRankTier: getPageRankTier(pageRankScores[filePath] ?? 0), + pageRankScore: computedPageRankScores[filePath] ?? 0, + pageRankTier: getPageRankTier(computedPageRankScores[filePath] ?? 0), pageRankActive: false, isArticulationPoint: false, apDisconnects: 0, @@ -1617,7 +1657,7 @@ export default function ArchitectureMap({ }); return { initialNodes: layoutNodes, initialEdges: layoutEdges, graphHash }; - }, [dependencyGraph, entryPoints, pageRankScores]); + }, [dependencyGraph, entryPoints, computedPageRankScores]); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -1724,7 +1764,7 @@ export default function ArchitectureMap({ ...n.data, pageRankActive: true, pageRankTier: tier, - pageRankScore: pageRankScores[n.id] ?? 0, + pageRankScore: computedPageRankScores[n.id] ?? 0, isBlastRadius: false, isPrModified: false, isPrBlast: false, @@ -1946,7 +1986,7 @@ export default function ArchitectureMap({ prModifiedNodes, prBlastNodes, pageRankTierMap, - pageRankScores, + computedPageRankScores, adjacencyList, apSet, apResult, @@ -2105,7 +2145,7 @@ export default function ArchitectureMap({ activeMode={activeMode} onActivateMode={handleActivateMode} onClearAll={handleClearAll} - pageRankScores={pageRankScores} + pageRankScores={computedPageRankScores} pageRankTopFiles={pageRankTopFiles} />
    diff --git a/lib/github/commits.ts b/lib/github/commits.ts new file mode 100644 index 0000000..729f095 --- /dev/null +++ b/lib/github/commits.ts @@ -0,0 +1,413 @@ +// lib/github/commits.ts + +// ───────────────────────────────────────────────────────────────────────────── +// CodeAutopsy · GitHub Commit Pipeline +// Fetches the last N commits for a repo and returns, per commit, the list of +// file paths that were changed. Designed to be called exclusively server-side +// (Server Actions / Route Handlers) so the token is never exposed to the client. +// +// Constraints honoured: +// • GitHub primary rate limit → tracked via X-RateLimit-* response headers; +// the caller blocks until the window resets before retrying. +// • GitHub secondary rate limit → detected via 403 + "secondary rate limit" +// body OR via Retry-After header on 429; backs off for the indicated period. +// • Concurrency → a Semaphore caps simultaneous in-flight detail +// requests so we never hammer GitHub with 200 parallel calls. +// • Truncated commits → GitHub silently caps `files[]` at 300 entries. +// We detect this and skip the commit to avoid skewing coupling data. +// ───────────────────────────────────────────────────────────────────────────── + +const GITHUB_API_BASE = "https://api.github.com"; + +// ─── Public option / return types ──────────────────────────────────────────── + +export interface FetchCommitsOptions { + /** GitHub repo owner (user or org). */ + owner: string; + /** GitHub repo name. */ + repo: string; + /** + * Personal Access Token or fine-grained token with at minimum `contents: read` + * on the target repo. Public repos work without a token but burn the 60 req/h + * unauthenticated limit extremely fast; always pass a token. + */ + token: string; + /** + * How many commits to walk. Clamped to [1, 200]. + * @default 100 + */ + maxCommits?: number; + /** + * Max simultaneous in-flight detail fetches. Keep ≤ 10 to stay well inside + * GitHub's concurrency secondary-rate-limit. + * @default 6 + */ + concurrency?: number; + /** + * Branch / tag / SHA to start from. Omit to use the repo's default branch. + */ + branch?: string; +} + +/** Each element is the set of file paths changed in one commit. */ +export type CommitFileMatrix = string[][]; + +// ─── Internal types ─────────────────────────────────────────────────────────── + +interface RateLimitState { + /** Requests remaining in the current window. */ + remaining: number; + /** Unix epoch (seconds) when the window resets. */ + reset: number; + /** + * Non-null while we are in a secondary-rate-limit back-off. + * Value is the number of ms to wait. + */ + retryAfterMs: number | null; +} + +interface GhCommitListItem { + sha: string; +} + +interface GhCommitFile { + filename: string; + status: + | "added" + | "removed" + | "modified" + | "renamed" + | "copied" + | "changed" + | "unchanged"; + previous_filename?: string; +} + +interface GhCommitDetail { + sha: string; + files?: GhCommitFile[]; +} + +// ─── Semaphore ──────────────────────────────────────────────────────────────── + +/** + * A classic promise-based counting semaphore. + * `run()` is the only public API consumers need; acquire/release are internal. + */ +class Semaphore { + private permits: number; + private readonly waitQueue: Array<() => void> = []; + + constructor(permits: number) { + if (permits < 1) throw new RangeError("Semaphore permits must be ≥ 1"); + this.permits = permits; + } + + private acquire(): Promise { + if (this.permits > 0) { + this.permits--; + return Promise.resolve(); + } + return new Promise((resolve) => this.waitQueue.push(resolve)); + } + + private release(): void { + const next = this.waitQueue.shift(); + if (next) { + // Pass the permit directly to the next waiter — no increment needed. + next(); + } else { + this.permits++; + } + } + + /** Acquire → run fn → release, even if fn throws. */ + async run(fn: () => Promise): Promise { + await this.acquire(); + try { + return await fn(); + } finally { + this.release(); + } + } +} + +// ─── Utilities ──────────────────────────────────────────────────────────────── + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Milliseconds until the rate-limit window resets, plus a 1 s buffer. */ +function msUntilReset(resetEpochSeconds: number): number { + const nowMs = Date.now(); + const resetMs = resetEpochSeconds * 1_000; + return Math.max(0, resetMs - nowMs) + 1_000; // 1 s safety buffer +} + +const SECONDARY_RATE_LIMIT_PATTERN = /secondary rate limit/i; +const RETRYABLE_HTTP_STATUS = new Set([500, 502, 503, 504]); +const MAX_RETRIES = 4; + +// ─── Rate-limit-aware fetch ─────────────────────────────────────────────────── + +/** + * Wrapper around `fetch` that: + * 1. Proactively waits when the primary rate limit is exhausted. + * 2. Detects secondary-rate-limit responses (403 / 429) and backs off. + * 3. Retries transient server errors with exponential back-off. + * 4. Updates the shared `RateLimitState` from every response. + */ +async function githubFetch( + url: string, + token: string, + rls: RateLimitState +): Promise { + // ── Pre-flight: secondary back-off ─────────────────────────────────────── + if (rls.retryAfterMs !== null) { + await sleep(rls.retryAfterMs); + rls.retryAfterMs = null; + } + + // ── Pre-flight: primary rate-limit exhausted ───────────────────────────── + if (rls.remaining <= 0) { + await sleep(msUntilReset(rls.reset)); + } + + let lastErr: Error = new Error("Unknown fetch error"); + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + if (attempt > 0) { + // Exponential back-off: 2 s, 4 s, 8 s + await sleep(Math.pow(2, attempt) * 1_000); + } + + let res: Response; + try { + res = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + // Disable Next.js fetch cache — we always want live data from GitHub. + "Cache-Control": "no-store", + }, + // Node ≥ 18 fetch accepts `cache` option; Next.js also respects it. + cache: "no-store", + }); + } catch (networkErr) { + lastErr = + networkErr instanceof Error ? networkErr : new Error(String(networkErr)); + continue; // Retry on network-level failures + } + + // ── Update shared rate-limit state ───────────────────────────────────── + const hRemaining = res.headers.get("X-RateLimit-Remaining"); + const hReset = res.headers.get("X-RateLimit-Reset"); + if (hRemaining !== null) rls.remaining = parseInt(hRemaining, 10); + if (hReset !== null) rls.reset = parseInt(hReset, 10); + + if (res.ok) return res; + + // ── 403 / 429 — rate limiting ────────────────────────────────────────── + if (res.status === 403 || res.status === 429) { + const retryAfterHeader = res.headers.get("Retry-After"); + + if (retryAfterHeader !== null) { + // Explicit back-off duration (seconds) + rls.retryAfterMs = parseInt(retryAfterHeader, 10) * 1_000 + 500; + await sleep(rls.retryAfterMs); + rls.retryAfterMs = null; + continue; + } + + // 403 without Retry-After could be secondary rate limit or auth error. + // Peek at body to distinguish. + let body = ""; + try { + body = await res.text(); + } catch { + /* ignore */ + } + + if (SECONDARY_RATE_LIMIT_PATTERN.test(body)) { + // GitHub recommends waiting at least 1 minute for secondary limits. + rls.retryAfterMs = 60_000 + 1_000; + await sleep(rls.retryAfterMs); + rls.retryAfterMs = null; + continue; + } + + if (rls.remaining <= 0) { + // Primary limit exhausted — wait for reset. + await sleep(msUntilReset(rls.reset)); + continue; + } + + // Hard auth error (bad token, missing scope) — no point retrying. + throw new Error( + `GitHub returned 403 for ${url}. Check token scopes. Body: ${body}` + ); + } + + // ── 5xx transient server errors ──────────────────────────────────────── + if (RETRYABLE_HTTP_STATUS.has(res.status)) { + lastErr = new Error(`GitHub API ${res.status} at ${url}`); + continue; + } + + // ── Non-retryable client error ───────────────────────────────────────── + let errBody = ""; + try { + errBody = await res.text(); + } catch { + /* ignore */ + } + throw new Error( + `GitHub API non-retryable error ${res.status} at ${url}: ${errBody}` + ); + } + + throw new Error( + `GitHub API failed after ${MAX_RETRIES} attempts for ${url}: ${lastErr.message}` + ); +} + +// ─── Step 1: Fetch commit SHA list ──────────────────────────────────────────── + +async function fetchCommitSHAs( + owner: string, + repo: string, + token: string, + maxCommits: number, + branch: string | undefined, + rls: RateLimitState +): Promise { + const shas: string[] = []; + const perPage = 100; // GitHub max per-page for this endpoint + const totalPages = Math.ceil(maxCommits / perPage); + + for (let page = 1; page <= totalPages; page++) { + if (shas.length >= maxCommits) break; + + const params = new URLSearchParams({ per_page: String(perPage), page: String(page) }); + if (branch) params.set("sha", branch); + + const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/commits?${params}`; + const res = await githubFetch(url, token, rls); + const items: GhCommitListItem[] = await res.json(); + + if (!Array.isArray(items) || items.length === 0) break; + + for (const item of items) { + if (shas.length >= maxCommits) break; + if (item.sha) shas.push(item.sha); + } + } + + return shas; +} + +// ─── Step 2: Fetch files for one commit ────────────────────────────────────── + +/** + * GitHub caps `files[]` at exactly 300 items without a truncation flag. + * Any commit touching ≥ 300 files is a mass refactor / generated-file dump — + * including it would poison coupling data, so we return null to signal "skip". + */ +const GH_MAX_FILES_PER_COMMIT = 300; + +async function fetchFilesForCommit( + owner: string, + repo: string, + sha: string, + token: string, + rls: RateLimitState +): Promise { + const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/commits/${sha}`; + const res = await githubFetch(url, token, rls); + const detail: GhCommitDetail = await res.json(); + const files = detail.files ?? []; + + if (files.length >= GH_MAX_FILES_PER_COMMIT) return null; // likely truncated + + return files + .filter((f) => f.status !== "removed") // deleted files are irrelevant for coupling + .map((f) => + // For renamed files, use the NEW filename so it aligns with the + // current dependencyGraph topology rather than a stale identity. + f.filename + ); +} + +// ─── Main export ───────────────────────────────────────────────────────────── + +/** + * Fetches up to `maxCommits` commits and returns, per commit, the list of + * file paths that were **added, modified, or renamed** in that commit. + * + * Commits are returned in the same chronological order GitHub returns them + * (newest-first). Only commits that changed ≥ 2 files are included, since + * single-file commits contribute nothing to coupling analysis. + * + * @example + * ```ts + * const commits = await fetchLogicalCouplingCommits({ + * owner: "vercel", + * repo: "next.js", + * token: process.env.GITHUB_TOKEN!, + * maxCommits: 200, + * concurrency: 8, + * }); + * // commits[0] → ["packages/next/src/server/app-render.tsx", "packages/next/src/server/render.tsx", ...] + * ``` + */ +export async function fetchLogicalCouplingCommits( + options: FetchCommitsOptions +): Promise { + const { + owner, + repo, + token, + maxCommits = 100, + concurrency = 6, + branch, + } = options; + + const clampedMax = Math.min(Math.max(maxCommits, 1), 200); + + const rls: RateLimitState = { + remaining: 5_000, // optimistic default; updated on first response + reset: Math.floor(Date.now() / 1_000) + 3_600, + retryAfterMs: null, + }; + + // ── Step 1: Collect SHAs (1–2 API calls) ────────────────────────────────── + const shas = await fetchCommitSHAs(owner, repo, token, clampedMax, branch, rls); + + // ── Step 2: Fetch file lists in parallel, bounded by Semaphore ──────────── + const semaphore = new Semaphore(concurrency); + const matrix: Array = new Array(shas.length).fill(null); + + await Promise.all( + shas.map((sha, idx) => + semaphore.run(async () => { + try { + matrix[idx] = await fetchFilesForCommit(owner, repo, sha, token, rls); + } catch { + // Non-fatal: a single failed commit detail fetch should not abort the + // entire pipeline. Log in development; silently skip in production. + if (process.env.NODE_ENV === "development") { + console.warn(`[CodeAutopsy] Could not fetch files for commit ${sha}`); + } + matrix[idx] = null; + } + }) + ) + ); + + // ── Step 3: Filter nulls and single-file commits ─────────────────────────── + return matrix.filter( + (files): files is string[] => files !== null && files.length >= 2 + ); +} \ No newline at end of file diff --git a/lib/github/implicitCoupling.ts b/lib/github/implicitCoupling.ts new file mode 100644 index 0000000..404e60d --- /dev/null +++ b/lib/github/implicitCoupling.ts @@ -0,0 +1,327 @@ +// lib/algorithms/implicitCoupling.ts + +// ───────────────────────────────────────────────────────────────────────────── +// CodeAutopsy · Implicit Coupling Math Engine +// +// Computes "logical coupling" (co-change probability) from raw git history. +// Surfaces file pairs that change together frequently but share NO explicit +// import edge — these are the "hidden ghost edges" rendered in React Flow. +// +// Algorithm: Association Rule Mining (ARM) over a transaction database where +// • each "transaction" = one commit +// • each "item" = one file path +// +// Metrics produced per pair (A, B): +// • support = P(A ∧ B) = coChangeCount / totalCommits +// • confidence(A→B) = P(B | A) = coChangeCount / totalCommitsWithA +// • confidence(B→A) = P(A | B) = coChangeCount / totalCommitsWithB +// • jaccard = coChangeCount / |commitsWithA ∪ commitsWithB| +// = coChange / (cA + cB - coChange) +// (symmetric; robust to files with very different activity) +// +// Complexity: +// Pass 1 — file commit counts : O(M · F) +// Pass 2 — co-change counts : O(M · F²) — F is tiny in practice (≈ 5–15) +// Pass 3 — dependency edge set : O(E) +// Pass 4 — confidence + filter : O(P) — P = unique co-change pairs +// Pass 5 — sort : O(P log P) +// +// Overall: O(M · F² + E + P log P) +// +// Memory: +// fileCommitCount : O(N) — N = unique files across all commits +// coChangeMap : O(P) — P ≤ C(N, 2) but << that in practice +// existingEdges : O(E) +// ───────────────────────────────────────────────────────────────────────────── + +// ─── Public interfaces ──────────────────────────────────────────────────────── + +/** One "ghost edge" candidate returned by the engine. */ +export interface ImplicitCouplingResult { + /** + * File with the higher conditional probability of co-changing with `fileB`. + * I.e. `confidence >= reverseConfidence` is always true. + */ + fileA: string; + /** The coupled counterpart. */ + fileB: string; + /** + * P(fileB changes | fileA changes). + * "When fileA changes, fileB also changes this fraction of the time." + */ + confidence: number; + /** + * P(fileA changes | fileB changes). + * The reverse direction — often < confidence, but surfaced for directed edges. + */ + reverseConfidence: number; + /** Raw count: commits where BOTH files were modified. */ + coChangeCount: number; + /** + * Jaccard similarity coefficient — symmetric, range [0, 1]. + * Preferred ranking metric because it penalises files that change in almost + * every commit (which would inflate raw confidence). + */ + jaccard: number; + /** + * support = coChangeCount / totalCommits. + * Tells you how globally frequent this coupling is. + */ + support: number; +} + +/** Tuning knobs for the coupling engine. */ +export interface CouplingOptions { + /** + * Minimum times two files must co-change to be included. + * Low values (1–2) are noisy; 3+ is recommended for production. + * @default 3 + */ + minCoChangeCount?: number; + /** + * Minimum Jaccard score required. Range [0, 1]. + * @default 0.1 + */ + minJaccard?: number; + /** + * Minimum confidence in the dominant direction. Range [0, 1]. + * @default 0.1 + */ + minConfidence?: number; + /** + * Cap on results returned (sorted by jaccard DESC). + * 0 means "no cap". + * @default 500 + */ + maxResults?: number; +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +/** + * Canonical key for an unordered pair of file paths. + * Sorting guarantees (A, B) and (B, A) produce the same key. + * + * Uses the NUL byte (U+0000) as separator because it is the one character + * that cannot legally appear in any OS file path. + */ +function pairKey(a: string, b: string): string { + return a < b ? `${a}\0${b}` : `${b}\0${a}`; +} + +/** + * Decode a canonical pair key back into its two constituent paths. + * @internal + */ +function splitPairKey(key: string): [string, string] { + const idx = key.indexOf("\0"); + return [key.slice(0, idx), key.slice(idx + 1)]; +} + +/** + * Build a Set of canonical pair keys from the existing dependency graph so we + * can do O(1) "is this pair already explicit?" lookups in Pass 4. + * + * Both directions (A→B and B→A) map to the same canonical key, so a single + * Set covers undirected filtering. + * + * Complexity: O(E) where E = total directed edges in the dependency graph. + */ +function buildExplicitEdgeSet( + dependencyGraph: Record +): Set { + const edges = new Set(); + for (const [source, targets] of Object.entries(dependencyGraph)) { + for (const target of targets) { + edges.add(pairKey(source, target)); + } + } + return edges; +} + +// ─── Main export ───────────────────────────────────────────────────────────── + +/** + * Computes implicit (logical) coupling between files based on co-change + * frequency in git history, then **subtracts** any pair already connected by + * an explicit import edge in `dependencyGraph`. + * + * The result is sorted by **Jaccard coefficient** descending — this ranks + * genuinely tightly-coupled pairs above files that happen to appear in many + * commits due to bulk churn. + * + * @param commits Output of `fetchLogicalCouplingCommits` — each inner + * array is the file paths changed in one commit. + * @param dependencyGraph Existing explicit import graph from your AST pass. + * @param options Optional filtering / result-cap configuration. + * + * @example + * ```ts + * const ghostEdges = computeImplicitCoupling(commits, dependencyGraph, { + * minCoChangeCount: 3, + * minJaccard: 0.15, + * maxResults: 200, + * }); + * + * // Render in React Flow: + * ghostEdges.forEach(({ fileA, fileB, jaccard, confidence }) => { + * addGhostEdge(fileA, fileB, { weight: jaccard, label: `${(confidence * 100).toFixed(0)}%` }); + * }); + * ``` + */ +export function computeImplicitCoupling( + commits: string[][], + dependencyGraph: Record, + options: CouplingOptions = {} +): ImplicitCouplingResult[] { + const { + minCoChangeCount = 3, + minJaccard = 0.1, + minConfidence = 0.1, + maxResults = 500, + } = options; + + const totalCommits = commits.length; + if (totalCommits === 0) return []; + + // ── Pass 1: Per-file commit frequency ───────────────────────────────────── + // O(M · F) + // fileCommitCount.get(f) = number of commits in which f appeared. + const fileCommitCount = new Map(); + + for (const files of commits) { + // Deduplicate within the commit — a file can theoretically be listed twice + // if GitHub returns both a rename source and target for the same path. + const unique = new Set(files); + for (const file of unique) { + fileCommitCount.set(file, (fileCommitCount.get(file) ?? 0) + 1); + } + } + + // ── Pass 2: Co-change frequency ─────────────────────────────────────────── + // O(M · F²) + // coChangeMap.get(pairKey(a, b)) = number of commits containing BOTH a and b. + // + // Inner loop: for a commit with F files, we generate C(F, 2) = F(F-1)/2 pairs. + // Real-world median F ≈ 5 → ≈ 10 pair insertions per commit — very cheap. + const coChangeMap = new Map(); + + for (const files of commits) { + const unique = Array.from(new Set(files)); + const n = unique.length; + + // Pairs are generated in a canonical nested loop; pairKey() handles sorting. + for (let i = 0; i < n - 1; i++) { + for (let j = i + 1; j < n; j++) { + const key = pairKey(unique[i], unique[j]); + coChangeMap.set(key, (coChangeMap.get(key) ?? 0) + 1); + } + } + } + + // ── Pass 3: Existing explicit edges ─────────────────────────────────────── + // O(E) + const explicitEdges = buildExplicitEdgeSet(dependencyGraph); + + // ── Pass 4: Compute metrics and apply filters ───────────────────────────── + // O(P) where P = coChangeMap.size + const results: ImplicitCouplingResult[] = []; + + for (const [key, coChangeCount] of coChangeMap) { + // ── Filter 1: minimum raw co-change count (noise gate) ───────────────── + if (coChangeCount < minCoChangeCount) continue; + + // ── Filter 2: skip pairs with an existing explicit import edge ────────── + if (explicitEdges.has(key)) continue; + + const [rawA, rawB] = splitPairKey(key); + + const cA = fileCommitCount.get(rawA) ?? 0; + const cB = fileCommitCount.get(rawB) ?? 0; + + // Guard: both files must appear in our commit data (should always be true). + if (cA === 0 || cB === 0) continue; + + // ── Confidence (directed conditional probabilities) ───────────────────── + const confAB = coChangeCount / cA; // P(B | A) + const confBA = coChangeCount / cB; // P(A | B) + + // Orient so that fileA is the "dominant" side (higher confidence). + // This makes fileA → fileB the primary edge direction for React Flow. + const [fileA, fileB, confidence, reverseConfidence] = + confAB >= confBA + ? [rawA, rawB, confAB, confBA] + : [rawB, rawA, confBA, confAB]; + + // ── Filter 3: minimum confidence (dominant direction) ─────────────────── + if (confidence < minConfidence) continue; + + // ── Jaccard similarity ─────────────────────────────────────────────────── + // jaccard = |A ∩ B| / |A ∪ B| = coChange / (cA + cB - coChange) + // Range: (0, 1]. Symmetric and unaffected by total commit volume. + const union = cA + cB - coChangeCount; + const jaccard = coChangeCount / union; + + // ── Filter 4: minimum Jaccard ──────────────────────────────────────────── + if (jaccard < minJaccard) continue; + + results.push({ + fileA, + fileB, + confidence, + reverseConfidence, + coChangeCount, + jaccard, + support: coChangeCount / totalCommits, + }); + } + + // ── Pass 5: Sort — primary key: jaccard DESC, tiebreak: coChangeCount DESC ─ + // O(P log P) + results.sort((a, b) => + b.jaccard !== a.jaccard + ? b.jaccard - a.jaccard + : b.coChangeCount - a.coChangeCount + ); + + // ── Apply result cap ─────────────────────────────────────────────────────── + return maxResults > 0 ? results.slice(0, maxResults) : results; +} + +// ─── Utility: enrich ghost edges with display metadata ─────────────────────── + +/** Display-ready ghost edge for React Flow consumption. */ +export interface GhostEdge { + id: string; + source: string; + target: string; + /** Normalised edge weight in [0, 1] based on Jaccard. */ + weight: number; + label: string; + data: ImplicitCouplingResult; +} + +/** + * Converts raw coupling results into React Flow edge descriptors. + * + * Useful if you want to pass the list directly to your ` ({ + id: `ghost::${pairKey(r.fileA, r.fileB)}`, + source: r.fileA, + target: r.fileB, + weight: r.jaccard, + label: `${(r.confidence * 100).toFixed(0)}% co-change · ${r.coChangeCount}×`, + data: r, + })); +} \ No newline at end of file diff --git a/lib/github/resolve-aliases.ts b/lib/github/resolve-aliases.ts index e69de29..5ad381a 100644 --- a/lib/github/resolve-aliases.ts +++ b/lib/github/resolve-aliases.ts @@ -0,0 +1,346 @@ +// lib/github/resolve-aliases.ts + +// --------------------------------------------------------------------------- +// CodeAutopsy - Path Alias Resolver +// +// The AST pipeline encounters import statements like: +// import { foo } from "@/lib/utils" +// import Bar from "~/components/Bar" +// +// Without resolving these aliases, the dependency graph has broken edges. +// This module: +// 1. Fetches tsconfig.json (jsconfig.json as fallback) from GitHub API +// 2. Parses compilerOptions.baseUrl + compilerOptions.paths +// 3. Detects Next.js and injects its implicit "@/" alias if not already set +// 4. Returns a compiled PathAliasResolver for fast lookups per import +// +// Used by: lib/pipeline/ast-pipeline.ts +// --------------------------------------------------------------------------- + +// ---------- Public types ---------------------------------------------------- + +/** + * Flat alias map after parsing tsconfig. + * Key = alias prefix WITHOUT trailing wildcard, e.g. "@/", "~/" + * Value = resolved directory prefix, e.g. "src/", "./" + */ +export type AliasMap = Record; + +/** + * The compiled resolver handed to the AST pipeline. + * Call resolve(importPath) on every import string encountered during + * graph construction. + * + * @example + * resolver.resolve("@/lib/utils") // "src/lib/utils" + * resolver.resolve("./sibling") // "./sibling" (unchanged) + */ +export interface PathAliasResolver { + resolve(importPath: string): string; + /** Raw alias map for debugging / display in the UI. */ + aliases: AliasMap; + /** False when no tsconfig was found or it had no path aliases. */ + hasAliases: boolean; +} + +// ---------- Internal types -------------------------------------------------- + +interface TsConfig { + compilerOptions?: { + baseUrl?: string; + paths?: Record; + }; + extends?: string; +} + +interface GitHubContentResponse { + type: string; + encoding: string; + content: string; +} + +// ---------- GitHub content fetch -------------------------------------------- + +/** + * Fetches a single file's raw text from a GitHub repo via the Contents API. + * Returns null on 404 or any non-OK response so callers can fall back cleanly. + */ +async function fetchRepoFile( + owner: string, + repo: string, + path: string, + token?: string, + branch?: string +): Promise { + const headers: Record = { + Accept: "application/vnd.github.v3+json", + }; + if (token) headers.Authorization = `Bearer ${token}`; + + const ref = branch ? `?ref=${encodeURIComponent(branch)}` : ""; + const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}${ref}`; + + try { + const res = await fetch(url, { headers }); + if (!res.ok) return null; + + const data: GitHubContentResponse = await res.json(); + + if (data.encoding === "base64" && data.content) { + // GitHub base64-encodes file content; embedded newlines must be stripped + const cleaned = data.content.replace(/\n/g, ""); + return Buffer.from(cleaned, "base64").toString("utf-8"); + } + + return null; + } catch { + return null; + } +} + +// ---------- tsconfig parsing ------------------------------------------------ + +/** + * Strips // line comments and block comments from JSON-like text so that + * tsconfig.json files (which allow comments) can be parsed with JSON.parse. + */ +function stripJsonComments(raw: string): string { + let result = raw.replace(/\/\/[^\n]*/g, ""); + result = result.replace(/\/\*[\s\S]*?\*\//g, ""); + return result; +} + +function parseTsConfig(raw: string): TsConfig | null { + try { + return JSON.parse(stripJsonComments(raw)) as TsConfig; + } catch { + return null; + } +} + +/** + * Resolves a tsconfig "extends" value to a repo-relative file path. + * Returns null for npm package references (e.g. "@tsconfig/node18/tsconfig.json") + * since those cannot be fetched from the repo itself. + */ +function resolveExtendsPath(extendsValue: string): string | null { + // npm package reference -- skip + if (!extendsValue.startsWith(".") && !extendsValue.startsWith("/")) return null; + return extendsValue.replace(/^\.\//, "").replace(/^\//, ""); +} + +/** + * Fetches the tsconfig and follows "extends" up to 3 levels deep, returning + * the merged compilerOptions. Child values always override parent values. + */ +async function fetchMergedTsConfig( + owner: string, + repo: string, + token: string | undefined, + branch: string | undefined, + configPath = "tsconfig.json", + depth = 0 +): Promise { + if (depth > 3) return null; + + const raw = await fetchRepoFile(owner, repo, configPath, token, branch); + if (!raw) return null; + + const config = parseTsConfig(raw); + if (!config) return null; + + let merged = { ...config.compilerOptions }; + + if (config.extends) { + const parentPath = resolveExtendsPath(config.extends); + if (parentPath) { + const parentOpts = await fetchMergedTsConfig( + owner, + repo, + token, + branch, + parentPath, + depth + 1 + ); + if (parentOpts) { + // Parent is the base; child overrides on top + merged = { ...parentOpts, ...merged }; + // Merge path maps: child entries override parent entries + merged.paths = { + ...(parentOpts.paths ?? {}), + ...(config.compilerOptions?.paths ?? {}), + }; + } + } + } + + return merged; +} + +// ---------- Next.js detection ----------------------------------------------- + +/** + * Returns true if the repo appears to be a Next.js project, detected by the + * presence of next.config.js / next.config.ts / next.config.mjs. + * + * When true we inject an implicit "@/" alias that Next.js provides even when + * it is absent from tsconfig.paths. + */ +async function detectNextJs( + owner: string, + repo: string, + token?: string, + branch?: string +): Promise { + const configs = ["next.config.js", "next.config.ts", "next.config.mjs"]; + for (const file of configs) { + const content = await fetchRepoFile(owner, repo, file, token, branch); + if (content !== null) return true; + } + return false; +} + +// ---------- Alias extraction ------------------------------------------------ + +/** + * Converts a single raw tsconfig paths entry into a prefix/target pair. + * + * tsconfig uses glob wildcards: + * "@/*" + "src/*" -> prefix "@/" target "src/" + * "~/*" + "./*" -> prefix "~/" target "./" + * "#utils" + "src/utils" -> prefix "#utils" target "src/utils" + * + * Only the first target in the array is used (standard convention). + */ +function extractAliasEntry( + aliasPattern: string, + targetPatterns: string[], + baseUrl: string +): { prefix: string; target: string } | null { + if (!targetPatterns.length) return null; + + const rawTarget = targetPatterns[0]; + + const prefix = aliasPattern.endsWith("/*") + ? aliasPattern.slice(0, -1) // "@/*" -> "@/" + : aliasPattern.endsWith("*") + ? aliasPattern.slice(0, -1) // "@*" -> "@" + : aliasPattern; // exact match + + const target = rawTarget.endsWith("/*") + ? rawTarget.slice(0, -1) // "src/*" -> "src/" + : rawTarget.endsWith("*") + ? rawTarget.slice(0, -1) + : rawTarget; + + // Prepend baseUrl when target is relative to it (not starting with ./ ../ /) + const resolvedTarget = + target.startsWith("./") || target.startsWith("/") || target.startsWith("../") + ? target.replace(/^\.\//, "") // strip "./" prefix + : baseUrl + ? `${baseUrl.replace(/\/$/, "")}/${target}`.replace(/^\.\//, "") + : target; + + return { prefix, target: resolvedTarget }; +} + +// ---------- Resolver factory ------------------------------------------------ + +function buildResolver(aliases: AliasMap): PathAliasResolver { + // Longest prefix first so "@/components" beats "@/" on a match + const sorted = Object.entries(aliases).sort((a, b) => b[0].length - a[0].length); + const hasAliases = sorted.length > 0; + + return { + aliases, + hasAliases, + resolve(importPath: string): string { + for (const [prefix, target] of sorted) { + if (importPath === prefix || importPath.startsWith(prefix)) { + return target + importPath.slice(prefix.length); + } + } + return importPath; + }, + }; +} + +// ---------- Main export ----------------------------------------------------- + +/** + * Fetches the repo's TypeScript/JavaScript path configuration and returns a + * compiled resolver the AST pipeline can call on every import it encounters. + * + * Resolution order: + * 1. tsconfig.json (follows "extends" up to 3 levels) + * 2. jsconfig.json if tsconfig is absent + * 3. Next.js implicit "@/" alias injected when next.config.* is detected + * + * Always returns a valid resolver -- if no config is found it is a no-op + * passthrough so the pipeline does not need to handle undefined. + * + * @example + * ```ts + * // In ast-pipeline.ts: + * const resolver = await buildPathAliasResolver({ owner, repo, token, branch }); + * + * // During AST import traversal: + * const realPath = resolver.resolve(importNode.source.value); + * dependencyGraph[currentFile].push(realPath); + * ``` + */ +export async function buildPathAliasResolver(options: { + owner: string; + repo: string; + token?: string; + branch?: string; +}): Promise { + const { owner, repo, token, branch } = options; + + // Step 1: Fetch tsconfig.json, fall back to jsconfig.json + const compilerOptions = + (await fetchMergedTsConfig(owner, repo, token, branch, "tsconfig.json")) ?? + (await fetchMergedTsConfig(owner, repo, token, branch, "jsconfig.json")); + + // Step 2: Detect Next.js for implicit alias injection + const isNextJs = await detectNextJs(owner, repo, token, branch); + + // Step 3: Build alias map from compilerOptions.paths + const aliasMap: AliasMap = {}; + + if (compilerOptions) { + const baseUrl = compilerOptions.baseUrl + ? compilerOptions.baseUrl.replace(/\/$/, "").replace(/^\.\//, "") + : ""; + + for (const [pattern, targets] of Object.entries(compilerOptions.paths ?? {})) { + const entry = extractAliasEntry(pattern, targets, baseUrl); + if (entry) { + aliasMap[entry.prefix] = entry.target; + } + } + } + + // Step 4: Inject Next.js "@/" alias if not already defined by tsconfig + // Next.js maps "@/" to the project root by default. "src/" is used here + // as it is the most common layout in modern Next.js projects. + if (isNextJs && !aliasMap["@/"]) { + aliasMap["@/"] = "src/"; + } + + return buildResolver(aliasMap); +} + +// ---------- Utility --------------------------------------------------------- + +/** + * One-shot resolver for cases where you already have an AliasMap and just + * need to resolve a single import path without building a full resolver object. + * + * @example + * resolveWithAliases("@/utils/format", { "@/": "src/" }) + * // "src/utils/format" + */ +export function resolveWithAliases(importPath: string, aliases: AliasMap): string { + return buildResolver(aliases).resolve(importPath); +} \ No newline at end of file