diff --git a/Makefile b/Makefile index 073de4f..3b0d88d 100644 --- a/Makefile +++ b/Makefile @@ -1,138 +1,54 @@ -.PHONY: help dev prod build test clean deploy +# CodeIntel Development Makefile +# Usage: make [target] -# Default target -help: - @echo "CodeIntel - Development Commands" - @echo "" - @echo "Local Development:" - @echo " make dev - Start local dev (uses .env.dev)" - @echo " make dev-prod - Test prod config locally (uses .env.prod)" - @echo " make stop - Stop all services" - @echo " make clean - Stop and remove all containers/volumes" - @echo " make logs - View all logs" - @echo " make health - Check service health" - @echo "" - @echo "Testing:" - @echo " make test - Run backend tests" - @echo " make test-ws - Run WebSocket auth tests only" - @echo " make coverage - Run tests with coverage report" - @echo "" - @echo "Deployment:" - @echo " make deploy-backend - Deploy backend to Railway" - @echo " make deploy-frontend - Deploy frontend to Vercel" +.PHONY: f b all logs restart down status clean help -# ============================================ -# LOCAL DEVELOPMENT -# ============================================ +# Default target - rebuild frontend +f frontend: + @echo "🔄 Rebuilding frontend..." + @docker compose build frontend + @docker compose up -d frontend + @echo "✅ Done" -# Development with .env.dev -dev: - @echo "🚀 Starting LOCAL DEV environment..." - @cp .env.dev .env - docker compose up -d --build - @echo "" - @echo "✅ Development environment started!" - @echo " Backend: http://localhost:8000" - @echo " API Docs: http://localhost:8000/docs" - @echo " Frontend: http://localhost:3000" - @echo " Redis: localhost:6379" - @echo "" - @echo "View logs: make logs" +b backend: + @echo "🔄 Rebuilding backend..." + @docker compose build backend + @docker compose up -d backend + @echo "✅ Done" -# Test production config locally (uses .env.prod) -dev-prod: - @echo "🚀 Starting LOCAL environment with PROD config..." - @cp .env.prod .env - docker compose up -d --build - @echo "" - @echo "✅ Prod-config environment started!" - @echo " Backend: http://localhost:8000" - @echo " Frontend: http://localhost:3000" +all: + @docker compose build + @docker compose up -d -# Stop services -stop: - docker compose down - @echo "✅ Services stopped" +up: + @docker compose up -d -# Clean everything (including volumes) -clean: - docker compose down -v --remove-orphans - @echo "✅ Cleaned all containers and volumes" +down: + @docker compose down + +restart: + @docker compose restart -# View logs logs: - docker compose logs -f + @docker compose logs -f frontend -# Logs for specific service logs-backend: - docker compose logs -f backend - -logs-frontend: - docker compose logs -f frontend - -# ============================================ -# TESTING -# ============================================ - -# Run all backend tests -test: - cd backend && python3 -m pytest tests/ -v --no-cov - -# Run WebSocket auth tests only -test-ws: - cd backend && python3 -m pytest tests/test_websocket_auth.py -v --no-cov - -# Run tests with coverage -coverage: - cd backend && python3 -m pytest tests/ --cov=. --cov-report=html --cov-report=term - @echo "" - @echo "Coverage report: backend/htmlcov/index.html" - -# ============================================ -# DEPLOYMENT -# ============================================ + @docker compose logs -f backend -# Deploy backend to Railway -deploy-backend: - @echo "🚀 Deploying backend to Railway..." - railway up - @echo "✅ Backend deployed!" +status ps: + @docker compose ps -# Deploy frontend to Vercel -deploy-frontend: - @echo "🚀 Deploying frontend to Vercel..." - cd frontend && vercel --prod - @echo "✅ Frontend deployed!" - -# Deploy everything -deploy-all: deploy-backend deploy-frontend - @echo "✅ All services deployed!" - -# ============================================ -# UTILITIES -# ============================================ - -# Check service health -health: - @echo "Checking services..." - @curl -s http://localhost:8000/health | python3 -m json.tool 2>/dev/null || echo "❌ Backend not responding" - @curl -s -o /dev/null -w "" http://localhost:3000 && echo "✅ Frontend is up" || echo "❌ Frontend not responding" - @docker compose exec -T redis redis-cli ping 2>/dev/null | grep -q PONG && echo "✅ Redis is up" || echo "❌ Redis not responding" - -# Shell into backend container -shell-backend: - docker compose exec backend bash - -# Shell into Redis -shell-redis: - docker compose exec redis redis-cli - -# Quick rebuild backend only -rebuild-backend: - docker compose up -d --build backend - @echo "✅ Backend rebuilt and restarted" +clean: + @echo "⚠️ Full rebuild (slow)..." + @docker compose build --no-cache + @docker compose up -d -# Quick rebuild frontend only -rebuild-frontend: - docker compose up -d --build frontend - @echo "✅ Frontend rebuilt and restarted" +help: + @echo "make f - Rebuild frontend (~10s)" + @echo "make b - Rebuild backend" + @echo "make all - Rebuild everything" + @echo "make up - Start services" + @echo "make down - Stop services" + @echo "make logs - Frontend logs" + @echo "make status - Container status" + @echo "make clean - Full rebuild (slow)" diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..2248f47 --- /dev/null +++ b/dev.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Quick rebuild script for CodeIntel development +# Usage: ./dev.sh [frontend|backend|all] + +set -e + +cd "$(dirname "$0")" + +case "${1:-frontend}" in + frontend|f) + echo "🔄 Rebuilding frontend only..." + docker compose build frontend + docker compose up -d frontend + echo "✅ Frontend rebuilt in ~10s" + ;; + backend|b) + echo "🔄 Rebuilding backend only..." + docker compose build backend + docker compose up -d backend + echo "✅ Backend rebuilt" + ;; + all|a) + echo "🔄 Rebuilding all services..." + docker compose build + docker compose up -d + echo "✅ All services rebuilt" + ;; + logs|l) + docker compose logs -f "${2:-frontend}" + ;; + restart|r) + echo "🔄 Restarting ${2:-all} without rebuild..." + if [ -n "$2" ]; then + docker compose restart "$2" + else + docker compose restart + fi + ;; + down|d) + docker compose down + ;; + status|s) + docker compose ps + ;; + clean) + echo "⚠️ Full rebuild with no cache..." + docker compose build --no-cache + docker compose up -d + ;; + *) + echo "Usage: ./dev.sh [command]" + echo "" + echo "Commands:" + echo " frontend, f Rebuild frontend only (~10s)" + echo " backend, b Rebuild backend only (~2min first time, cached after)" + echo " all, a Rebuild all services" + echo " logs, l [svc] Follow logs (default: frontend)" + echo " restart, r Restart without rebuild" + echo " down, d Stop all services" + echo " status, s Show container status" + echo " clean Full rebuild, no cache (slow!)" + ;; +esac diff --git a/frontend/Dockerfile b/frontend/Dockerfile index b120408..bb24bdc 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,5 +1,5 @@ -# Frontend Dockerfile - Multi-stage build -FROM node:20-alpine AS builder +# Frontend Dockerfile - Multi-stage build with Bun +FROM oven/bun:1 AS builder WORKDIR /app @@ -13,17 +13,17 @@ ENV VITE_API_URL=$VITE_API_URL ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY -# Copy package files -COPY package*.json ./ +# Copy package files (lockfile required for deterministic builds) +COPY package.json bun.lock ./ -# Install dependencies -RUN npm ci +# Install dependencies (fail if lockfile missing or outdated) +RUN bun install --frozen-lockfile # Copy source code COPY . . # Build for production -RUN npm run build +RUN bun run build # Production image - nginx FROM nginx:alpine diff --git a/frontend/src/components/DependencyGraph.tsx b/frontend/src/components/DependencyGraph.tsx deleted file mode 100644 index 01d426c..0000000 --- a/frontend/src/components/DependencyGraph.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import { useEffect, useState, useCallback } from 'react' -import ReactFlow, { - Controls, - Background, - useNodesState, - useEdgesState, - MarkerType, - MiniMap, -} from 'reactflow' -import type { Node, Edge } from 'reactflow' -import dagre from 'dagre' -import { Lightbulb } from 'lucide-react' -import 'reactflow/dist/style.css' -import { useDependencyGraph } from '../hooks/useCachedQuery' -import { DependencyGraphSkeleton } from './ui/Skeleton' - -interface DependencyGraphProps { - repoId: string - apiUrl: string - apiKey: string -} - -const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { - const dagreGraph = new dagre.graphlib.Graph() - dagreGraph.setDefaultEdgeLabel(() => ({})) - dagreGraph.setGraph({ rankdir: 'TB', ranksep: 80, nodesep: 40 }) - - nodes.forEach((node) => { - dagreGraph.setNode(node.id, { width: 180, height: 60 }) - }) - - edges.forEach((edge) => { - dagreGraph.setEdge(edge.source, edge.target) - }) - - dagre.layout(dagreGraph) - - nodes.forEach((node) => { - const nodeWithPosition = dagreGraph.node(node.id) - node.position = { - x: nodeWithPosition.x - 90, - y: nodeWithPosition.y - 30, - } - }) - - return { nodes, edges } -} - -export function DependencyGraph({ repoId, apiUrl, apiKey }: DependencyGraphProps) { - const [nodes, setNodes, onNodesChange] = useNodesState([]) - const [edges, setEdges, onEdgesChange] = useEdgesState([]) - const [metrics, setMetrics] = useState(null) - const [filterCritical, setFilterCritical] = useState(false) - const [minDeps, setMinDeps] = useState(0) - const [highlightedNode, setHighlightedNode] = useState(null) - const [allNodes, setAllNodes] = useState([]) - const [allEdges, setAllEdges] = useState([]) - - const { data, isLoading: loading, isFetching } = useDependencyGraph({ repoId, apiKey }) - - useEffect(() => { - if (data) processGraphData(data) - }, [data]) - - useEffect(() => { - if (allNodes.length > 0) applyFilters() - }, [filterCritical, minDeps, allNodes, allEdges]) - - const applyFilters = () => { - let filteredNodes = [...allNodes] - let filteredEdges = [...allEdges] - - if (filterCritical || minDeps > 0) { - const threshold = minDeps || 3 - filteredNodes = allNodes.filter((node: any) => - (node.data.imports || 0) >= threshold || allEdges.some(e => e.target === node.id) - ) - const nodeIds = new Set(filteredNodes.map(n => n.id)) - filteredEdges = allEdges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target)) - } - - const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(filteredNodes, filteredEdges) - setNodes(layoutedNodes) - setEdges(layoutedEdges) - } - - const processGraphData = (data: any) => { - const flowNodes: Node[] = data.nodes.map((node: any) => { - const fileName = node.label || node.id.split('/').pop() - const fullPath = node.id - const importCount = node.import_count || node.imports || 0 - - return { - id: node.id, - type: 'default', - data: { - label: ( -
-
{fileName}
- {importCount > 0 &&
{importCount} imports
} -
- ), - language: node.language, - imports: importCount - }, - position: { x: 0, y: 0 }, - style: { - background: getLanguageColor(node.language), - color: 'white', - border: '2px solid hsl(var(--primary))', - borderRadius: '8px', - padding: '8px 12px', - fontSize: '11px', - fontFamily: 'monospace', - width: 180, - height: 60 - } - } - }) - - const flowEdges: Edge[] = data.edges.map((edge: any) => ({ - id: `${edge.source}-${edge.target}`, - source: edge.source, - target: edge.target, - animated: false, - style: { stroke: '#6b7280', strokeWidth: 1.5 }, - markerEnd: { type: MarkerType.ArrowClosed, color: '#6b7280' }, - })) - - setAllNodes(flowNodes) - setAllEdges(flowEdges) - setMetrics(data.metrics) - - const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(flowNodes, flowEdges) - setNodes(layoutedNodes) - setEdges(layoutedEdges) - } - - const handleNodeClick = useCallback((event: any, node: Node) => { - setHighlightedNode(node.id) - const connectedNodeIds = new Set([node.id]) - allEdges.forEach(edge => { - if (edge.source === node.id) connectedNodeIds.add(edge.target) - if (edge.target === node.id) connectedNodeIds.add(edge.source) - }) - - setNodes(nodes => nodes.map(n => ({ - ...n, - style: { - ...n.style, - opacity: connectedNodeIds.has(n.id) ? 1 : 0.3, - border: n.id === node.id ? '3px solid #ef4444' : n.style?.border - } - }))) - - setEdges(edges => edges.map(e => ({ - ...e, - style: { - ...e.style, - opacity: e.source === node.id || e.target === node.id ? 1 : 0.1, - strokeWidth: e.source === node.id || e.target === node.id ? 2.5 : 1.5 - } - }))) - }, [allEdges]) - - const resetHighlight = () => { - setHighlightedNode(null) - setNodes(nodes => nodes.map(n => ({ ...n, style: { ...n.style, opacity: 1, border: '2px solid hsl(var(--primary))' } }))) - setEdges(edges => edges.map(e => ({ ...e, style: { ...e.style, opacity: 1, strokeWidth: 1.5 } }))) - } - - const getLanguageColor = (language: string) => { - const colors: any = { 'python': '#3776ab', 'javascript': '#f7df1e', 'typescript': '#3178c6', 'unknown': '#6b7280' } - return colors[language] || colors.unknown - } - - if (loading) { - return - } - - return ( -
- {/* Metrics */} -
-
-
Total Files
-
{allNodes.length}
-
-
-
Dependencies
-
{edges.length}
-
-
-
Avg per File
-
{metrics?.avg_dependencies?.toFixed(1) || 0}
-
-
-
Showing
-
{nodes.length}
-
-
- - {/* Filter Controls */} -
-
- - -
- - setMinDeps(Number(e.target.value))} className="w-32 accent-primary" /> - {minDeps} -
- - {highlightedNode && ( - - )} -
-
- - {/* Most Critical Files */} - {metrics?.most_critical_files && metrics.most_critical_files.length > 0 && ( -
-

Most Critical Files

-
- {metrics.most_critical_files.slice(0, 5).map((item: any, idx: number) => ( -
- {item.file.split('/').slice(-2).join('/')} - {item.dependents} dependents -
- ))} -
-
- )} - - {/* Graph Visualization */} -
- - - - (node.style as any)?.background || '#6b7280'} - maskColor="rgba(0, 0, 0, 0.5)" - className="!bg-card !border-border" - /> - -
- - {/* Legend */} -
-

