diff --git a/app/api/debug/analyze/route.ts b/app/api/debug/analyze/route.ts index cb4424c..ea7872c 100644 --- a/app/api/debug/analyze/route.ts +++ b/app/api/debug/analyze/route.ts @@ -1,18 +1,54 @@ +// app/api/debug/analyze/route.ts + import { NextRequest, NextResponse } from "next/server"; import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; import { parseStackTrace, extractErrorInfo } from "@/lib/debug/stack-parser"; import { traverseFromCrash } from "@/lib/debug/graph-traversal"; -import { analyzeDebugWithGemini } from "@/lib/debug/gemini-debug"; +import { analyzeDebugWithGemini } from "@/lib/debug/groq-debug"; import { highlightDebugPath } from "@/lib/debug/mermaid-highlighter"; import { fetchMissingFiles, extractLineContext } from "@/lib/debug/file-fetcher"; import { getCachedDebug, cacheDebug, hashStackTrace } from "@/lib/debug/cache"; import { applyDebugHeuristics, calculateConfidence, requiresRuntimeCheck } from "@/lib/debug/heuristics"; import { parseRepoUrl } from "@/lib/github"; +// ─── Constants ─────────────────────────────────────────────────────────────── + +const DAILY_LIMIT = 3; +const MAX_TRAVERSAL_NODES: Record = { + TypeError: 5, + ReferenceError: 5, + SyntaxError: 3, + RangeError: 4, + default: 10, +}; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function getMaxNodes(errorType: string): number { + return MAX_TRAVERSAL_NODES[errorType] ?? MAX_TRAVERSAL_NODES.default; +} + +function stripComments(content: string): string { + return content + .split("\n") + .map((line) => line.trim()) + .filter( + (line) => + line.length > 0 && + !line.startsWith("//") && + !line.startsWith("/*") && + !line.startsWith("*") + ) + .join("\n"); +} + +// ─── Route Handler ─────────────────────────────────────────────────────────── + export async function POST(req: NextRequest) { try { const cookieStore = await cookies(); + const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, @@ -25,55 +61,100 @@ export async function POST(req: NextRequest) { } ); - let userId = undefined; - let providerToken = undefined; + // ── 1. Auth ─────────────────────────────────────────────────────────────── + let userId: string | undefined; + let providerToken: string | undefined; try { - const { data: { user } } = await supabase.auth.getUser(); + const { + data: { user }, + } = await supabase.auth.getUser(); userId = user?.id; - - const { data: { session } } = await supabase.auth.getSession(); + + const { + data: { session }, + } = await supabase.auth.getSession(); providerToken = session?.provider_token ?? undefined; - } catch { + + if (!providerToken) { + console.warn( + "[debug/analyze] No provider token — GitHub file fetching will use unauthenticated API (60 req/hr limit)." + ); + } + } catch (authErr) { + console.warn( + "[debug/analyze] Auth fetch failed, continuing as guest:", + authErr + ); } + // ── 2. Input validation ─────────────────────────────────────────────────── const body = await req.json(); - const { repoUrl, stackTrace } = body; if (!repoUrl || !stackTrace || stackTrace.trim() === "") { return NextResponse.json( - { error: `Missing data. Received repoUrl: ${!!repoUrl}, stackTrace: ${!!stackTrace}` }, + { + error: `Missing data. Received repoUrl: ${!!repoUrl}, stackTrace: ${!!stackTrace}`, + }, { status: 400 } ); } + // ── 3. Validate repo URL before any DB/cache hits ───────────────────────── + const parsed = parseRepoUrl(repoUrl); + if (!parsed) { + return NextResponse.json( + { error: "Invalid GitHub URL" }, + { status: 400 } + ); + } + + const { owner, repo } = parsed; + + // ── 4. Rate limiting ────────────────────────────────────────────────────── + if (userId) { + const today = new Date().toISOString().split("T")[0]; + + const { count, error: countError } = await supabase + .from("debug_analyses") + .select("*", { count: "exact", head: true }) + .eq("user_id", userId) + .gte("created_at", `${today}T00:00:00.000Z`); + + if (countError) { + console.error("[debug/analyze] Rate limit check failed:", countError); + } else if ((count ?? 0) >= DAILY_LIMIT) { + return NextResponse.json( + { error: `Daily limit of ${DAILY_LIMIT} diagnoses reached. Try again tomorrow.` }, + { status: 429 } + ); + } + } + + // ── 5. Extract error info ───────────────────────────────────────────────── const { error_type, error_message } = extractErrorInfo(stackTrace); + // ── 6. Cache check ──────────────────────────────────────────────────────── const traceHash = hashStackTrace(stackTrace); const cached = await getCachedDebug(repoUrl, traceHash); - + if (cached) { return NextResponse.json({ ...cached, cached: true }); } - const parsed = parseRepoUrl(repoUrl); - if (!parsed) { - return NextResponse.json({ error: "Invalid GitHub URL" }, { status: 400 }); - } - - const { owner, repo } = parsed; - + // ── 7. Fetch latest analysis from DB (dynamic version) ──────────────────── const { data: analysis, error: dbError } = await supabase .from("analyses") .select("*") .eq("repo_url", repoUrl) - .eq("analysis_version", 3) + .order("analysis_version", { ascending: false }) .order("created_at", { ascending: false }) .limit(1) .maybeSingle(); if (dbError) { + console.error("[debug/analyze] DB fetch failed:", dbError); return NextResponse.json( { error: "Failed to fetch analysis from database" }, { status: 500 } @@ -90,28 +171,30 @@ export async function POST(req: NextRequest) { const { result_json } = analysis; const { dependencyGraph, fanIn, mermaidDiagram } = result_json; + // ── 8. Parse stack trace ────────────────────────────────────────────────── const allFiles = Object.keys(dependencyGraph); - const crashNode = parseStackTrace(stackTrace, allFiles); if (!crashNode) { return NextResponse.json( - { error: "Could not extract crash location from stack trace. Make sure the file exists in the analyzed repository." }, + { + error: + "Could not extract crash location from stack trace. Make sure the file exists in the analyzed repository.", + }, { status: 400 } ); } - let traversalPath = traverseFromCrash( - crashNode.file, - dependencyGraph, - fanIn - ); - + // ── 9. Graph traversal + heuristics ─────────────────────────────────────── + let traversalPath = traverseFromCrash(crashNode.file, dependencyGraph, fanIn); traversalPath = applyDebugHeuristics(traversalPath, error_type); - const fileContents = result_json.fileContents || []; + // ── 10. Fetch file contents ─────────────────────────────────────────────── + const fileContents: { path: string; content: string }[] = + result_json.fileContents || []; + const existingContents = new Map( - fileContents.map((f: { path: string; content: string }) => [f.path, f.content]) + fileContents.map((f) => [f.path, f.content]) ); const allContents = await fetchMissingFiles( @@ -122,41 +205,33 @@ export async function POST(req: NextRequest) { providerToken ); - const relevantCode = traversalPath.slice(0, 10).map((node) => { + // ── 11. Build relevant code snapshot ───────────────────────────────────── + const maxNodes = getMaxNodes(error_type); + + const relevantCode = traversalPath.slice(0, maxNodes).map((node) => { const content = allContents.get(node.file) || ""; - const is_crash_site = node.file === crashNode.file; - - let finalContent = ""; - if (is_crash_site) { - finalContent = extractLineContext(content, crashNode.line).snippet; - } else { - finalContent = content - .split("\n") - .map(line => line.trim()) - .filter(line => - line.length > 0 && - !line.startsWith("//") && - !line.startsWith("/*") && - !line.startsWith("*") - ) - .slice(0, 300) - .join("\n"); - } + const isCrashSite = node.file === crashNode.file; - let line_context = undefined; - if (is_crash_site) { - const ctx = extractLineContext(content, crashNode.line); - line_context = { start: ctx.start, end: ctx.end }; - } + const finalContent = isCrashSite + ? extractLineContext(content, crashNode.line).snippet + : stripComments(content).split("\n").slice(0, 300).join("\n"); + + const lineContext = isCrashSite + ? (() => { + const ctx = extractLineContext(content, crashNode.line); + return { start: ctx.start, end: ctx.end }; + })() + : undefined; return { file: node.file, content: finalContent, - is_crash_site, - line_context, + is_crash_site: isCrashSite, + line_context: lineContext, }; }); + // ── 12. AI analysis ─────────────────────────────────────────────────────── const debugInput = { error_type, error_message, @@ -172,10 +247,14 @@ export async function POST(req: NextRequest) { const debugResult = await analyzeDebugWithGemini(debugInput); + // ── 13. Post-processing ─────────────────────────────────────────────────── const confidence = calculateConfidence(traversalPath); const requires_runtime = requiresRuntimeCheck(error_type, error_message); - const suspectedRootCause = traversalPath.find(n => n.relationship === "upstream")?.file; + const suspectedRootCause = traversalPath.find( + (n) => n.relationship === "upstream" + )?.file; + const highlightedMermaid = highlightDebugPath( mermaidDiagram, crashNode.file, @@ -183,7 +262,8 @@ export async function POST(req: NextRequest) { suspectedRootCause ); - const { data: stored } = await supabase + // ── 14. Persist to DB ───────────────────────────────────────────────────── + const { data: stored, error: insertError } = await supabase .from("debug_analyses") .insert({ analysis_id: analysis.id, @@ -201,6 +281,11 @@ export async function POST(req: NextRequest) { .select() .maybeSingle(); + if (insertError) { + console.error("[debug/analyze] Failed to store debug result:", insertError); + } + + // ── 15. Cache + respond ─────────────────────────────────────────────────── const finalResult = { debug_id: stored?.id || "unknown", crash_node: crashNode, @@ -208,7 +293,7 @@ export async function POST(req: NextRequest) { root_cause_hypothesis: debugResult.root_cause_hypothesis, fix_suggestions: debugResult.fix_suggestions, verification_steps: debugResult.verification_steps, - confidence: confidence, + confidence, requires_runtime_check: requires_runtime, highlighted_mermaid: highlightedMermaid, }; @@ -218,6 +303,7 @@ export async function POST(req: NextRequest) { return NextResponse.json(finalResult); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; + console.error("[debug/analyze] Unhandled error:", message); return NextResponse.json({ error: message }, { status: 500 }); } } \ No newline at end of file diff --git a/components/ArchitectureMap.tsx b/components/ArchitectureMap.tsx index 97fbf0e..b9c8024 100644 --- a/components/ArchitectureMap.tsx +++ b/components/ArchitectureMap.tsx @@ -1,5 +1,3 @@ -// components/ArchitectureMap.tsx - "use client"; import InfoTooltip from "@/components/InfoTooltip"; @@ -44,6 +42,11 @@ import { GitPullRequest, Zap, } from "lucide-react"; +import { + computeArticulationPoints, + getRankedArticulationPoints, + type RankedArticulationPoint, +} from "@/lib/algorithms/articulationPoints"; function detectCircularDependencies(graph: Record): { cycleNodes: Set; @@ -133,6 +136,39 @@ function computePrBlastRadius( return { modifiedNodes, blastNodes }; } +// ── NEW: Self-contained PageRank computation ────────────────────────────────── +function computePageRank( + graph: Record, + iterations = 20, + dampingFactor = 0.85, +): Record { + const nodes = Object.keys(graph); + if (nodes.length === 0) return {}; + + const scores: Record = {}; + nodes.forEach((n) => (scores[n] = 1)); + + for (let iter = 0; iter < iterations; iter++) { + const next: Record = {}; + nodes.forEach((n) => (next[n] = 1 - dampingFactor)); + nodes.forEach((src) => { + const deps = graph[src] || []; + if (deps.length === 0) return; + deps.forEach((tgt) => { + next[tgt] = + (next[tgt] || 0) + (dampingFactor * scores[src]) / deps.length; + }); + }); + Object.assign(scores, next); + } + + // Normalize to 0–100 + const max = Math.max(...Object.values(scores), 1); + nodes.forEach((n) => (scores[n] = Math.round((scores[n] / max) * 100))); + return scores; +} +// ───────────────────────────────────────────────────────────────────────────── + type PageRankTier = 0 | 1 | 2 | 3; function getPageRankTier(score: number): PageRankTier { @@ -148,6 +184,7 @@ type ActiveMode = | "orphan" | "pr-blast" | "pagerank" + | "fragile" | null; interface GlassNodeData { @@ -165,6 +202,9 @@ interface GlassNodeData { pageRankScore?: number; pageRankTier?: PageRankTier; pageRankActive?: boolean; + isArticulationPoint?: boolean; + apDisconnects?: number; + isBridge?: boolean; } const PR_TIER_STYLES: Record< @@ -299,7 +339,17 @@ const GlassNode = ({ data }: { data: GlassNodeData }) => { let textClass = "text-sm font-mono truncate max-w-[200px] transition-colors"; let handleColor = "!bg-slate-500"; - if (isPrModified) { + if ( + data.isArticulationPoint && + !isPrModified && + !isPrBlast && + !isBlastRadius + ) { + containerClass += + " bg-red-500/8 border border-red-400/50 shadow-[0_0_18px_rgba(248,113,113,0.3)]"; + textClass += " text-red-200"; + handleColor = "!bg-red-400"; + } else if (isPrModified) { containerClass += " bg-yellow-500/15 border border-yellow-400/60 shadow-[0_0_20px_rgba(234,179,8,0.25)]"; textClass += " text-yellow-200"; @@ -370,9 +420,29 @@ const GlassNode = ({ data }: { data: GlassNodeData }) => { Circular )} + {data.isArticulationPoint && + !isBlastRadius && + !isPrModified && + !isPrBlast && + !isCircular && ( +
+ + Fragile +
+ )}
{data.label}
+ {data.isArticulationPoint && + data.apDisconnects !== undefined && + data.apDisconnects > 0 && + !isPrModified && + !isPrBlast && + !isBlastRadius && ( +
+ {data.apDisconnects} files affected +
+ )} ; prBlastNodes: Set; + rankedAPs: RankedArticulationPoint[]; + bridges: Array<[string, string]>; activeMode: ActiveMode; onActivateMode: (mode: ActiveMode) => void; onClearAll: () => void; @@ -545,6 +617,8 @@ function AnalysisSidebar({ prChangedFiles, prModifiedNodes, prBlastNodes, + rankedAPs, + bridges, activeMode, onActivateMode, onClearAll, @@ -558,6 +632,7 @@ function AnalysisSidebar({ const hasOrphans = orphans.length > 0; const hasPrData = prChangedFiles.length > 0; const hasPageRank = Object.keys(pageRankScores).length > 0; + const hasAPs = rankedAPs.length > 0; const RAIL = [ { @@ -710,7 +785,9 @@ function AnalysisSidebar({ ? "text-violet-400" : activeMode === "pagerank" ? "text-cyan-400" - : "text-slate-600" + : activeMode === "fragile" + ? "text-red-400" + : "text-slate-600" }`} > {activeMode === "pr-blast" @@ -723,14 +800,17 @@ function AnalysisSidebar({ ? "Orphan" : activeMode === "pagerank" ? "PageRank" - : heatmapEnabled - ? "Heatmap" - : "Default"} + : activeMode === "fragile" + ? "Fragile" + : heatmapEnabled + ? "Heatmap" + : "Default"}
+ {/* ── PR Blast Radius ── */}
@@ -844,6 +924,7 @@ function AnalysisSidebar({
+ {/* ── Blast Radius ── */}
+ {/* ── Circular Deps ── */}
@@ -958,6 +1040,125 @@ function AnalysisSidebar({
+ {/* ── Fragile Points ── */} +
+ + + + {activeMode === "fragile" && hasAPs && ( + +
+
+
+
+ + Articulation points + + + {rankedAPs.length} + +
+
+
+ + Bridge edges + + + {bridges.length} + +
+
+
+

+ Ranked by severity +

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

+ +{rankedAPs.length - 8} more +

+ )} +
+
+ + )} + +
+ + {/* ── Complexity Heatmap ── */}
@@ -1030,6 +1231,7 @@ function AnalysisSidebar({
+ {/* ── Orphans ── */}
@@ -1126,6 +1328,7 @@ function AnalysisSidebar({
+ {/* ── Nerve Center / PageRank ── */}
@@ -1344,22 +1547,47 @@ export default function ArchitectureMap({ return { prModifiedNodes: modifiedNodes, prBlastNodes: blastNodes }; }, [prChangedFiles, adjacencyList]); + // ── NEW: Compute PageRank internally if not passed in from parent ────────── + const computedPageRankScores = useMemo(() => { + if (Object.keys(pageRankScores).length > 0) return pageRankScores; + return computePageRank(dependencyGraph); + }, [dependencyGraph, pageRankScores]); + // ────────────────────────────────────────────────────────────────────────── + const pageRankTopFiles = useMemo( () => - Object.entries(pageRankScores) + Object.entries(computedPageRankScores) .sort(([, a], [, b]) => b - a) .slice(0, 20) .map(([path, score]) => ({ path, score })), - [pageRankScores], + [computedPageRankScores], ); const pageRankTierMap = useMemo(() => { const map = new Map(); - for (const [path, score] of Object.entries(pageRankScores)) { + for (const [path, score] of Object.entries(computedPageRankScores)) { map.set(path, getPageRankTier(score)); } return map; - }, [pageRankScores]); + }, [computedPageRankScores]); + + // ── Articulation point computation ──────────────────────────────────────── + const apResult = useMemo( + () => computeArticulationPoints(dependencyGraph), + [dependencyGraph], + ); + + const rankedAPs = useMemo( + () => getRankedArticulationPoints(apResult), + [apResult], + ); + + const apSet = useMemo(() => apResult.articulationPoints, [apResult]); + + const bridgeSet = useMemo( + () => new Set(apResult.bridges.map(([s, t]) => `e-${s}-${t}`)), + [apResult], + ); const prevPrFilesRef = useRef(""); useEffect(() => { @@ -1392,9 +1620,12 @@ export default function ArchitectureMap({ isOrphanHighlighted: false, isPrModified: false, isPrBlast: false, - pageRankScore: pageRankScores[filePath] ?? 0, - pageRankTier: getPageRankTier(pageRankScores[filePath] ?? 0), + pageRankScore: computedPageRankScores[filePath] ?? 0, + pageRankTier: getPageRankTier(computedPageRankScores[filePath] ?? 0), pageRankActive: false, + isArticulationPoint: false, + apDisconnects: 0, + isBridge: false, }, position: { x: 0, y: 0 }, }); @@ -1426,7 +1657,7 @@ export default function ArchitectureMap({ }); return { initialNodes: layoutNodes, initialEdges: layoutEdges, graphHash }; - }, [dependencyGraph, entryPoints, pageRankScores]); + }, [dependencyGraph, entryPoints, computedPageRankScores]); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -1468,6 +1699,8 @@ export default function ArchitectureMap({ isPrModified: false, isPrBlast: false, pageRankActive: false, + isArticulationPoint: false, + apDisconnects: 0, }, })), ); @@ -1482,6 +1715,45 @@ export default function ArchitectureMap({ return; } + if (activeMode === "fragile") { + setNodes((nds) => + nds.map((n) => ({ + ...n, + data: { + ...n.data, + pageRankActive: false, + isBlastRadius: false, + isPrModified: false, + isPrBlast: false, + isOrphanHighlighted: false, + isArticulationPoint: apSet.has(n.id), + apDisconnects: apResult.componentSizes.get(n.id) ?? 0, + isDimmed: !apSet.has(n.id), + }, + })), + ); + setEdges((eds) => + eds.map((e) => { + const isBridge = bridgeSet.has(e.id); + return { + ...e, + style: { + stroke: isBridge ? "#f87171" : "#475569", + strokeWidth: isBridge ? 3 : 2, + strokeDasharray: isBridge ? "6 3" : undefined, + opacity: isBridge ? 1 : 0.08, + }, + animated: false, + markerEnd: { + type: MarkerType.ArrowClosed, + color: isBridge ? "#f87171" : "#475569", + }, + }; + }), + ); + return; + } + if (activeMode === "pagerank") { setNodes((nds) => nds.map((n) => { @@ -1492,11 +1764,12 @@ export default function ArchitectureMap({ ...n.data, pageRankActive: true, pageRankTier: tier, - pageRankScore: pageRankScores[n.id] ?? 0, + pageRankScore: computedPageRankScores[n.id] ?? 0, isBlastRadius: false, isPrModified: false, isPrBlast: false, isOrphanHighlighted: false, + isArticulationPoint: false, isDimmed: tier === 0, }, }; @@ -1543,6 +1816,7 @@ export default function ArchitectureMap({ pageRankActive: false, isBlastRadius: false, isOrphanHighlighted: false, + isArticulationPoint: false, isPrModified: prModifiedNodes.has(n.id), isPrBlast: prBlastNodes.has(n.id), isDimmed: !prModifiedNodes.has(n.id) && !prBlastNodes.has(n.id), @@ -1594,6 +1868,7 @@ export default function ArchitectureMap({ isBlastRadius: false, isPrModified: false, isPrBlast: false, + isArticulationPoint: false, isDimmed: selectedOrphan ? n.id !== selectedOrphan : false, isOrphanHighlighted: selectedOrphan ? n.id === selectedOrphan @@ -1626,6 +1901,7 @@ export default function ArchitectureMap({ isBlastRadius: false, isPrModified: false, isPrBlast: false, + isArticulationPoint: false, isDimmed: !cycleNodes.has(n.id), isOrphanHighlighted: false, }, @@ -1679,6 +1955,7 @@ export default function ArchitectureMap({ isOrphanHighlighted: false, isPrModified: false, isPrBlast: false, + isArticulationPoint: false, }, })), ); @@ -1709,8 +1986,12 @@ export default function ArchitectureMap({ prModifiedNodes, prBlastNodes, pageRankTierMap, - pageRankScores, + computedPageRankScores, adjacencyList, + apSet, + apResult, + bridgeSet, + rankedAPs, setNodes, setEdges, ]); @@ -1788,7 +2069,9 @@ export default function ArchitectureMap({ ? "bg-orange-500/10 border-orange-500/25 text-orange-400" : activeMode === "pagerank" ? "bg-cyan-500/10 border-cyan-500/25 text-cyan-400" - : "bg-violet-500/10 border-violet-500/25 text-violet-400" + : activeMode === "fragile" + ? "bg-red-500/10 border-red-500/25 text-red-400" + : "bg-violet-500/10 border-violet-500/25 text-violet-400" }`} > @@ -1800,9 +2083,11 @@ export default function ArchitectureMap({ ? "Circular deps highlighted" : activeMode === "pagerank" ? `PageRank · ${pageRankTopFiles.filter((f) => getPageRankTier(f.score) >= 2).length} hubs` - : selectedOrphan - ? `Orphan: ${selectedOrphan.split("/").pop()}` - : "Orphan mode — select a file"} + : activeMode === "fragile" + ? `Fragile · ${rankedAPs.length} APs · ${apResult.bridges.length} bridges` + : selectedOrphan + ? `Orphan: ${selectedOrphan.split("/").pop()}` + : "Orphan mode — select a file"} @@ -1855,10 +2140,12 @@ export default function ArchitectureMap({ prChangedFiles={prChangedFiles} prModifiedNodes={prModifiedNodes} prBlastNodes={prBlastNodes} + rankedAPs={rankedAPs} + bridges={apResult.bridges} activeMode={activeMode} onActivateMode={handleActivateMode} onClearAll={handleClearAll} - pageRankScores={pageRankScores} + pageRankScores={computedPageRankScores} pageRankTopFiles={pageRankTopFiles} />
diff --git a/components/RiskDashboard.tsx b/components/RiskDashboard.tsx index 5d8e32a..ceb88a0 100644 --- a/components/RiskDashboard.tsx +++ b/components/RiskDashboard.tsx @@ -10,7 +10,6 @@ import { Target, AlertCircle, Lock, - Sparkles, } from "lucide-react"; interface CoverageGap { @@ -140,21 +139,33 @@ export default function RiskDashboard({ - -

- - Auto-Patch is a Pro feature. + +

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

)} @@ -210,7 +221,7 @@ export default function RiskDashboard({ ) : ( <> - + Auto-Patch )} @@ -218,7 +229,7 @@ export default function RiskDashboard({ ) : (
); @@ -701,12 +708,10 @@ function StabilityTab({ data }: { data: RepoData }) { )}
-
+
@@ -954,7 +959,7 @@ export default function ArchInsightsPanel({ {tab.label} {isLocked && ( - + )} @@ -973,7 +978,7 @@ export default function ArchInsightsPanel({ {current.proGated && !isPro && ( - + Pro )} diff --git a/components/analyze/PrImpactTab.tsx b/components/analyze/PrImpactTab.tsx index 38d0b57..40d0d56 100644 --- a/components/analyze/PrImpactTab.tsx +++ b/components/analyze/PrImpactTab.tsx @@ -16,7 +16,10 @@ import { ArrowLeft, GitBranch, Lock, - Sparkles, + Zap, + ShieldAlert, + UserCheck, + Share2, } from "lucide-react"; import { RepoData, PRAnalysisResult } from "@/lib/types/analyze"; @@ -27,6 +30,8 @@ interface PrImpactTabProps { onViewOnGraph: () => void; } +// ── Pro Gate ────────────────────────────────────────────────────────────────── + function ProGate({ onUpgrade }: { onUpgrade: () => void }) { return ( void }) { transition={{ type: "spring", stiffness: 380, damping: 30 }} className="w-full mt-4" > - {/* Blurred ghost preview */} -
-
-
-
-
+
+ {/* Ghost preview */} +
+
+
+
-
+
{[...Array(4)].map((_, i) => (
))}
@@ -54,38 +59,102 @@ function ProGate({ onUpgrade }: { onUpgrade: () => void }) { {[...Array(3)].map((_, i) => (
))}
{/* Overlay */} -
-
- +
+ {/* Lock icon */} +
+
-
-

Pro Feature

-

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

+

+ PR Impact requires Pro

+

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

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

+ Already on Pro?{" "} + +

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

+ PR Impact Analyzer +

+

+ Blast radius · Breaking deps · Reviewer suggestions +

+
-

- PR Impact Analyzer -

-

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

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

+

{prError}

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

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

-

+

{prResult.description}

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

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

+ Blast Radius

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

+

{item.impact}

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

+

No files impacted.

)}
-
-
-

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

+ Architectural Changes

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

- Breaking Dependencies + +
+

+ Breaking Dependencies

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

- Context-Aware Reviewers +

+ Context-Aware Reviewers

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

+

{reviewer.reason}

@@ -416,21 +497,22 @@ export default function PrImpactTab({ )} + {/* Back button */} diff --git a/components/analyze/RiskRadarPanel.tsx b/components/analyze/RiskRadarPanel.tsx index 8be9b85..e3e70c6 100644 --- a/components/analyze/RiskRadarPanel.tsx +++ b/components/analyze/RiskRadarPanel.tsx @@ -1,5 +1,3 @@ -// components/analyze/RiskRadarPanel.tsx - "use client"; import { motion } from "framer-motion"; @@ -7,6 +5,11 @@ import dynamic from "next/dynamic"; import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { RepoData } from "@/lib/types/analyze"; import SkeletonLoader from "@/components/analyze/SkeletonLoader"; +import { + computeArticulationPoints, + getRankedArticulationPoints, +} from "@/lib/algorithms/articulationPoints"; +import { useMemo } from "react"; const RiskDashboard = dynamic(() => import("@/components/RiskDashboard"), { loading: () => , @@ -19,6 +22,26 @@ interface RiskRadarPanelProps { } export default function RiskRadarPanel({ data, isPro }: RiskRadarPanelProps) { + const apResult = useMemo( + () => + data.dependencyGraph + ? computeArticulationPoints(data.dependencyGraph) + : { + articulationPoints: new Set(), + bridges: [] as Array<[string, string]>, + componentSizes: new Map(), + }, + [data.dependencyGraph], + ); + + const rankedAPs = useMemo( + () => getRankedArticulationPoints(apResult), + [apResult], + ); + + const hasAPs = rankedAPs.length > 0; + const hasBridges = apResult.bridges.length > 0; + return ( - {data.coverageGaps && data.fileContents ? ( - - - - ) : ( -
- No risk data available for this codebase. -
- )} +
+ {/* ── Fragile Points Panel ── */} + {data.dependencyGraph && ( +
+
+
+
+ + Fragile Points + +
+
+ + {hasAPs ? `${rankedAPs.length} APs` : "CLEAN"} + + + {hasBridges + ? `${apResult.bridges.length} bridges` + : "NO BRIDGES"} + +
+
+ + {!hasAPs && !hasBridges ? ( +
+
+ + + +
+

+ No structural single points of failure detected +

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

+ Articulation points — ranked by severity +

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

+ {path} +

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

+ Bridge edges — critical import paths +

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

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

+ )} +
+
+ )} +
+ )} +
+ )} + + {/* ── Existing RiskDashboard ── */} + {data.coverageGaps && data.fileContents ? ( + + + + ) : ( + !data.dependencyGraph && ( +
+ No risk data available for this codebase. +
+ ) + )} +
); } diff --git a/lib/algorithms/__tests__/articulationPoints.test.ts b/lib/algorithms/__tests__/articulationPoints.test.ts new file mode 100644 index 0000000..f0b7bee --- /dev/null +++ b/lib/algorithms/__tests__/articulationPoints.test.ts @@ -0,0 +1,280 @@ +// lib/algorithms/__tests__/articulationPoints.test.ts + +import { + computeArticulationPoints, + getRankedArticulationPoints, + type ArticulationPointResult, +} from "../articulationPoints"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Sorted array of AP paths — stable for snapshot comparisons. */ +function sortedAPs(result: ArticulationPointResult): string[] { + return [...result.articulationPoints].sort(); +} + +/** Sorted array of bridge strings "source->target" — stable for comparisons. */ +function sortedBridges(result: ArticulationPointResult): string[] { + return [...result.bridges] + .map(([s, t]) => `${s}->${t}`) + .sort(); +} + +// ── Edge-case / defensive tests ─────────────────────────────────────────────── + +describe("Edge cases", () => { + it("returns empty result for an empty graph", () => { + const result = computeArticulationPoints({}); + expect(result.articulationPoints.size).toBe(0); + expect(result.bridges).toHaveLength(0); + expect(result.componentSizes.size).toBe(0); + }); + + it("returns empty result for a single node with no edges", () => { + const result = computeArticulationPoints({ "a.ts": [] }); + expect(result.articulationPoints.size).toBe(0); + expect(result.bridges).toHaveLength(0); + }); + + it("handles self-loops without crashing or false positives", () => { + // A self-import is structurally meaningless — should be ignored + const result = computeArticulationPoints({ "a.ts": ["a.ts"] }); + expect(result.articulationPoints.size).toBe(0); + expect(result.bridges).toHaveLength(0); + }); + + it("handles duplicate import entries (same target twice)", () => { + // Duplicate imports must collapse to one undirected edge — not a false bridge + const result = computeArticulationPoints({ + "a.ts": ["b.ts", "b.ts"], + "b.ts": [], + }); + expect(sortedBridges(result)).toEqual(["a.ts->b.ts"]); + // a.ts is NOT an AP — it's a leaf with one child, and the root + // with only 1 DFS child is not an AP + expect(result.articulationPoints.has("a.ts")).toBe(false); + }); + + it("handles nodes that appear only as import targets (no outgoing edges)", () => { + // "leaf.ts" is never a key but is a valid node + const result = computeArticulationPoints({ + "entry.ts": ["middle.ts"], + "middle.ts": ["leaf.ts"], + }); + // middle.ts is on the only path from entry.ts to leaf.ts → AP + expect(result.articulationPoints.has("middle.ts")).toBe(true); + expect(sortedBridges(result).length).toBeGreaterThan(0); + }); + + it("handles empty string targets gracefully", () => { + const result = computeArticulationPoints({ "a.ts": ["", "b.ts", ""] }); + // Empty strings are ignored; a.ts→b.ts is a single bridge + expect(result.articulationPoints.size).toBe(0); + expect(sortedBridges(result)).toEqual(["a.ts->b.ts"]); + }); +}); + +// ── Simple chain (A → B → C) ────────────────────────────────────────────────── + +describe("Linear chain: A → B → C", () => { + const graph = { + "a.ts": ["b.ts"], + "b.ts": ["c.ts"], + "c.ts": [], + }; + + let result: ArticulationPointResult; + beforeAll(() => { result = computeArticulationPoints(graph); }); + + it("identifies b.ts as the only articulation point", () => { + expect(sortedAPs(result)).toEqual(["b.ts"]); + }); + + it("identifies both edges as bridges", () => { + expect(sortedBridges(result)).toEqual(["a.ts->b.ts", "b.ts->c.ts"]); + }); + + it("records a non-zero component size for b.ts", () => { + expect(result.componentSizes.get("b.ts")).toBeGreaterThan(0); + }); +}); + +// ── Triangle (no APs or bridges) ───────────────────────────────────────────── + +describe("Triangle: A → B → C → A", () => { + const graph = { + "a.ts": ["b.ts"], + "b.ts": ["c.ts"], + "c.ts": ["a.ts"], + }; + + let result: ArticulationPointResult; + beforeAll(() => { result = computeArticulationPoints(graph); }); + + it("finds no articulation points", () => { + expect(result.articulationPoints.size).toBe(0); + }); + + it("finds no bridges", () => { + expect(result.bridges).toHaveLength(0); + }); +}); + +// ── Classic AP graph ────────────────────────────────────────────────────────── +// +// 1 ─ 2 ─ 3 +// │ +// 4 ─ 5 ─ 6 +// +// Node 2 connects {1,3} to {4,5,6} — AP. +// Node 5 connects {4} to {6} via 2 — AP. + +describe("Classic AP graph", () => { + const graph: Record = { + "1": ["2"], + "2": ["1", "3", "5"], + "3": ["2"], + "4": ["5"], + "5": ["2", "4", "6"], + "6": ["5"], + }; + + let result: ArticulationPointResult; + beforeAll(() => { result = computeArticulationPoints(graph); }); + + it("identifies nodes 2 and 5 as articulation points", () => { + expect(sortedAPs(result)).toEqual(["2", "5"]); + }); + + it("finds no bridges (all edges have alternative paths via the cycle)", () => { + // The 2–5 edge is a bridge; everything else is in a biconnected component + expect(sortedBridges(result)).toEqual(["2->5"]); + }); + + it("component size for node 2 reflects the larger fragment", () => { + // Removing 2: fragments are {1,3} (size 2) and {4,5,6} (size 3) → largest = 3 + expect(result.componentSizes.get("2")).toBe(3); + }); + + it("component size for node 5 reflects the larger fragment", () => { + // Removing 5: fragments are {4} (size 1) and {1,2,3,6} (size 4) → largest = 4 + expect(result.componentSizes.get("5")).toBe(4); + }); +}); + +// ── Mutual imports (A ↔ B) ──────────────────────────────────────────────────── + +describe("Mutual imports: A ↔ B", () => { + const graph = { + "a.ts": ["b.ts"], + "b.ts": ["a.ts"], + }; + + let result: ArticulationPointResult; + beforeAll(() => { result = computeArticulationPoints(graph); }); + + it("finds no articulation points (the pair forms a cycle)", () => { + expect(result.articulationPoints.size).toBe(0); + }); + + it("finds no bridges (the mutual import provides a redundant path)", () => { + expect(result.bridges).toHaveLength(0); + }); +}); + +// ── Disconnected graph ──────────────────────────────────────────────────────── +// +// Island 1: A → B → C (B is AP) +// Island 2: D → E (bridge D→E) + +describe("Disconnected graph", () => { + const graph = { + "a.ts": ["b.ts"], + "b.ts": ["c.ts"], + "c.ts": [], + "d.ts": ["e.ts"], + "e.ts": [], + }; + + let result: ArticulationPointResult; + beforeAll(() => { result = computeArticulationPoints(graph); }); + + it("finds b.ts as an AP in island 1", () => { + expect(result.articulationPoints.has("b.ts")).toBe(true); + }); + + it("finds bridges in both islands", () => { + const bridges = sortedBridges(result); + expect(bridges).toContain("a.ts->b.ts"); + expect(bridges).toContain("b.ts->c.ts"); + expect(bridges).toContain("d.ts->e.ts"); + }); +}); + +// ── Star topology ───────────────────────────────────────────────────────────── +// +// hub imports 4 leaves — hub is an AP; all edges are bridges. + +describe("Star topology: hub → [l1, l2, l3, l4]", () => { + const graph = { + "hub.ts": ["l1.ts", "l2.ts", "l3.ts", "l4.ts"], + "l1.ts": [], + "l2.ts": [], + "l3.ts": [], + "l4.ts": [], + }; + + let result: ArticulationPointResult; + beforeAll(() => { result = computeArticulationPoints(graph); }); + + it("identifies hub.ts as the only articulation point", () => { + expect(sortedAPs(result)).toEqual(["hub.ts"]); + }); + + it("identifies all 4 edges as bridges", () => { + expect(result.bridges).toHaveLength(4); + }); + + it("records the correct component size for hub.ts", () => { + // Removing hub: 4 isolated leaves → largest fragment = 1 + expect(result.componentSizes.get("hub.ts")).toBe(1); + }); +}); + +// ── getRankedArticulationPoints ─────────────────────────────────────────────── + +describe("getRankedArticulationPoints", () => { + it("returns APs sorted by disconnects descending", () => { + // Chain A→B→C→D: both B and C are APs + // Removing B: {A} and {C,D} → largest fragment = 2 + // Removing C: {A,B} and {D} → largest fragment = 2 + // (tie — order stable if sort is stable, but we only check structure) + const graph = { + "a.ts": ["b.ts"], + "b.ts": ["c.ts"], + "c.ts": ["d.ts"], + "d.ts": [], + }; + const result = computeArticulationPoints(graph); + const ranked = getRankedArticulationPoints(result); + + expect(ranked.length).toBe(2); + // Every entry has both required fields + for (const entry of ranked) { + expect(typeof entry.path).toBe("string"); + expect(typeof entry.disconnects).toBe("number"); + } + // Sorted: first entry has disconnects >= last entry + expect(ranked[0].disconnects).toBeGreaterThanOrEqual( + ranked[ranked.length - 1].disconnects, + ); + }); + + it("returns an empty array when there are no APs", () => { + const result = computeArticulationPoints({ + "a.ts": ["b.ts"], + "b.ts": ["a.ts"], + }); + expect(getRankedArticulationPoints(result)).toEqual([]); + }); +}); \ No newline at end of file diff --git a/lib/algorithms/articulationPoints.ts b/lib/algorithms/articulationPoints.ts index d9083a7..24cf752 100644 --- a/lib/algorithms/articulationPoints.ts +++ b/lib/algorithms/articulationPoints.ts @@ -1,112 +1,383 @@ -// lib/algorithms/articulationPoints.ts +/** + * Articulation Point & Bridge Detection — Single Points of Failure + * + * Identifies structural vulnerabilities in the directed dependency graph by + * analysing the underlying undirected topology. + * + * ── Definitions ────────────────────────────────────────────────────────────── + * + * Articulation Point (AP) + * A vertex whose removal increases the number of connected components. + * CodeAutopsy interpretation: a file that, if deleted or fatally broken, + * splits the codebase into disconnected islands. + * + * Bridge + * An edge whose removal increases the number of connected components. + * CodeAutopsy interpretation: a single import relationship with no + * alternative path around it — severing it severs the graph. + * + * ── Mathematical basis: Tarjan (1972) ──────────────────────────────────────── + * + * disc[u] Discovery time of vertex u in the DFS traversal. + * low[u] Minimum disc value reachable from u's DFS subtree via ≤1 back-edge. + * + * Non-root AP: ∃ DFS child v of u s.t. low[v] >= disc[u] + * Root AP: DFS-tree root with ≥ 2 independent subtree children + * Bridge (u,v): low[v] > disc[u] where v is a DFS-tree child of u + * + * ── Engineering decisions ──────────────────────────────────────────────────── + * + * 1. ITERATIVE DFS + * V8's call stack saturates at ~10k–15k frames. Recursive Tarjan will + * throw "Maximum call stack size exceeded" on any large monorepo. + * An explicit DFSFrame stack is semantically equivalent and safe at + * any graph depth. + * + * 2. EDGE-ID PARENT TRACKING (not node-index tracking) + * The classic "skip the parent node" heuristic silently breaks for mutual + * imports (A→B and B→A both present). Using a per-edge integer ID means + * only the exact arrival edge is skipped; the second undirected edge from + * the mutual import is correctly processed as a back-edge, proving the + * A–B pair is not a bridge and neither node is a false AP. + * + * 3. DIRECTED-EDGE DEDUPLICATION + * Duplicate import statements (same target twice in one file) would create + * a false multi-edge, incorrectly suppressing bridge detection. A packed- + * integer Set (u × N + v) collapses identical directed edges in O(1). + * + * 4. O(1) BFS QUEUE (head-pointer index) + * Array.shift() is O(n) — it copies every remaining element one slot. + * The component-size BFS uses a monotonically advancing head index into a + * plain array, reducing dequeue cost to O(1) amortised. + * + * 5. TYPED-ARRAY SEEN-TRACKING + * Uint8Array over a plain boolean[] or Set for the BFS visited + * state. V8 maps Uint8Array to a contiguous byte buffer; fill(0) compiles + * to a single memset call — reset cost between AP iterations is negligible. + * + * Complexity: O(V + E) — main algorithm (Tarjan) + * O(V + E) — component-size pass (amortised across all APs) + */ +// ── Public types ────────────────────────────────────────────────────────────── + +/** + * The complete result of the articulation-point analysis. + */ export interface ArticulationPointResult { + /** + * File paths that are structural single points of failure. + * + * Stored as a `Set` for O(1) membership checks — the dominant use-case + * when decorating thousands of React Flow nodes in a render pass: + * `if (articulationPoints.has(node.id)) { ... }`. + */ articulationPoints: Set; + + /** + * Directed import edges whose removal disconnects the graph. + * + * Each tuple `[source, target]` preserves the original dependency-graph + * direction: `source` imports `target`, and that specific import is the + * sole structural connection between their respective components. + */ bridges: Array<[string, string]>; - componentSizes: Map; + + /** + * For each articulation point, the node-count of the *largest* component + * that fragments off when it is removed. + * + * Use this to rank AP severity in the UI: an AP that disconnects 800 files + * is categorically more critical than one that disconnects 3. + * + * UI label should read "X files affected" — not "X components" — since + * this value is a file count, not a component count. + */ + componentSizes: Map; +} + +/** + * A single articulation point with its pre-computed severity rank. + * Returned by `getRankedArticulationPoints` for direct UI consumption. + */ +export interface RankedArticulationPoint { + /** Absolute file path of the articulation point. */ + path: string; + /** + * Number of nodes in the largest fragment that splits off on removal. + * Higher = more severe. + */ + disconnects: number; +} + +// ── Internal types ──────────────────────────────────────────────────────────── + +/** Single entry in the undirected adjacency list. */ +interface AdjEntry { + readonly v: number; + /** + * Unique integer ID of the undirected edge. + * Both endpoints share this ID — used for parent-edge exclusion + * instead of node-index comparison. + */ + readonly edgeId: number; } +/** + * One frame on the explicit DFS call stack. + * Mirrors a single recursive invocation of dfs(u). + * `adjIdx` and `children` are mutated in-place as the frame is processed. + */ +interface DFSFrame { + readonly u: number; + /** + * Edge ID on which we arrived at `u`. + * `-1` for the DFS-forest root of each connected component. + */ + readonly parentEdgeId: number; + /** Cursor into `adj[u]` — resumed on re-entry after a child returns. */ + adjIdx: number; + /** + * DFS-tree child count. Only meaningful for component roots + * (root AP check: children >= 2). + */ + children: number; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function emptyResult(): ArticulationPointResult { + return { + articulationPoints: new Set(), + bridges: [], + componentSizes: new Map(), + }; +} + +// ── Core algorithm ──────────────────────────────────────────────────────────── + +/** + * Finds every articulation point, bridge, and per-AP component-size in the + * underlying undirected topology of a directed dependency graph. + * + * @param dependencyGraph `graph[A] = [B, C]` means file A imports files B and C. + * @returns Deduplicated sets of APs, bridges, and severity sizes. + */ export function computeArticulationPoints( - graph: Record, + dependencyGraph: Record, ): ArticulationPointResult { - const nodes = Object.keys(graph); - if (nodes.length === 0) { - return { - articulationPoints: new Set(), - bridges: [], - componentSizes: new Map(), - }; + // ── 0. Fast path ────────────────────────────────────────────────────────── + if (Object.keys(dependencyGraph).length === 0) return emptyResult(); + + // ── 1. Enumerate all vertices ───────────────────────────────────────────── + // Include import targets that are not graph keys (leaf files with no + // outgoing imports) so the undirected graph is topologically complete. + const nodeSet = new Set(Object.keys(dependencyGraph)); + for (const targets of Object.values(dependencyGraph)) { + for (const t of targets) { + if (t) nodeSet.add(t); + } } - // Build undirected adjacency for AP detection - const adj = new Map>(); - for (const node of nodes) { - if (!adj.has(node)) adj.set(node, new Set()); - for (const dep of graph[node] || []) { - if (!adj.has(dep)) adj.set(dep, new Set()); - adj.get(node)!.add(dep); - adj.get(dep)!.add(node); + const nodes = Array.from(nodeSet); + const nodeId = new Map(nodes.map((n, i) => [n, i] as const)); + const N = nodes.length; + + if (N === 0) return emptyResult(); + + // ── 2. Build undirected adjacency list with edge IDs ────────────────────── + // + // Each unique directed edge A→B becomes ONE undirected edge registered in + // both adj[A] and adj[B] under the same integer edgeId. + // + // Deduplication key: ordered pair (u, v) packed as (u × N + v). + // Duplicate import statements → same key → collapsed to one edge. + // A→B and B→A (mutual imports) → different keys → two separate edges, + // which correctly prevents false bridge / false AP detection. + // + // edgeOrigin[eid] = [directedSrc, directedTgt] preserves the original + // arrow direction for accurate bridge-tuple reporting. + + const adj : AdjEntry[][] = Array.from({ length: N }, () => []); + const edgeOrigin : [number, number][] = []; + const seenEdges = new Set(); + + for (const [source, targets] of Object.entries(dependencyGraph)) { + const u = nodeId.get(source); + if (u === undefined) continue; + + for (const target of targets) { + if (!target) continue; + + const v = nodeId.get(target); + if (v === undefined || u === v) continue; // guard + self-loop elimination + + const packed = u * N + v; + if (seenEdges.has(packed)) continue; + seenEdges.add(packed); + + const eid = edgeOrigin.length; + edgeOrigin.push([u, v]); + adj[u].push({ v, edgeId: eid }); + adj[v].push({ v: u, edgeId: eid }); } } - const allNodes = Array.from(adj.keys()); - const visited = new Set(); - const disc = new Map(); // discovery time - const low = new Map(); // lowest discovery reachable - const parent = new Map(); - const aps = new Set(); - const bridges: Array<[string, string]> = []; - let timer = 0; - - const dfs = (u: string) => { - visited.add(u); - disc.set(u, timer); - low.set(u, timer); - timer++; - - let childCount = 0; - - for (const v of adj.get(u) || []) { - if (!visited.has(v)) { - childCount++; - parent.set(v, u); - dfs(v); - - low.set(u, Math.min(low.get(u)!, low.get(v)!)); - - // AP condition 1: u is root with 2+ children - if (parent.get(u) === null && childCount > 1) aps.add(u); - - // AP condition 2: u is not root and low[v] >= disc[u] - if (parent.get(u) !== null && low.get(v)! >= disc.get(u)!) aps.add(u); - - // Bridge condition - if (low.get(v)! > disc.get(u)!) bridges.push([u, v]); - } else if (v !== parent.get(u)) { - low.set(u, Math.min(low.get(u)!, disc.get(v)!)); + // ── 3. Tarjan's AP / Bridge — iterative DFS ─────────────────────────────── + // + // Outer loop restarts from every unvisited node, ensuring full coverage of + // disconnected sub-graphs and isolated files. + + const disc = new Int32Array(N).fill(-1); // -1 ≡ unvisited + const low = new Int32Array(N); + + const apIndices = new Set(); // vertex indices of confirmed APs + const bridgeEids = new Set(); // edge IDs of confirmed bridges + let timer = 0; + + for (let root = 0; root < N; root++) { + if (disc[root] !== -1) continue; + + disc[root] = low[root] = timer++; + + const stack: DFSFrame[] = [ + { u: root, parentEdgeId: -1, adjIdx: 0, children: 0 }, + ]; + + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const { u } = frame; + let pushedChild = false; + + // Scan u's adjacency list from where we last paused. + // Back-edges are processed inline; we pause and descend on tree-edges. + while (frame.adjIdx < adj[u].length) { + const { v, edgeId: eid } = adj[u][frame.adjIdx++]; + + if (eid === frame.parentEdgeId) continue; // skip the arrival edge + + if (disc[v] === -1) { + // Tree edge — descend into child + frame.children++; + disc[v] = low[v] = timer++; + stack.push({ u: v, parentEdgeId: eid, adjIdx: 0, children: 0 }); + pushedChild = true; + break; // adjIdx already advanced; resume here after child returns + } else { + // Back-edge — tighten low[u] + if (disc[v] < low[u]) low[u] = disc[v]; + } } - } - }; - for (const node of allNodes) { - if (!visited.has(node)) { - parent.set(node, null); - dfs(node); + if (pushedChild) continue; + + // u fully explored — simulate recursive return + stack.pop(); + + // Root AP: a DFS-tree root is an AP iff it spawned ≥ 2 children, + // because only then does its removal disconnect those subtrees. + if (frame.parentEdgeId === -1) { + if (frame.children >= 2) apIndices.add(u); + continue; // root has no parent; nothing to propagate + } + + const parentFrame = stack[stack.length - 1]; // always valid here + const pu = parentFrame.u; + + // Propagate low upward + if (low[u] < low[pu]) low[pu] = low[u]; + + // Non-root AP: u's subtree cannot back-reach any ancestor of pu, + // so pu is the sole connector. Guard skips the check when pu is a + // root (its AP status is governed by children count only). + if (parentFrame.parentEdgeId !== -1 && low[u] >= disc[pu]) { + apIndices.add(pu); + } + + // Bridge: u's subtree cannot even back-reach pu — the tree-edge + // (pu → u) is the only structural connection; removing it disconnects. + if (low[u] > disc[pu]) { + bridgeEids.add(frame.parentEdgeId); + } } } - // For each AP, estimate how many nodes become disconnected if removed + // ── 4. Map numeric indices back to file-path strings ───────────────────── + const articulationPoints = new Set( + Array.from(apIndices, (i) => nodes[i]), + ); + + const bridges: Array<[string, string]> = Array.from(bridgeEids, (eid) => { + const [s, t] = edgeOrigin[eid]; + return [nodes[s], nodes[t]]; + }); + + // ── 5. Per-AP largest-fragment estimation ───────────────────────────────── + // + // For each AP, BFS the graph without that node and record the size of + // the largest resulting fragment. Drives severity ranking in the UI. + // + // One Uint8Array allocated outside the loop; fill(0) between iterations + // compiles to a single V8-optimised memset — cheaper than per-AP allocation. + // BFS queue uses a head-pointer index: O(1) dequeue vs O(n) Array.shift(). + const componentSizes = new Map(); - for (const ap of aps) { - // BFS without the AP node to count disconnected components - const remaining = new Set(allNodes.filter((n) => n !== ap)); - const neighbors = Array.from(adj.get(ap) || []).filter((n) => - remaining.has(n), - ); - - let maxDisconnected = 0; - const seen = new Set(); - - for (const start of neighbors) { - if (seen.has(start)) continue; - const queue = [start]; - seen.add(start); - let count = 0; - while (queue.length > 0) { - const cur = queue.shift()!; + const seen = new Uint8Array(N); + + for (const apIdx of apIndices) { + seen.fill(0); + seen[apIdx] = 1; // treat the AP itself as permanently visited + + let maxFragment = 0; + + for (const { v: seed } of adj[apIdx]) { + if (seen[seed]) continue; // already part of a counted fragment + + const queue: number[] = [seed]; + seen[seed] = 1; + let head = 0; + let count = 0; + + while (head < queue.length) { + const cur = queue[head++]; count++; - for (const nb of adj.get(cur) || []) { - if (!seen.has(nb) && remaining.has(nb)) { - seen.add(nb); - queue.push(nb); + for (const { v } of adj[cur]) { + if (!seen[v]) { + seen[v] = 1; + queue.push(v); } } } - maxDisconnected = Math.max(maxDisconnected, count); + + if (count > maxFragment) maxFragment = count; } - componentSizes.set(ap, maxDisconnected); + componentSizes.set(nodes[apIdx], maxFragment); } - return { articulationPoints: aps, bridges, componentSizes }; + return { articulationPoints, bridges, componentSizes }; +} + +// ── Utility ─────────────────────────────────────────────────────────────────── + +/** + * Returns every articulation point sorted by severity (highest first). + * + * Convenience wrapper for direct UI consumption — eliminates the sort + + * map boilerplate from the view layer. + * + * @example + * const result = computeArticulationPoints(dependencyGraph); + * const ranked = getRankedArticulationPoints(result); + * // ranked[0] is the most dangerous single point of failure + */ +export function getRankedArticulationPoints( + result: ArticulationPointResult, +): RankedArticulationPoint[] { + return Array.from(result.articulationPoints) + .map((path): RankedArticulationPoint => ({ + path, + disconnects: result.componentSizes.get(path) ?? 0, + })) + .sort((a, b) => b.disconnects - a.disconnects); } \ No newline at end of file diff --git a/lib/debug/cache.ts b/lib/debug/cache.ts index f011c62..4e6f88b 100644 --- a/lib/debug/cache.ts +++ b/lib/debug/cache.ts @@ -1,37 +1,92 @@ +// lib/debug/cache.ts import crypto from "crypto"; import { redis } from "@/lib/ratelimit"; import { DebugResult } from "./types"; +const CACHE_TTL_SECONDS = 3600 * 24; // 24 hours + +// ── Key builder ───────────────────────────────────────────────────────────── +// Centralised so all cache operations use identical key format. +function buildCacheKey(repoUrl: string, stackTraceHash: string): string { + return `debug:${repoUrl}:${stackTraceHash}`; +} + +// ── Read ───────────────────────────────────────────────────────────────────── export async function getCachedDebug( repoUrl: string, stackTraceHash: string ): Promise { try { - const key = `debug:${repoUrl}:${stackTraceHash}`; + const key = buildCacheKey(repoUrl, stackTraceHash); const cached = await redis.get(key); - return cached ? JSON.parse(cached as string) : null; + + if (!cached) return null; + + // Previously: no try/catch — corrupted Redis data crashed the request. + try { + return JSON.parse(cached as string) as DebugResult; + } catch { + console.warn( + `[cache] Corrupted cache entry for key ${key} — discarding.` + ); + await redis.del(key); + return null; + } } catch (err) { - console.error("Cache fetch error:", err); + console.error("[cache] Cache fetch error:", err); return null; } } +// ── Write ───────────────────────────────────────────────────────────────────── export async function cacheDebug( repoUrl: string, stackTraceHash: string, result: DebugResult ): Promise { try { - const key = `debug:${repoUrl}:${stackTraceHash}`; - await redis.set(key, JSON.stringify(result), { ex: 3600 * 24 }); + const key = buildCacheKey(repoUrl, stackTraceHash); + await redis.set(key, JSON.stringify(result), { ex: CACHE_TTL_SECONDS }); } catch (err) { - console.error("Cache write error:", err); + console.error("[cache] Cache write error:", err); } } +// ── Invalidate ──────────────────────────────────────────────────────────────── +// Call this whenever a repo is re-analyzed so stale diagnosis +// results don't persist for 24 hours after new analysis is stored. +export async function invalidateRepoCache(repoUrl: string): Promise { + try { + // Redis SCAN to find all keys matching this repo + let cursor = "0"; + const pattern = `debug:${repoUrl}:*`; + + do { + const [nextCursor, keys]: [string, string[]] = await (redis as unknown as { scan: (...args: unknown[]) => Promise<[string, string[]]> }).scan( + cursor, + "MATCH", + pattern, + "COUNT", + 100 + ); + cursor = nextCursor; + + if (keys.length > 0) { + await redis.del(...keys); + console.log( + `[cache] Invalidated ${keys.length} cache entries for ${repoUrl}` + ); + } + } while (cursor !== "0"); + } catch (err) { + console.error("[cache] Cache invalidation error:", err); + } +} +// ── Hash ────────────────────────────────────────────────────────────────────── export function hashStackTrace(trace: string): string { - + // 16 hex chars = 64 bits of SHA-256 — sufficient for cache keys + // where collision risk is acceptable (worst case: a cache miss). return crypto.createHash("sha256").update(trace).digest("hex").slice(0, 16); } \ No newline at end of file diff --git a/lib/debug/file-fetcher.ts b/lib/debug/file-fetcher.ts index da61469..5ce5c88 100644 --- a/lib/debug/file-fetcher.ts +++ b/lib/debug/file-fetcher.ts @@ -1,8 +1,12 @@ - +// lib/debug/file-fetcher.ts import { TraversalNode } from "./types"; import { fetchFileContent } from "@/lib/github"; +// Consistent line limit used for both GitHub-fetched and +// existing file content — prevents the 100 vs 300 mismatch. +const MAX_LINES_PER_FILE = 300; + export async function fetchMissingFiles( traversalPath: TraversalNode[], existingContents: Map, @@ -12,31 +16,42 @@ export async function fetchMissingFiles( ): Promise> { const missing = traversalPath .filter((n) => !existingContents.has(n.file)) - .slice(0, 10); + .slice(0, 10); const fetched = new Map(existingContents); - for (const node of missing) { - try { - const content = await fetchFileContent( - owner, - repo, - node.file, - providerToken + if (missing.length === 0) return fetched; + + // ── Parallel fetch ────────────────────────────────────────────────────────── + // Previously sequential — each file waited for the prior one. + // Promise.allSettled fetches all missing files concurrently and + // handles individual failures without aborting the entire batch. + const results = await Promise.allSettled( + missing.map((node) => + fetchFileContent(owner, repo, node.file, providerToken).then( + (content) => ({ file: node.file, content }) + ) + ) + ); + + for (const result of results) { + if (result.status === "fulfilled") { + const { file, content } = result.value; + fetched.set( + file, + content.split("\n").slice(0, MAX_LINES_PER_FILE).join("\n") ); - - fetched.set(node.file, content.split("\n").slice(0, 100).join("\n")); - } catch (err) { - console.warn(`Failed to fetch ${node.file}:`, err); - - fetched.set(node.file, "// Content not available"); + } else { + // Find the node that failed to log it properly + const failedFile = missing[results.indexOf(result)]?.file ?? "unknown"; + console.warn(`[file-fetcher] Failed to fetch ${failedFile}:`, result.reason); + fetched.set(failedFile, "// Content not available"); } } return fetched; } - export function extractLineContext( content: string, crashLine: number, diff --git a/lib/debug/gemini-debug.ts b/lib/debug/gemini-debug.ts deleted file mode 100644 index 18d2f66..0000000 --- a/lib/debug/gemini-debug.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { DebugContext, DebugResult } from "./types"; - -export async function analyzeDebugWithGemini( - input: DebugContext -): Promise { - - if (process.env.USE_GROQ_FOR_ANALYSIS !== 'true') { - throw new Error("Gemini is currently disabled. Please use Groq. Check Vercel Env Variables."); - } - - const apiKey = process.env.GROQ_API_KEY; - if (!apiKey) { - throw new Error("Missing GROQ_API_KEY environment variable"); - } - - const url = "https://api.groq.com/openai/v1/chat/completions"; - - const codeSnippets = input.relevant_code - .map((file) => { - const marker = file.is_crash_site ? "🔴 CRASH SITE" : ""; - return `=== ${file.file} ${marker} ===\n${file.content}`; - }) - .join("\n\n"); - - const traversalSummary = input.traversal_path - .slice(0, 10) - .map( - (n) => - `- ${n.file} (distance: ${n.distance}, fan-in: ${n.fan_in}, ${n.relationship})` - ) - .join("\n"); - - const systemPrompt = `You are a debugging assistant analyzing a crash in a codebase. - -CRITICAL CONSTRAINTS: -- You ONLY have access to static code dependencies, NOT runtime state. -- If the error suggests missing data (undefined, null), your FIRST suggestion must involve checking: - 1. Database queries - 2. API responses - 3. Environment variables (.env) - 4. Authentication state - -ERROR DETAILS: -Type: ${input.error_type} -Message: ${input.error_message} -Crash Location: ${input.crash_location.file}:${input.crash_location.line} -${input.crash_location.function ? `Function: ${input.crash_location.function}` : ""} - -DEPENDENCY ANALYSIS: -Files analyzed (ordered by relevance): -${traversalSummary} - -CODE SNIPPETS: -${codeSnippets} - -REPOSITORY CONTEXT: -Name: ${input.repo_context.repo_name} -Entry Points: ${input.repo_context.entry_points.join(", ")} -Tech Stack: ${input.repo_context.tech_stack.map((t) => t.name).join(", ")} - -TASK: -Analyze the crash and return ONLY a valid JSON object with this exact structure: -{ - "root_cause_hypothesis": "Your best guess at the root cause (2-3 sentences)", - "fix_suggestions": ["concrete fix 1", "concrete fix 2", "concrete fix 3"], - "verification_steps": ["step to verify fix 1", "step 2", "step 3"], - "confidence": "high | medium | low", - "requires_runtime_check": true | false -}`; - - let res: Response | undefined; - - for (let attempt = 1; attempt <= 3; attempt++) { - res = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: "llama-3.3-70b-versatile", - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: "Analyze this crash and return only the JSON." }, - ], - temperature: 0.2, - response_format: { type: "json_object" }, - }), - }); - - if (res.ok) break; - - if ((res.status === 503 || res.status === 429) && attempt < 3) { - await new Promise((r) => setTimeout(r, 5000 * attempt)); - continue; - } - - const error = await res.text(); - throw new Error(`Groq API error ${res.status}: ${error}`); - } - - if (!res || !res.ok) throw new Error("Groq API failed after retries"); - - const data = await res.json(); - const text = data.choices?.[0]?.message?.content; - if (!text) throw new Error("Empty response from Groq"); - - return JSON.parse(text) as DebugResult; -} \ No newline at end of file diff --git a/lib/debug/graph-traversal.ts b/lib/debug/graph-traversal.ts index ca5ae48..f34f3ff 100644 --- a/lib/debug/graph-traversal.ts +++ b/lib/debug/graph-traversal.ts @@ -1,4 +1,4 @@ - +// lib/debug/graph-traversal.ts import { DependencyGraph } from "@/lib/dependency-graph"; import { TraversalNode } from "./types"; @@ -9,6 +9,30 @@ interface TraversalConfig { prioritizeUpstream: boolean; } +// ── Reverse graph cache ─────────────────────────────────────────────────────── +// Previously: buildReverseGraph() was called on every request, +// rebuilding the full reverse graph each time. +// Now: memoized per graph reference — rebuilt only when graph changes. +const reverseGraphCache = new WeakMap(); + +function getOrBuildReverseGraph(graph: DependencyGraph): DependencyGraph { + if (reverseGraphCache.has(graph)) { + return reverseGraphCache.get(graph)!; + } + + const reverse: DependencyGraph = {}; + for (const [file, imports] of Object.entries(graph)) { + for (const imp of imports) { + if (!reverse[imp]) reverse[imp] = []; + reverse[imp].push(file); + } + } + + reverseGraphCache.set(graph, reverse); + return reverse; +} + +// ── Main traversal ──────────────────────────────────────────────────────────── export function traverseFromCrash( crashNode: string, graph: DependencyGraph, @@ -20,47 +44,48 @@ export function traverseFromCrash( } ): TraversalNode[] { const result: TraversalNode[] = []; - const visited = new Set(); - + // Separate visited sets per direction so upstream traversal + // is not blocked by nodes already visited during downstream traversal. + const visitedDownstream = new Set([crashNode]); + const visitedUpstream = new Set([crashNode]); + result.push({ file: crashNode, distance: 0, fan_in: fanIn[crashNode] || 0, relationship: "crash_site", - relevance_score: 1000, + relevance_score: 1000, }); - visited.add(crashNode); - const downstreamNodes = bfsTraversal( crashNode, graph, fanIn, config.maxDepth, "downstream", - visited + visitedDownstream ); - + const reverseGraph = getOrBuildReverseGraph(graph); + const upstreamNodes = bfsTraversal( crashNode, - buildReverseGraph(graph), + reverseGraph, fanIn, config.maxDepth, "upstream", - visited + visitedUpstream ); result.push(...downstreamNodes, ...upstreamNodes); - result.sort((a, b) => b.relevance_score - a.relevance_score); - return result.slice(0, config.maxNodes); } +// ── BFS ─────────────────────────────────────────────────────────────────────── function bfsTraversal( startNode: string, graph: DependencyGraph, @@ -69,13 +94,16 @@ function bfsTraversal( relationship: "upstream" | "downstream", visited: Set ): TraversalNode[] { + // Using an index pointer instead of array.shift() avoids O(n) + // dequeue cost on large graphs. const queue: { node: string; depth: number }[] = [ { node: startNode, depth: 0 }, ]; + let head = 0; const result: TraversalNode[] = []; - while (queue.length > 0) { - const { node, depth } = queue.shift()!; + while (head < queue.length) { + const { node, depth } = queue[head++]; if (depth >= maxDepth) continue; @@ -88,8 +116,6 @@ function bfsTraversal( const distance = depth + 1; const fanInScore = fanIn[neighbor] || 0; - - const relevance_score = (maxDepth - distance) * (1 + Math.log(fanInScore + 1)); @@ -106,17 +132,4 @@ function bfsTraversal( } return result; -} - -function buildReverseGraph(graph: DependencyGraph): DependencyGraph { - const reverse: DependencyGraph = {}; - - for (const [file, imports] of Object.entries(graph)) { - for (const imp of imports) { - if (!reverse[imp]) reverse[imp] = []; - reverse[imp].push(file); - } - } - - return reverse; } \ No newline at end of file diff --git a/lib/debug/groq-debug.ts b/lib/debug/groq-debug.ts new file mode 100644 index 0000000..c65f414 --- /dev/null +++ b/lib/debug/groq-debug.ts @@ -0,0 +1,139 @@ +// lib/debug/groq-debug.ts + +import { DebugContext, DebugResult } from "./types"; + +export async function analyzeDebugWithGroq( + input: DebugContext +): Promise { + if (process.env.USE_GROQ_FOR_ANALYSIS !== "true") { + throw new Error( + "Groq analysis is disabled. Set USE_GROQ_FOR_ANALYSIS=true in environment variables." + ); + } + + const apiKey = process.env.GROQ_API_KEY; + if (!apiKey) { + throw new Error("Missing GROQ_API_KEY environment variable"); + } + + const url = "https://api.groq.com/openai/v1/chat/completions"; + + // ── Build prompt ──────────────────────────────────────────────────────────── + const codeSnippets = input.relevant_code + .map((file) => { + const marker = file.is_crash_site ? "🔴 CRASH SITE" : ""; + return `=== ${file.file} ${marker} ===\n${file.content}`; + }) + .join("\n\n"); + + const traversalSummary = input.traversal_path + .slice(0, 10) + .map( + (n) => + `- ${n.file} (distance: ${n.distance}, fan-in: ${n.fan_in}, ${n.relationship})` + ) + .join("\n"); + + // NOTE: Confidence is intentionally excluded from AI prompt. + // It is calculated deterministically via calculateConfidence() in heuristics.ts + // to avoid inconsistent AI-generated confidence scores. + const systemPrompt = `You are a debugging assistant analyzing a crash in a codebase. + +CRITICAL CONSTRAINTS: +- You ONLY have access to static code dependencies, NOT runtime state. +- If the error suggests missing data (undefined, null), your FIRST suggestion must involve checking: + 1. Database queries + 2. API responses + 3. Environment variables (.env) + 4. Authentication state + +ERROR DETAILS: +Type: ${input.error_type} +Message: ${input.error_message} +Crash Location: ${input.crash_location.file}:${input.crash_location.line} +${input.crash_location.function ? `Function: ${input.crash_location.function}` : ""} + +DEPENDENCY ANALYSIS: +Files analyzed (ordered by relevance): +${traversalSummary} + +CODE SNIPPETS: +${codeSnippets} + +REPOSITORY CONTEXT: +Name: ${input.repo_context.repo_name} +Entry Points: ${input.repo_context.entry_points.join(", ")} +Tech Stack: ${input.repo_context.tech_stack.map((t) => t.name).join(", ")} + +TASK: +Analyze the crash and return ONLY a valid JSON object with this exact structure: +{ + "root_cause_hypothesis": "Your best guess at the root cause (2-3 sentences)", + "fix_suggestions": ["concrete fix 1", "concrete fix 2", "concrete fix 3"], + "verification_steps": ["step to verify fix 1", "step 2", "step 3"], + "requires_runtime_check": true | false +}`; + + // ── Fetch with retry ──────────────────────────────────────────────────────── + let res: Response | undefined; + let lastError: string = ""; + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: "llama-3.3-70b-versatile", + messages: [ + { role: "system", content: systemPrompt }, + { + role: "user", + content: "Analyze this crash and return only the JSON.", + }, + ], + temperature: 0.2, + response_format: { type: "json_object" }, + }), + }); + + if (res.ok) break; + + if ((res.status === 503 || res.status === 429) && attempt < 3) { + await new Promise((r) => setTimeout(r, 5000 * attempt)); + continue; + } + + lastError = await res.text(); + throw new Error(`Groq API error ${res.status}: ${lastError}`); + } catch (err) { + if (attempt === 3) throw err; + await new Promise((r) => setTimeout(r, 5000 * attempt)); + } + } + + if (!res || !res.ok) { + throw new Error(`Groq API failed after 3 retries. Last error: ${lastError}`); + } + + // ── Parse response ────────────────────────────────────────────────────────── + const data = await res.json(); + const text = data.choices?.[0]?.message?.content; + + if (!text) { + throw new Error("Empty response from Groq"); + } + + try { + return JSON.parse(text) as DebugResult; + } catch { + throw new Error( + `Groq returned malformed JSON. Raw response: ${text.slice(0, 200)}` + ); + } +} + +export const analyzeDebugWithGemini = analyzeDebugWithGroq; \ No newline at end of file diff --git a/lib/debug/heuristics.ts b/lib/debug/heuristics.ts index 408368d..970ad73 100644 --- a/lib/debug/heuristics.ts +++ b/lib/debug/heuristics.ts @@ -1,7 +1,8 @@ - +// lib/debug/heuristics.ts import { TraversalNode } from "./types"; +// ── Debug heuristics ────────────────────────────────────────────────────────── export function applyDebugHeuristics( nodes: TraversalNode[], errorType: string @@ -9,22 +10,12 @@ export function applyDebugHeuristics( return nodes.map((node) => { let bonus = 0; - - if (node.relationship === "upstream") { - bonus += 2; - } + if (node.relationship === "upstream") bonus += 2; - - if (/config|setup|init|bootstrap|env/i.test(node.file)) { - bonus += 1.5; - } + if (/config|setup|init|bootstrap|env/i.test(node.file)) bonus += 1.5; - - if (node.file.includes("index.") || node.file.includes("main.")) { - bonus += 1; - } + if (node.file.includes("index.") || node.file.includes("main.")) bonus += 1; - if ( (errorType === "TypeError" || errorType === "ReferenceError") && (node.file.includes("api/") || @@ -35,7 +26,6 @@ export function applyDebugHeuristics( bonus += 1.5; } - if ( errorType.toLowerCase().includes("auth") && (node.file.includes("auth") || @@ -45,7 +35,6 @@ export function applyDebugHeuristics( bonus += 2; } - if ( node.file.includes("middleware") || node.file.includes("route") || @@ -54,58 +43,61 @@ export function applyDebugHeuristics( bonus += 1; } - return { - ...node, - relevance_score: node.relevance_score + bonus, - }; + return { ...node, relevance_score: node.relevance_score + bonus }; }); } - +// ── Confidence ──────────────────────────────────────────────────────────────── +// Confidence is derived from traversal quality only — NOT from the AI response, +// which previously generated a confidence value that was silently discarded. export function calculateConfidence( - traversalPath: TraversalNode[], + traversalPath: TraversalNode[] ): "high" | "medium" | "low" { - const upstreamCount = traversalPath.filter( + const total = traversalPath.length; + + // Not enough context to be confident + if (total <= 1) return "low"; + + const upstreamNodes = traversalPath.filter( (n) => n.relationship === "upstream" - ).length; - const highFanInCount = traversalPath.filter((n) => n.fan_in > 5).length; + ); + const upstreamCount = upstreamNodes.length; - - if (upstreamCount >= 3 && highFanInCount >= 2) { - return "high"; - } + const highFanInCount = traversalPath.filter((n) => n.fan_in > 5).length; - - if (upstreamCount >= 1) { - return "medium"; - } + // Score based on proportion, not fixed thresholds + const upstreamRatio = upstreamCount / total; + const fanInRatio = highFanInCount / total; - + if (upstreamRatio >= 0.3 && fanInRatio >= 0.2) return "high"; + if (upstreamCount >= 1) return "medium"; return "low"; } - +// ── Runtime check ───────────────────────────────────────────────────────────── +// Previously matched overly broad patterns like /undefined/ and /null/ which +// appear in nearly every TypeError, making this flag meaningless. +// Now targets patterns that specifically indicate runtime/external state issues. export function requiresRuntimeCheck( errorType: string, errorMessage: string ): boolean { const runtimeIndicators = [ - /undefined/i, - /null/i, - /cannot read/i, - /is not a function/i, - /missing/i, - /not found/i, /failed to fetch/i, - /network/i, + /network error/i, /timeout/i, - /connection/i, + /connection refused/i, + /ECONNREFUSED/, /database/i, - /authentication/i, + /authentication failed/i, /unauthorized/i, + /403/, + /401/, + /500/, + /missing.*env/i, + /env.*missing/i, ]; const fullText = `${errorType} ${errorMessage}`; - return runtimeIndicators.some((pattern) => pattern.test(fullText)); } \ No newline at end of file diff --git a/lib/debug/mermaid-highlighter.ts b/lib/debug/mermaid-highlighter.ts index e65f921..e66b970 100644 --- a/lib/debug/mermaid-highlighter.ts +++ b/lib/debug/mermaid-highlighter.ts @@ -1,100 +1,53 @@ - +// lib/debug/mermaid-highlighter.ts import { TraversalNode } from "./types"; +// ── Node ID sanitizer ───────────────────────────────────────────────────────── +// Previously: all non-alphanumeric chars → "_" +// app/auth.ts and app-auth.ts both produced "app_auth_ts" → node ID collision. +// Now: includes the full depth of path segments to guarantee uniqueness. +function sanitize(filePath: string): string { + // Replace path separators with double underscore to preserve folder context + return filePath.replace(/\//g, "__").replace(/[^a-zA-Z0-9_]/g, "_"); +} + +// ── Highlight crash path on existing mermaid diagram ───────────────────────── export function highlightDebugPath( originalMermaid: string, crashNode: string, traversalPath: TraversalNode[], rootCauseFile?: string ): string { - let enhanced = originalMermaid; - - - const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_]/g, "_"); - const crashId = sanitize(crashNode); const rootCauseId = rootCauseFile ? sanitize(rootCauseFile) : null; - const styles: string[] = []; - + // Crash site — red, thick border styles.push( `style ${crashId} fill:#ff4444,stroke:#cc0000,stroke-width:3px,color:#fff` ); - traversalPath.forEach((node) => { const id = sanitize(node.file); - if (id === crashId) return; + if (id === crashId) return; - const opacity = Math.max(0.3, 1 - node.distance * 0.2); + // Previously used `opacity` which is not a valid Mermaid style property. + // Now uses stroke-width to visually indicate distance instead. + const strokeWidth = Math.max(1, 3 - node.distance); const color = node.relationship === "upstream" ? "#ff9933" : "#3399ff"; - styles.push(`style ${id} fill:${color},opacity:${opacity}`); + + styles.push( + `style ${id} fill:${color},stroke:#fff,stroke-width:${strokeWidth}px,color:#fff` + ); }); - + // Root cause — amber, distinct border if (rootCauseId && rootCauseId !== crashId) { styles.push( - `style ${rootCauseId} fill:#ffaa00,stroke:#ff6600,stroke-width:2px` + `style ${rootCauseId} fill:#ffaa00,stroke:#ff6600,stroke-width:2px,color:#000` ); } - - enhanced += "\n\n" + styles.join("\n"); - - - enhanced += ` - -subgraph Legend - direction LR - crash["🔴 Crash Site"] - upstream["🟠 Upstream Caller"] - downstream["🔵 Downstream Dependency"] -end`; - - return enhanced; -} - - -export function generateErrorGraph( - crashNode: string, - traversalPath: TraversalNode[] -): string { - const lines: string[] = ["graph TD"]; - - const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_]/g, "_"); - - - const crashId = sanitize(crashNode); - const crashName = crashNode.split("/").pop() || crashNode; - lines.push(` ${crashId}["🔴 ${crashName}"]`); - lines.push( - ` style ${crashId} fill:#ff4444,stroke:#cc0000,stroke-width:3px,color:#fff` - ); - - - const topNodes = traversalPath.slice(0, 10); - - topNodes.forEach((node) => { - const id = sanitize(node.file); - const name = node.file.split("/").pop() || node.file; - const icon = node.relationship === "upstream" ? "⬆️" : "⬇️"; - - lines.push(` ${id}["${icon} ${name}"]`); - - - if (node.relationship === "upstream") { - lines.push(` ${id} --> ${crashId}`); - } else { - lines.push(` ${crashId} --> ${id}`); - } - - - const color = node.relationship === "upstream" ? "#ff9933" : "#3399ff"; - lines.push(` style ${id} fill:${color},opacity:0.7`); - }); - - return lines.join("\n"); + return `${originalMermaid}\n\n${styles.join("\n")}`; } \ No newline at end of file diff --git a/lib/debug/stack-parser.ts b/lib/debug/stack-parser.ts index befe0b0..f0ab4e8 100644 --- a/lib/debug/stack-parser.ts +++ b/lib/debug/stack-parser.ts @@ -1,7 +1,10 @@ - +// lib/debug/stack-parser.ts import { CrashNode } from "./types"; +// ── Noise filters ───────────────────────────────────────────────────────────── +// Previously included "at async" which accidentally filtered valid +// user async functions. Now only exact/prefix noise is listed. const FRAMEWORK_NOISE = [ "node_modules/", "webpack:", @@ -11,9 +14,8 @@ const FRAMEWORK_NOISE = [ "next/dist/", "node:internal/", "_middleware", - "at eval", + "at eval (", "at processTicksAndRejections", - "at async", "at Module.", "at Object.", ]; @@ -40,6 +42,7 @@ interface StackFrame { raw: string; } +// ── Stack trace parser ──────────────────────────────────────────────────────── export function parseStackTrace( trace: string, allFiles: string[] @@ -48,12 +51,8 @@ export function parseStackTrace( const frames: StackFrame[] = []; for (const line of lines) { - if (!line.trim() || !line.includes("at ")) continue; - - - const match = line.match( /at\s+(?:(.+?)\s+\()?([^()]+):(\d+):(\d+)\)?/ ); @@ -65,10 +64,7 @@ export function parseStackTrace( const lineNum = parseInt(match[3]); const colNum = parseInt(match[4]); - - if (FRAMEWORK_NOISE.some((noise) => filePath.includes(noise))) { - continue; - } + if (FRAMEWORK_NOISE.some((noise) => filePath.includes(noise))) continue; frames.push({ file: filePath, @@ -79,10 +75,8 @@ export function parseStackTrace( }); } - for (const frame of frames) { const matched = findMatchingFile(frame.file, allFiles); - if (matched) { return { file: matched, @@ -93,50 +87,76 @@ export function parseStackTrace( } } - return null; + return null; } +// ── File matcher ────────────────────────────────────────────────────────────── +// Previously returned the first filename match without disambiguation. +// Two files with the same name in different folders would silently resolve +// to whichever came first in the array. +// Now: prefers the match whose full path contains more segments from framePath. function findMatchingFile(framePath: string, allFiles: string[]): string | null { - + // 1. Exact match if (allFiles.includes(framePath)) return framePath; - - const filename = framePath.split("/").pop(); - if (filename) { - const match = allFiles.find((f) => f.endsWith(filename)); - if (match) return match; - } - - - const segments = framePath.split("/"); - if (segments.length >= 2) { - const partial = segments.slice(-2).join("/"); - const match = allFiles.find((f) => f.endsWith(partial)); - if (match) return match; - } - - + // 2. Repo-root relative path match for (const pattern of REPO_ROOT_PATTERNS) { const idx = framePath.indexOf(pattern); if (idx !== -1) { - const repoPath = framePath.substring(idx + 1); + const repoPath = framePath.substring(idx + 1); if (allFiles.includes(repoPath)) return repoPath; } } + // 3. Partial path match (last 2 segments) with disambiguation: + // score each candidate by how many path segments match the frame + const frameSegments = framePath.split("/"); + + if (frameSegments.length >= 2) { + const partial = frameSegments.slice(-2).join("/"); + const candidates = allFiles.filter((f) => f.endsWith(partial)); + + if (candidates.length === 1) return candidates[0]; + + if (candidates.length > 1) { + // Pick the candidate that shares the most path segments with framePath + let bestMatch = candidates[0]; + let bestScore = 0; + + for (const candidate of candidates) { + const candidateSegments = candidate.split("/"); + const score = frameSegments.filter((seg) => + candidateSegments.includes(seg) + ).length; + if (score > bestScore) { + bestScore = score; + bestMatch = candidate; + } + } + + return bestMatch; + } + } + + // 4. Filename-only fallback (least precise — only used if nothing else matches) + const filename = frameSegments.pop(); + if (filename) { + const candidates = allFiles.filter((f) => f.endsWith(filename)); + if (candidates.length === 1) return candidates[0]; + // Multiple files with same name and no better match — return null + // rather than silently picking the wrong one + if (candidates.length > 1) return null; + } + return null; } - +// ── Error info extractor ────────────────────────────────────────────────────── export function extractErrorInfo(trace: string): { error_type: string; error_message: string; } { - const lines = trace.split("\n"); - const firstLine = lines[0]?.trim() || ""; - - // Try to extract "ErrorType: message" - + const firstLine = trace.split("\n")[0]?.trim() || ""; const errorMatch = firstLine.match(/(?:.*\s)?(\w+Error):\s*(.+)$/); if (errorMatch) { @@ -146,27 +166,8 @@ export function extractErrorInfo(trace: string): { }; } - return { error_type: "UnknownError", error_message: firstLine || "No error message provided", }; -} - - -export function extractAllCrashNodes( - trace: string, - allFiles: string[] -): CrashNode[] { - const lines = trace.split("\n"); - const crashes: CrashNode[] = []; - - for (const line of lines) { - const node = parseStackTrace(line, allFiles); - if (node && !crashes.some((c) => c.file === node.file)) { - crashes.push(node); - } - } - - return crashes; } \ No newline at end of file diff --git a/lib/github/commits.ts b/lib/github/commits.ts new file mode 100644 index 0000000..729f095 --- /dev/null +++ b/lib/github/commits.ts @@ -0,0 +1,413 @@ +// lib/github/commits.ts + +// ───────────────────────────────────────────────────────────────────────────── +// CodeAutopsy · GitHub Commit Pipeline +// Fetches the last N commits for a repo and returns, per commit, the list of +// file paths that were changed. Designed to be called exclusively server-side +// (Server Actions / Route Handlers) so the token is never exposed to the client. +// +// Constraints honoured: +// • GitHub primary rate limit → tracked via X-RateLimit-* response headers; +// the caller blocks until the window resets before retrying. +// • GitHub secondary rate limit → detected via 403 + "secondary rate limit" +// body OR via Retry-After header on 429; backs off for the indicated period. +// • Concurrency → a Semaphore caps simultaneous in-flight detail +// requests so we never hammer GitHub with 200 parallel calls. +// • Truncated commits → GitHub silently caps `files[]` at 300 entries. +// We detect this and skip the commit to avoid skewing coupling data. +// ───────────────────────────────────────────────────────────────────────────── + +const GITHUB_API_BASE = "https://api.github.com"; + +// ─── Public option / return types ──────────────────────────────────────────── + +export interface FetchCommitsOptions { + /** GitHub repo owner (user or org). */ + owner: string; + /** GitHub repo name. */ + repo: string; + /** + * Personal Access Token or fine-grained token with at minimum `contents: read` + * on the target repo. Public repos work without a token but burn the 60 req/h + * unauthenticated limit extremely fast; always pass a token. + */ + token: string; + /** + * How many commits to walk. Clamped to [1, 200]. + * @default 100 + */ + maxCommits?: number; + /** + * Max simultaneous in-flight detail fetches. Keep ≤ 10 to stay well inside + * GitHub's concurrency secondary-rate-limit. + * @default 6 + */ + concurrency?: number; + /** + * Branch / tag / SHA to start from. Omit to use the repo's default branch. + */ + branch?: string; +} + +/** Each element is the set of file paths changed in one commit. */ +export type CommitFileMatrix = string[][]; + +// ─── Internal types ─────────────────────────────────────────────────────────── + +interface RateLimitState { + /** Requests remaining in the current window. */ + remaining: number; + /** Unix epoch (seconds) when the window resets. */ + reset: number; + /** + * Non-null while we are in a secondary-rate-limit back-off. + * Value is the number of ms to wait. + */ + retryAfterMs: number | null; +} + +interface GhCommitListItem { + sha: string; +} + +interface GhCommitFile { + filename: string; + status: + | "added" + | "removed" + | "modified" + | "renamed" + | "copied" + | "changed" + | "unchanged"; + previous_filename?: string; +} + +interface GhCommitDetail { + sha: string; + files?: GhCommitFile[]; +} + +// ─── Semaphore ──────────────────────────────────────────────────────────────── + +/** + * A classic promise-based counting semaphore. + * `run()` is the only public API consumers need; acquire/release are internal. + */ +class Semaphore { + private permits: number; + private readonly waitQueue: Array<() => void> = []; + + constructor(permits: number) { + if (permits < 1) throw new RangeError("Semaphore permits must be ≥ 1"); + this.permits = permits; + } + + private acquire(): Promise { + if (this.permits > 0) { + this.permits--; + return Promise.resolve(); + } + return new Promise((resolve) => this.waitQueue.push(resolve)); + } + + private release(): void { + const next = this.waitQueue.shift(); + if (next) { + // Pass the permit directly to the next waiter — no increment needed. + next(); + } else { + this.permits++; + } + } + + /** Acquire → run fn → release, even if fn throws. */ + async run(fn: () => Promise): Promise { + await this.acquire(); + try { + return await fn(); + } finally { + this.release(); + } + } +} + +// ─── Utilities ──────────────────────────────────────────────────────────────── + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Milliseconds until the rate-limit window resets, plus a 1 s buffer. */ +function msUntilReset(resetEpochSeconds: number): number { + const nowMs = Date.now(); + const resetMs = resetEpochSeconds * 1_000; + return Math.max(0, resetMs - nowMs) + 1_000; // 1 s safety buffer +} + +const SECONDARY_RATE_LIMIT_PATTERN = /secondary rate limit/i; +const RETRYABLE_HTTP_STATUS = new Set([500, 502, 503, 504]); +const MAX_RETRIES = 4; + +// ─── Rate-limit-aware fetch ─────────────────────────────────────────────────── + +/** + * Wrapper around `fetch` that: + * 1. Proactively waits when the primary rate limit is exhausted. + * 2. Detects secondary-rate-limit responses (403 / 429) and backs off. + * 3. Retries transient server errors with exponential back-off. + * 4. Updates the shared `RateLimitState` from every response. + */ +async function githubFetch( + url: string, + token: string, + rls: RateLimitState +): Promise { + // ── Pre-flight: secondary back-off ─────────────────────────────────────── + if (rls.retryAfterMs !== null) { + await sleep(rls.retryAfterMs); + rls.retryAfterMs = null; + } + + // ── Pre-flight: primary rate-limit exhausted ───────────────────────────── + if (rls.remaining <= 0) { + await sleep(msUntilReset(rls.reset)); + } + + let lastErr: Error = new Error("Unknown fetch error"); + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + if (attempt > 0) { + // Exponential back-off: 2 s, 4 s, 8 s + await sleep(Math.pow(2, attempt) * 1_000); + } + + let res: Response; + try { + res = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + // Disable Next.js fetch cache — we always want live data from GitHub. + "Cache-Control": "no-store", + }, + // Node ≥ 18 fetch accepts `cache` option; Next.js also respects it. + cache: "no-store", + }); + } catch (networkErr) { + lastErr = + networkErr instanceof Error ? networkErr : new Error(String(networkErr)); + continue; // Retry on network-level failures + } + + // ── Update shared rate-limit state ───────────────────────────────────── + const hRemaining = res.headers.get("X-RateLimit-Remaining"); + const hReset = res.headers.get("X-RateLimit-Reset"); + if (hRemaining !== null) rls.remaining = parseInt(hRemaining, 10); + if (hReset !== null) rls.reset = parseInt(hReset, 10); + + if (res.ok) return res; + + // ── 403 / 429 — rate limiting ────────────────────────────────────────── + if (res.status === 403 || res.status === 429) { + const retryAfterHeader = res.headers.get("Retry-After"); + + if (retryAfterHeader !== null) { + // Explicit back-off duration (seconds) + rls.retryAfterMs = parseInt(retryAfterHeader, 10) * 1_000 + 500; + await sleep(rls.retryAfterMs); + rls.retryAfterMs = null; + continue; + } + + // 403 without Retry-After could be secondary rate limit or auth error. + // Peek at body to distinguish. + let body = ""; + try { + body = await res.text(); + } catch { + /* ignore */ + } + + if (SECONDARY_RATE_LIMIT_PATTERN.test(body)) { + // GitHub recommends waiting at least 1 minute for secondary limits. + rls.retryAfterMs = 60_000 + 1_000; + await sleep(rls.retryAfterMs); + rls.retryAfterMs = null; + continue; + } + + if (rls.remaining <= 0) { + // Primary limit exhausted — wait for reset. + await sleep(msUntilReset(rls.reset)); + continue; + } + + // Hard auth error (bad token, missing scope) — no point retrying. + throw new Error( + `GitHub returned 403 for ${url}. Check token scopes. Body: ${body}` + ); + } + + // ── 5xx transient server errors ──────────────────────────────────────── + if (RETRYABLE_HTTP_STATUS.has(res.status)) { + lastErr = new Error(`GitHub API ${res.status} at ${url}`); + continue; + } + + // ── Non-retryable client error ───────────────────────────────────────── + let errBody = ""; + try { + errBody = await res.text(); + } catch { + /* ignore */ + } + throw new Error( + `GitHub API non-retryable error ${res.status} at ${url}: ${errBody}` + ); + } + + throw new Error( + `GitHub API failed after ${MAX_RETRIES} attempts for ${url}: ${lastErr.message}` + ); +} + +// ─── Step 1: Fetch commit SHA list ──────────────────────────────────────────── + +async function fetchCommitSHAs( + owner: string, + repo: string, + token: string, + maxCommits: number, + branch: string | undefined, + rls: RateLimitState +): Promise { + const shas: string[] = []; + const perPage = 100; // GitHub max per-page for this endpoint + const totalPages = Math.ceil(maxCommits / perPage); + + for (let page = 1; page <= totalPages; page++) { + if (shas.length >= maxCommits) break; + + const params = new URLSearchParams({ per_page: String(perPage), page: String(page) }); + if (branch) params.set("sha", branch); + + const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/commits?${params}`; + const res = await githubFetch(url, token, rls); + const items: GhCommitListItem[] = await res.json(); + + if (!Array.isArray(items) || items.length === 0) break; + + for (const item of items) { + if (shas.length >= maxCommits) break; + if (item.sha) shas.push(item.sha); + } + } + + return shas; +} + +// ─── Step 2: Fetch files for one commit ────────────────────────────────────── + +/** + * GitHub caps `files[]` at exactly 300 items without a truncation flag. + * Any commit touching ≥ 300 files is a mass refactor / generated-file dump — + * including it would poison coupling data, so we return null to signal "skip". + */ +const GH_MAX_FILES_PER_COMMIT = 300; + +async function fetchFilesForCommit( + owner: string, + repo: string, + sha: string, + token: string, + rls: RateLimitState +): Promise { + const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/commits/${sha}`; + const res = await githubFetch(url, token, rls); + const detail: GhCommitDetail = await res.json(); + const files = detail.files ?? []; + + if (files.length >= GH_MAX_FILES_PER_COMMIT) return null; // likely truncated + + return files + .filter((f) => f.status !== "removed") // deleted files are irrelevant for coupling + .map((f) => + // For renamed files, use the NEW filename so it aligns with the + // current dependencyGraph topology rather than a stale identity. + f.filename + ); +} + +// ─── Main export ───────────────────────────────────────────────────────────── + +/** + * Fetches up to `maxCommits` commits and returns, per commit, the list of + * file paths that were **added, modified, or renamed** in that commit. + * + * Commits are returned in the same chronological order GitHub returns them + * (newest-first). Only commits that changed ≥ 2 files are included, since + * single-file commits contribute nothing to coupling analysis. + * + * @example + * ```ts + * const commits = await fetchLogicalCouplingCommits({ + * owner: "vercel", + * repo: "next.js", + * token: process.env.GITHUB_TOKEN!, + * maxCommits: 200, + * concurrency: 8, + * }); + * // commits[0] → ["packages/next/src/server/app-render.tsx", "packages/next/src/server/render.tsx", ...] + * ``` + */ +export async function fetchLogicalCouplingCommits( + options: FetchCommitsOptions +): Promise { + const { + owner, + repo, + token, + maxCommits = 100, + concurrency = 6, + branch, + } = options; + + const clampedMax = Math.min(Math.max(maxCommits, 1), 200); + + const rls: RateLimitState = { + remaining: 5_000, // optimistic default; updated on first response + reset: Math.floor(Date.now() / 1_000) + 3_600, + retryAfterMs: null, + }; + + // ── Step 1: Collect SHAs (1–2 API calls) ────────────────────────────────── + const shas = await fetchCommitSHAs(owner, repo, token, clampedMax, branch, rls); + + // ── Step 2: Fetch file lists in parallel, bounded by Semaphore ──────────── + const semaphore = new Semaphore(concurrency); + const matrix: Array = new Array(shas.length).fill(null); + + await Promise.all( + shas.map((sha, idx) => + semaphore.run(async () => { + try { + matrix[idx] = await fetchFilesForCommit(owner, repo, sha, token, rls); + } catch { + // Non-fatal: a single failed commit detail fetch should not abort the + // entire pipeline. Log in development; silently skip in production. + if (process.env.NODE_ENV === "development") { + console.warn(`[CodeAutopsy] Could not fetch files for commit ${sha}`); + } + matrix[idx] = null; + } + }) + ) + ); + + // ── Step 3: Filter nulls and single-file commits ─────────────────────────── + return matrix.filter( + (files): files is string[] => files !== null && files.length >= 2 + ); +} \ No newline at end of file diff --git a/lib/github/implicitCoupling.ts b/lib/github/implicitCoupling.ts new file mode 100644 index 0000000..404e60d --- /dev/null +++ b/lib/github/implicitCoupling.ts @@ -0,0 +1,327 @@ +// lib/algorithms/implicitCoupling.ts + +// ───────────────────────────────────────────────────────────────────────────── +// CodeAutopsy · Implicit Coupling Math Engine +// +// Computes "logical coupling" (co-change probability) from raw git history. +// Surfaces file pairs that change together frequently but share NO explicit +// import edge — these are the "hidden ghost edges" rendered in React Flow. +// +// Algorithm: Association Rule Mining (ARM) over a transaction database where +// • each "transaction" = one commit +// • each "item" = one file path +// +// Metrics produced per pair (A, B): +// • support = P(A ∧ B) = coChangeCount / totalCommits +// • confidence(A→B) = P(B | A) = coChangeCount / totalCommitsWithA +// • confidence(B→A) = P(A | B) = coChangeCount / totalCommitsWithB +// • jaccard = coChangeCount / |commitsWithA ∪ commitsWithB| +// = coChange / (cA + cB - coChange) +// (symmetric; robust to files with very different activity) +// +// Complexity: +// Pass 1 — file commit counts : O(M · F) +// Pass 2 — co-change counts : O(M · F²) — F is tiny in practice (≈ 5–15) +// Pass 3 — dependency edge set : O(E) +// Pass 4 — confidence + filter : O(P) — P = unique co-change pairs +// Pass 5 — sort : O(P log P) +// +// Overall: O(M · F² + E + P log P) +// +// Memory: +// fileCommitCount : O(N) — N = unique files across all commits +// coChangeMap : O(P) — P ≤ C(N, 2) but << that in practice +// existingEdges : O(E) +// ───────────────────────────────────────────────────────────────────────────── + +// ─── Public interfaces ──────────────────────────────────────────────────────── + +/** One "ghost edge" candidate returned by the engine. */ +export interface ImplicitCouplingResult { + /** + * File with the higher conditional probability of co-changing with `fileB`. + * I.e. `confidence >= reverseConfidence` is always true. + */ + fileA: string; + /** The coupled counterpart. */ + fileB: string; + /** + * P(fileB changes | fileA changes). + * "When fileA changes, fileB also changes this fraction of the time." + */ + confidence: number; + /** + * P(fileA changes | fileB changes). + * The reverse direction — often < confidence, but surfaced for directed edges. + */ + reverseConfidence: number; + /** Raw count: commits where BOTH files were modified. */ + coChangeCount: number; + /** + * Jaccard similarity coefficient — symmetric, range [0, 1]. + * Preferred ranking metric because it penalises files that change in almost + * every commit (which would inflate raw confidence). + */ + jaccard: number; + /** + * support = coChangeCount / totalCommits. + * Tells you how globally frequent this coupling is. + */ + support: number; +} + +/** Tuning knobs for the coupling engine. */ +export interface CouplingOptions { + /** + * Minimum times two files must co-change to be included. + * Low values (1–2) are noisy; 3+ is recommended for production. + * @default 3 + */ + minCoChangeCount?: number; + /** + * Minimum Jaccard score required. Range [0, 1]. + * @default 0.1 + */ + minJaccard?: number; + /** + * Minimum confidence in the dominant direction. Range [0, 1]. + * @default 0.1 + */ + minConfidence?: number; + /** + * Cap on results returned (sorted by jaccard DESC). + * 0 means "no cap". + * @default 500 + */ + maxResults?: number; +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +/** + * Canonical key for an unordered pair of file paths. + * Sorting guarantees (A, B) and (B, A) produce the same key. + * + * Uses the NUL byte (U+0000) as separator because it is the one character + * that cannot legally appear in any OS file path. + */ +function pairKey(a: string, b: string): string { + return a < b ? `${a}\0${b}` : `${b}\0${a}`; +} + +/** + * Decode a canonical pair key back into its two constituent paths. + * @internal + */ +function splitPairKey(key: string): [string, string] { + const idx = key.indexOf("\0"); + return [key.slice(0, idx), key.slice(idx + 1)]; +} + +/** + * Build a Set of canonical pair keys from the existing dependency graph so we + * can do O(1) "is this pair already explicit?" lookups in Pass 4. + * + * Both directions (A→B and B→A) map to the same canonical key, so a single + * Set covers undirected filtering. + * + * Complexity: O(E) where E = total directed edges in the dependency graph. + */ +function buildExplicitEdgeSet( + dependencyGraph: Record +): Set { + const edges = new Set(); + for (const [source, targets] of Object.entries(dependencyGraph)) { + for (const target of targets) { + edges.add(pairKey(source, target)); + } + } + return edges; +} + +// ─── Main export ───────────────────────────────────────────────────────────── + +/** + * Computes implicit (logical) coupling between files based on co-change + * frequency in git history, then **subtracts** any pair already connected by + * an explicit import edge in `dependencyGraph`. + * + * The result is sorted by **Jaccard coefficient** descending — this ranks + * genuinely tightly-coupled pairs above files that happen to appear in many + * commits due to bulk churn. + * + * @param commits Output of `fetchLogicalCouplingCommits` — each inner + * array is the file paths changed in one commit. + * @param dependencyGraph Existing explicit import graph from your AST pass. + * @param options Optional filtering / result-cap configuration. + * + * @example + * ```ts + * const ghostEdges = computeImplicitCoupling(commits, dependencyGraph, { + * minCoChangeCount: 3, + * minJaccard: 0.15, + * maxResults: 200, + * }); + * + * // Render in React Flow: + * ghostEdges.forEach(({ fileA, fileB, jaccard, confidence }) => { + * addGhostEdge(fileA, fileB, { weight: jaccard, label: `${(confidence * 100).toFixed(0)}%` }); + * }); + * ``` + */ +export function computeImplicitCoupling( + commits: string[][], + dependencyGraph: Record, + options: CouplingOptions = {} +): ImplicitCouplingResult[] { + const { + minCoChangeCount = 3, + minJaccard = 0.1, + minConfidence = 0.1, + maxResults = 500, + } = options; + + const totalCommits = commits.length; + if (totalCommits === 0) return []; + + // ── Pass 1: Per-file commit frequency ───────────────────────────────────── + // O(M · F) + // fileCommitCount.get(f) = number of commits in which f appeared. + const fileCommitCount = new Map(); + + for (const files of commits) { + // Deduplicate within the commit — a file can theoretically be listed twice + // if GitHub returns both a rename source and target for the same path. + const unique = new Set(files); + for (const file of unique) { + fileCommitCount.set(file, (fileCommitCount.get(file) ?? 0) + 1); + } + } + + // ── Pass 2: Co-change frequency ─────────────────────────────────────────── + // O(M · F²) + // coChangeMap.get(pairKey(a, b)) = number of commits containing BOTH a and b. + // + // Inner loop: for a commit with F files, we generate C(F, 2) = F(F-1)/2 pairs. + // Real-world median F ≈ 5 → ≈ 10 pair insertions per commit — very cheap. + const coChangeMap = new Map(); + + for (const files of commits) { + const unique = Array.from(new Set(files)); + const n = unique.length; + + // Pairs are generated in a canonical nested loop; pairKey() handles sorting. + for (let i = 0; i < n - 1; i++) { + for (let j = i + 1; j < n; j++) { + const key = pairKey(unique[i], unique[j]); + coChangeMap.set(key, (coChangeMap.get(key) ?? 0) + 1); + } + } + } + + // ── Pass 3: Existing explicit edges ─────────────────────────────────────── + // O(E) + const explicitEdges = buildExplicitEdgeSet(dependencyGraph); + + // ── Pass 4: Compute metrics and apply filters ───────────────────────────── + // O(P) where P = coChangeMap.size + const results: ImplicitCouplingResult[] = []; + + for (const [key, coChangeCount] of coChangeMap) { + // ── Filter 1: minimum raw co-change count (noise gate) ───────────────── + if (coChangeCount < minCoChangeCount) continue; + + // ── Filter 2: skip pairs with an existing explicit import edge ────────── + if (explicitEdges.has(key)) continue; + + const [rawA, rawB] = splitPairKey(key); + + const cA = fileCommitCount.get(rawA) ?? 0; + const cB = fileCommitCount.get(rawB) ?? 0; + + // Guard: both files must appear in our commit data (should always be true). + if (cA === 0 || cB === 0) continue; + + // ── Confidence (directed conditional probabilities) ───────────────────── + const confAB = coChangeCount / cA; // P(B | A) + const confBA = coChangeCount / cB; // P(A | B) + + // Orient so that fileA is the "dominant" side (higher confidence). + // This makes fileA → fileB the primary edge direction for React Flow. + const [fileA, fileB, confidence, reverseConfidence] = + confAB >= confBA + ? [rawA, rawB, confAB, confBA] + : [rawB, rawA, confBA, confAB]; + + // ── Filter 3: minimum confidence (dominant direction) ─────────────────── + if (confidence < minConfidence) continue; + + // ── Jaccard similarity ─────────────────────────────────────────────────── + // jaccard = |A ∩ B| / |A ∪ B| = coChange / (cA + cB - coChange) + // Range: (0, 1]. Symmetric and unaffected by total commit volume. + const union = cA + cB - coChangeCount; + const jaccard = coChangeCount / union; + + // ── Filter 4: minimum Jaccard ──────────────────────────────────────────── + if (jaccard < minJaccard) continue; + + results.push({ + fileA, + fileB, + confidence, + reverseConfidence, + coChangeCount, + jaccard, + support: coChangeCount / totalCommits, + }); + } + + // ── Pass 5: Sort — primary key: jaccard DESC, tiebreak: coChangeCount DESC ─ + // O(P log P) + results.sort((a, b) => + b.jaccard !== a.jaccard + ? b.jaccard - a.jaccard + : b.coChangeCount - a.coChangeCount + ); + + // ── Apply result cap ─────────────────────────────────────────────────────── + return maxResults > 0 ? results.slice(0, maxResults) : results; +} + +// ─── Utility: enrich ghost edges with display metadata ─────────────────────── + +/** Display-ready ghost edge for React Flow consumption. */ +export interface GhostEdge { + id: string; + source: string; + target: string; + /** Normalised edge weight in [0, 1] based on Jaccard. */ + weight: number; + label: string; + data: ImplicitCouplingResult; +} + +/** + * Converts raw coupling results into React Flow edge descriptors. + * + * Useful if you want to pass the list directly to your ` ({ + id: `ghost::${pairKey(r.fileA, r.fileB)}`, + source: r.fileA, + target: r.fileB, + weight: r.jaccard, + label: `${(r.confidence * 100).toFixed(0)}% co-change · ${r.coChangeCount}×`, + data: r, + })); +} \ No newline at end of file diff --git a/lib/github/resolve-aliases.ts b/lib/github/resolve-aliases.ts index e69de29..5ad381a 100644 --- a/lib/github/resolve-aliases.ts +++ b/lib/github/resolve-aliases.ts @@ -0,0 +1,346 @@ +// lib/github/resolve-aliases.ts + +// --------------------------------------------------------------------------- +// CodeAutopsy - Path Alias Resolver +// +// The AST pipeline encounters import statements like: +// import { foo } from "@/lib/utils" +// import Bar from "~/components/Bar" +// +// Without resolving these aliases, the dependency graph has broken edges. +// This module: +// 1. Fetches tsconfig.json (jsconfig.json as fallback) from GitHub API +// 2. Parses compilerOptions.baseUrl + compilerOptions.paths +// 3. Detects Next.js and injects its implicit "@/" alias if not already set +// 4. Returns a compiled PathAliasResolver for fast lookups per import +// +// Used by: lib/pipeline/ast-pipeline.ts +// --------------------------------------------------------------------------- + +// ---------- Public types ---------------------------------------------------- + +/** + * Flat alias map after parsing tsconfig. + * Key = alias prefix WITHOUT trailing wildcard, e.g. "@/", "~/" + * Value = resolved directory prefix, e.g. "src/", "./" + */ +export type AliasMap = Record; + +/** + * The compiled resolver handed to the AST pipeline. + * Call resolve(importPath) on every import string encountered during + * graph construction. + * + * @example + * resolver.resolve("@/lib/utils") // "src/lib/utils" + * resolver.resolve("./sibling") // "./sibling" (unchanged) + */ +export interface PathAliasResolver { + resolve(importPath: string): string; + /** Raw alias map for debugging / display in the UI. */ + aliases: AliasMap; + /** False when no tsconfig was found or it had no path aliases. */ + hasAliases: boolean; +} + +// ---------- Internal types -------------------------------------------------- + +interface TsConfig { + compilerOptions?: { + baseUrl?: string; + paths?: Record; + }; + extends?: string; +} + +interface GitHubContentResponse { + type: string; + encoding: string; + content: string; +} + +// ---------- GitHub content fetch -------------------------------------------- + +/** + * Fetches a single file's raw text from a GitHub repo via the Contents API. + * Returns null on 404 or any non-OK response so callers can fall back cleanly. + */ +async function fetchRepoFile( + owner: string, + repo: string, + path: string, + token?: string, + branch?: string +): Promise { + const headers: Record = { + Accept: "application/vnd.github.v3+json", + }; + if (token) headers.Authorization = `Bearer ${token}`; + + const ref = branch ? `?ref=${encodeURIComponent(branch)}` : ""; + const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}${ref}`; + + try { + const res = await fetch(url, { headers }); + if (!res.ok) return null; + + const data: GitHubContentResponse = await res.json(); + + if (data.encoding === "base64" && data.content) { + // GitHub base64-encodes file content; embedded newlines must be stripped + const cleaned = data.content.replace(/\n/g, ""); + return Buffer.from(cleaned, "base64").toString("utf-8"); + } + + return null; + } catch { + return null; + } +} + +// ---------- tsconfig parsing ------------------------------------------------ + +/** + * Strips // line comments and block comments from JSON-like text so that + * tsconfig.json files (which allow comments) can be parsed with JSON.parse. + */ +function stripJsonComments(raw: string): string { + let result = raw.replace(/\/\/[^\n]*/g, ""); + result = result.replace(/\/\*[\s\S]*?\*\//g, ""); + return result; +} + +function parseTsConfig(raw: string): TsConfig | null { + try { + return JSON.parse(stripJsonComments(raw)) as TsConfig; + } catch { + return null; + } +} + +/** + * Resolves a tsconfig "extends" value to a repo-relative file path. + * Returns null for npm package references (e.g. "@tsconfig/node18/tsconfig.json") + * since those cannot be fetched from the repo itself. + */ +function resolveExtendsPath(extendsValue: string): string | null { + // npm package reference -- skip + if (!extendsValue.startsWith(".") && !extendsValue.startsWith("/")) return null; + return extendsValue.replace(/^\.\//, "").replace(/^\//, ""); +} + +/** + * Fetches the tsconfig and follows "extends" up to 3 levels deep, returning + * the merged compilerOptions. Child values always override parent values. + */ +async function fetchMergedTsConfig( + owner: string, + repo: string, + token: string | undefined, + branch: string | undefined, + configPath = "tsconfig.json", + depth = 0 +): Promise { + if (depth > 3) return null; + + const raw = await fetchRepoFile(owner, repo, configPath, token, branch); + if (!raw) return null; + + const config = parseTsConfig(raw); + if (!config) return null; + + let merged = { ...config.compilerOptions }; + + if (config.extends) { + const parentPath = resolveExtendsPath(config.extends); + if (parentPath) { + const parentOpts = await fetchMergedTsConfig( + owner, + repo, + token, + branch, + parentPath, + depth + 1 + ); + if (parentOpts) { + // Parent is the base; child overrides on top + merged = { ...parentOpts, ...merged }; + // Merge path maps: child entries override parent entries + merged.paths = { + ...(parentOpts.paths ?? {}), + ...(config.compilerOptions?.paths ?? {}), + }; + } + } + } + + return merged; +} + +// ---------- Next.js detection ----------------------------------------------- + +/** + * Returns true if the repo appears to be a Next.js project, detected by the + * presence of next.config.js / next.config.ts / next.config.mjs. + * + * When true we inject an implicit "@/" alias that Next.js provides even when + * it is absent from tsconfig.paths. + */ +async function detectNextJs( + owner: string, + repo: string, + token?: string, + branch?: string +): Promise { + const configs = ["next.config.js", "next.config.ts", "next.config.mjs"]; + for (const file of configs) { + const content = await fetchRepoFile(owner, repo, file, token, branch); + if (content !== null) return true; + } + return false; +} + +// ---------- Alias extraction ------------------------------------------------ + +/** + * Converts a single raw tsconfig paths entry into a prefix/target pair. + * + * tsconfig uses glob wildcards: + * "@/*" + "src/*" -> prefix "@/" target "src/" + * "~/*" + "./*" -> prefix "~/" target "./" + * "#utils" + "src/utils" -> prefix "#utils" target "src/utils" + * + * Only the first target in the array is used (standard convention). + */ +function extractAliasEntry( + aliasPattern: string, + targetPatterns: string[], + baseUrl: string +): { prefix: string; target: string } | null { + if (!targetPatterns.length) return null; + + const rawTarget = targetPatterns[0]; + + const prefix = aliasPattern.endsWith("/*") + ? aliasPattern.slice(0, -1) // "@/*" -> "@/" + : aliasPattern.endsWith("*") + ? aliasPattern.slice(0, -1) // "@*" -> "@" + : aliasPattern; // exact match + + const target = rawTarget.endsWith("/*") + ? rawTarget.slice(0, -1) // "src/*" -> "src/" + : rawTarget.endsWith("*") + ? rawTarget.slice(0, -1) + : rawTarget; + + // Prepend baseUrl when target is relative to it (not starting with ./ ../ /) + const resolvedTarget = + target.startsWith("./") || target.startsWith("/") || target.startsWith("../") + ? target.replace(/^\.\//, "") // strip "./" prefix + : baseUrl + ? `${baseUrl.replace(/\/$/, "")}/${target}`.replace(/^\.\//, "") + : target; + + return { prefix, target: resolvedTarget }; +} + +// ---------- Resolver factory ------------------------------------------------ + +function buildResolver(aliases: AliasMap): PathAliasResolver { + // Longest prefix first so "@/components" beats "@/" on a match + const sorted = Object.entries(aliases).sort((a, b) => b[0].length - a[0].length); + const hasAliases = sorted.length > 0; + + return { + aliases, + hasAliases, + resolve(importPath: string): string { + for (const [prefix, target] of sorted) { + if (importPath === prefix || importPath.startsWith(prefix)) { + return target + importPath.slice(prefix.length); + } + } + return importPath; + }, + }; +} + +// ---------- Main export ----------------------------------------------------- + +/** + * Fetches the repo's TypeScript/JavaScript path configuration and returns a + * compiled resolver the AST pipeline can call on every import it encounters. + * + * Resolution order: + * 1. tsconfig.json (follows "extends" up to 3 levels) + * 2. jsconfig.json if tsconfig is absent + * 3. Next.js implicit "@/" alias injected when next.config.* is detected + * + * Always returns a valid resolver -- if no config is found it is a no-op + * passthrough so the pipeline does not need to handle undefined. + * + * @example + * ```ts + * // In ast-pipeline.ts: + * const resolver = await buildPathAliasResolver({ owner, repo, token, branch }); + * + * // During AST import traversal: + * const realPath = resolver.resolve(importNode.source.value); + * dependencyGraph[currentFile].push(realPath); + * ``` + */ +export async function buildPathAliasResolver(options: { + owner: string; + repo: string; + token?: string; + branch?: string; +}): Promise { + const { owner, repo, token, branch } = options; + + // Step 1: Fetch tsconfig.json, fall back to jsconfig.json + const compilerOptions = + (await fetchMergedTsConfig(owner, repo, token, branch, "tsconfig.json")) ?? + (await fetchMergedTsConfig(owner, repo, token, branch, "jsconfig.json")); + + // Step 2: Detect Next.js for implicit alias injection + const isNextJs = await detectNextJs(owner, repo, token, branch); + + // Step 3: Build alias map from compilerOptions.paths + const aliasMap: AliasMap = {}; + + if (compilerOptions) { + const baseUrl = compilerOptions.baseUrl + ? compilerOptions.baseUrl.replace(/\/$/, "").replace(/^\.\//, "") + : ""; + + for (const [pattern, targets] of Object.entries(compilerOptions.paths ?? {})) { + const entry = extractAliasEntry(pattern, targets, baseUrl); + if (entry) { + aliasMap[entry.prefix] = entry.target; + } + } + } + + // Step 4: Inject Next.js "@/" alias if not already defined by tsconfig + // Next.js maps "@/" to the project root by default. "src/" is used here + // as it is the most common layout in modern Next.js projects. + if (isNextJs && !aliasMap["@/"]) { + aliasMap["@/"] = "src/"; + } + + return buildResolver(aliasMap); +} + +// ---------- Utility --------------------------------------------------------- + +/** + * One-shot resolver for cases where you already have an AliasMap and just + * need to resolve a single import path without building a full resolver object. + * + * @example + * resolveWithAliases("@/utils/format", { "@/": "src/" }) + * // "src/utils/format" + */ +export function resolveWithAliases(importPath: string, aliases: AliasMap): string { + return buildResolver(aliases).resolve(importPath); +} \ No newline at end of file diff --git a/lib/types/analyze.ts b/lib/types/analyze.ts index 40b77cc..f6c94c0 100644 --- a/lib/types/analyze.ts +++ b/lib/types/analyze.ts @@ -42,6 +42,9 @@ export interface RepoData { }[]; fileContents?: { path: string; content: string }[]; pageRankScores?: Record; +articulationPoints?: string[]; +bridges?: Array<[string, string]>; +componentSizes?: Record; } export interface PRBlastRadiusItem {