diff --git a/app/analyze/page.tsx b/app/analyze/page.tsx index a7864e1..8bbe5bf 100644 --- a/app/analyze/page.tsx +++ b/app/analyze/page.tsx @@ -9,6 +9,7 @@ import { memo, } from "react"; import { useSearchParams, useRouter } from "next/navigation"; +import ArchInsightsPanel from "@/components/analyze/ArchInsightsPanel"; import { AnimatePresence } from "framer-motion"; import dynamic from "next/dynamic"; import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; @@ -215,6 +216,14 @@ const AnalyzeContent = memo(() => { )} {activeTab === "risk_radar" && } + {activeTab === "arch_insights" && ( + + + + )} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index b9c6167..ddcaf36 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,17 +1,11 @@ +// app/dashboard/page.tsx import { createClient } from "@/utils/supabase/server"; import { redirect } from "next/navigation"; import BackButton from "@/components/BackButton"; import Link from "next/link"; import Image from "next/image"; -import { - Database, - Clock, - GitBranch, - ArrowRight, - LayoutGrid, - Plus, - GitPullRequest, -} from "lucide-react"; +import { LayoutGrid, Plus, GitPullRequest } from "lucide-react"; +import DashboardGrid from "@/components/dashboard/DashboardGrid"; export const metadata = { title: "Dashboard | CodeAutopsy", @@ -36,9 +30,9 @@ export default async function DashboardPage() { return (
- {/* ======================================================================== - DASHBOARD NAVBAR -======================================================================== */} + {/* ================================================================ + DASHBOARD NAVBAR + ================================================================ */} + + {/* Background effects */}
+ {/* ================================================================ + HEADER + ================================================================ */}
@@ -85,6 +84,9 @@ export default async function DashboardPage() {
+ {/* ================================================================ + WORKSPACE TOOLS + ================================================================ */}

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

-
-

- Historical Scans -

- - {error ? ( -
-

- Database Error -

-

{error.message}

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

- No Autopsies Found -

-

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

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

- {analysis.repo_name} -

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

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

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

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

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

+ Top architectural hubs +

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

+ +{pageRankTopFiles.length - 8} more +

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

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

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

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

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

+ {content} +

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

{title}

+

{description}

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

+ {label} +

+

{value}

+

{sub}

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

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

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

+ Files — sorted by impact +

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

+ {fileName(ap)} +

+ + {SEVERITY_LABEL[severity]} + +
+

+ {dirName(ap)} +

+
+ +
+

+ {disconnects} +

+

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

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

+ Dependency Bridges +

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

+ +{extraBridges} more bridges +

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

+ Instability Distribution +

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

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

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

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

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

+ +{extraFiles} more files +

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

+ Entry Points Traced +

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

+ Unreachable files by directory +

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

+ Architecture Insights +

+

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

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

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

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

+ Database Error +

+

{error}

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

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

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

+ No Autopsies Found +

+

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

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

+ {repo.repo_name} +

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