🌀 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(
+ ,
+ 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 (
+
+ );
+});
+
+// ─── 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 }) => (
+
+ ))}
+
+
+
+ {/* 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}
+
+ )}
+
+
+
+
+
+ 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) => (
+
+ ))}
+
+
+ )}
+
+
+ ))}
+
+
+ );
+}
+
+// ─── 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/DoctorPanel.tsx b/components/analyze/DoctorPanel.tsx
index a880163..68d65f8 100644
--- a/components/analyze/DoctorPanel.tsx
+++ b/components/analyze/DoctorPanel.tsx
@@ -51,14 +51,11 @@ export default function DoctorPanel({
{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") && (
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/constants/landing.ts b/lib/constants/landing.ts
index 3fcf03d..7df1b5e 100644
--- a/lib/constants/landing.ts
+++ b/lib/constants/landing.ts
@@ -106,32 +106,33 @@ export const pricingPlans = [
period: "/forever",
description: "For learning, scouting, and lightweight repo analysis.",
features: [
- "5 public repositories / month",
+ "5 autopsies / day",
"Groq Llama analysis",
"Architecture summary",
"Basic dependency map",
],
cta: "Start free",
- isPrimary: true,
+ isPrimary: false,
status: "active",
href: "/signup",
},
{
name: "Specialist",
- price: "$19",
+ price: "₹99",
period: "/mo",
description: "For engineers using CodeAutopsy in daily work.",
features: [
+ "100 autopsies / day",
"Private repositories",
"Advanced model routing",
"Markdown exports",
"Priority analysis queue",
],
- cta: "Locked",
- isPrimary: false,
- status: "coming-soon",
- badge: "Coming Soon",
- href: "#",
+ cta: "Upgrade to Specialist →",
+ isPrimary: true,
+ status: "active",
+ badge: "Early Access",
+ href: "/pricing",
},
{
name: "Chief Surgeon",
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/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/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
diff --git a/lib/usage.ts b/lib/usage.ts
index 399febd..613ad7e 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 = 5;
+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",