Graph Legend

-
-
-
- Python -
-
-
- TypeScript -
-
-
- JavaScript -
-
-
- Dependency -
-
-
- - Click any node to highlight its dependencies • Drag to pan • Scroll to zoom -
-
-
- ) -} diff --git a/frontend/src/components/DependencyGraph/GraphNode.tsx b/frontend/src/components/DependencyGraph/GraphNode.tsx new file mode 100644 index 0000000..f62b1af --- /dev/null +++ b/frontend/src/components/DependencyGraph/GraphNode.tsx @@ -0,0 +1,144 @@ +import { memo } from 'react' +import { Handle, Position } from 'reactflow' +import type { NodeProps } from 'reactflow' +import { + FileCode2, + FileJson, + FileText, + TestTube2, + Settings, + File +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { Badge } from '@/components/ui/badge' +import type { RiskLevel } from './hooks/useImpactAnalysis' + +export interface GraphNodeData { + label: string + fullPath: string + language: string + dependentCount: number + importCount: number + loc?: number + riskLevel: RiskLevel + isEntryPoint: boolean + state: 'default' | 'selected' | 'direct' | 'transitive' | 'dimmed' +} + +const FILE_ICONS: Record = { + python: FileCode2, + javascript: FileCode2, + typescript: FileCode2, + json: FileJson, + yaml: FileText, + markdown: FileText, + test: TestTube2, + config: Settings, + unknown: File, +} + +const LANGUAGE_COLORS: Record = { + python: 'text-blue-500 dark:text-blue-400', + javascript: 'text-yellow-600 dark:text-yellow-400', + typescript: 'text-blue-600 dark:text-blue-500', + json: 'text-zinc-500 dark:text-zinc-400', + yaml: 'text-zinc-500 dark:text-zinc-400', + markdown: 'text-zinc-500 dark:text-zinc-400', + config: 'text-zinc-500 dark:text-zinc-400', + test: 'text-purple-500 dark:text-purple-400', + unknown: 'text-zinc-400 dark:text-zinc-500', +} + +const STATE_STYLES: Record = { + default: 'border-zinc-300 bg-white dark:border-zinc-700 dark:bg-zinc-900/90', + selected: 'border-indigo-500 bg-white dark:bg-zinc-900 ring-2 ring-indigo-500/50 shadow-lg shadow-indigo-500/20', + direct: 'border-rose-500 bg-white dark:bg-zinc-900 ring-1 ring-rose-500/30', + transitive: 'border-amber-500 bg-white dark:bg-zinc-900 ring-1 ring-amber-500/30', + dimmed: 'border-zinc-200 bg-zinc-50/50 opacity-40 dark:border-zinc-800 dark:bg-zinc-900/50', +} + +const RISK_CONFIG: Record = { + low: { variant: 'secondary', label: 'Low', className: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400' }, + medium: { variant: 'secondary', label: 'Med', className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/10 dark:text-yellow-400' }, + high: { variant: 'secondary', label: 'High', className: 'bg-orange-100 text-orange-700 dark:bg-orange-500/10 dark:text-orange-400' }, + critical: { variant: 'destructive', label: 'Crit', className: 'bg-rose-100 text-rose-700 dark:bg-rose-500/10 dark:text-rose-400' }, +} + +function getFileType(path: string, language: string): string { + const fileName = path.split('/').pop() || '' + const lower = fileName.toLowerCase() + const ext = lower.split('.').pop() || '' + + if (lower.includes('.test.') || lower.includes('_test.') || lower.includes('.spec.')) { + return 'test' + } + if (ext === 'json') return 'json' + if (ext === 'yaml' || ext === 'yml') return 'yaml' + if (ext === 'md' || ext === 'markdown') return 'markdown' + if (lower.includes('config')) return 'config' + if (['python', 'javascript', 'typescript'].includes(language)) return language + return 'unknown' +} + +function GraphNodeComponent({ data }: NodeProps) { + const fileType = getFileType(data.fullPath, data.language) + const Icon = FILE_ICONS[fileType] || FILE_ICONS.unknown + const iconColor = LANGUAGE_COLORS[fileType] || LANGUAGE_COLORS.unknown + const stateStyle = STATE_STYLES[data.state] + const risk = RISK_CONFIG[data.riskLevel] + + return ( + <> + + +
+
+ + + {data.label} + + {data.dependentCount > 0 && ( + + {risk.label} + + )} +
+ +
+ = 15 ? 'text-rose-600 dark:text-rose-400' : + data.dependentCount >= 5 ? 'text-amber-600 dark:text-amber-400' : + 'text-zinc-500 dark:text-zinc-400' + )}> + {data.dependentCount} dependents + + + {data.importCount} imports +
+
+ + + + ) +} + +export const GraphNode = memo(GraphNodeComponent) diff --git a/frontend/src/components/DependencyGraph/GraphToolbar.tsx b/frontend/src/components/DependencyGraph/GraphToolbar.tsx new file mode 100644 index 0000000..feb7f97 --- /dev/null +++ b/frontend/src/components/DependencyGraph/GraphToolbar.tsx @@ -0,0 +1,76 @@ +import { memo } from 'react' +import { RotateCcw, Maximize2, Filter, Eye, EyeOff } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' + +interface GraphToolbarProps { + totalFiles: number + visibleFiles: number + showAll: boolean + showTests: boolean + onToggleShowAll: () => void + onToggleTests: () => void + onResetView: () => void + onFullscreen?: () => void +} + +function GraphToolbarComponent({ + totalFiles, + visibleFiles, + showAll, + showTests, + onToggleShowAll, + onToggleTests, + onResetView, + onFullscreen, +}: GraphToolbarProps) { + return ( +
+
+ + Showing {visibleFiles} + {!showAll && totalFiles > visibleFiles && ( + of {totalFiles} + )} + {' '}files + +
+ +
+ + + +
+ +
+ + + {onFullscreen && ( + + )} +
+
+ ) +} + +export const GraphToolbar = memo(GraphToolbarComponent) diff --git a/frontend/src/components/DependencyGraph/ImpactPanel.tsx b/frontend/src/components/DependencyGraph/ImpactPanel.tsx new file mode 100644 index 0000000..ff0cb9c --- /dev/null +++ b/frontend/src/components/DependencyGraph/ImpactPanel.tsx @@ -0,0 +1,258 @@ +import { memo, useState, useEffect } from 'react' +import { + X, + ChevronDown, + ChevronRight, + AlertTriangle, + FileCode2, + ExternalLink, + Search, + CheckCircle2, + Flame, + CircleAlert, + MapPin +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import type { RiskLevel, ImpactResult } from './hooks/useImpactAnalysis' + +interface ImpactPanelProps { + fileName: string + fullPath: string + impact: ImpactResult + onClose: () => void + onFileClick: (fileId: string) => void + onFileHover: (fileId: string | null) => void + onAnalyzeInSearch?: (fullPath: string) => void +} + +const RISK_CONFIG: Record = { + low: { + bg: 'bg-emerald-50 dark:bg-emerald-500/10', + border: 'border-emerald-200 dark:border-emerald-500/30', + text: 'text-emerald-600 dark:text-emerald-400', + icon: CheckCircle2, + label: 'Low Risk' + }, + medium: { + bg: 'bg-yellow-50 dark:bg-yellow-500/10', + border: 'border-yellow-200 dark:border-yellow-500/30', + text: 'text-yellow-600 dark:text-yellow-400', + icon: CircleAlert, + label: 'Medium Risk' + }, + high: { + bg: 'bg-orange-50 dark:bg-orange-500/10', + border: 'border-orange-200 dark:border-orange-500/30', + text: 'text-orange-600 dark:text-orange-400', + icon: AlertTriangle, + label: 'High Risk' + }, + critical: { + bg: 'bg-rose-50 dark:bg-rose-500/10', + border: 'border-rose-200 dark:border-rose-500/30', + text: 'text-rose-600 dark:text-rose-400', + icon: Flame, + label: 'Critical' + }, +} + +function FileListItem({ + fileId, + onClick, + onHover +}: { + fileId: string + onClick: () => void + onHover: (hovering: boolean) => void +}) { + const fileName = fileId.split('/').pop() || fileId + const dirPath = fileId.split('/').slice(0, -1).join('/') + + return ( + + ) +} + +function CollapsibleSection({ + title, + count, + files, + defaultOpen = false, + variant = 'default', + onFileClick, + onFileHover, +}: { + title: string + count: number + files: string[] + defaultOpen?: boolean + variant?: 'direct' | 'transitive' | 'default' + onFileClick: (fileId: string) => void + onFileHover: (fileId: string | null) => void +}) { + const [isOpen, setIsOpen] = useState(defaultOpen) + + // Reset collapse state when file selection changes + useEffect(() => { + setIsOpen(defaultOpen) + }, [defaultOpen, files]) + + const variantStyles = { + direct: 'text-rose-600 dark:text-rose-400', + transitive: 'text-amber-600 dark:text-amber-400', + default: 'text-zinc-700 dark:text-zinc-300', + } + + if (count === 0) return null + + return ( +
+ + + {isOpen && ( +
+ {files.map(fileId => ( + onFileClick(fileId)} + onHover={(hovering) => onFileHover(hovering ? fileId : null)} + /> + ))} +
+ )} +
+ ) +} + +function ImpactPanelComponent({ + fileName, + fullPath, + impact, + onClose, + onFileClick, + onFileHover, + onAnalyzeInSearch, +}: ImpactPanelProps) { + const risk = RISK_CONFIG[impact.riskLevel] + const RiskIcon = risk.icon + const totalDependents = impact.allDependents.length + + return ( +
+ +
+
+

+ {fileName} +

+

+ {fullPath} +

+
+ +
+ +
+ +
+
{risk.label}
+
+ {totalDependents === 0 + ? 'No files depend on this' + : `${totalDependents} file${totalDependents === 1 ? '' : 's'} will be affected` + } +
+
+
+ + {impact.riskLevel === 'critical' && ( +
+ + Changes to this file have high blast radius. Test thoroughly. +
+ )} + + {impact.isEntryPoint && ( +
+ + Entry point - root of the dependency tree +
+ )} +
+ + + + + + + + {onAnalyzeInSearch && ( +
+ +
+ )} +
+ ) +} + +export const ImpactPanel = memo(ImpactPanelComponent) diff --git a/frontend/src/components/DependencyGraph/hooks/useImpactAnalysis.ts b/frontend/src/components/DependencyGraph/hooks/useImpactAnalysis.ts new file mode 100644 index 0000000..a797fb0 --- /dev/null +++ b/frontend/src/components/DependencyGraph/hooks/useImpactAnalysis.ts @@ -0,0 +1,172 @@ +import { useMemo, useCallback } from 'react' + +export type RiskLevel = 'low' | 'medium' | 'high' | 'critical' + +export interface ImpactResult { + directDependents: string[] + transitiveDependents: string[] + allDependents: string[] + riskLevel: RiskLevel + riskScore: number + isEntryPoint: boolean +} + +export interface FileMetrics { + id: string + dependentCount: number + importCount: number + importance: number + isEntryPoint: boolean + riskLevel: RiskLevel +} + +interface GraphData { + nodes: Array<{ id: string; import_count?: number; imports?: number }> + edges: Array<{ source: string; target: string }> +} + +// Risk thresholds based on dependent count +const RISK_THRESHOLDS = { + low: 5, + medium: 15, + high: 30, +} as const + +function calculateRiskLevel(dependentCount: number): RiskLevel { + if (dependentCount >= RISK_THRESHOLDS.high) return 'critical' + if (dependentCount >= RISK_THRESHOLDS.medium) return 'high' + if (dependentCount >= RISK_THRESHOLDS.low) return 'medium' + return 'low' +} + +export function useImpactAnalysis(graphData: GraphData | null) { + // Build adjacency maps: who imports whom, who depends on whom + const { fileToImports, fileToDependents, importCounts } = useMemo(() => { + if (!graphData) { + return { fileToImports: new Map(), fileToDependents: new Map(), importCounts: new Map() } + } + + const fileToImports = new Map>() + const fileToDependents = new Map>() + const importCounts = new Map() + + // Initialize all nodes + graphData.nodes.forEach(node => { + fileToImports.set(node.id, new Set()) + fileToDependents.set(node.id, new Set()) + importCounts.set(node.id, node.import_count || node.imports || 0) + }) + + // Build relationships from edges + // edge: source imports target (source -> target means source depends on target) + graphData.edges.forEach(edge => { + // source imports target + fileToImports.get(edge.source)?.add(edge.target) + // target has source as dependent + fileToDependents.get(edge.target)?.add(edge.source) + }) + + return { fileToImports, fileToDependents, importCounts } + }, [graphData]) + + // Get all dependents (files that would break if this file changes) + const getDependents = useCallback((fileId: string, maxDepth = Infinity): ImpactResult => { + const directDependents: string[] = [] + const transitiveDependents: string[] = [] + const visited = new Set() + + function traverse(currentId: string, depth: number) { + if (visited.has(currentId) || depth > maxDepth) return + visited.add(currentId) + + const dependents = fileToDependents.get(currentId) + if (!dependents) return + + dependents.forEach(depId => { + if (depId === fileId) return // skip self + + if (depth === 0) { + if (!directDependents.includes(depId)) directDependents.push(depId) + } else { + if (!transitiveDependents.includes(depId) && !directDependents.includes(depId)) { + transitiveDependents.push(depId) + } + } + traverse(depId, depth + 1) + }) + } + + traverse(fileId, 0) + + const allDependents = [...directDependents, ...transitiveDependents] + const totalCount = allDependents.length + const riskLevel = calculateRiskLevel(totalCount) + + // Entry point: has dependents but imports nothing (or very little) + const imports = fileToImports.get(fileId) + const isEntryPoint = totalCount > 0 && (imports?.size || 0) === 0 + + return { + directDependents, + transitiveDependents, + allDependents, + riskLevel, + riskScore: totalCount, + isEntryPoint, + } + }, [fileToDependents, fileToImports]) + + // Get imports (what this file depends on) + const getImports = useCallback((fileId: string): string[] => { + return Array.from(fileToImports.get(fileId) || []) + }, [fileToImports]) + + // Calculate metrics for all files + const fileMetrics = useMemo((): FileMetrics[] => { + if (!graphData) return [] + + return graphData.nodes.map(node => { + const dependents = fileToDependents.get(node.id) + const imports = fileToImports.get(node.id) + const dependentCount = dependents?.size || 0 + const importCount = imports?.size || 0 + + // Importance = dependents weigh more (files that break things are more important) + const importance = dependentCount * 2 + importCount + + return { + id: node.id, + dependentCount, + importCount, + importance, + isEntryPoint: dependentCount > 0 && importCount === 0, + riskLevel: calculateRiskLevel(dependentCount), + } + }).sort((a, b) => b.importance - a.importance) + }, [graphData, fileToDependents, fileToImports]) + + // Get top N most important files + const getTopFiles = useCallback((n: number): string[] => { + return fileMetrics.slice(0, n).map(f => f.id) + }, [fileMetrics]) + + // Get entry points (files with dependents but no imports) + const entryPoints = useMemo((): string[] => { + return fileMetrics.filter(f => f.isEntryPoint).map(f => f.id) + }, [fileMetrics]) + + // Get metrics for a specific file + const getFileMetrics = useCallback((fileId: string): FileMetrics | null => { + return fileMetrics.find(f => f.id === fileId) || null + }, [fileMetrics]) + + return { + getDependents, + getImports, + getTopFiles, + getFileMetrics, + fileMetrics, + entryPoints, + isReady: !!graphData, + } +} diff --git a/frontend/src/components/DependencyGraph/index.tsx b/frontend/src/components/DependencyGraph/index.tsx new file mode 100644 index 0000000..69d052c --- /dev/null +++ b/frontend/src/components/DependencyGraph/index.tsx @@ -0,0 +1,374 @@ +import { useEffect, useState, useCallback, useMemo } from 'react' +import ReactFlow, { + Controls, + Background, + useNodesState, + useEdgesState, + useReactFlow, + ReactFlowProvider, +} from 'reactflow' +import type { Node, Edge } from 'reactflow' +import dagre from 'dagre' +import { useTheme } from 'next-themes' +import { FileCode2, GitBranch, Navigation, AlertTriangle } from 'lucide-react' +import 'reactflow/dist/style.css' + +import { useDependencyGraph } from '../../hooks/useCachedQuery' +import { DependencyGraphSkeleton } from '../ui/Skeleton' +import { Card, CardContent } from '@/components/ui/card' +import { useImpactAnalysis, type ImpactResult } from './hooks/useImpactAnalysis' +import { GraphNode, type GraphNodeData } from './GraphNode' +import { ImpactPanel } from './ImpactPanel' +import { GraphToolbar } from './GraphToolbar' + +interface DependencyGraphProps { + repoId: string + apiUrl: string + apiKey: string +} + +const nodeTypes = { custom: GraphNode } + +const LAYOUT_CONFIG = { + rankdir: 'LR', + ranksep: 100, + nodesep: 60, + nodeWidth: 200, + nodeHeight: 70, +} + +const DEFAULT_VISIBLE_COUNT = 15 + +function getLayoutedElements(nodes: Node[], edges: Edge[]) { + const dagreGraph = new dagre.graphlib.Graph() + dagreGraph.setDefaultEdgeLabel(() => ({})) + dagreGraph.setGraph({ + rankdir: LAYOUT_CONFIG.rankdir, + ranksep: LAYOUT_CONFIG.ranksep, + nodesep: LAYOUT_CONFIG.nodesep, + }) + + nodes.forEach((node) => { + dagreGraph.setNode(node.id, { + width: LAYOUT_CONFIG.nodeWidth, + height: LAYOUT_CONFIG.nodeHeight + }) + }) + + edges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target) + }) + + dagre.layout(dagreGraph) + + const layoutedNodes = nodes.map((node) => { + const nodeWithPosition = dagreGraph.node(node.id) + return { + ...node, + position: { + x: nodeWithPosition.x - LAYOUT_CONFIG.nodeWidth / 2, + y: nodeWithPosition.y - LAYOUT_CONFIG.nodeHeight / 2, + }, + } + }) + + return { nodes: layoutedNodes, edges } +} + +const getEdgeStyle = (state: 'default' | 'highlighted' | 'dimmed' | 'incoming' | 'outgoing', isDark: boolean) => { + const styles = { + default: { stroke: isDark ? '#52525b' : '#a1a1aa', strokeWidth: 1, opacity: 0.6 }, + highlighted: { stroke: '#6366f1', strokeWidth: 2, opacity: 1 }, + dimmed: { stroke: isDark ? '#27272a' : '#e4e4e7', strokeWidth: 1, opacity: 0.3 }, + incoming: { stroke: '#f43f5e', strokeWidth: 2, opacity: 1 }, + outgoing: { stroke: '#6366f1', strokeWidth: 2, opacity: 1 }, + } + return styles[state] +} + +function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) { + const [nodes, setNodes, onNodesChange] = useNodesState([]) + const [edges, setEdges, onEdgesChange] = useEdgesState([]) + const [selectedNodeId, setSelectedNodeId] = useState(null) + const [hoveredFileId, setHoveredFileId] = useState(null) + const [showAll, setShowAll] = useState(false) + const [showTests, setShowTests] = useState(true) + const [rawGraphData, setRawGraphData] = useState(null) + + const { fitView } = useReactFlow() + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + + const { data, isLoading } = useDependencyGraph({ repoId, apiKey }) + const impact = useImpactAnalysis(rawGraphData) + + useEffect(() => { + if (data) setRawGraphData(data) + }, [data]) + + const visibleNodeIds = useMemo(() => { + if (!rawGraphData || !impact.isReady) return new Set() + + let nodeIds: string[] = rawGraphData.nodes.map((n: any) => n.id) + + if (!showTests) { + nodeIds = nodeIds.filter((id: string) => { + const fileName = id.split('/').pop() || '' + return !fileName.includes('.test.') && !fileName.includes('_test.') && !fileName.includes('.spec.') + }) + } + + if (!showAll) { + const topFiles = impact.getTopFiles(DEFAULT_VISIBLE_COUNT) + nodeIds = nodeIds.filter((id: string) => topFiles.includes(id)) + } + + return new Set(nodeIds) + }, [rawGraphData, impact.isReady, impact.fileMetrics, showAll, showTests]) + + const selectedImpact = useMemo((): ImpactResult | null => { + if (!selectedNodeId || !impact.isReady) return null + return impact.getDependents(selectedNodeId) + }, [selectedNodeId, impact]) + + useEffect(() => { + if (!rawGraphData || !impact.isReady) return + + const flowNodes: Node[] = rawGraphData.nodes + .filter((node: any) => visibleNodeIds.has(node.id)) + .map((node: any) => { + const fileName = node.label || node.id.split('/').pop() + const metrics = impact.getFileMetrics(node.id) + + let state: GraphNodeData['state'] = 'default' + if (selectedNodeId) { + if (node.id === selectedNodeId) { + state = 'selected' + } else if (selectedImpact?.directDependents.includes(node.id)) { + state = 'direct' + } else if (selectedImpact?.transitiveDependents.includes(node.id)) { + state = 'transitive' + } else { + state = 'dimmed' + } + } + + if (hoveredFileId === node.id && state === 'dimmed') { + state = 'direct' + } + + return { + id: node.id, + type: 'custom', + data: { + label: fileName, + fullPath: node.id, + language: node.language || 'unknown', + dependentCount: metrics?.dependentCount || 0, + importCount: metrics?.importCount || 0, + riskLevel: metrics?.riskLevel || 'low', + isEntryPoint: metrics?.isEntryPoint || false, + state, + }, + position: { x: 0, y: 0 }, + } + }) + + const flowEdges: Edge[] = rawGraphData.edges + .filter((edge: any) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target)) + .map((edge: any) => { + let edgeState: 'default' | 'highlighted' | 'dimmed' | 'incoming' | 'outgoing' = 'default' + if (selectedNodeId) { + if (edge.source === selectedNodeId) { + edgeState = 'outgoing' + } else if (edge.target === selectedNodeId) { + edgeState = 'incoming' + } else { + edgeState = 'dimmed' + } + } + + return { + id: `${edge.source}-${edge.target}`, + source: edge.source, + target: edge.target, + style: getEdgeStyle(edgeState, isDark), + animated: edgeState === 'incoming', + } + }) + + const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(flowNodes, flowEdges) + setNodes(layoutedNodes) + setEdges(layoutedEdges) + }, [rawGraphData, impact.isReady, visibleNodeIds, selectedNodeId, selectedImpact, hoveredFileId, isDark]) + + useEffect(() => { + if (nodes.length > 0) { + const minZoom = nodes.length > 20 ? 0.5 : 0.3 + setTimeout(() => fitView({ padding: 0.2, duration: 300, minZoom }), 100) + } + }, [showAll, showTests]) + + const handleNodeClick = useCallback((_: any, node: Node) => { + setSelectedNodeId(prev => prev === node.id ? null : node.id) + }, []) + + const handlePaneClick = useCallback(() => { + setSelectedNodeId(null) + }, []) + + const handlePanelFileClick = useCallback((fileId: string) => { + setSelectedNodeId(fileId) + const node = nodes.find(n => n.id === fileId) + if (node) { + fitView({ nodes: [node], padding: 0.5, duration: 300 }) + } + }, [nodes, fitView]) + + const handleResetView = useCallback(() => { + setSelectedNodeId(null) + const minZoom = nodes.length > 20 ? 0.5 : 0.3 + fitView({ padding: 0.2, duration: 300, minZoom }) + }, [fitView, nodes.length]) + + if (isLoading) { + return + } + + const selectedNode = rawGraphData?.nodes.find((n: any) => n.id === selectedNodeId) + const selectedFileName = selectedNode?.label || selectedNodeId?.split('/').pop() || '' + const criticalCount = impact.fileMetrics.filter(f => f.riskLevel === 'critical' || f.riskLevel === 'high').length + + return ( +
+ {/* Metrics Bar */} +
+ + +
+ + Total Files +
+
+ {rawGraphData?.nodes?.length || 0} +
+
+
+ + +
+ + Dependencies +
+
+ {rawGraphData?.edges?.length || 0} +
+
+
+ + +
+ + Entry Points +
+
+ {impact.entryPoints.length} +
+
+
+ + +
+ + Critical Files +
+
+ {criticalCount} +
+
+
+
+ + setShowAll(prev => !prev)} + onToggleTests={() => setShowTests(prev => !prev)} + onResetView={handleResetView} + /> + +
+
+ + + + + + {/* Legend */} + + +
Legend
+
+
+
+ Selected +
+
+
+ Direct dependent +
+
+
+ Transitive dependent +
+
+
+ Entry point +
+
+
+ Click node to analyze impact +
+ + +
+ + {selectedNodeId && selectedImpact && ( + setSelectedNodeId(null)} + onFileClick={handlePanelFileClick} + onFileHover={setHoveredFileId} + /> + )} +
+
+ ) +} + +export function DependencyGraph(props: DependencyGraphProps) { + return ( + + + + ) +} diff --git a/frontend/src/components/dashboard/DashboardHome.tsx b/frontend/src/components/dashboard/DashboardHome.tsx index 8b13b32..fbbb8ee 100644 --- a/frontend/src/components/dashboard/DashboardHome.tsx +++ b/frontend/src/components/dashboard/DashboardHome.tsx @@ -286,7 +286,7 @@ export function DashboardHome() {
{/* Tab Content */} -
+
{activeTab === 'overview' && (