From b7e9a90066b0ec34e71eab92c99c470810e8ecb4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:52:27 +0000 Subject: [PATCH 1/2] chore(deps): bump stefanzweifel/git-auto-commit-action from 5 to 7 Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 5 to 7. - [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases) - [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v5...v7) --- updated-dependencies: - dependency-name: stefanzweifel/git-auto-commit-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/auto-format.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml index aebaf14..548bdc6 100644 --- a/.github/workflows/auto-format.yml +++ b/.github/workflows/auto-format.yml @@ -41,6 +41,6 @@ jobs: run: npx prettier --config frontend/.prettierrc --write --ignore-unknown "frontend/src/**/*.{ts,tsx,js,jsx,css}" "frontend/public/**/*" - name: Commit formatting changes - uses: stefanzweifel/git-auto-commit-action@v5 + uses: stefanzweifel/git-auto-commit-action@v7 with: commit_message: "chore: auto-format code on PR branch" From 24ac40084ffbf0c70a08e69a2adb37829c952ab1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:52:46 +0000 Subject: [PATCH 2/2] chore: auto-format code on PR branch --- backend/backend/__init__.py | 1 + backend/config.py | 28 +- backend/database.py | 9 +- .../features/llm_analysis/claude_client.py | 67 +- backend/features/llm_analysis/cost_guard.py | 18 +- backend/features/llm_analysis/llm_router.py | 9 +- .../features/llm_analysis/prompt_builder.py | 34 +- backend/features/llm_analysis/router.py | 33 +- backend/features/repo_ingestion/bus_factor.py | 27 +- .../features/repo_ingestion/clone_service.py | 70 +- .../features/repo_ingestion/commit_walker.py | 45 +- .../features/repo_ingestion/graph_builder.py | 91 +- .../features/repo_ingestion/health_scorer.py | 249 ++--- .../repo_ingestion/metrics_extractor.py | 21 +- backend/features/repo_ingestion/router.py | 153 +-- .../repo_ingestion/semantic_analyzer.py | 7 +- backend/main.py | 5 +- backend/shared/models.py | 249 ++--- backend/shared/schemas.py | 13 +- backend/sitecustomize.py | 1 + backend/tests/test_config.py | 15 +- backend/tests/test_database_migrations.py | 2 + backend/tests/test_llm_logic.py | 6 +- backend/tests/test_repo_api.py | 313 +++--- backend/tests/test_repo_ingestion_logic.py | 12 +- frontend/src/App.test.tsx | 10 +- frontend/src/App.tsx | 4 +- frontend/src/components/AmbientBackground.tsx | 18 +- .../src/components/BusFactorTable.test.tsx | 4 +- frontend/src/components/BusFactorTable.tsx | 49 +- frontend/src/components/CommitList.tsx | 32 +- frontend/src/components/CostMeter.tsx | 20 +- frontend/src/components/GraphDiffPanel.tsx | 34 +- frontend/src/components/GraphExplorer.tsx | 920 ++++++++++-------- frontend/src/components/HealthTimeline.tsx | 185 ++-- frontend/src/components/HotspotMap.tsx | 75 +- frontend/src/components/NarrativeCard.tsx | 54 +- frontend/src/components/ui/HealthBadge.tsx | 29 +- frontend/src/components/ui/ThemeToggle.tsx | 38 +- frontend/src/index.css | 228 +++-- frontend/src/lib/api.test.ts | 18 +- frontend/src/lib/api.ts | 6 +- frontend/src/lib/utils.ts | 7 +- frontend/src/main.tsx | 2 +- frontend/src/pages/AnalyzePage.test.tsx | 2 +- frontend/src/pages/AnalyzePage.tsx | 64 +- frontend/src/pages/CommitDetailPage.tsx | 81 +- frontend/src/pages/DashboardPage.test.tsx | 25 +- frontend/src/pages/DashboardPage.tsx | 200 ++-- frontend/src/pages/DemoPage.test.tsx | 2 +- frontend/src/pages/DemoPage.tsx | 15 +- frontend/src/pages/LandingPage.test.tsx | 7 +- frontend/src/pages/LandingPage.tsx | 93 +- frontend/src/pages/NotFoundPage.tsx | 15 +- frontend/src/types/index.test.ts | 8 +- frontend/src/types/index.ts | 10 +- 56 files changed, 2200 insertions(+), 1533 deletions(-) diff --git a/backend/backend/__init__.py b/backend/backend/__init__.py index bad37b4..d308e64 100644 --- a/backend/backend/__init__.py +++ b/backend/backend/__init__.py @@ -1,4 +1,5 @@ """Compatibility package for running commands from the backend directory.""" + from __future__ import annotations from pathlib import Path diff --git a/backend/config.py b/backend/config.py index 6ba52e7..ddd285b 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,9 +1,11 @@ import os -from dotenv import load_dotenv from pathlib import Path +from dotenv import load_dotenv + load_dotenv() + def _normalize_database_url(url: str) -> str: if url.startswith("postgres://"): return url.replace("postgres://", "postgresql+asyncpg://", 1) @@ -36,17 +38,23 @@ def _cors_origins(environment: str, raw_origins: str | None) -> list[str]: return _parse_csv(DEFAULT_LOCAL_CORS_ORIGINS) -ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") -GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") -GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "") -DATABASE_URL = _normalize_database_url(os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./commitiq.db")) -REPO_STORAGE_PATH = Path(os.getenv("REPO_STORAGE_PATH", "/tmp/commitiq_repos")) -MAX_COMMITS = int(os.getenv("MAX_COMMITS_PER_INGESTION", os.getenv("MAX_COMMITS", "500"))) -LLM_MAX_CALLS = int(os.getenv("LLM_BUDGET_PER_REPO", os.getenv("LLM_MAX_CALLS_PER_REPO", os.getenv("LLM_MAX_CALLS", "25")))) +ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") +GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "") +DATABASE_URL = _normalize_database_url( + os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./commitiq.db") +) +REPO_STORAGE_PATH = Path(os.getenv("REPO_STORAGE_PATH", "/tmp/commitiq_repos")) +MAX_COMMITS = int(os.getenv("MAX_COMMITS_PER_INGESTION", os.getenv("MAX_COMMITS", "500"))) +LLM_MAX_CALLS = int( + os.getenv( + "LLM_BUDGET_PER_REPO", os.getenv("LLM_MAX_CALLS_PER_REPO", os.getenv("LLM_MAX_CALLS", "25")) + ) +) LLM_BUDGET_PER_REPO_USD = float(os.getenv("LLM_BUDGET_PER_REPO_USD", "0.50")) ENABLE_SEMANTIC_ANALYSIS = _parse_bool(os.getenv("ENABLE_SEMANTIC_ANALYSIS"), default=True) ENABLE_GRAPHCODEBERT = _parse_bool(os.getenv("ENABLE_GRAPHCODEBERT"), default=False) -ENVIRONMENT = os.getenv("ENVIRONMENT", "development") -CORS_ORIGINS = _cors_origins(ENVIRONMENT, os.getenv("CORS_ORIGINS")) +ENVIRONMENT = os.getenv("ENVIRONMENT", "development") +CORS_ORIGINS = _cors_origins(ENVIRONMENT, os.getenv("CORS_ORIGINS")) REPO_STORAGE_PATH.mkdir(parents=True, exist_ok=True) diff --git a/backend/database.py b/backend/database.py index 7d9202d..6c24c4f 100644 --- a/backend/database.py +++ b/backend/database.py @@ -60,14 +60,9 @@ async def _execute_statement(conn, statement: str, *, is_sqlite: bool = _IS_SQLI def _migration_statements(migration_sql: str) -> list[str]: migration_lines = migration_sql.splitlines() uncommented_sql = "\n".join( - line for line in migration_lines - if not line.lstrip().startswith("--") + line for line in migration_lines if not line.lstrip().startswith("--") ) - return [ - statement.strip() - for statement in uncommented_sql.split(";") - if statement.strip() - ] + return [statement.strip() for statement in uncommented_sql.split(";") if statement.strip()] async def _ensure_migration_table(conn) -> None: diff --git a/backend/features/llm_analysis/claude_client.py b/backend/features/llm_analysis/claude_client.py index 2dda109..472349f 100644 --- a/backend/features/llm_analysis/claude_client.py +++ b/backend/features/llm_analysis/claude_client.py @@ -1,24 +1,26 @@ """LLM call wrapper for on-demand CommitIQ narratives.""" + from typing import AsyncGenerator -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession -from backend.shared.models import LLMNarrative, Commit, HealthSnapshot +from backend.config import ANTHROPIC_API_KEY from backend.features.llm_analysis.cache import make_cache_key -from backend.features.llm_analysis.cost_guard import check_budget -from backend.features.llm_analysis.cost_guard import estimate_cost_usd +from backend.features.llm_analysis.cost_guard import check_budget, estimate_cost_usd from backend.features.llm_analysis.llm_router import get_narrative_non_streaming, model_for_provider from backend.features.llm_analysis.prompt_builder import ( - EXPLAIN_DROP_SYSTEM, PREDICT_MERGE_SYSTEM, - build_explain_prompt, build_predict_prompt + EXPLAIN_DROP_SYSTEM, + PREDICT_MERGE_SYSTEM, + build_explain_prompt, + build_predict_prompt, ) -from backend.config import ANTHROPIC_API_KEY, GEMINI_API_KEY +from backend.shared.models import Commit, HealthSnapshot, LLMNarrative # Sonnet pricing (as of 2025): # Input: $3.00 per 1M tokens = $0.000003 per token # Output: $15.00 per 1M tokens = $0.000015 per token -INPUT_COST_PER_TOKEN = 0.000003 +INPUT_COST_PER_TOKEN = 0.000003 OUTPUT_COST_PER_TOKEN = 0.000015 CLAUDE_MODEL = "claude-3-5-sonnet-20241022" @@ -26,6 +28,7 @@ def _get_anthropic(): try: import anthropic + return anthropic except ImportError: return None @@ -34,6 +37,7 @@ def _get_anthropic(): def _get_genai(): try: import google.generativeai as genai + return genai except ImportError: return None @@ -67,7 +71,7 @@ async def stream_narrative(prompt: str) -> AsyncGenerator[str, None]: "You are a code health analyst. Given metric deltas for a Git commit, " "explain in plain English why the health score changed. Be specific about " "which files and metrics drove the change. Use bullet points. Max 4 sentences. " - "Always end with: \"Estimated impact: [Low/Medium/High] risk to next sprint.\" " + 'Always end with: "Estimated impact: [Low/Medium/High] risk to next sprint." ' "Never mention token costs or that you're an AI." ), ) as stream: @@ -93,10 +97,12 @@ async def get_or_create_narrative( Checks cache → checks budget → fires API → stores result. """ commit_result = await db.execute( - select(Commit).where( + select(Commit) + .where( Commit.repo_id == repo_id, Commit.sha == commit_sha[:12], - ).limit(1) + ) + .limit(1) ) commit = commit_result.scalar_one_or_none() if not commit: @@ -116,11 +122,11 @@ async def get_or_create_narrative( "prompt_type": prompt_type, "explanation": cached.response_text, "tokens_used": cached.tokens_input + cached.tokens_output, - "cost_usd": cached.cost_usd, - "cached": True, - "model": cached.model_used, - "provider": "cache", - "demo_mode": False, + "cost_usd": cached.cost_usd, + "cached": True, + "model": cached.model_used, + "provider": "cache", + "demo_mode": False, } # 3. Check budget — hard limit @@ -135,12 +141,12 @@ async def get_or_create_narrative( raise ValueError(f"No health snapshot for commit {commit_sha}") after_dict = { - "health_score": snapshot.health_score, - "avg_complexity": snapshot.avg_complexity, - "churn_rate": snapshot.churn_rate, + "health_score": snapshot.health_score, + "avg_complexity": snapshot.avg_complexity, + "churn_rate": snapshot.churn_rate, "num_files_changed": snapshot.num_files_changed, - "bus_factor_min": snapshot.bus_factor_min, - "top_files_json": snapshot.top_files_json, + "bus_factor_min": snapshot.bus_factor_min, + "top_files_json": snapshot.top_files_json, "avg_semantic_drift": snapshot.avg_semantic_drift, "semantic_health_score": snapshot.semantic_health_score, "high_drift_files": snapshot.high_drift_files, @@ -148,10 +154,13 @@ async def get_or_create_narrative( } prev_commit_result = await db.execute( - select(Commit).where( + select(Commit) + .where( Commit.repo_id == repo_id, Commit.committed_at < commit.committed_at, - ).order_by(Commit.committed_at.desc()).limit(1) + ) + .order_by(Commit.committed_at.desc()) + .limit(1) ) prev_commit = prev_commit_result.scalar_one_or_none() @@ -161,7 +170,7 @@ async def get_or_create_narrative( ) prev_snap = prev_snap_result.scalar_one_or_none() before_dict = { - "health_score": prev_snap.health_score if prev_snap else 0, + "health_score": prev_snap.health_score if prev_snap else 0, "avg_complexity": prev_snap.avg_complexity if prev_snap else 0, "bus_factor_min": prev_snap.bus_factor_min if prev_snap else 1, } @@ -227,9 +236,9 @@ async def get_or_create_narrative( "prompt_type": prompt_type, "explanation": response_text, "tokens_used": tokens_in + tokens_out, - "cost_usd": round(cost, 5), - "cached": False, - "model": model_used, - "provider": provider_used, - "demo_mode": demo_mode, + "cost_usd": round(cost, 5), + "cached": False, + "model": model_used, + "provider": provider_used, + "demo_mode": demo_mode, } diff --git a/backend/features/llm_analysis/cost_guard.py b/backend/features/llm_analysis/cost_guard.py index 9106936..2bcbb80 100644 --- a/backend/features/llm_analysis/cost_guard.py +++ b/backend/features/llm_analysis/cost_guard.py @@ -1,9 +1,9 @@ -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -from backend.shared.models import LLMNarrative +from sqlalchemy.ext.asyncio import AsyncSession + from backend.config import LLM_BUDGET_PER_REPO_USD, LLM_MAX_CALLS from backend.features.llm_analysis.llm_router import provider_from_model - +from backend.shared.models import LLMNarrative PROVIDER_COSTS_PER_1K_OUTPUT = { "anthropic": 0.015, @@ -44,8 +44,12 @@ async def get_usage_summary(repo_id: int, db: AsyncSession) -> dict: cached_rows = [row for row in rows if row.is_pre_cached] total_tokens = sum(row.tokens_input + row.tokens_output for row in provider_rows) total_cost = sum(float(row.cost_usd or 0.0) for row in provider_rows) - anthropic_calls = sum(1 for row in provider_rows if provider_from_model(row.model_used) == "anthropic") - gemini_calls = sum(1 for row in provider_rows if provider_from_model(row.model_used) == "gemini") + anthropic_calls = sum( + 1 for row in provider_rows if provider_from_model(row.model_used) == "anthropic" + ) + gemini_calls = sum( + 1 for row in provider_rows if provider_from_model(row.model_used) == "gemini" + ) cached_tokens = sum(row.tokens_input + row.tokens_output for row in cached_rows) cache_hits = len(cached_rows) return { @@ -56,7 +60,9 @@ async def get_usage_summary(repo_id: int, db: AsyncSession) -> dict: "gemini_calls": gemini_calls, "total_tokens": total_tokens, "total_cost_usd": round(float(total_cost or 0.0), 6), - "cache_savings_usd": round((cached_tokens / 1000.0) * PROVIDER_COSTS_PER_1K_OUTPUT["anthropic"], 6), + "cache_savings_usd": round( + (cached_tokens / 1000.0) * PROVIDER_COSTS_PER_1K_OUTPUT["anthropic"], 6 + ), "budget_remaining": max(0, LLM_MAX_CALLS - len(provider_rows)), "max_calls": LLM_MAX_CALLS, } diff --git a/backend/features/llm_analysis/llm_router.py b/backend/features/llm_analysis/llm_router.py index 203dd8d..fd44865 100644 --- a/backend/features/llm_analysis/llm_router.py +++ b/backend/features/llm_analysis/llm_router.py @@ -1,4 +1,5 @@ """Multi-provider LLM routing for CommitIQ narratives.""" + from __future__ import annotations import asyncio @@ -76,7 +77,9 @@ async def stream_narrative( except Exception as exc: logger.error("Gemini fallback failed: %s", exc) - raise RuntimeError("All LLM providers unavailable. Configure ANTHROPIC_API_KEY or GEMINI_API_KEY.") + raise RuntimeError( + "All LLM providers unavailable. Configure ANTHROPIC_API_KEY or GEMINI_API_KEY." + ) async def _stream_anthropic(prompt: str, max_tokens: int) -> AsyncGenerator[str, None]: @@ -127,7 +130,9 @@ def _sync_stream(): yield text -async def get_narrative_non_streaming(prompt: str, max_tokens: int = 600) -> tuple[str, LLMProvider]: +async def get_narrative_non_streaming( + prompt: str, max_tokens: int = 600 +) -> tuple[str, LLMProvider]: full_text: list[str] = [] provider_used = LLMProvider.NONE async for token, provider in stream_narrative(prompt, max_tokens): diff --git a/backend/features/llm_analysis/prompt_builder.py b/backend/features/llm_analysis/prompt_builder.py index 8180686..30c30e8 100644 --- a/backend/features/llm_analysis/prompt_builder.py +++ b/backend/features/llm_analysis/prompt_builder.py @@ -4,8 +4,8 @@ Input is always a JSON diff of metric values. Target: under 700 input tokens. """ -import json +import json EXPLAIN_DROP_SYSTEM = """You are a senior engineering analyst reviewing codebase health data. Given two commit health snapshots and their metric diff, explain in 3-4 sentences why the @@ -31,21 +31,19 @@ def build_explain_prompt( Total cost: ~$0.003–0.004 per call. """ diff = { - "commit_message": commit_msg[:120], - "health_before": before.get("health_score", 0), - "health_after": after.get("health_score", 0), - "health_delta": round( - after.get("health_score", 0) - before.get("health_score", 0), 2 - ), + "commit_message": commit_msg[:120], + "health_before": before.get("health_score", 0), + "health_after": after.get("health_score", 0), + "health_delta": round(after.get("health_score", 0) - before.get("health_score", 0), 2), "complexity_before": before.get("avg_complexity", 0), - "complexity_after": after.get("avg_complexity", 0), - "complexity_delta": round( + "complexity_after": after.get("avg_complexity", 0), + "complexity_delta": round( after.get("avg_complexity", 0) - before.get("avg_complexity", 0), 2 ), - "churn_rate": after.get("churn_rate", 0), - "files_changed": after.get("num_files_changed", 0), + "churn_rate": after.get("churn_rate", 0), + "files_changed": after.get("num_files_changed", 0), "bus_factor_before": before.get("bus_factor_min", 1), - "bus_factor_after": after.get("bus_factor_min", 1), + "bus_factor_after": after.get("bus_factor_min", 1), "avg_semantic_drift": after.get("avg_semantic_drift", 0), "semantic_health_score": after.get("semantic_health_score", 100), "high_drift_files": after.get("high_drift_files", 0), @@ -62,15 +60,15 @@ def build_predict_prompt(branch_metrics: dict, main_metrics: dict) -> str: Cost: ~$0.002–0.003 per call. """ comparison = { - "main_health": main_metrics.get("health_score", 0), - "branch_health": branch_metrics.get("health_score", 0), - "health_delta": round( + "main_health": main_metrics.get("health_score", 0), + "branch_health": branch_metrics.get("health_score", 0), + "health_delta": round( branch_metrics.get("health_score", 0) - main_metrics.get("health_score", 0), 2 ), - "complexity_main": main_metrics.get("avg_complexity", 0), + "complexity_main": main_metrics.get("avg_complexity", 0), "complexity_branch": branch_metrics.get("avg_complexity", 0), - "churn_branch": branch_metrics.get("churn_rate", 0), + "churn_branch": branch_metrics.get("churn_rate", 0), "bus_factor_branch": branch_metrics.get("bus_factor_min", 1), - "files_to_merge": branch_metrics.get("num_files_changed", 0), + "files_to_merge": branch_metrics.get("num_files_changed", 0), } return f"Predict health impact of merging this branch:\n{json.dumps(comparison, indent=2)}" diff --git a/backend/features/llm_analysis/router.py b/backend/features/llm_analysis/router.py index 8f34560..563aadb 100644 --- a/backend/features/llm_analysis/router.py +++ b/backend/features/llm_analysis/router.py @@ -31,9 +31,17 @@ def _build_demo_narrative( before: dict, after: dict, ) -> str: - health_delta = float(after.get("health_score", 0) or 0) - float(before.get("health_score", 0) or 0) - complexity_delta = float(after.get("avg_complexity", 0) or 0) - float(before.get("avg_complexity", 0) or 0) - risk_level = "High" if health_delta <= -15 or after.get("bus_factor_min", 1) <= 1 else "Medium" if health_delta < 0 else "Low" + health_delta = float(after.get("health_score", 0) or 0) - float( + before.get("health_score", 0) or 0 + ) + complexity_delta = float(after.get("avg_complexity", 0) or 0) - float( + before.get("avg_complexity", 0) or 0 + ) + risk_level = ( + "High" + if health_delta <= -15 or after.get("bus_factor_min", 1) <= 1 + else "Medium" if health_delta < 0 else "Low" + ) top_files = after.get("top_files_json") or "[]" return ( @@ -94,19 +102,24 @@ async def explain_commit_stream( db: AsyncSession = Depends(get_db), ): commit_result = await db.execute( - select(Commit).where( + select(Commit) + .where( Commit.repo_id == request.repo_id, (Commit.sha == request.commit_sha[:12]) | (Commit.full_sha == request.commit_sha), - ).limit(1) + ) + .limit(1) ) commit = commit_result.scalar_one_or_none() if not commit: raise HTTPException(status_code=404, detail=f"Commit {request.commit_sha} not found") cache_key = make_cache_key(request.repo_id, commit.full_sha, request.prompt_type) - cached_result = await db.execute(select(LLMNarrative).where(LLMNarrative.cache_key == cache_key)) + cached_result = await db.execute( + select(LLMNarrative).where(LLMNarrative.cache_key == cache_key) + ) cached = cached_result.scalar_one_or_none() if cached: + async def replay_cache(): for word in cached.response_text.split(" "): yield f"data: {json.dumps({'token': word + ' ', 'done': False})}\n\n" @@ -120,9 +133,13 @@ async def replay_cache(): ) if not await check_budget(request.repo_id, db): - raise HTTPException(status_code=429, detail="LLM budget exceeded for this repo. Cache will be used.") + raise HTTPException( + status_code=429, detail="LLM budget exceeded for this repo. Cache will be used." + ) - snapshot_result = await db.execute(select(HealthSnapshot).where(HealthSnapshot.commit_id == commit.id)) + snapshot_result = await db.execute( + select(HealthSnapshot).where(HealthSnapshot.commit_id == commit.id) + ) snapshot = snapshot_result.scalar_one_or_none() if not snapshot: raise HTTPException(status_code=404, detail="Health snapshot not found") diff --git a/backend/features/repo_ingestion/bus_factor.py b/backend/features/repo_ingestion/bus_factor.py index 8e89b6c..cff1a5d 100644 --- a/backend/features/repo_ingestion/bus_factor.py +++ b/backend/features/repo_ingestion/bus_factor.py @@ -2,7 +2,6 @@ from collections import defaultdict from pathlib import Path - EXCLUDE_PATTERNS = ( ".github/", "ISSUE_TEMPLATE", @@ -92,7 +91,9 @@ def compute_bus_factor_from_history( Falls back to commit authorship counts if a file no longer exists at HEAD. """ touched_files: dict[str, str | None] = {} - fallback_counts: dict[str, dict[tuple[str, str | None], int]] = defaultdict(lambda: defaultdict(int)) + fallback_counts: dict[str, dict[tuple[str, str | None], int]] = defaultdict( + lambda: defaultdict(int) + ) for commit in commit_history: author = commit.get("author_name") or "unknown" @@ -118,15 +119,17 @@ def compute_bus_factor_from_history( top_pct = top_count / total contributor_count = len(ranked) - entries.append({ - "module_path": module_path, - "contributor_count": contributor_count, - "top_contributor": top_author, - "top_contributor_email": top_email, - "top_contributor_pct": round(top_pct, 4), - "total_commits_to_module": sum(fallback_counts[module_path].values()), - "risk_level": _risk_level(contributor_count, top_pct), - "last_commit_sha": touched_files[module_path], - }) + entries.append( + { + "module_path": module_path, + "contributor_count": contributor_count, + "top_contributor": top_author, + "top_contributor_email": top_email, + "top_contributor_pct": round(top_pct, 4), + "total_commits_to_module": sum(fallback_counts[module_path].values()), + "risk_level": _risk_level(contributor_count, top_pct), + "last_commit_sha": touched_files[module_path], + } + ) return entries diff --git a/backend/features/repo_ingestion/clone_service.py b/backend/features/repo_ingestion/clone_service.py index 4bbb5f2..bb2e049 100644 --- a/backend/features/repo_ingestion/clone_service.py +++ b/backend/features/repo_ingestion/clone_service.py @@ -1,10 +1,12 @@ import logging +import re +import shutil import subprocess from pathlib import Path -import shutil -from backend.config import REPO_STORAGE_PATH, GITHUB_TOKEN + import httpx -import re + +from backend.config import GITHUB_TOKEN, REPO_STORAGE_PATH logger = logging.getLogger(__name__) @@ -24,47 +26,49 @@ def _is_valid_github_name(value: str) -> bool: def parse_github_url(url: str) -> tuple[str, str]: """Parse GitHub URL or shorthand to ('owner', 'repo').""" s = url.strip() - - while s.endswith('/') or s.endswith('.git'): - if s.endswith('/'): + + while s.endswith("/") or s.endswith(".git"): + if s.endswith("/"): s = s[:-1] - elif s.endswith('.git'): + elif s.endswith(".git"): s = s[:-4] - + explicit_host = False - if s.startswith('https://'): - s = s[len('https://'):] + if s.startswith("https://"): + s = s[len("https://") :] explicit_host = True - elif s.startswith('http://'): - s = s[len('http://'):] + elif s.startswith("http://"): + s = s[len("http://") :] explicit_host = True - - if s.startswith('www.'): + + if s.startswith("www."): s = s[4:] - - if s.startswith('github.com/'): - s = s[len('github.com/'):] + + if s.startswith("github.com/"): + s = s[len("github.com/") :] elif explicit_host: raise ValueError(f"Cannot parse GitHub URL: {url}. Expected a github.com repository URL.") - - parts = s.split('/') + + parts = s.split("/") if len(parts) < 2 or not parts[0] or not parts[1]: - raise ValueError(f"Cannot parse GitHub URL: {url}. Expected format 'owner/repo' or 'github.com/owner/repo'.") - + raise ValueError( + f"Cannot parse GitHub URL: {url}. Expected format 'owner/repo' or 'github.com/owner/repo'." + ) + owner = parts[0] repo = parts[1] - + # Validate owner/repo format to avoid invalid directory names or bad parameters if not _is_valid_github_name(owner) or not _is_valid_github_name(repo): raise ValueError(f"Invalid owner or repository name in URL: {url}") - + return owner, repo def make_repo_slug(owner: str, repo: str) -> str: slug = f"{owner}-{repo}".lower() - slug = re.sub(r'[^a-z0-9\-]', '-', slug) + slug = re.sub(r"[^a-z0-9\-]", "-", slug) return slug @@ -83,21 +87,12 @@ def clone_repo(repo_url: str, repo_id: int, max_commits: int = 150) -> Path: # Use git clone via HTTPS — never uses GitHub REST API, no rate limits if GITHUB_TOKEN: # This only speeds up clone for large repos — not required for public - auth_url = repo_url.replace( - 'https://github.com/', - f'https://{GITHUB_TOKEN}@github.com/' - ) + auth_url = repo_url.replace("https://github.com/", f"https://{GITHUB_TOKEN}@github.com/") else: auth_url = repo_url result = subprocess.run( - [ - "git", "clone", - "--depth", str(max_commits), - "--single-branch", - auth_url, - str(target) - ], + ["git", "clone", "--depth", str(max_commits), "--single-branch", auth_url, str(target)], capture_output=True, text=True, timeout=300, @@ -150,10 +145,7 @@ async def fetch_github_metadata(owner: str, repo: str) -> dict: try: async with httpx.AsyncClient(timeout=5) as client: - r = await client.get( - f"https://api.github.com/repos/{owner}/{repo}", - headers=headers - ) + r = await client.get(f"https://api.github.com/repos/{owner}/{repo}", headers=headers) if r.status_code == 200: data = r.json() return { diff --git a/backend/features/repo_ingestion/commit_walker.py b/backend/features/repo_ingestion/commit_walker.py index 2885f63..d0cfd89 100644 --- a/backend/features/repo_ingestion/commit_walker.py +++ b/backend/features/repo_ingestion/commit_walker.py @@ -1,9 +1,10 @@ -import git import subprocess from datetime import datetime, timezone from pathlib import Path from typing import Iterator +import git + def walk_commits(repo_path: Path, limit: int = 150) -> Iterator[dict]: """ @@ -13,7 +14,7 @@ def walk_commits(repo_path: Path, limit: int = 150) -> Iterator[dict]: Metrics are computed from git stats, not file inspection. """ repo = git.Repo(repo_path) - commits = list(repo.iter_commits('HEAD', max_count=limit)) + commits = list(repo.iter_commits("HEAD", max_count=limit)) commits.reverse() # oldest → newest for timeline total = len(commits) @@ -23,8 +24,8 @@ def walk_commits(repo_path: Path, limit: int = 150) -> Iterator[dict]: try: stats = commit.stats files_changed = list(stats.files.keys()) - insertions = stats.total.get('insertions', 0) - deletions = stats.total.get('deletions', 0) + insertions = stats.total.get("insertions", 0) + deletions = stats.total.get("deletions", 0) except Exception: # Fallback for shallow clone boundary commits where parent object is missing files_changed = [] @@ -32,9 +33,13 @@ def walk_commits(repo_path: Path, limit: int = 150) -> Iterator[dict]: deletions = 0 try: cmd = ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", commit.hexsha] - res = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, errors="replace") + res = subprocess.run( + cmd, cwd=repo_path, capture_output=True, text=True, errors="replace" + ) if res.returncode == 0: - files_changed = [line.strip() for line in res.stdout.splitlines() if line.strip()] + files_changed = [ + line.strip() for line in res.stdout.splitlines() if line.strip() + ] # Set dummy insertions/deletions as proxy to avoid zero metrics division issues insertions = len(files_changed) * 15 deletions = 5 @@ -42,19 +47,19 @@ def walk_commits(repo_path: Path, limit: int = 150) -> Iterator[dict]: pass yield { - "sha": commit.hexsha[:12], - "full_sha": commit.hexsha, - "message": commit.message.strip()[:500], - "author_name": commit.author.name, - "author_email": commit.author.email, - "committed_at": datetime.fromtimestamp( - commit.committed_date, tz=timezone.utc - ).isoformat(), - "insertions": insertions, - "deletions": deletions, + "sha": commit.hexsha[:12], + "full_sha": commit.hexsha, + "message": commit.message.strip()[:500], + "author_name": commit.author.name, + "author_email": commit.author.email, + "committed_at": datetime.fromtimestamp( + commit.committed_date, tz=timezone.utc + ).isoformat(), + "insertions": insertions, + "deletions": deletions, "files_changed": len(files_changed), - "files_list": files_changed[:100], # cap to 100 for storage - "parent_sha": parent_sha, - "index": idx, - "total": total, + "files_list": files_changed[:100], # cap to 100 for storage + "parent_sha": parent_sha, + "index": idx, + "total": total, } diff --git a/backend/features/repo_ingestion/graph_builder.py b/backend/features/repo_ingestion/graph_builder.py index 8658be4..45e2489 100644 --- a/backend/features/repo_ingestion/graph_builder.py +++ b/backend/features/repo_ingestion/graph_builder.py @@ -2,9 +2,10 @@ Builds import edges (from AST parsing of HEAD state) and co-change edges (from commit history co-occurrence). """ + import ast -import re import os +import re from collections import defaultdict from pathlib import Path @@ -26,87 +27,83 @@ def extract_python_imports(file_content: str) -> list[str]: def extract_js_imports(file_content: str) -> list[str]: imports = [] - + # 1. Matches: import ... from 'path' and export ... from 'path' (including multiline and type imports) es6_from_pattern = r"""(?:import|export)\s+(?:type\s+)?[\s\S]*?\s+from\s+['"]([^'"]+)['"]""" imports.extend(re.findall(es6_from_pattern, file_content)) - + # 2. Matches: import 'path' (side-effect imports) es6_side_effect_pattern = r"""import\s+['"]([^'"]+)['"]""" imports.extend(re.findall(es6_side_effect_pattern, file_content)) - + # 3. Matches require('path') require_pattern = r"""require\s*\(\s*['"]([^'"]+)['"]\s*\)""" imports.extend(re.findall(require_pattern, file_content)) - + # 4. Matches require 'path' (CommonJS without parentheses) require_no_paren = r"""require\s+['"]([^'"]+)['"]""" imports.extend(re.findall(require_no_paren, file_content)) - + # 5. Matches dynamic import('path') dynamic_import_pattern = r"""import\s*\(\s*['"]([^'"]+)['"]\s*\)""" imports.extend(re.findall(dynamic_import_pattern, file_content)) - + # De-duplicate while preserving order seen = set() unique_imports = [] for imp in imports: # Strip query params or webpack loaders if present - imp_clean = imp.split('!')[-1].split('?')[0].strip() + imp_clean = imp.split("!")[-1].split("?")[0].strip() if imp_clean and imp_clean not in seen: seen.add(imp_clean) unique_imports.append(imp_clean) - + return unique_imports -def resolve_import_to_file( - import_path: str, - source_file: str, - all_files: list[str] -) -> str | None: +def resolve_import_to_file(import_path: str, source_file: str, all_files: list[str]) -> str | None: """ Try to resolve an import path to an actual file in the repo. Supports relative imports, direct match, and suffix/sub-path match (e.g. monorepo packages). Returns the resolved path or None if not resolvable. """ - import_path_clean = import_path.replace('\\', '/').strip() + import_path_clean = import_path.replace("\\", "/").strip() if not import_path_clean: return None # Helper set of files for O(1) lookup file_set = set(all_files) - if import_path_clean.startswith('.'): + if import_path_clean.startswith("."): source_dir = os.path.dirname(source_file) candidate = os.path.normpath(os.path.join(source_dir, import_path_clean)) - candidate_clean = candidate.replace('\\', '/') - - for ext in ['', '.js', '.ts', '.jsx', '.tsx', '.py']: + candidate_clean = candidate.replace("\\", "/") + + for ext in ["", ".js", ".ts", ".jsx", ".tsx", ".py"]: with_ext = candidate_clean + ext if with_ext in file_set: return with_ext index = f"{candidate_clean}/index{ext}" if index in file_set: return index - index_norm = os.path.normpath(index).replace('\\', '/') + index_norm = os.path.normpath(index).replace("\\", "/") if index_norm in file_set: return index_norm return None # 2. Handle absolute/package/module-alias imports (e.g. monorepo sub-paths) - for ext in ['', '.js', '.ts', '.jsx', '.tsx', '.py']: + for ext in ["", ".js", ".ts", ".jsx", ".tsx", ".py"]: with_ext = import_path_clean + ext if with_ext in file_set: return with_ext # Suffix match (e.g. "react-reconciler/src/ReactFiberReconciler" matching "packages/react-reconciler/src/ReactFiberReconciler.js") - if '/' in import_path_clean: + if "/" in import_path_clean: for f in all_files: - f_clean = f.replace('\\', '/') - for ext in ['', '.js', '.ts', '.jsx', '.tsx', '.py']: + f_clean = f.replace("\\", "/") + for ext in ["", ".js", ".ts", ".jsx", ".tsx", ".py"]: suffix = import_path_clean + ext - if f_clean.endswith('/' + suffix): + if f_clean.endswith("/" + suffix): return f return None @@ -120,15 +117,15 @@ def build_import_edges(repo_path: Path, all_files: list[str]) -> list[dict]: for rel_path in all_files: full_path = os.path.join(repo_path, rel_path) try: - with open(full_path, 'r', encoding='utf-8', errors='ignore') as f: + with open(full_path, "r", encoding="utf-8", errors="ignore") as f: content = f.read() except Exception: continue ext = os.path.splitext(rel_path)[1].lower() - if ext == '.py': + if ext == ".py": raw_imports = extract_python_imports(content) - elif ext in {'.js', '.ts', '.jsx', '.tsx'}: + elif ext in {".js", ".ts", ".jsx", ".tsx"}: raw_imports = extract_js_imports(content) else: continue @@ -136,13 +133,15 @@ def build_import_edges(repo_path: Path, all_files: list[str]) -> list[dict]: for imp in raw_imports: target = resolve_import_to_file(imp, rel_path, all_files) if target and target != rel_path: - edges.append({ - "source_file": rel_path, - "target_file": target, - "edge_type": "import", - "weight": 1, - "cochange_count": None, - }) + edges.append( + { + "source_file": rel_path, + "target_file": target, + "edge_type": "import", + "weight": 1, + "cochange_count": None, + } + ) return edges @@ -155,21 +154,23 @@ def build_cochange_edges(commit_history: list[dict], min_cooccurrence: int = 3) cochange_counts = defaultdict(int) for commit in commit_history: - files = sorted({file_path for file_path in commit.get('files_list', []) if file_path}) + files = sorted({file_path for file_path in commit.get("files_list", []) if file_path}) for i, f1 in enumerate(files): - for f2 in files[i+1:]: + for f2 in files[i + 1 :]: cochange_counts[(f1, f2)] += 1 edges = [] for (f1, f2), count in sorted(cochange_counts.items()): if count >= min_cooccurrence: - edges.append({ - "source_file": f1, - "target_file": f2, - "edge_type": "co_change", - "weight": count, - "cochange_count": count, - }) + edges.append( + { + "source_file": f1, + "target_file": f2, + "edge_type": "co_change", + "weight": count, + "cochange_count": count, + } + ) return edges @@ -178,6 +179,6 @@ def get_top_files_by_frequency(commit_history: list[dict], top_n: int = 50) -> l """Return the top N most frequently changed files.""" freq = defaultdict(int) for commit in commit_history: - for f in commit.get('files_list', []): + for f in commit.get("files_list", []): freq[f] += 1 return [f for f, _ in sorted(freq.items(), key=lambda x: -x[1])[:top_n]] diff --git a/backend/features/repo_ingestion/health_scorer.py b/backend/features/repo_ingestion/health_scorer.py index b9e7fe6..8dbc079 100644 --- a/backend/features/repo_ingestion/health_scorer.py +++ b/backend/features/repo_ingestion/health_scorer.py @@ -57,135 +57,165 @@ def build_risk_reasons( reasons: list[dict] = [] if avg_complexity >= 10: - reasons.append(_risk_reason( - "high_complexity", - "high", - "High complexity", - f"Changed files average {avg_complexity:.1f} cyclomatic complexity.", - min(avg_complexity * 2.0, 30.0), - )) + reasons.append( + _risk_reason( + "high_complexity", + "high", + "High complexity", + f"Changed files average {avg_complexity:.1f} cyclomatic complexity.", + min(avg_complexity * 2.0, 30.0), + ) + ) elif avg_complexity >= 5: - reasons.append(_risk_reason( - "elevated_complexity", - "medium", - "Elevated complexity", - f"Changed files average {avg_complexity:.1f} cyclomatic complexity.", - min(avg_complexity * 1.4, 18.0), - )) + reasons.append( + _risk_reason( + "elevated_complexity", + "medium", + "Elevated complexity", + f"Changed files average {avg_complexity:.1f} cyclomatic complexity.", + min(avg_complexity * 1.4, 18.0), + ) + ) if prev_avg_complexity > 0: drift_pct = (avg_complexity - prev_avg_complexity) / prev_avg_complexity if drift_pct > 0.20: - reasons.append(_risk_reason( - "complexity_jump", - "medium", - "Complexity jump", - f"Average complexity rose {drift_pct * 100.0:.0f}% from the previous analyzed commit.", - min(drift_pct * 35.0, 20.0), - )) + reasons.append( + _risk_reason( + "complexity_jump", + "medium", + "Complexity jump", + f"Average complexity rose {drift_pct * 100.0:.0f}% from the previous analyzed commit.", + min(drift_pct * 35.0, 20.0), + ) + ) if churn_rate >= 0.50: - reasons.append(_risk_reason( - "high_churn", - "high", - "High churn", - f"This commit rewrote about {churn_rate * 100.0:.0f}% of the analyzed lines.", - 22.0, - )) + reasons.append( + _risk_reason( + "high_churn", + "high", + "High churn", + f"This commit rewrote about {churn_rate * 100.0:.0f}% of the analyzed lines.", + 22.0, + ) + ) elif churn_rate >= 0.25: - reasons.append(_risk_reason( - "elevated_churn", - "medium", - "Elevated churn", - f"This commit touched about {churn_rate * 100.0:.0f}% of the analyzed lines.", - 12.0, - )) + reasons.append( + _risk_reason( + "elevated_churn", + "medium", + "Elevated churn", + f"This commit touched about {churn_rate * 100.0:.0f}% of the analyzed lines.", + 12.0, + ) + ) if hotspot_files: - reasons.append(_risk_reason( - "active_hotspots", - "high" if len(hotspot_files) > 2 else "medium", - "Active hotspots", - f"{len(hotspot_files)} high-complexity file(s) are repeatedly changing.", - min(len(hotspot_files) * 8.0, 24.0), - )) + reasons.append( + _risk_reason( + "active_hotspots", + "high" if len(hotspot_files) > 2 else "medium", + "Active hotspots", + f"{len(hotspot_files)} high-complexity file(s) are repeatedly changing.", + min(len(hotspot_files) * 8.0, 24.0), + ) + ) if hotspot_persistence_score >= 60: - reasons.append(_risk_reason( - "persistent_hotspots", - "high", - "Persistent hotspots", - "Risky files are staying active across several recent commits.", - min(hotspot_persistence_score / 4.0, 25.0), - )) + reasons.append( + _risk_reason( + "persistent_hotspots", + "high", + "Persistent hotspots", + "Risky files are staying active across several recent commits.", + min(hotspot_persistence_score / 4.0, 25.0), + ) + ) elif hotspot_persistence_score >= 30: - reasons.append(_risk_reason( - "persistent_hotspots", - "medium", - "Persistent hotspots", - "Some risky files are recurring in the recent commit window.", - min(hotspot_persistence_score / 5.0, 15.0), - )) + reasons.append( + _risk_reason( + "persistent_hotspots", + "medium", + "Persistent hotspots", + "Some risky files are recurring in the recent commit window.", + min(hotspot_persistence_score / 5.0, 15.0), + ) + ) if bus_factor_min <= 1: - reasons.append(_risk_reason( - "single_owner", - "critical", - "Single-owner risk", - "At least one critical module has only one active contributor.", - 30.0, - )) + reasons.append( + _risk_reason( + "single_owner", + "critical", + "Single-owner risk", + "At least one critical module has only one active contributor.", + 30.0, + ) + ) elif bus_factor_min == 2: - reasons.append(_risk_reason( - "limited_ownership", - "medium", - "Limited ownership", - "Critical ownership is concentrated across two contributors.", - 14.0, - )) + reasons.append( + _risk_reason( + "limited_ownership", + "medium", + "Limited ownership", + "Critical ownership is concentrated across two contributors.", + 14.0, + ) + ) if dependency_density >= 2.0: - reasons.append(_risk_reason( - "dense_dependencies", - "high", - "Dense dependency graph", - f"Dependency density is {dependency_density:.2f} edges per analyzed file.", - 18.0, - )) + reasons.append( + _risk_reason( + "dense_dependencies", + "high", + "Dense dependency graph", + f"Dependency density is {dependency_density:.2f} edges per analyzed file.", + 18.0, + ) + ) elif dependency_density >= 1.0: - reasons.append(_risk_reason( - "dense_dependencies", - "medium", - "Dense dependency graph", - f"Dependency density is {dependency_density:.2f} edges per analyzed file.", - 10.0, - )) + reasons.append( + _risk_reason( + "dense_dependencies", + "medium", + "Dense dependency graph", + f"Dependency density is {dependency_density:.2f} edges per analyzed file.", + 10.0, + ) + ) if has_cycles: - reasons.append(_risk_reason( - "dependency_cycle", - "high", - "Dependency cycle", - "Import cycles were detected in the analyzed dependency graph.", - 20.0, - )) + reasons.append( + _risk_reason( + "dependency_cycle", + "high", + "Dependency cycle", + "Import cycles were detected in the analyzed dependency graph.", + 20.0, + ) + ) if semantic_health_score < 70: - reasons.append(_risk_reason( - "semantic_drift", - "high", - "High semantic drift", - f"Semantic health is {semantic_health_score:.0f}/100 for this snapshot.", - 20.0, - )) + reasons.append( + _risk_reason( + "semantic_drift", + "high", + "High semantic drift", + f"Semantic health is {semantic_health_score:.0f}/100 for this snapshot.", + 20.0, + ) + ) elif semantic_health_score < 85: - reasons.append(_risk_reason( - "semantic_drift", - "medium", - "Semantic drift", - f"Semantic health is {semantic_health_score:.0f}/100 for this snapshot.", - 10.0, - )) + reasons.append( + _risk_reason( + "semantic_drift", + "medium", + "Semantic drift", + f"Semantic health is {semantic_health_score:.0f}/100 for this snapshot.", + 10.0, + ) + ) return sorted(reasons, key=lambda item: item["impact"], reverse=True)[:6] @@ -281,9 +311,7 @@ def compute_full_snapshot( total_loc = sum(item.get("loc", 0) for item in metrics) complexities = [ - item.get("avg_complexity", 0.0) - for item in metrics - if item.get("avg_complexity", 0.0) > 0 + item.get("avg_complexity", 0.0) for item in metrics if item.get("avg_complexity", 0.0) > 0 ] max_complexities = [item.get("max_complexity", 0.0) for item in metrics] @@ -292,7 +320,10 @@ def compute_full_snapshot( lines_changed = commit_data["insertions"] + commit_data["deletions"] churn_rate = round(min(1.0, lines_changed / max(total_loc, lines_changed, 1)), 4) hotspot_persistence_score = round( - min(100.0, sum(float(item.get("recent_commit_count", 0)) * 12.5 for item in persistent_hotspots)), + min( + 100.0, + sum(float(item.get("recent_commit_count", 0)) * 12.5 for item in persistent_hotspots), + ), 1, ) diff --git a/backend/features/repo_ingestion/metrics_extractor.py b/backend/features/repo_ingestion/metrics_extractor.py index dd535fc..2074efc 100644 --- a/backend/features/repo_ingestion/metrics_extractor.py +++ b/backend/features/repo_ingestion/metrics_extractor.py @@ -115,10 +115,9 @@ def extract_commit_metrics( checkout_commit(repo_path, commit_data["full_sha"]) metrics: dict[str, dict] = {} - files = [ - fpath for fpath in commit_data.get("files_list", []) - if _is_supported(fpath) - ][:max_files] + files = [fpath for fpath in commit_data.get("files_list", []) if _is_supported(fpath)][ + :max_files + ] for rel_path in files: full_path = repo_path / rel_path @@ -149,17 +148,14 @@ def scan_repo_head(repo_path: Path, max_files: int = 200) -> dict[str, dict]: for root, dirs, files in os.walk(repo_path): dirs[:] = [ - d for d in dirs - if d not in {".git", "node_modules", "__pycache__", "dist", "build"} + d for d in dirs if d not in {".git", "node_modules", "__pycache__", "dist", "build"} ] for fname in files: if count >= max_files: return file_metrics rel_path = os.path.relpath(os.path.join(root, fname), repo_path) if _is_supported(rel_path): - file_metrics[rel_path] = extract_file_metrics_from_path( - str(repo_path / rel_path) - ) + file_metrics[rel_path] = extract_file_metrics_from_path(str(repo_path / rel_path)) count += 1 return file_metrics @@ -182,12 +178,7 @@ def compute_health_score( bus_score = min(100.0, float(bus_factor_min) * 20.0) loc_score = max(0.0, 100.0 - (loc_touched / 20.0)) - health = ( - cc_score * 0.30 + - churn_score * 0.25 + - bus_score * 0.25 + - loc_score * 0.20 - ) + health = cc_score * 0.30 + churn_score * 0.25 + bus_score * 0.25 + loc_score * 0.20 health = round(max(0.0, min(100.0, health)), 2) return health, { diff --git a/backend/features/repo_ingestion/router.py b/backend/features/repo_ingestion/router.py index fafa97b..9e6a7cf 100644 --- a/backend/features/repo_ingestion/router.py +++ b/backend/features/repo_ingestion/router.py @@ -136,7 +136,7 @@ def _hotspot_files( current_index: int, file_metrics_map: dict, ) -> list[str]: - recent_commits = commit_history[max(0, current_index - 4):current_index + 1] + recent_commits = commit_history[max(0, current_index - 4) : current_index + 1] churn_counts: dict[str, int] = {} for commit_data in recent_commits: for fpath in set(commit_data.get("files_list", [])): @@ -154,7 +154,7 @@ def _persistent_hotspots( file_metrics_map: dict, min_recent_commits: int = 3, ) -> list[dict]: - recent_commits = commit_history[max(0, current_index - 5):current_index + 1] + recent_commits = commit_history[max(0, current_index - 5) : current_index + 1] churn_counts: dict[str, int] = {} for commit_data in recent_commits: for fpath in set(commit_data.get("files_list", [])): @@ -166,12 +166,14 @@ def _persistent_hotspots( avg_complexity = float(metrics.get("avg_complexity", 0.0)) if recent_count < min_recent_commits or avg_complexity <= 5.0: continue - hotspots.append({ - "path": fpath, - "recent_commit_count": recent_count, - "complexity": round(avg_complexity, 2), - "loc": int(metrics.get("loc", 0)), - }) + hotspots.append( + { + "path": fpath, + "recent_commit_count": recent_count, + "complexity": round(avg_complexity, 2), + "loc": int(metrics.get("loc", 0)), + } + ) return sorted( hotspots, key=lambda item: (item["recent_commit_count"], item["complexity"], item["loc"]), @@ -259,10 +261,14 @@ async def _graph_payload(db: AsyncSession, repo_id: int, commit: Commit) -> dict if fallback_commit: commit = fallback_commit nodes_result = await db.execute( - select(GraphNode).where(GraphNode.repo_id == repo_id, GraphNode.commit_id == commit.id) + select(GraphNode).where( + GraphNode.repo_id == repo_id, GraphNode.commit_id == commit.id + ) ) edges_result = await db.execute( - select(GraphEdge).where(GraphEdge.repo_id == repo_id, GraphEdge.commit_id == commit.id) + select(GraphEdge).where( + GraphEdge.repo_id == repo_id, GraphEdge.commit_id == commit.id + ) ) nodes = nodes_result.scalars().all() edges = edges_result.scalars().all() @@ -323,7 +329,9 @@ async def _bus_factor_payload(db: AsyncSession, repo_id: int) -> dict: } -async def _commit_graph_rows(db: AsyncSession, repo_id: int, sha: str) -> tuple[list[GraphNode], list[GraphEdge], Commit]: +async def _commit_graph_rows( + db: AsyncSession, repo_id: int, sha: str +) -> tuple[list[GraphNode], list[GraphEdge], Commit]: commit = await _find_commit(db, repo_id, sha) if not commit: raise _http_error(404, "Commit not found.", "commit_not_found") @@ -354,7 +362,10 @@ async def _latest_active_job(db: AsyncSession, repo_id: int) -> AnalysisJob | No async def run_ingestion(repo_id: int, job_id: int, max_commits: int) -> None: from backend.database import AsyncSessionLocal - from backend.features.repo_ingestion.metrics_extractor import checkout_commit, extract_commit_metrics + from backend.features.repo_ingestion.metrics_extractor import ( + checkout_commit, + extract_commit_metrics, + ) async with AsyncSessionLocal() as db: repo = await db.get(Repo, repo_id) @@ -363,7 +374,9 @@ async def run_ingestion(repo_id: int, job_id: int, max_commits: int) -> None: job = await db.get(AnalysisJob, job_id) if not job or job.repo_id != repo_id: - logger.warning("Skipping ingestion for repo_id=%s because job_id=%s was not found", repo_id, job_id) + logger.warning( + "Skipping ingestion for repo_id=%s because job_id=%s was not found", repo_id, job_id + ) return clone_path = None @@ -397,7 +410,9 @@ async def run_ingestion(repo_id: int, job_id: int, max_commits: int) -> None: await _clear_repo_data(db, repo_id) await _raise_if_cancelled(db, job) - await _update_job(db, job, status="computing_bus_factor", current_stage="Computing bus factor") + await _update_job( + db, job, status="computing_bus_factor", current_stage="Computing bus factor" + ) await _raise_if_cancelled(db, job) checkout_commit(clone_path, commit_history[-1]["full_sha"]) bus_entries = compute_bus_factor_from_history(commit_history, clone_path) @@ -420,7 +435,7 @@ async def run_ingestion(repo_id: int, job_id: int, max_commits: int) -> None: file_metrics_map = extract_commit_metrics(clone_path, commit_data) top_files = list(file_metrics_map.keys())[:50] import_edges = build_import_edges(clone_path, top_files) - cochange_edges = build_cochange_edges(commit_history[:idx + 1]) + cochange_edges = build_cochange_edges(commit_history[: idx + 1]) top_set = set(top_files) filtered_edges = [] seen_edges = set() @@ -470,33 +485,39 @@ async def run_ingestion(repo_id: int, job_id: int, max_commits: int) -> None: for fpath in top_files: metrics = file_metrics_map.get(fpath, {}) - db.add(GraphNode( - repo_id=repo_id, - commit_id=commit_obj.id, - full_sha=commit_obj.full_sha, - file_path=fpath, - module_name=Path(fpath).name, - loc=metrics.get("loc", 0), - avg_complexity=metrics.get("avg_complexity", 0.0), - health_color=assign_health_color(metrics.get("avg_complexity", 0.0)), - is_entry_point=Path(fpath).stem in {"index", "main", "app", "server"}, - semantic_drift_score=metrics.get("semantic_drift_score", 0.0), - drift_method=metrics.get("drift_method", "none"), - )) + db.add( + GraphNode( + repo_id=repo_id, + commit_id=commit_obj.id, + full_sha=commit_obj.full_sha, + file_path=fpath, + module_name=Path(fpath).name, + loc=metrics.get("loc", 0), + avg_complexity=metrics.get("avg_complexity", 0.0), + health_color=assign_health_color(metrics.get("avg_complexity", 0.0)), + is_entry_point=Path(fpath).stem in {"index", "main", "app", "server"}, + semantic_drift_score=metrics.get("semantic_drift_score", 0.0), + drift_method=metrics.get("drift_method", "none"), + ) + ) for edge in filtered_edges: - db.add(GraphEdge( - repo_id=repo_id, - commit_id=commit_obj.id, - full_sha=commit_obj.full_sha, - **edge, - )) + db.add( + GraphEdge( + repo_id=repo_id, + commit_id=commit_obj.id, + full_sha=commit_obj.full_sha, + **edge, + ) + ) prev_health = snapshot_data["health_score"] prev_avg_complexity = snapshot_data["avg_complexity"] await db.commit() - await _update_job(db, job, status="computing_bus_factor", current_stage="Computing bus factor") + await _update_job( + db, job, status="computing_bus_factor", current_stage="Computing bus factor" + ) for entry in bus_entries: db.add(BusFactor(repo_id=repo_id, **entry)) @@ -739,13 +760,17 @@ async def get_commit_detail(repo_id: int, sha: str, db: AsyncSession = Depends(g if not commit: raise _http_error(404, "Commit not found.", "commit_not_found") - snap_result = await db.execute(select(HealthSnapshot).where(HealthSnapshot.commit_id == commit.id)) + snap_result = await db.execute( + select(HealthSnapshot).where(HealthSnapshot.commit_id == commit.id) + ) snapshot = snap_result.scalar_one_or_none() if not snapshot: raise _http_error(404, "Health snapshot not found for commit.", "snapshot_not_found") narrative_key = make_cache_key(repo_id, commit.full_sha, "explain_drop") - narrative_result = await db.execute(select(LLMNarrative).where(LLMNarrative.cache_key == narrative_key)) + narrative_result = await db.execute( + select(LLMNarrative).where(LLMNarrative.cache_key == narrative_key) + ) narrative = narrative_result.scalar_one_or_none() narrative_payload = None @@ -804,14 +829,18 @@ async def get_graph_diff( if before_cx > 0: delta_pct = (after_cx - before_cx) / before_cx if abs(delta_pct) > 0.10: - nodes_changed.append({ - "file": fpath, - "before_complexity": round(before_cx, 2), - "after_complexity": round(after_cx, 2), - "delta_pct": round(delta_pct * 100.0, 1), - }) - - before_edge_set = {(edge.source_file, edge.target_file, edge.edge_type) for edge in before_edges} + nodes_changed.append( + { + "file": fpath, + "before_complexity": round(before_cx, 2), + "after_complexity": round(after_cx, 2), + "delta_pct": round(delta_pct * 100.0, 1), + } + ) + + before_edge_set = { + (edge.source_file, edge.target_file, edge.edge_type) for edge in before_edges + } after_edge_set = {(edge.source_file, edge.target_file, edge.edge_type) for edge in after_edges} added_edges = sorted(after_edge_set - before_edge_set) removed_edges = sorted(before_edge_set - after_edge_set) @@ -828,7 +857,9 @@ async def get_graph_diff( }, "nodes_added": nodes_added, "nodes_removed": nodes_removed, - "nodes_changed": sorted(nodes_changed, key=lambda item: abs(item["delta_pct"]), reverse=True)[:20], + "nodes_changed": sorted( + nodes_changed, key=lambda item: abs(item["delta_pct"]), reverse=True + )[:20], "edges_added": [ {"source": source, "target": target, "type": edge_type} for source, target, edge_type in added_edges[:30] @@ -855,8 +886,11 @@ async def get_hotspots(repo_id: int, sha: str | None = None, db: AsyncSession = raise _http_error(404, "Commit not found.", "commit_not_found") nodes_result = await db.execute( - select(GraphNode) - .where(GraphNode.repo_id == repo_id, GraphNode.commit_id == commit.id, GraphNode.avg_complexity > 5.0) + select(GraphNode).where( + GraphNode.repo_id == repo_id, + GraphNode.commit_id == commit.id, + GraphNode.avg_complexity > 5.0, + ) ) nodes = nodes_result.scalars().all() if not nodes: @@ -864,14 +898,19 @@ async def get_hotspots(repo_id: int, sha: str | None = None, db: AsyncSession = file_paths = [node.file_path for node in nodes] commits_result = await db.execute( - select(Commit).where(Commit.repo_id == repo_id).order_by(desc(Commit.committed_at)).limit(20) + select(Commit) + .where(Commit.repo_id == repo_id) + .order_by(desc(Commit.committed_at)) + .limit(20) ) recent_commits = commits_result.scalars().all() churn_counts = {fpath: 0 for fpath in file_paths} for recent in recent_commits: # Querying full historical file lists is not stored; approximate churn by graph node presence. node_result = await db.execute( - select(GraphNode.file_path).where(GraphNode.repo_id == repo_id, GraphNode.commit_id == recent.id) + select(GraphNode.file_path).where( + GraphNode.repo_id == repo_id, GraphNode.commit_id == recent.id + ) ) recent_paths = set(node_result.scalars().all()) for fpath in file_paths: @@ -884,12 +923,14 @@ async def get_hotspots(repo_id: int, sha: str | None = None, db: AsyncSession = if churn_count <= 2: continue risk_score = min(100.0, node.avg_complexity * 8.0 + churn_count * 6.0) - hotspots.append({ - "file": node.file_path, - "complexity": round(node.avg_complexity, 2), - "churn_count": churn_count, - "risk_score": round(risk_score, 1), - }) + hotspots.append( + { + "file": node.file_path, + "complexity": round(node.avg_complexity, 2), + "churn_count": churn_count, + "risk_score": round(risk_score, 1), + } + ) return { "repo_id": repo_id, diff --git a/backend/features/repo_ingestion/semantic_analyzer.py b/backend/features/repo_ingestion/semantic_analyzer.py index fc7fec2..8ed171b 100644 --- a/backend/features/repo_ingestion/semantic_analyzer.py +++ b/backend/features/repo_ingestion/semantic_analyzer.py @@ -4,6 +4,7 @@ Runs during ingestion only. Request handlers should read the stored drift scores from SQLite rather than invoking this module. """ + from __future__ import annotations import difflib @@ -162,7 +163,11 @@ def compute_repo_semantic_health(file_drifts: list[dict]) -> dict: scores = [float(item.get("semantic_drift_score", 0.0)) for item in file_drifts] avg_drift = sum(scores) / max(len(scores), 1) max_drift = max(scores) - method = "graphcodebert" if any(item.get("method") == "graphcodebert" for item in file_drifts) else file_drifts[0].get("method", "none") + method = ( + "graphcodebert" + if any(item.get("method") == "graphcodebert" for item in file_drifts) + else file_drifts[0].get("method", "none") + ) return { "avg_semantic_drift": round(avg_drift, 4), diff --git a/backend/main.py b/backend/main.py index 476db6c..6c0d835 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,11 +1,12 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager from backend.config import CORS_ORIGINS from backend.database import engine, init_db -from backend.features.repo_ingestion.router import router as ingestion_router from backend.features.llm_analysis.router import router as llm_router +from backend.features.repo_ingestion.router import router as ingestion_router @asynccontextmanager diff --git a/backend/shared/models.py b/backend/shared/models.py index aef8a33..7612598 100644 --- a/backend/shared/models.py +++ b/backend/shared/models.py @@ -1,58 +1,71 @@ from sqlalchemy import ( - Column, Integer, String, Float, Boolean, DateTime, Text, - ForeignKey, UniqueConstraint, Index + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, ) from sqlalchemy.orm import relationship from sqlalchemy.sql import func + from backend.database import Base class Repo(Base): __tablename__ = "repos" - id = Column(Integer, primary_key=True, autoincrement=True) - url = Column(String, nullable=False, unique=True) - name = Column(String, nullable=False) - owner = Column(String, nullable=False, default="") - repo_slug = Column(String, nullable=False, unique=True) - default_branch = Column(String, nullable=False, default="main") - ingested_at = Column(DateTime(timezone=True), server_default=func.now()) - last_updated_at = Column(DateTime(timezone=True), nullable=True) - total_commits = Column(Integer, nullable=False, default=0) - analyzed_commits = Column(Integer, nullable=False, default=0) - status = Column(String, nullable=False, default="pending") - error_message = Column(Text, nullable=True) + id = Column(Integer, primary_key=True, autoincrement=True) + url = Column(String, nullable=False, unique=True) + name = Column(String, nullable=False) + owner = Column(String, nullable=False, default="") + repo_slug = Column(String, nullable=False, unique=True) + default_branch = Column(String, nullable=False, default="main") + ingested_at = Column(DateTime(timezone=True), server_default=func.now()) + last_updated_at = Column(DateTime(timezone=True), nullable=True) + total_commits = Column(Integer, nullable=False, default=0) + analyzed_commits = Column(Integer, nullable=False, default=0) + status = Column(String, nullable=False, default="pending") + error_message = Column(Text, nullable=True) max_commits_setting = Column(Integer, nullable=False, default=150) - github_stars = Column(Integer, nullable=True) - github_language = Column(String, nullable=True) - github_description = Column(Text, nullable=True) + github_stars = Column(Integer, nullable=True) + github_language = Column(String, nullable=True) + github_description = Column(Text, nullable=True) - commits = relationship("Commit", back_populates="repo", cascade="all, delete-orphan") + commits = relationship("Commit", back_populates="repo", cascade="all, delete-orphan") analysis_jobs = relationship("AnalysisJob", back_populates="repo", cascade="all, delete-orphan") - bus_factor = relationship("BusFactor", back_populates="repo", cascade="all, delete-orphan") + bus_factor = relationship("BusFactor", back_populates="repo", cascade="all, delete-orphan") class Commit(Base): __tablename__ = "commits" - id = Column(Integer, primary_key=True, autoincrement=True) - repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) - sha = Column(String, nullable=False) - full_sha = Column(String, nullable=False) - message = Column(Text, nullable=True) - author_name = Column(String, nullable=True) - author_email = Column(String, nullable=True) - committed_at = Column(DateTime(timezone=True), nullable=False) - insertions = Column(Integer, nullable=False, default=0) - deletions = Column(Integer, nullable=False, default=0) + id = Column(Integer, primary_key=True, autoincrement=True) + repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) + sha = Column(String, nullable=False) + full_sha = Column(String, nullable=False) + message = Column(Text, nullable=True) + author_name = Column(String, nullable=True) + author_email = Column(String, nullable=True) + committed_at = Column(DateTime(timezone=True), nullable=False) + insertions = Column(Integer, nullable=False, default=0) + deletions = Column(Integer, nullable=False, default=0) files_changed = Column(Integer, nullable=False, default=0) - parent_sha = Column(String, nullable=True) + parent_sha = Column(String, nullable=True) - repo = relationship("Repo", back_populates="commits") - health_snapshot = relationship("HealthSnapshot", back_populates="commit", uselist=False, cascade="all, delete-orphan") - graph_nodes = relationship("GraphNode", back_populates="commit", cascade="all, delete-orphan") - graph_edges = relationship("GraphEdge", back_populates="commit", cascade="all, delete-orphan") - llm_narratives = relationship("LLMNarrative", back_populates="commit", cascade="all, delete-orphan") + repo = relationship("Repo", back_populates="commits") + health_snapshot = relationship( + "HealthSnapshot", back_populates="commit", uselist=False, cascade="all, delete-orphan" + ) + graph_nodes = relationship("GraphNode", back_populates="commit", cascade="all, delete-orphan") + graph_edges = relationship("GraphEdge", back_populates="commit", cascade="all, delete-orphan") + llm_narratives = relationship( + "LLMNarrative", back_populates="commit", cascade="all, delete-orphan" + ) __table_args__ = ( UniqueConstraint("repo_id", "full_sha", name="uq_commits_repo_fullsha"), @@ -64,38 +77,40 @@ class Commit(Base): class HealthSnapshot(Base): __tablename__ = "health_snapshots" - id = Column(Integer, primary_key=True, autoincrement=True) - repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) - commit_id = Column(Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False, unique=True) - full_sha = Column(String, nullable=False) - health_score = Column(Float, nullable=False) - avg_complexity = Column(Float, nullable=False, default=0.0) - max_complexity = Column(Float, nullable=False, default=0.0) - total_loc = Column(Integer, nullable=False, default=0) - churn_rate = Column(Float, nullable=False, default=0.0) + id = Column(Integer, primary_key=True, autoincrement=True) + repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) + commit_id = Column( + Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False, unique=True + ) + full_sha = Column(String, nullable=False) + health_score = Column(Float, nullable=False) + avg_complexity = Column(Float, nullable=False, default=0.0) + max_complexity = Column(Float, nullable=False, default=0.0) + total_loc = Column(Integer, nullable=False, default=0) + churn_rate = Column(Float, nullable=False, default=0.0) num_files_changed = Column(Integer, nullable=False, default=0) - bus_factor_min = Column(Integer, nullable=False, default=1) - health_delta = Column(Float, nullable=True) - cc_score = Column(Float, nullable=False, default=0.0) - churn_score = Column(Float, nullable=False, default=0.0) - bus_score = Column(Float, nullable=False, default=0.0) - loc_score = Column(Float, nullable=False, default=0.0) - complexity_drift_score = Column(Float, nullable=False, default=0.0) - churn_risk_score = Column(Float, nullable=False, default=0.0) - bus_factor_risk_score = Column(Float, nullable=False, default=0.0) + bus_factor_min = Column(Integer, nullable=False, default=1) + health_delta = Column(Float, nullable=True) + cc_score = Column(Float, nullable=False, default=0.0) + churn_score = Column(Float, nullable=False, default=0.0) + bus_score = Column(Float, nullable=False, default=0.0) + loc_score = Column(Float, nullable=False, default=0.0) + complexity_drift_score = Column(Float, nullable=False, default=0.0) + churn_risk_score = Column(Float, nullable=False, default=0.0) + bus_factor_risk_score = Column(Float, nullable=False, default=0.0) dependency_health_score = Column(Float, nullable=False, default=0.0) - dependency_density = Column(Float, nullable=False, default=0.0) - has_cycles = Column(Boolean, nullable=False, default=False) - hotspot_count = Column(Integer, nullable=False, default=0) - avg_semantic_drift = Column(Float, nullable=False, default=0.0) - semantic_health_score = Column(Float, nullable=False, default=100.0) - high_drift_files = Column(Integer, nullable=False, default=0) - semantic_drift_method = Column(String, nullable=False, default="none") - risk_reasons_json = Column(Text, nullable=True) + dependency_density = Column(Float, nullable=False, default=0.0) + has_cycles = Column(Boolean, nullable=False, default=False) + hotspot_count = Column(Integer, nullable=False, default=0) + avg_semantic_drift = Column(Float, nullable=False, default=0.0) + semantic_health_score = Column(Float, nullable=False, default=100.0) + high_drift_files = Column(Integer, nullable=False, default=0) + semantic_drift_method = Column(String, nullable=False, default="none") + risk_reasons_json = Column(Text, nullable=True) hotspot_persistence_score = Column(Float, nullable=False, default=0.0) persistent_hotspots_json = Column(Text, nullable=True) - top_files_json = Column(Text, nullable=True) - computed_at = Column(DateTime(timezone=True), server_default=func.now()) + top_files_json = Column(Text, nullable=True) + computed_at = Column(DateTime(timezone=True), server_default=func.now()) commit = relationship("Commit", back_populates="health_snapshot") @@ -108,18 +123,18 @@ class HealthSnapshot(Base): class GraphNode(Base): __tablename__ = "graph_nodes" - id = Column(Integer, primary_key=True, autoincrement=True) - repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) - commit_id = Column(Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False) - full_sha = Column(String, nullable=False) - file_path = Column(String, nullable=False) - module_name = Column(String, nullable=True) - loc = Column(Integer, nullable=False, default=0) + id = Column(Integer, primary_key=True, autoincrement=True) + repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) + commit_id = Column(Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False) + full_sha = Column(String, nullable=False) + file_path = Column(String, nullable=False) + module_name = Column(String, nullable=True) + loc = Column(Integer, nullable=False, default=0) avg_complexity = Column(Float, nullable=False, default=0.0) - health_color = Column(String, nullable=False, default="green") + health_color = Column(String, nullable=False, default="green") is_entry_point = Column(Boolean, nullable=False, default=False) semantic_drift_score = Column(Float, nullable=False, default=0.0) - drift_method = Column(String, nullable=False, default="none") + drift_method = Column(String, nullable=False, default="none") commit = relationship("Commit", back_populates="graph_nodes") @@ -133,14 +148,14 @@ class GraphNode(Base): class GraphEdge(Base): __tablename__ = "graph_edges" - id = Column(Integer, primary_key=True, autoincrement=True) - repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) - commit_id = Column(Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False) - full_sha = Column(String, nullable=False) - source_file = Column(String, nullable=False) - target_file = Column(String, nullable=False) - edge_type = Column(String, nullable=False) # 'import' | 'co_change' - weight = Column(Integer, nullable=False, default=1) + id = Column(Integer, primary_key=True, autoincrement=True) + repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) + commit_id = Column(Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False) + full_sha = Column(String, nullable=False) + source_file = Column(String, nullable=False) + target_file = Column(String, nullable=False) + edge_type = Column(String, nullable=False) # 'import' | 'co_change' + weight = Column(Integer, nullable=False, default=1) cochange_count = Column(Integer, nullable=True) commit = relationship("Commit", back_populates="graph_edges") @@ -156,17 +171,19 @@ class GraphEdge(Base): class BusFactor(Base): __tablename__ = "bus_factor" - id = Column(Integer, primary_key=True, autoincrement=True) - repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) - module_path = Column(String, nullable=False) - contributor_count = Column(Integer, nullable=False) - top_contributor = Column(String, nullable=True) - top_contributor_email = Column(String, nullable=True) - top_contributor_pct = Column(Float, nullable=False, default=0.0) + id = Column(Integer, primary_key=True, autoincrement=True) + repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) + module_path = Column(String, nullable=False) + contributor_count = Column(Integer, nullable=False) + top_contributor = Column(String, nullable=True) + top_contributor_email = Column(String, nullable=True) + top_contributor_pct = Column(Float, nullable=False, default=0.0) total_commits_to_module = Column(Integer, nullable=False, default=0) - risk_level = Column(String, nullable=False, default="low") - last_commit_sha = Column(String, nullable=True) - last_updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + risk_level = Column(String, nullable=False, default="low") + last_commit_sha = Column(String, nullable=True) + last_updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) repo = relationship("Repo", back_populates="bus_factor") @@ -179,20 +196,20 @@ class BusFactor(Base): class LLMNarrative(Base): __tablename__ = "llm_narratives" - id = Column(Integer, primary_key=True, autoincrement=True) - repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) - commit_id = Column(Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False) - full_sha = Column(String, nullable=False) - prompt_type = Column(String, nullable=False) # 'explain_drop' | 'predict_merge' - cache_key = Column(String, nullable=False, unique=True) - prompt_input = Column(Text, nullable=False) + id = Column(Integer, primary_key=True, autoincrement=True) + repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) + commit_id = Column(Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False) + full_sha = Column(String, nullable=False) + prompt_type = Column(String, nullable=False) # 'explain_drop' | 'predict_merge' + cache_key = Column(String, nullable=False, unique=True) + prompt_input = Column(Text, nullable=False) response_text = Column(Text, nullable=False) - tokens_input = Column(Integer, nullable=False, default=0) + tokens_input = Column(Integer, nullable=False, default=0) tokens_output = Column(Integer, nullable=False, default=0) - cost_usd = Column(Float, nullable=False, default=0.0) - model_used = Column(String, nullable=False, default="claude-sonnet-4-20250514") + cost_usd = Column(Float, nullable=False, default=0.0) + model_used = Column(String, nullable=False, default="claude-sonnet-4-20250514") is_pre_cached = Column(Boolean, nullable=False, default=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) + created_at = Column(DateTime(timezone=True), server_default=func.now()) commit = relationship("Commit", back_populates="llm_narratives") @@ -206,23 +223,21 @@ class LLMNarrative(Base): class AnalysisJob(Base): __tablename__ = "analysis_jobs" - id = Column(Integer, primary_key=True, autoincrement=True) - repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) - status = Column(String, nullable=False, default="queued") - total_commits = Column(Integer, nullable=False, default=0) + id = Column(Integer, primary_key=True, autoincrement=True) + repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) + status = Column(String, nullable=False, default="queued") + total_commits = Column(Integer, nullable=False, default=0) processed_commits = Column(Integer, nullable=False, default=0) - current_sha = Column(String, nullable=True) - current_stage = Column(String, nullable=True) - progress_pct = Column(Float, nullable=False, default=0.0) - started_at = Column(DateTime(timezone=True), nullable=True) - completed_at = Column(DateTime(timezone=True), nullable=True) - duration_seconds = Column(Float, nullable=True) - error_message = Column(Text, nullable=True) - triggered_by = Column(String, nullable=False, default="user") - created_at = Column(DateTime(timezone=True), server_default=func.now()) + current_sha = Column(String, nullable=True) + current_stage = Column(String, nullable=True) + progress_pct = Column(Float, nullable=False, default=0.0) + started_at = Column(DateTime(timezone=True), nullable=True) + completed_at = Column(DateTime(timezone=True), nullable=True) + duration_seconds = Column(Float, nullable=True) + error_message = Column(Text, nullable=True) + triggered_by = Column(String, nullable=False, default="user") + created_at = Column(DateTime(timezone=True), server_default=func.now()) repo = relationship("Repo", back_populates="analysis_jobs") - __table_args__ = ( - Index("idx_jobs_repo_created", "repo_id", "created_at"), - ) + __table_args__ = (Index("idx_jobs_repo_created", "repo_id", "created_at"),) diff --git a/backend/shared/schemas.py b/backend/shared/schemas.py index 5191ffc..78a83f9 100644 --- a/backend/shared/schemas.py +++ b/backend/shared/schemas.py @@ -2,12 +2,21 @@ from datetime import datetime from typing import Literal -from backend.config import MAX_COMMITS from pydantic import BaseModel, ConfigDict, Field, field_validator +from backend.config import MAX_COMMITS RepoStatus = Literal["pending", "processing", "ready", "error"] -JobStatus = Literal["queued", "cloning", "analyzing", "building_graph", "computing_bus_factor", "ready", "error", "cancelled"] +JobStatus = Literal[ + "queued", + "cloning", + "analyzing", + "building_graph", + "computing_bus_factor", + "ready", + "error", + "cancelled", +] PromptType = Literal["explain_drop", "predict_merge"] RiskLevel = Literal["low", "medium", "high", "critical"] GraphEdgeType = Literal["import", "co_change"] diff --git a/backend/sitecustomize.py b/backend/sitecustomize.py index 56b84ab..b174068 100644 --- a/backend/sitecustomize.py +++ b/backend/sitecustomize.py @@ -4,6 +4,7 @@ `backend/`, while the application imports use the package name `backend`. Adding the project root to sys.path keeps both invocation styles working. """ + from __future__ import annotations import sys diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index c33102e..791b15c 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -2,9 +2,18 @@ def test_normalize_database_url_uses_asyncpg_for_postgres_urls(): - assert _normalize_database_url("postgres://user:pass@host/db") == "postgresql+asyncpg://user:pass@host/db" - assert _normalize_database_url("postgresql://user:pass@host/db") == "postgresql+asyncpg://user:pass@host/db" - assert _normalize_database_url("sqlite+aiosqlite:///./commitiq.db") == "sqlite+aiosqlite:///./commitiq.db" + assert ( + _normalize_database_url("postgres://user:pass@host/db") + == "postgresql+asyncpg://user:pass@host/db" + ) + assert ( + _normalize_database_url("postgresql://user:pass@host/db") + == "postgresql+asyncpg://user:pass@host/db" + ) + assert ( + _normalize_database_url("sqlite+aiosqlite:///./commitiq.db") + == "sqlite+aiosqlite:///./commitiq.db" + ) def test_parse_csv_trims_empty_values(): diff --git a/backend/tests/test_database_migrations.py b/backend/tests/test_database_migrations.py index 7231e62..bb2b0f2 100644 --- a/backend/tests/test_database_migrations.py +++ b/backend/tests/test_database_migrations.py @@ -70,6 +70,7 @@ def test_apply_sql_migrations_records_and_skips_applied_files(tmp_path: Path): """, encoding="utf-8", ) + async def run_assertions(): conn = FakeAsyncConnection() @@ -92,6 +93,7 @@ def test_apply_sql_migrations_keeps_sqlite_add_column_idempotent(tmp_path: Path) "ALTER TABLE repos ADD COLUMN status TEXT DEFAULT 'pending';", encoding="utf-8", ) + async def run_assertions(): conn = FakeAsyncConnection() await conn.execute("CREATE TABLE repos (id INTEGER PRIMARY KEY)") diff --git a/backend/tests/test_llm_logic.py b/backend/tests/test_llm_logic.py index bd77835..8b2f153 100644 --- a/backend/tests/test_llm_logic.py +++ b/backend/tests/test_llm_logic.py @@ -3,7 +3,11 @@ from types import SimpleNamespace from backend.features.llm_analysis.cache import make_cache_key -from backend.features.llm_analysis.cost_guard import check_budget, estimate_cost_usd, get_usage_summary +from backend.features.llm_analysis.cost_guard import ( + check_budget, + estimate_cost_usd, + get_usage_summary, +) from backend.features.llm_analysis.llm_router import ( LLMProvider, model_for_provider, diff --git a/backend/tests/test_repo_api.py b/backend/tests/test_repo_api.py index 235395a..9a37cb6 100644 --- a/backend/tests/test_repo_api.py +++ b/backend/tests/test_repo_api.py @@ -1,6 +1,6 @@ +import json from collections.abc import AsyncIterator from datetime import datetime, timezone -import json import pytest from fastapi import HTTPException @@ -15,10 +15,10 @@ get_bus_factor, get_commit_detail, get_graph, - ingest_progress, get_llm_usage, get_repo_by_slug, get_timeline, + ingest_progress, ingest_repo, list_repos, run_ingestion, @@ -179,120 +179,124 @@ def _seed_repo(session: Session) -> None: session.add_all([first_commit, second_commit]) session.flush() - session.add_all([ - HealthSnapshot( - repo_id=repo.id, - commit_id=first_commit.id, - full_sha=first_commit.full_sha, - health_score=82.0, - avg_complexity=2.0, - max_complexity=3.0, - total_loc=120, - churn_rate=0.2, - num_files_changed=1, - bus_factor_min=2, - health_delta=None, - cc_score=90, - churn_score=80, - bus_score=40, - loc_score=85, - complexity_drift_score=90, - churn_risk_score=80, - bus_factor_risk_score=40, - dependency_health_score=85, - top_files_json='[{"path":"src/app.py","complexity":2.0,"loc":120}]', - ), - HealthSnapshot( - repo_id=repo.id, - commit_id=second_commit.id, - full_sha=second_commit.full_sha, - health_score=68.5, - avg_complexity=7.25, - max_complexity=12.0, - total_loc=240, - churn_rate=0.35, - num_files_changed=2, - bus_factor_min=1, - health_delta=-13.5, - cc_score=63.8, - churn_score=65, - bus_score=20, - loc_score=80, - complexity_drift_score=63.8, - churn_risk_score=65, - bus_factor_risk_score=20, - dependency_health_score=80, - dependency_density=0.5, - avg_semantic_drift=0.12, - semantic_health_score=88, - high_drift_files=0, - semantic_drift_method="fallback_levenshtein", - risk_reasons_json='[{"code":"single_owner","severity":"critical","label":"Single-owner risk","detail":"At least one critical module has only one active contributor.","impact":30.0}]', - hotspot_persistence_score=37.5, - persistent_hotspots_json='[{"path":"src/service.py","recent_commit_count":3,"complexity":7.25,"loc":160}]', - top_files_json='[{"path":"src/service.py","complexity":7.25,"loc":160}]', - ), - ]) - - session.add_all([ - GraphNode( - repo_id=repo.id, - commit_id=second_commit.id, - full_sha=second_commit.full_sha, - file_path="src/service.py", - module_name="service.py", - loc=160, - avg_complexity=7.25, - health_color="yellow", - is_entry_point=False, - semantic_drift_score=0.12, - drift_method="fallback_levenshtein", - ), - GraphNode( - repo_id=repo.id, - commit_id=second_commit.id, - full_sha=second_commit.full_sha, - file_path="src/app.py", - module_name="app.py", - loc=80, - avg_complexity=2.0, - health_color="green", - is_entry_point=True, - ), - GraphEdge( - repo_id=repo.id, - commit_id=second_commit.id, - full_sha=second_commit.full_sha, - source_file="src/app.py", - target_file="src/service.py", - edge_type="import", - weight=1, - ), - BusFactor( - repo_id=repo.id, - module_path="src/service.py", - contributor_count=1, - top_contributor="Noor", - top_contributor_email="noor@example.com", - top_contributor_pct=1.0, - total_commits_to_module=2, - risk_level="critical", - last_commit_sha=second_commit.sha, - ), - LLMNarrative( - repo_id=repo.id, - commit_id=second_commit.id, - full_sha=second_commit.full_sha, - prompt_type="explain_drop", - cache_key=make_cache_key(repo.id, second_commit.full_sha, "explain_drop"), - prompt_input="{}", - response_text="Complexity increased in the service layer.", - tokens_input=10, - tokens_output=8, - cost_usd=0.00015, - model_used="claude-3-5-sonnet-20241022", - ), - ]) + session.add_all( + [ + HealthSnapshot( + repo_id=repo.id, + commit_id=first_commit.id, + full_sha=first_commit.full_sha, + health_score=82.0, + avg_complexity=2.0, + max_complexity=3.0, + total_loc=120, + churn_rate=0.2, + num_files_changed=1, + bus_factor_min=2, + health_delta=None, + cc_score=90, + churn_score=80, + bus_score=40, + loc_score=85, + complexity_drift_score=90, + churn_risk_score=80, + bus_factor_risk_score=40, + dependency_health_score=85, + top_files_json='[{"path":"src/app.py","complexity":2.0,"loc":120}]', + ), + HealthSnapshot( + repo_id=repo.id, + commit_id=second_commit.id, + full_sha=second_commit.full_sha, + health_score=68.5, + avg_complexity=7.25, + max_complexity=12.0, + total_loc=240, + churn_rate=0.35, + num_files_changed=2, + bus_factor_min=1, + health_delta=-13.5, + cc_score=63.8, + churn_score=65, + bus_score=20, + loc_score=80, + complexity_drift_score=63.8, + churn_risk_score=65, + bus_factor_risk_score=20, + dependency_health_score=80, + dependency_density=0.5, + avg_semantic_drift=0.12, + semantic_health_score=88, + high_drift_files=0, + semantic_drift_method="fallback_levenshtein", + risk_reasons_json='[{"code":"single_owner","severity":"critical","label":"Single-owner risk","detail":"At least one critical module has only one active contributor.","impact":30.0}]', + hotspot_persistence_score=37.5, + persistent_hotspots_json='[{"path":"src/service.py","recent_commit_count":3,"complexity":7.25,"loc":160}]', + top_files_json='[{"path":"src/service.py","complexity":7.25,"loc":160}]', + ), + ] + ) + + session.add_all( + [ + GraphNode( + repo_id=repo.id, + commit_id=second_commit.id, + full_sha=second_commit.full_sha, + file_path="src/service.py", + module_name="service.py", + loc=160, + avg_complexity=7.25, + health_color="yellow", + is_entry_point=False, + semantic_drift_score=0.12, + drift_method="fallback_levenshtein", + ), + GraphNode( + repo_id=repo.id, + commit_id=second_commit.id, + full_sha=second_commit.full_sha, + file_path="src/app.py", + module_name="app.py", + loc=80, + avg_complexity=2.0, + health_color="green", + is_entry_point=True, + ), + GraphEdge( + repo_id=repo.id, + commit_id=second_commit.id, + full_sha=second_commit.full_sha, + source_file="src/app.py", + target_file="src/service.py", + edge_type="import", + weight=1, + ), + BusFactor( + repo_id=repo.id, + module_path="src/service.py", + contributor_count=1, + top_contributor="Noor", + top_contributor_email="noor@example.com", + top_contributor_pct=1.0, + total_commits_to_module=2, + risk_level="critical", + last_commit_sha=second_commit.sha, + ), + LLMNarrative( + repo_id=repo.id, + commit_id=second_commit.id, + full_sha=second_commit.full_sha, + prompt_type="explain_drop", + cache_key=make_cache_key(repo.id, second_commit.full_sha, "explain_drop"), + prompt_input="{}", + response_text="Complexity increased in the service layer.", + tokens_input=10, + tokens_output=8, + cost_usd=0.00015, + model_used="claude-3-5-sonnet-20241022", + ), + ] + ) session.commit() @@ -341,7 +345,9 @@ async def test_timeline_returns_snapshot_payloads(db_session: AsyncSessionAdapte assert payload["commits"][1]["persistent_hotspots"][0]["path"] == "src/service.py" -async def test_graph_bus_factor_and_usage_endpoints_return_seeded_data(db_session: AsyncSessionAdapter): +async def test_graph_bus_factor_and_usage_endpoints_return_seeded_data( + db_session: AsyncSessionAdapter, +): graph = await get_graph(repo_id=1, sha="def456", db=db_session) assert graph["commit_sha"] == "def456abc123" assert {node["file"] for node in graph["nodes"]} == {"src/app.py", "src/service.py"} @@ -365,7 +371,9 @@ async def test_graph_bus_factor_and_usage_endpoints_return_seeded_data(db_sessio assert usage["total_tokens"] == 18 -async def test_commit_detail_includes_nested_snapshot_graph_and_cached_narrative(db_session: AsyncSessionAdapter): +async def test_commit_detail_includes_nested_snapshot_graph_and_cached_narrative( + db_session: AsyncSessionAdapter, +): detail = await get_commit_detail(repo_id=1, sha="def456", db=db_session) assert detail["repo"].repo_slug == "example-project" @@ -378,7 +386,9 @@ async def test_commit_detail_includes_nested_snapshot_graph_and_cached_narrative assert detail["narrative"]["explanation"] == "Complexity increased in the service layer." -async def test_streaming_narrative_falls_back_to_demo_mode(db_session: AsyncSessionAdapter, monkeypatch): +async def test_streaming_narrative_falls_back_to_demo_mode( + db_session: AsyncSessionAdapter, monkeypatch +): async def failing_stream(prompt: str): raise RuntimeError("provider keys missing") yield prompt, None @@ -399,15 +409,21 @@ async def failing_stream(prompt: str): assert "DEMO MODE" in final_payload["explanation"] assert "error" not in final_payload - cached = db_session.session.query(LLMNarrative).filter( - LLMNarrative.cache_key - == make_cache_key(1, "abc123def4567890abc123def4567890abc123de", "explain_drop") - ).one() + cached = ( + db_session.session.query(LLMNarrative) + .filter( + LLMNarrative.cache_key + == make_cache_key(1, "abc123def4567890abc123def4567890abc123de", "explain_drop") + ) + .one() + ) assert cached.model_used == "demo-mode" assert cached.cost_usd == 0.0 -async def test_ingest_repo_reuses_active_job_without_scheduling_duplicate(db_session: AsyncSessionAdapter): +async def test_ingest_repo_reuses_active_job_without_scheduling_duplicate( + db_session: AsyncSessionAdapter, +): active_job = AnalysisJob( repo_id=1, status="analyzing", @@ -435,7 +451,9 @@ async def test_ingest_repo_reuses_active_job_without_scheduling_duplicate(db_ses assert background_tasks.tasks == [] -async def test_ingest_repo_schedules_created_job_by_id(db_session: AsyncSessionAdapter, monkeypatch): +async def test_ingest_repo_schedules_created_job_by_id( + db_session: AsyncSessionAdapter, monkeypatch +): async def fake_fetch_github_metadata(owner: str, repo: str): return { "github_stars": 0, @@ -570,27 +588,30 @@ async def test_ingest_progress_stream_picks_up_cancelled_job_updates(monkeypatch async def skip_sleep(seconds: float): return None - db = _mock_progress_db(monkeypatch, [ - AnalysisJob( - repo_id=1, - status="queued", - total_commits=5, - processed_commits=0, - current_stage="Queued", - progress_pct=0.0, - triggered_by="user", - ), - AnalysisJob( - repo_id=1, - status="cancelled", - total_commits=5, - processed_commits=0, - current_stage="Cancelled", - progress_pct=0.0, - error_message="Ingestion cancelled by user.", - triggered_by="user", - ), - ]) + db = _mock_progress_db( + monkeypatch, + [ + AnalysisJob( + repo_id=1, + status="queued", + total_commits=5, + processed_commits=0, + current_stage="Queued", + progress_pct=0.0, + triggered_by="user", + ), + AnalysisJob( + repo_id=1, + status="cancelled", + total_commits=5, + processed_commits=0, + current_stage="Cancelled", + progress_pct=0.0, + error_message="Ingestion cancelled by user.", + triggered_by="user", + ), + ], + ) monkeypatch.setattr("backend.features.repo_ingestion.router.asyncio.sleep", skip_sleep) response = await ingest_progress(repo_id=1) diff --git a/backend/tests/test_repo_ingestion_logic.py b/backend/tests/test_repo_ingestion_logic.py index ba7843a..d96c7de 100644 --- a/backend/tests/test_repo_ingestion_logic.py +++ b/backend/tests/test_repo_ingestion_logic.py @@ -4,6 +4,7 @@ from pydantic import ValidationError from backend.config import MAX_COMMITS +from backend.features.repo_ingestion import semantic_analyzer from backend.features.repo_ingestion.bus_factor import is_code_file from backend.features.repo_ingestion.clone_service import ( cleanup_repo, @@ -19,7 +20,6 @@ resolve_import_to_file, ) from backend.features.repo_ingestion.health_scorer import compute_full_snapshot -from backend.features.repo_ingestion import semantic_analyzer from backend.shared.schemas import IngestRequest @@ -109,15 +109,13 @@ def test_import_extractors_and_resolver_cover_common_python_and_ts_patterns(): assert "os" in python_imports assert "package.module" in python_imports - js_imports = extract_js_imports( - """ + js_imports = extract_js_imports(""" import type { User } from './types' export { Button } from './Button' import './polyfill' const mod = require('../lib/mod') const lazy = import('./lazy') - """ - ) + """) assert js_imports == ["./types", "./Button", "./polyfill", "../lib/mod", "./lazy"] files = [ @@ -187,7 +185,9 @@ def fail_if_model_loads(): monkeypatch.setattr(semantic_analyzer, "ENABLE_GRAPHCODEBERT", False) monkeypatch.setattr(semantic_analyzer, "_load_model", fail_if_model_loads) - result = semantic_analyzer.compute_semantic_drift("def value():\n return 1\n", "def value():\n return 2\n") + result = semantic_analyzer.compute_semantic_drift( + "def value():\n return 1\n", "def value():\n return 2\n" + ) assert result["method"] == "fallback_levenshtein" assert result["model"] == "difflib.SequenceMatcher" diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index af6c948..3db6abe 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -59,13 +59,17 @@ vi.mock('./components/HealthTimeline', () => ({ vi.mock('./components/HotspotMap', () => ({ HotspotMap: ({ repoId, sha }: { repoId: number; sha: string | null }) => ( -
hotspots {repoId}:{sha ?? 'none'}
+
+ hotspots {repoId}:{sha ?? 'none'} +
), })) vi.mock('./components/NarrativeCard', () => ({ NarrativeCard: ({ repoId, commitSha }: { repoId: number; commitSha: string }) => ( -
narrative {repoId}:{commitSha}
+
+ narrative {repoId}:{commitSha} +
), })) @@ -141,7 +145,7 @@ function renderApp() { return render( new Map(), dedupingInterval: 0, revalidateOnFocus: false }}> - , + ) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ced0e27..18d989a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,7 +12,9 @@ const NotFoundPage = lazy(() => import('./pages/NotFoundPage')) function RouteFallback() { return (
-
Loading workspace...
+
+ Loading workspace... +
) } diff --git a/frontend/src/components/AmbientBackground.tsx b/frontend/src/components/AmbientBackground.tsx index 6f22a5b..b04360a 100644 --- a/frontend/src/components/AmbientBackground.tsx +++ b/frontend/src/components/AmbientBackground.tsx @@ -1,24 +1,24 @@ export default function AmbientBackground() { return (
-
-
-
-
-
{ last_commit_sha: 'abc123', }, ]} - />, + /> ) expect(screen.getByText('src/core/risky.ts')).toBeInTheDocument() expect(consoleError).not.toHaveBeenCalledWith( expect.stringContaining('validateDOMNesting'), expect.anything(), - expect.anything(), + expect.anything() ) }) }) diff --git a/frontend/src/components/BusFactorTable.tsx b/frontend/src/components/BusFactorTable.tsx index 9fd7cb2..3d79717 100644 --- a/frontend/src/components/BusFactorTable.tsx +++ b/frontend/src/components/BusFactorTable.tsx @@ -11,7 +11,9 @@ const RISK_COLORS: Record = { const RISK_ORDER = { critical: 0, high: 1, medium: 2, low: 3 } export function BusFactorTable({ modules }: BusFactorTableProps) { - const sorted = [...modules].sort((a, b) => RISK_ORDER[a.risk_level] - RISK_ORDER[b.risk_level]).slice(0, 20) + const sorted = [...modules] + .sort((a, b) => RISK_ORDER[a.risk_level] - RISK_ORDER[b.risk_level]) + .slice(0, 20) return (
@@ -21,9 +23,13 @@ export function BusFactorTable({ modules }: BusFactorTableProps) {
-

Bus Factor Index

+

+ Bus Factor Index +

-

Contributor concentration distribution per system module

+

+ Contributor concentration distribution per system module +

@@ -38,20 +44,29 @@ export function BusFactorTable({ modules }: BusFactorTableProps) { - - - - + + + + {sorted.map((module) => { const rgb = RISK_COLORS[module.risk_level] || '156, 163, 175' - const isCritical = module.risk_level === 'critical' || module.risk_level === 'high' + const isCritical = + module.risk_level === 'critical' || module.risk_level === 'high' return ( - @@ -60,14 +75,14 @@ export function BusFactorTable({ modules }: BusFactorTableProps) { {module.module_path} - +
Module PathRisk TierContributorsPrincipal Owner + Module Path + + Risk Tier + + Contributors + + Principal Owner +
- {isCritical ? ( @@ -80,7 +95,7 @@ export function BusFactorTable({ modules }: BusFactorTableProps) { - diff --git a/frontend/src/components/CommitList.tsx b/frontend/src/components/CommitList.tsx index 4a1da7a..45d7d49 100644 --- a/frontend/src/components/CommitList.tsx +++ b/frontend/src/components/CommitList.tsx @@ -30,11 +30,11 @@ export function CommitList({ commits, repoSlug, selectedSha, onSelect }: CommitL const healthColor = getHealthColor(commit.health_score) return ( -
@@ -42,37 +42,37 @@ export function CommitList({ commits, repoSlug, selectedSha, onSelect }: CommitL )} - - diff --git a/frontend/src/components/CostMeter.tsx b/frontend/src/components/CostMeter.tsx index 554746a..8e6c060 100644 --- a/frontend/src/components/CostMeter.tsx +++ b/frontend/src/components/CostMeter.tsx @@ -22,9 +22,7 @@ export function CostMeter({ usage, loading, error }: CostMeterProps) { if (!usage) { return ( -
- No active resource tracking established. -
+
No active resource tracking established.
) } @@ -40,10 +38,10 @@ export function CostMeter({ usage, loading, error }: CostMeterProps) { API Allocation
- @@ -52,13 +50,13 @@ export function CostMeter({ usage, loading, error }: CostMeterProps) {
-
diff --git a/frontend/src/components/GraphDiffPanel.tsx b/frontend/src/components/GraphDiffPanel.tsx index 6dda05e..90b4834 100644 --- a/frontend/src/components/GraphDiffPanel.tsx +++ b/frontend/src/components/GraphDiffPanel.tsx @@ -19,17 +19,25 @@ function StatChip({ label, value }: { label: string; value: number }) { export function GraphDiffPanel({ repoId, commitSha, previousSha }: GraphDiffPanelProps) { const diffState = useSWR( previousSha ? ['graph-diff', repoId, previousSha, commitSha] : null, - () => getGraphDiff(repoId, previousSha as string, commitSha), + () => getGraphDiff(repoId, previousSha as string, commitSha) ) if (!previousSha) return null if (diffState.isLoading) { - return
Loading structural diff...
+ return ( +
+ Loading structural diff... +
+ ) } if (diffState.error || !diffState.data) { - return
Could not load structural diff.
+ return ( +
+ Could not load structural diff. +
+ ) } const diff = diffState.data @@ -47,13 +55,25 @@ export function GraphDiffPanel({ repoId, commitSha, previousSha }: GraphDiffPane {diff.nodes_changed.length > 0 ? (
-

Biggest Complexity Shifts

+

+ Biggest Complexity Shifts +

{diff.nodes_changed.slice(0, 5).map((node) => ( -
+
{node.file} - 0 ? 'text-health-critical font-mono' : 'text-health-good font-mono'}> - {node.delta_pct > 0 ? '+' : ''}{node.delta_pct}% + 0 + ? 'text-health-critical font-mono' + : 'text-health-good font-mono' + } + > + {node.delta_pct > 0 ? '+' : ''} + {node.delta_pct}%
))} diff --git a/frontend/src/components/GraphExplorer.tsx b/frontend/src/components/GraphExplorer.tsx index 0d51879..66b6c56 100644 --- a/frontend/src/components/GraphExplorer.tsx +++ b/frontend/src/components/GraphExplorer.tsx @@ -1,25 +1,25 @@ import { useCallback, useState, useEffect, useRef, useMemo } from 'react' import ForceGraph2D, { type ForceGraphMethods } from 'react-force-graph-2d' import { forceCollide } from 'd3-force' -import { - Play, - Pause, - Search, - Filter, - AlertTriangle, - ShieldCheck, - Activity, - Maximize2, - Minimize2, - ZoomIn, - ZoomOut, - RefreshCw, - Layers, - Compass, +import { + Play, + Pause, + Search, + Filter, + AlertTriangle, + ShieldCheck, + Activity, + Maximize2, + Minimize2, + ZoomIn, + ZoomOut, + RefreshCw, + Layers, + Compass, Info, ChevronRight, TrendingUp, - GitCommit + GitCommit, } from 'lucide-react' import type { ForceGraphLink, ForceGraphNode, GraphExplorerProps } from '../types' @@ -48,7 +48,13 @@ const HEALTH_COLORS_RGB: Record = { neutral: '156, 163, 175', } -const NODE_SIZE_METRICS = new Set(['loc', 'churn', 'coupling', 'instability', 'equal']) +const NODE_SIZE_METRICS = new Set([ + 'loc', + 'churn', + 'coupling', + 'instability', + 'equal', +]) function isNodeSizeMetric(value: string): value is NodeSizeMetric { return NODE_SIZE_METRICS.has(value as NodeSizeMetric) @@ -63,7 +69,12 @@ const getNodeId = (node: unknown): string => { return String(node) } -export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCommit }: GraphExplorerProps) { +export function GraphExplorer({ + graphData, + selectedSha, + commits = [], + onSelectCommit, +}: GraphExplorerProps) { const [hoveredNode, setHoveredNode] = useState(null) const [selectedNodeId, setSelectedNodeId] = useState(null) const [searchQuery, setSearchQuery] = useState('') @@ -77,7 +88,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo const [showCoChange, setShowCoChange] = useState(true) const [selectedModule, setSelectedModule] = useState('all') const [selectedRisk, setSelectedRisk] = useState('all') - + const [highlightCyclic, setHighlightCyclic] = useState(false) const [highlightHotspots, setHighlightHotspots] = useState(false) const [highlightStability, setHighlightStability] = useState(false) @@ -144,15 +155,15 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo if (graphData && graphData.nodes.length > 0) { if (prevNodesRef.current.length > 0) { const prevSet = new Set(prevNodesRef.current) - const currentSet = new Set(graphData.nodes.map(n => n.id)) + const currentSet = new Set(graphData.nodes.map((n) => n.id)) const newlyAdded = new Set() - - currentSet.forEach(id => { + + currentSet.forEach((id) => { if (!prevSet.has(id)) newlyAdded.add(id) }) setAddedNodeIds(newlyAdded) } - prevNodesRef.current = graphData.nodes.map(n => n.id) + prevNodesRef.current = graphData.nodes.map((n) => n.id) } }, [graphData]) @@ -167,16 +178,20 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo health_color: node.health_color, loc: node.loc, health: node.health, - is_entry_point: node.is_entry_point || node.file.includes('main') || node.file.includes('index') || node.file.includes('App') + is_entry_point: + node.is_entry_point || + node.file.includes('main') || + node.file.includes('index') || + node.file.includes('App'), })) }, [graphData]) const links = useMemo(() => { if (!graphData) return [] - const filteredEdges = graphData.edges.filter((edge) => ( - (showImports && edge.type === 'import') || - (showCoChange && edge.type === 'co_change') - )) + const filteredEdges = graphData.edges.filter( + (edge) => + (showImports && edge.type === 'import') || (showCoChange && edge.type === 'co_change') + ) return filteredEdges.map((edge) => ({ source: edge.source, target: edge.target, @@ -195,11 +210,12 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo // Client-Side Cyclic Dependency Detection DFS const cyclicNodesAndEdges = useMemo(() => { - if (!links.length || !nodes.length) return { nodes: new Set(), edges: new Set() } - - const importLinks = links.filter(l => l.type === 'import') + if (!links.length || !nodes.length) + return { nodes: new Set(), edges: new Set() } + + const importLinks = links.filter((l) => l.type === 'import') const adj = new Map() - importLinks.forEach(l => { + importLinks.forEach((l) => { const s = getNodeId(l.source) const t = getNodeId(l.target) if (!adj.has(s)) adj.set(s, []) @@ -215,13 +231,13 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo visiting.add(node) parentPath.push(node) const neighbors = adj.get(node) || [] - + for (const next of neighbors) { if (visiting.has(next)) { const startIdx = parentPath.indexOf(next) if (startIdx !== -1) { const cyclePath = parentPath.slice(startIdx) - cyclePath.forEach(n => cyclicNodes.add(n)) + cyclePath.forEach((n) => cyclicNodes.add(n)) for (let i = 0; i < cyclePath.length; i++) { const u = cyclePath[i] const v = cyclePath[(i + 1) % cyclePath.length] @@ -237,7 +253,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo visited.add(node) } - nodes.forEach(n => { + nodes.forEach((n) => { if (!visited.has(n.id)) { dfs(n.id, []) } @@ -248,7 +264,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo const uniqueModules = useMemo(() => { const mods = new Set() - nodes.forEach(n => { + nodes.forEach((n) => { if (n.module) mods.add(n.module) }) return Array.from(mods) @@ -258,16 +274,16 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo const couplingMetrics = useMemo(() => { const afferent = new Map() const efferent = new Map() - - nodes.forEach(n => { + + nodes.forEach((n) => { afferent.set(n.id, 0) efferent.set(n.id, 0) }) - links.forEach(l => { + links.forEach((l) => { const s = getNodeId(l.source) const t = getNodeId(l.target) - + if (l.type === 'import') { efferent.set(s, (efferent.get(s) || 0) + 1) afferent.set(t, (afferent.get(t) || 0) + 1) @@ -275,7 +291,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo }) const instMap = new Map() - nodes.forEach(n => { + nodes.forEach((n) => { const ca = afferent.get(n.id) || 0 const ce = efferent.get(n.id) || 0 const denominator = ca + ce @@ -288,13 +304,13 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo const selectedNodeDetails = useMemo(() => { if (!selectedNodeId) return null - const matched = nodes.find(n => n.id === selectedNodeId) + const matched = nodes.find((n) => n.id === selectedNodeId) if (!matched) return null const importsList: string[] = [] const importedByList: string[] = [] - links.forEach(l => { + links.forEach((l) => { const s = getNodeId(l.source) const t = getNodeId(l.target) if (l.type === 'import') { @@ -315,21 +331,21 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo instability, isCyclic, imports: importsList, - importedBy: importedByList + importedBy: importedByList, } }, [selectedNodeId, nodes, links, couplingMetrics, cyclicNodesAndEdges]) const systemStability = useMemo(() => { if (nodes.length === 0) return 1.0 let totalInstability = 0 - nodes.forEach(n => { + nodes.forEach((n) => { totalInstability += couplingMetrics.instability.get(n.id) || 0 }) - return Math.max(0, Math.min(1, 1 - (totalInstability / nodes.length))) + return Math.max(0, Math.min(1, 1 - totalInstability / nodes.length)) }, [nodes, couplingMetrics]) const filteredGraphData = useMemo(() => { - const matchingNodes = nodes.filter(n => { + const matchingNodes = nodes.filter((n) => { if (selectedModule !== 'all' && n.module !== selectedModule) return false if (selectedRisk !== 'all' && n.health_color !== selectedRisk) return false if (searchQuery) { @@ -339,9 +355,9 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo return true }) - const matchingNodeIds = new Set(matchingNodes.map(n => n.id)) + const matchingNodeIds = new Set(matchingNodes.map((n) => n.id)) - const matchingLinks = links.filter(l => { + const matchingLinks = links.filter((l) => { const s = getNodeId(l.source) const t = getNodeId(l.target) return matchingNodeIds.has(s) && matchingNodeIds.has(t) @@ -350,23 +366,26 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo return { nodes: matchingNodes, links: matchingLinks } }, [nodes, links, selectedModule, selectedRisk, searchQuery]) - const getNodeSize = useCallback((node: RenderNode): number => { - let base = 5 - if (nodeSizeMetric === 'loc') { - base = Math.sqrt(Math.max(node.loc || 0, 10)) * 0.9 + 2.5 - } else if (nodeSizeMetric === 'churn') { - base = Math.sqrt(Math.max(node.churn || 0, 0.05)) * 5.5 + 2.5 - } else if (nodeSizeMetric === 'coupling') { - const afferent = couplingMetrics.afferent.get(node.id) || 0 - const efferent = couplingMetrics.efferent.get(node.id) || 0 - base = Math.sqrt(afferent + efferent) * 2.2 + 2.5 - } else if (nodeSizeMetric === 'instability') { - base = (couplingMetrics.instability.get(node.id) || 0) * 6.5 + 2.5 - } else { - base = 5.5 - } - return Math.min(Math.max(base, 4.0), 16) - }, [nodeSizeMetric, couplingMetrics]) + const getNodeSize = useCallback( + (node: RenderNode): number => { + let base = 5 + if (nodeSizeMetric === 'loc') { + base = Math.sqrt(Math.max(node.loc || 0, 10)) * 0.9 + 2.5 + } else if (nodeSizeMetric === 'churn') { + base = Math.sqrt(Math.max(node.churn || 0, 0.05)) * 5.5 + 2.5 + } else if (nodeSizeMetric === 'coupling') { + const afferent = couplingMetrics.afferent.get(node.id) || 0 + const efferent = couplingMetrics.efferent.get(node.id) || 0 + base = Math.sqrt(afferent + efferent) * 2.2 + 2.5 + } else if (nodeSizeMetric === 'instability') { + base = (couplingMetrics.instability.get(node.id) || 0) * 6.5 + 2.5 + } else { + base = 5.5 + } + return Math.min(Math.max(base, 4.0), 16) + }, + [nodeSizeMetric, couplingMetrics] + ) // D3 force-directed stable layout fit centering const refitPendingRef = useRef(true) @@ -377,7 +396,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo if (charge) charge.strength(-650) const link = graphRef.current.d3Force('link') if (link) link.distance(160) - + const collide = forceCollide().radius((node) => getNodeSize(node) + 32) graphRef.current.d3Force('collide', collide) } @@ -404,19 +423,22 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo } }, [filteredGraphData]) - const handleFocusNode = useCallback((nodeId: string) => { - const canvasNode = nodes.find(n => n.id === nodeId) - if (canvasNode && graphRef.current) { - graphRef.current.centerAt(canvasNode.x, canvasNode.y, 800) - graphRef.current.zoom(1.8, 800) - setSelectedNodeId(nodeId) - setIsSidebarOpen(true) - } - }, [nodes]) + const handleFocusNode = useCallback( + (nodeId: string) => { + const canvasNode = nodes.find((n) => n.id === nodeId) + if (canvasNode && graphRef.current) { + graphRef.current.centerAt(canvasNode.x, canvasNode.y, 800) + graphRef.current.zoom(1.8, 800) + setSelectedNodeId(nodeId) + setIsSidebarOpen(true) + } + }, + [nodes] + ) useEffect(() => { if (isPlaying && commits.length > 0 && onSelectCommit) { - const currentIndex = commits.findIndex(c => c.sha === selectedSha) + const currentIndex = commits.findIndex((c) => c.sha === selectedSha) playIntervalRef.current = setInterval(() => { const nextIndex = (currentIndex + 1) % commits.length onSelectCommit(commits[nextIndex]) @@ -430,244 +452,270 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo } }, [isPlaying, commits, selectedSha, playSpeed, onSelectCommit]) - const getNodeColor = useCallback((node: RenderNode): string => { - const focusNodeId = selectedNodeId || hoveredNode - - let isFocused = true - if (focusNodeId) { - isFocused = node.id === focusNodeId || links.some(l => { - const s = getNodeId(l.source) - const t = getNodeId(l.target) - return (s === focusNodeId && t === node.id) || (t === focusNodeId && s === node.id) - }) - } - const baseOpacity = focusNodeId ? (isFocused ? 1.0 : 0.12) : 1.0 + const getNodeColor = useCallback( + (node: RenderNode): string => { + const focusNodeId = selectedNodeId || hoveredNode + + let isFocused = true + if (focusNodeId) { + isFocused = + node.id === focusNodeId || + links.some((l) => { + const s = getNodeId(l.source) + const t = getNodeId(l.target) + return (s === focusNodeId && t === node.id) || (t === focusNodeId && s === node.id) + }) + } + const baseOpacity = focusNodeId ? (isFocused ? 1.0 : 0.12) : 1.0 - if (highlightStability) { - const instability = couplingMetrics.instability.get(node.id) || 0.5 - const red = Math.round(instability * 239) - const green = Math.round((1 - instability) * 197) - return `rgba(${red}, ${green}, 120, ${baseOpacity})` - } + if (highlightStability) { + const instability = couplingMetrics.instability.get(node.id) || 0.5 + const red = Math.round(instability * 239) + const green = Math.round((1 - instability) * 197) + return `rgba(${red}, ${green}, 120, ${baseOpacity})` + } - const baseColor = HEALTH_COLORS_RGB[node.health_color] || HEALTH_COLORS_RGB.neutral - return `rgba(${baseColor}, ${baseOpacity})` - }, [hoveredNode, selectedNodeId, links, highlightStability, couplingMetrics]) - - const drawNodeCanvas = useCallback((node: RenderNode, ctx: CanvasRenderingContext2D, globalScale: number) => { - if ( - !node || - node.x === undefined || - node.y === undefined || - node.x === null || - node.y === null || - isNaN(node.x) || - isNaN(node.y) || - !isFinite(node.x) || - !isFinite(node.y) - ) return - - const size = getNodeSize(node) - const color = getNodeColor(node) - - const isHotspot = highlightHotspots && node.health_color === 'red' && (couplingMetrics.afferent.get(node.id) || 0) > 2 - if (isHotspot) { - const pulseTime = Date.now() / 300 - const radius = size + 4 + Math.sin(pulseTime) * 2 - ctx.beginPath() - ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI) - ctx.fillStyle = 'rgba(239, 68, 68, 0.12)' - ctx.fill() - ctx.strokeStyle = 'rgba(239, 68, 68, 0.35)' - ctx.lineWidth = 1.0 / globalScale - ctx.stroke() - } + const baseColor = HEALTH_COLORS_RGB[node.health_color] || HEALTH_COLORS_RGB.neutral + return `rgba(${baseColor}, ${baseOpacity})` + }, + [hoveredNode, selectedNodeId, links, highlightStability, couplingMetrics] + ) - const isAdded = addedNodeIds.has(node.id) - if (isAdded) { - const pulseTime = Date.now() / 200 - const radius = size + 5 + Math.sin(pulseTime) * 1.5 - ctx.beginPath() - ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI) - ctx.fillStyle = 'rgba(52, 211, 153, 0.08)' - ctx.fill() - ctx.strokeStyle = 'rgba(52, 211, 153, 0.5)' - ctx.lineWidth = 1.5 / globalScale - ctx.stroke() - } + const drawNodeCanvas = useCallback( + (node: RenderNode, ctx: CanvasRenderingContext2D, globalScale: number) => { + if ( + !node || + node.x === undefined || + node.y === undefined || + node.x === null || + node.y === null || + isNaN(node.x) || + isNaN(node.y) || + !isFinite(node.x) || + !isFinite(node.y) + ) + return + + const size = getNodeSize(node) + const color = getNodeColor(node) + + const isHotspot = + highlightHotspots && + node.health_color === 'red' && + (couplingMetrics.afferent.get(node.id) || 0) > 2 + if (isHotspot) { + const pulseTime = Date.now() / 300 + const radius = size + 4 + Math.sin(pulseTime) * 2 + ctx.beginPath() + ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI) + ctx.fillStyle = 'rgba(239, 68, 68, 0.12)' + ctx.fill() + ctx.strokeStyle = 'rgba(239, 68, 68, 0.35)' + ctx.lineWidth = 1.0 / globalScale + ctx.stroke() + } - const isCyclic = highlightCyclic && cyclicNodesAndEdges.nodes.has(node.id) - if (isCyclic) { - ctx.beginPath() - ctx.arc(node.x, node.y, size + 2, 0, 2 * Math.PI) - ctx.strokeStyle = 'rgba(245, 158, 11, 0.65)' - ctx.lineWidth = 2.0 / globalScale - ctx.setLineDash([2.5, 2]) - ctx.stroke() - ctx.setLineDash([]) - } + const isAdded = addedNodeIds.has(node.id) + if (isAdded) { + const pulseTime = Date.now() / 200 + const radius = size + 5 + Math.sin(pulseTime) * 1.5 + ctx.beginPath() + ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI) + ctx.fillStyle = 'rgba(52, 211, 153, 0.08)' + ctx.fill() + ctx.strokeStyle = 'rgba(52, 211, 153, 0.5)' + ctx.lineWidth = 1.5 / globalScale + ctx.stroke() + } - ctx.save() - ctx.beginPath() - if (node.is_entry_point) { - ctx.moveTo(node.x, node.y - size) - ctx.lineTo(node.x + size, node.y) - ctx.lineTo(node.x, node.y + size) - ctx.lineTo(node.x - size, node.y) - ctx.closePath() - } else { - ctx.arc(node.x, node.y, size, 0, 2 * Math.PI, false) - } + const isCyclic = highlightCyclic && cyclicNodesAndEdges.nodes.has(node.id) + if (isCyclic) { + ctx.beginPath() + ctx.arc(node.x, node.y, size + 2, 0, 2 * Math.PI) + ctx.strokeStyle = 'rgba(245, 158, 11, 0.65)' + ctx.lineWidth = 2.0 / globalScale + ctx.setLineDash([2.5, 2]) + ctx.stroke() + ctx.setLineDash([]) + } - const gradient = ctx.createRadialGradient( - node.x - size * 0.35, - node.y - size * 0.35, - size * 0.1, - node.x, - node.y, - size - ) - - gradient.addColorStop(0, '#ffffff') - gradient.addColorStop(0.15, color) - gradient.addColorStop(0.85, color.replace(/[\d.]+\)$/, '0.9)')) - gradient.addColorStop(1, color.replace(/[\d.]+\)$/, '0.7)')) - - ctx.fillStyle = gradient - ctx.fill() - - const isHovered = hoveredNode === node.id - const isSelected = selectedNodeId === node.id - if (isHovered || isSelected) { - ctx.shadowColor = isSelected ? '#A78BFA' : '#60A5FA' - ctx.shadowBlur = 12 / globalScale - ctx.lineWidth = isSelected ? 2.5 / globalScale : 1.5 / globalScale - ctx.strokeStyle = isSelected ? '#C084FC' : '#93C5FD' - ctx.stroke() - } else { - ctx.lineWidth = 0.85 / globalScale - ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)' - ctx.stroke() - } - ctx.restore() - - if (globalScale > 0.45) { - const label = node.file.split('/').pop() || node.name - const fontSize = Math.max(3.5, size * 0.6) - ctx.font = `500 ${fontSize}px var(--font-mono, monospace)` - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - - const textWidth = ctx.measureText(label).width - const isFocused = isHovered || isSelected - - const paddingX = 6 - const paddingY = 3.5 - const rectX = node.x - textWidth / 2 - paddingX - const rectY = node.y + size + 5 - const rectW = textWidth + paddingX * 2 - const rectH = fontSize + paddingY * 2 - - ctx.fillStyle = 'rgba(10, 11, 16, 0.72)' + ctx.save() ctx.beginPath() - if (typeof ctx.roundRect === 'function') { - ctx.roundRect(rectX, rectY, rectW, rectH, 6) + if (node.is_entry_point) { + ctx.moveTo(node.x, node.y - size) + ctx.lineTo(node.x + size, node.y) + ctx.lineTo(node.x, node.y + size) + ctx.lineTo(node.x - size, node.y) + ctx.closePath() } else { - ctx.rect(rectX, rectY, rectW, rectH) + ctx.arc(node.x, node.y, size, 0, 2 * Math.PI, false) } + + const gradient = ctx.createRadialGradient( + node.x - size * 0.35, + node.y - size * 0.35, + size * 0.1, + node.x, + node.y, + size + ) + + gradient.addColorStop(0, '#ffffff') + gradient.addColorStop(0.15, color) + gradient.addColorStop(0.85, color.replace(/[\d.]+\)$/, '0.9)')) + gradient.addColorStop(1, color.replace(/[\d.]+\)$/, '0.7)')) + + ctx.fillStyle = gradient ctx.fill() - - ctx.strokeStyle = isFocused - ? 'rgba(167, 139, 250, 0.75)' - : 'rgba(255, 255, 255, 0.08)' - ctx.lineWidth = 0.65 / globalScale - ctx.stroke() - - ctx.fillStyle = isFocused ? '#FFFFFF' : '#E2E8F0' - ctx.fillText(label, node.x, rectY + rectH / 2 + 0.2) - } - }, [getNodeSize, getNodeColor, hoveredNode, selectedNodeId, addedNodeIds, cyclicNodesAndEdges, highlightCyclic, highlightHotspots, couplingMetrics]) - const drawBackgroundClusters = useCallback((ctx: CanvasRenderingContext2D, globalScale: number) => { - if (nodes.length === 0) return + const isHovered = hoveredNode === node.id + const isSelected = selectedNodeId === node.id + if (isHovered || isSelected) { + ctx.shadowColor = isSelected ? '#A78BFA' : '#60A5FA' + ctx.shadowBlur = 12 / globalScale + ctx.lineWidth = isSelected ? 2.5 / globalScale : 1.5 / globalScale + ctx.strokeStyle = isSelected ? '#C084FC' : '#93C5FD' + ctx.stroke() + } else { + ctx.lineWidth = 0.85 / globalScale + ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)' + ctx.stroke() + } + ctx.restore() + + if (globalScale > 0.45) { + const label = node.file.split('/').pop() || node.name + const fontSize = Math.max(3.5, size * 0.6) + ctx.font = `500 ${fontSize}px var(--font-mono, monospace)` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + const textWidth = ctx.measureText(label).width + const isFocused = isHovered || isSelected + + const paddingX = 6 + const paddingY = 3.5 + const rectX = node.x - textWidth / 2 - paddingX + const rectY = node.y + size + 5 + const rectW = textWidth + paddingX * 2 + const rectH = fontSize + paddingY * 2 + + ctx.fillStyle = 'rgba(10, 11, 16, 0.72)' + ctx.beginPath() + if (typeof ctx.roundRect === 'function') { + ctx.roundRect(rectX, rectY, rectW, rectH, 6) + } else { + ctx.rect(rectX, rectY, rectW, rectH) + } + ctx.fill() - const moduleGroups = new Map() - nodes.forEach(node => { - const parts = node.file.split('/') - const moduleName = parts.length > 1 ? parts[0] : 'core' - if (!moduleGroups.has(moduleName)) { - moduleGroups.set(moduleName, []) + ctx.strokeStyle = isFocused ? 'rgba(167, 139, 250, 0.75)' : 'rgba(255, 255, 255, 0.08)' + ctx.lineWidth = 0.65 / globalScale + ctx.stroke() + + ctx.fillStyle = isFocused ? '#FFFFFF' : '#E2E8F0' + ctx.fillText(label, node.x, rectY + rectH / 2 + 0.2) } - moduleGroups.get(moduleName)!.push(node) - }) + }, + [ + getNodeSize, + getNodeColor, + hoveredNode, + selectedNodeId, + addedNodeIds, + cyclicNodesAndEdges, + highlightCyclic, + highlightHotspots, + couplingMetrics, + ] + ) - const clusterAccent = (mod: string): string => { - const hash = mod.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0) - const hues = [267, 190, 142, 35, 12, 335] - const hue = hues[hash % hues.length] - return `hsla(${hue}, 70%, 40%, 0.035)` - } - - const clusterStroke = (mod: string): string => { - const hash = mod.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0) - const hues = [267, 190, 142, 35, 12, 335] - const hue = hues[hash % hues.length] - return `hsla(${hue}, 75%, 55%, 0.09)` - } + const drawBackgroundClusters = useCallback( + (ctx: CanvasRenderingContext2D, globalScale: number) => { + if (nodes.length === 0) return - ctx.save() - moduleGroups.forEach((groupNodes, moduleName) => { - if (groupNodes.length < 2) return - - let minX = Infinity, minY = Infinity - let maxX = -Infinity, maxY = -Infinity - groupNodes.forEach(n => { - if ( - !n || - n.x === undefined || - n.y === undefined || - n.x === null || - n.y === null || - isNaN(n.x) || - isNaN(n.y) || - !isFinite(n.x) || - !isFinite(n.y) - ) return - const size = getNodeSize(n) - minX = Math.min(minX, n.x - size) - minY = Math.min(minY, n.y - size) - maxX = Math.max(maxX, n.x + size) - maxY = Math.max(maxY, n.y + size) + const moduleGroups = new Map() + nodes.forEach((node) => { + const parts = node.file.split('/') + const moduleName = parts.length > 1 ? parts[0] : 'core' + if (!moduleGroups.has(moduleName)) { + moduleGroups.set(moduleName, []) + } + moduleGroups.get(moduleName)!.push(node) }) - if (minX === Infinity || !isFinite(minX) || isNaN(minX)) return - - const padding = 28 - const x = minX - padding - const y = minY - padding - const w = (maxX - minX) + padding * 2 - const h = (maxY - minY) + padding * 2 + const clusterAccent = (mod: string): string => { + const hash = mod.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0) + const hues = [267, 190, 142, 35, 12, 335] + const hue = hues[hash % hues.length] + return `hsla(${hue}, 70%, 40%, 0.035)` + } - ctx.beginPath() - if (ctx.roundRect) { - ctx.roundRect(x, y, w, h, 24) - } else { - ctx.rect(x, y, w, h) + const clusterStroke = (mod: string): string => { + const hash = mod.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0) + const hues = [267, 190, 142, 35, 12, 335] + const hue = hues[hash % hues.length] + return `hsla(${hue}, 75%, 55%, 0.09)` } - - ctx.fillStyle = clusterAccent(moduleName) - ctx.fill() - ctx.strokeStyle = clusterStroke(moduleName) - ctx.lineWidth = 1.0 / globalScale - ctx.stroke() - - const fontSize = Math.max(9, 12 / globalScale) - ctx.font = `bold ${fontSize}px var(--font-sans, system-ui)` - ctx.fillStyle = 'rgba(255, 255, 255, 0.25)' - ctx.fillText(moduleName.toUpperCase(), x + 16, y + fontSize + 10) - }) - ctx.restore() - }, [nodes, getNodeSize]) + + ctx.save() + moduleGroups.forEach((groupNodes, moduleName) => { + if (groupNodes.length < 2) return + + let minX = Infinity, + minY = Infinity + let maxX = -Infinity, + maxY = -Infinity + groupNodes.forEach((n) => { + if ( + !n || + n.x === undefined || + n.y === undefined || + n.x === null || + n.y === null || + isNaN(n.x) || + isNaN(n.y) || + !isFinite(n.x) || + !isFinite(n.y) + ) + return + const size = getNodeSize(n) + minX = Math.min(minX, n.x - size) + minY = Math.min(minY, n.y - size) + maxX = Math.max(maxX, n.x + size) + maxY = Math.max(maxY, n.y + size) + }) + + if (minX === Infinity || !isFinite(minX) || isNaN(minX)) return + + const padding = 28 + const x = minX - padding + const y = minY - padding + const w = maxX - minX + padding * 2 + const h = maxY - minY + padding * 2 + + ctx.beginPath() + if (ctx.roundRect) { + ctx.roundRect(x, y, w, h, 24) + } else { + ctx.rect(x, y, w, h) + } + + ctx.fillStyle = clusterAccent(moduleName) + ctx.fill() + ctx.strokeStyle = clusterStroke(moduleName) + ctx.lineWidth = 1.0 / globalScale + ctx.stroke() + + const fontSize = Math.max(9, 12 / globalScale) + ctx.font = `bold ${fontSize}px var(--font-sans, system-ui)` + ctx.fillStyle = 'rgba(255, 255, 255, 0.25)' + ctx.fillText(moduleName.toUpperCase(), x + 16, y + fontSize + 10) + }) + ctx.restore() + }, + [nodes, getNodeSize] + ) const toggleFullscreen = () => { if (!wrapperRef.current) return @@ -701,10 +749,12 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo const autocompleteSuggestions = useMemo(() => { if (!searchQuery) return [] const q = searchQuery.toLowerCase() - return nodes.filter(n => n.file.toLowerCase().includes(q) || n.module.toLowerCase().includes(q)).slice(0, 5) + return nodes + .filter((n) => n.file.toLowerCase().includes(q) || n.module.toLowerCase().includes(q)) + .slice(0, 5) }, [searchQuery, nodes]) - const activeCommitIndex = commits.findIndex(c => c.sha === selectedSha) + const activeCommitIndex = commits.findIndex((c) => c.sha === selectedSha) const driftRate = useMemo(() => { if (commits.length === 0 || activeCommitIndex === -1) return 0 const startScore = commits[0].health_score @@ -713,17 +763,21 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo }, [commits, activeCommitIndex]) return ( -
-

Software Knowledge Graph

+

+ Software Knowledge Graph +

@@ -736,7 +790,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo
- {searchQuery && ( - ))}
@@ -773,23 +829,23 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo
- - -
@@ -855,10 +917,18 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo className="w-full px-3 py-2 text-xs bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-purple-500/45 cursor-pointer" > - - - - + + + +
@@ -872,11 +942,21 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo }} className="w-full px-3 py-2 text-xs bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-purple-500/45 cursor-pointer" > - - - - - + + + + +
@@ -887,23 +967,31 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo Observability Layer
- - - - - -
@@ -1029,10 +1135,11 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo onRenderFramePre={drawBackgroundClusters} linkColor={(link) => { const key = getEdgeKey(link) - if (highlightCyclic && cyclicNodesAndEdges.edges.has(key)) return 'rgba(245, 158, 11, 0.75)' + if (highlightCyclic && cyclicNodesAndEdges.edges.has(key)) + return 'rgba(245, 158, 11, 0.75)' const isImport = (link as ForceGraphLink).type === 'import' const focusNodeId = selectedNodeId || hoveredNode - + let edgeOpacity = 0.28 if (focusNodeId) { const s = getNodeId(link.source) @@ -1040,8 +1147,10 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo const isEdgeFocused = s === focusNodeId || t === focusNodeId edgeOpacity = isEdgeFocused ? 0.85 : 0.03 } - - return isImport ? `rgba(96, 165, 250, ${edgeOpacity})` : `rgba(249, 115, 22, ${edgeOpacity})` + + return isImport + ? `rgba(96, 165, 250, ${edgeOpacity})` + : `rgba(249, 115, 22, ${edgeOpacity})` }} linkWidth={(link) => { const key = getEdgeKey(link) @@ -1114,11 +1223,17 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo
- {selectedNodeDetails.module} -

{selectedNodeDetails.file.split('/').pop()}

-

{selectedNodeDetails.file}

+ + {selectedNodeDetails.module} + +

+ {selectedNodeDetails.file.split('/').pop()} +

+

+ {selectedNodeDetails.file} +

-
)}
-
Lines of Code
-
{selectedNodeDetails.loc}
+
+ Lines of Code +
+
+ {selectedNodeDetails.loc} +
-
Complexity Score
-
{selectedNodeDetails.health.toFixed(1)}
+
+ Complexity Score +
+
+ {selectedNodeDetails.health.toFixed(1)} +
@@ -1159,7 +1283,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo
-
@@ -1179,7 +1303,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo Outbound Dependencies ({selectedNodeDetails.imports.length})
- {selectedNodeDetails.imports.map(fileId => ( + {selectedNodeDetails.imports.map((fileId) => (
- {selectedNodeDetails.importedBy.map(fileId => ( + {selectedNodeDetails.importedBy.map((fileId) => (
) : (
-
)}
@@ -1240,15 +1369,19 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo {commits.length > 0 && onSelectCommit && (
-
@@ -1296,7 +1439,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo }} className="w-full h-1 bg-white/10 rounded-full appearance-none cursor-pointer accent-purple-500 focus:outline-none" /> -
@@ -1311,17 +1454,26 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo
DRIFT:{' '} - 0 ? 'text-emerald-400' : driftRate < 0 ? 'text-rose-400' : 'text-slate-400' - }`}> - {driftRate > 0 ? '+' : ''}{driftRate.toFixed(1)} + 0 + ? 'text-emerald-400' + : driftRate < 0 + ? 'text-rose-400' + : 'text-slate-400' + }`} + > + {driftRate > 0 ? '+' : ''} + {driftRate.toFixed(1)}
- {activeCommitIndex + 1} / {commits.length} + + {activeCommitIndex + 1} / {commits.length} +
diff --git a/frontend/src/components/HealthTimeline.tsx b/frontend/src/components/HealthTimeline.tsx index 4acf700..bc39f1a 100644 --- a/frontend/src/components/HealthTimeline.tsx +++ b/frontend/src/components/HealthTimeline.tsx @@ -11,7 +11,12 @@ import { XAxis, YAxis, } from 'recharts' -import type { AreaDotProps, ChartClickState, ChartTooltipProps, HealthTimelineProps } from '../types' +import type { + AreaDotProps, + ChartClickState, + ChartTooltipProps, + HealthTimelineProps, +} from '../types' import { formatSha, getHealthColor } from '../types' import { formatDateShort } from '../lib/utils' import { GitCommit, TrendingUp, Cpu, Flame } from 'lucide-react' @@ -33,7 +38,7 @@ function CommitTooltip({ active, payload }: ChartTooltipProps) { {formatDateShort(data.committed_at)}
- +
{data.message || 'No commit message'}
@@ -48,7 +53,7 @@ function CommitTooltip({ active, payload }: ChartTooltipProps) { {data.health_score.toFixed(1)}
- +
@@ -67,7 +72,7 @@ function CommitTooltip({ active, payload }: ChartTooltipProps) { {(data.churn_rate * 100).toFixed(1)}%
- +
@@ -88,7 +93,12 @@ function CommitTooltip({ active, payload }: ChartTooltipProps) { ) } -export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit }: HealthTimelineProps) { +export function HealthTimeline({ + commits, + repoSlug, + selectedSha, + onSelectCommit, +}: HealthTimelineProps) { const navigate = useNavigate() const [visibleLines, setVisibleLines] = useState({ complexity_drift: false, @@ -115,7 +125,7 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit } const toggleLine = (key: keyof typeof visibleLines) => { - setVisibleLines(prev => ({ ...prev, [key]: !prev[key] })) + setVisibleLines((prev) => ({ ...prev, [key]: !prev[key] })) } return ( @@ -127,11 +137,15 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit
-

Codebase Health Timeline

+

+ Codebase Health Timeline +

-

Interactive timeline tracker highlighting drift over recent commits

+

+ Interactive timeline tracker highlighting drift over recent commits +

- +
@@ -144,7 +158,8 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit label: 'Complexity', color: 'text-amber-300', dotBg: 'bg-amber-400', - activeBg: 'bg-amber-500/15 border-amber-500/30 shadow-[0_0_15px_rgba(245,158,11,0.15)]', + activeBg: + 'bg-amber-500/15 border-amber-500/30 shadow-[0_0_15px_rgba(245,158,11,0.15)]', }, { key: 'churn_risk', @@ -158,14 +173,16 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit label: 'Bus Factor', color: 'text-fuchsia-300', dotBg: 'bg-fuchsia-400', - activeBg: 'bg-fuchsia-500/15 border-fuchsia-500/30 shadow-[0_0_15px_rgba(217,70,239,0.15)]', + activeBg: + 'bg-fuchsia-500/15 border-fuchsia-500/30 shadow-[0_0_15px_rgba(217,70,239,0.15)]', }, { key: 'dependency_health', label: 'Dependencies', color: 'text-emerald-300', dotBg: 'bg-emerald-400', - activeBg: 'bg-emerald-500/15 border-emerald-500/30 shadow-[0_0_15px_rgba(16,185,129,0.15)]', + activeBg: + 'bg-emerald-500/15 border-emerald-500/30 shadow-[0_0_15px_rgba(16,185,129,0.15)]', }, { key: 'semantic_drift', @@ -173,7 +190,7 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit color: 'text-cyan-300', dotBg: 'bg-cyan-400', activeBg: 'bg-cyan-500/15 border-cyan-500/30 shadow-[0_0_15px_rgba(6,182,212,0.15)]', - } + }, ].map((item) => { const isActive = visibleLines[item.key as keyof typeof visibleLines] return ( @@ -181,12 +198,14 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit key={item.key} onClick={() => toggleLine(item.key as keyof typeof visibleLines)} className={`flex items-center gap-2 px-3 py-1.5 rounded-full transition-all duration-300 border text-xs font-semibold cursor-pointer ${ - isActive - ? `${item.activeBg} ${item.color}` + isActive + ? `${item.activeBg} ${item.color}` : 'bg-white/5 border-white/5 text-slate-400 hover:bg-white/10 hover:text-slate-300' }`} > - + {item.label} ) @@ -204,23 +223,23 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit - formatDateShort(value)} - tick={{ fill: 'var(--color-muted)', fontSize: 10, fontFamily: 'var(--font-body)' }} - axisLine={false} - tickLine={false} - minTickGap={40} + formatDateShort(value)} + tick={{ fill: 'var(--color-muted)', fontSize: 10, fontFamily: 'var(--font-body)' }} + axisLine={false} + tickLine={false} + minTickGap={40} /> - - } + } cursor={{ stroke: 'var(--glass-border)', strokeWidth: 1.5 }} wrapperStyle={{ zIndex: 999999, pointerEvents: 'none' }} useTranslate3d={true} @@ -241,90 +260,90 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit return ( {isSelected && ( - )} - ) }} /> - - - - - - - - formatDateShort(value)} - height={26} - stroke="var(--glass-border)" - fill="var(--glass-bg)" - travellerWidth={7} + formatDateShort(value)} + height={26} + stroke="var(--glass-border)" + fill="var(--glass-bg)" + travellerWidth={7} /> diff --git a/frontend/src/components/HotspotMap.tsx b/frontend/src/components/HotspotMap.tsx index ed78fcc..d88f00e 100644 --- a/frontend/src/components/HotspotMap.tsx +++ b/frontend/src/components/HotspotMap.tsx @@ -41,9 +41,26 @@ function HotspotCell(props: TreemapNode) { const label = name.length > labelLimit ? `${name.slice(0, labelLimit)}...` : name return ( - + {width > 44 && height > 22 && ( - + {label} )} @@ -52,7 +69,9 @@ function HotspotCell(props: TreemapNode) { } export function HotspotMap({ repoId, sha }: HotspotMapProps) { - const hotspotState = useSWR(['hotspots', repoId, sha], () => getHotspots(repoId, sha || undefined)) + const hotspotState = useSWR(['hotspots', repoId, sha], () => + getHotspots(repoId, sha || undefined) + ) const hotspots = hotspotState.data?.hotspots || [] const treemapData: TreemapNode[] = hotspots.map((hotspot) => ({ name: hotspot.file.split('/').pop() || hotspot.file, @@ -69,21 +88,38 @@ export function HotspotMap({ repoId, sha }: HotspotMapProps) {
-

Complexity Churn Hotspots

-

Area represents file complexity scaled by recent churn volume

+

+ Complexity Churn Hotspots +

+

+ Area represents file complexity scaled by recent churn volume +

- Critical - High - Medium + + {' '} + Critical + + + {' '} + High + + + {' '} + Medium +
{hotspotState.isLoading ? ( -
Loading hotspots...
+
+ Loading hotspots... +
) : hotspots.length === 0 ? ( -
No high-complexity churn hotspots found for this commit.
+
+ No high-complexity churn hotspots found for this commit. +
) : ( -

{item.fullPath}

+

+ {item.fullPath} +

-

Complexity: {item.complexity ?? 0}

-

Churn count: {item.churnCount ?? 0}

-

Risk score: {item.riskScore ?? 0}/100

+

+ Complexity:{' '} + {item.complexity ?? 0} +

+

+ Churn count:{' '} + {item.churnCount ?? 0} +

+

+ Risk score:{' '} + {item.riskScore ?? 0}/100 +

) diff --git a/frontend/src/components/NarrativeCard.tsx b/frontend/src/components/NarrativeCard.tsx index 2edf4fe..55d195f 100644 --- a/frontend/src/components/NarrativeCard.tsx +++ b/frontend/src/components/NarrativeCard.tsx @@ -71,13 +71,15 @@ export function NarrativeCard({ repoId, commitSha }: NarrativeCardProps) { return (
- +
- AI Narrative Analyst + + AI Narrative Analyst +
Claude to Gemini @@ -90,17 +92,20 @@ export function NarrativeCard({ repoId, commitSha }: NarrativeCardProps) {
-

Generate Intelligence Narrative

+

+ Generate Intelligence Narrative +

- Request an AI-generated natural language report explaining the architectural health fluctuations, code coupling anomalies, and refactor opportunities. + Request an AI-generated natural language report explaining the architectural + health fluctuations, code coupling anomalies, and refactor opportunities.

- +
-
@@ -143,12 +155,16 @@ export function NarrativeCard({ repoId, commitSha }: NarrativeCardProps) {
{meta.provider && PROVIDER_BADGE[meta.provider] ? ( - + {PROVIDER_BADGE[meta.provider].label} ) : ( - {meta.model || 'model unavailable'} + + {meta.model || 'model unavailable'} + )}
@@ -157,8 +173,16 @@ export function NarrativeCard({ repoId, commitSha }: NarrativeCardProps) {
Tokens: {(meta.tokens_total || 0).toLocaleString()} - {meta.cached && CACHED} - {meta.demo_mode && DEMO MODE} + {meta.cached && ( + + CACHED + + )} + {meta.demo_mode && ( + + DEMO MODE + + )}
)} diff --git a/frontend/src/components/ui/HealthBadge.tsx b/frontend/src/components/ui/HealthBadge.tsx index a7bb13e..b3dc277 100644 --- a/frontend/src/components/ui/HealthBadge.tsx +++ b/frontend/src/components/ui/HealthBadge.tsx @@ -20,26 +20,33 @@ export function HealthBadge({ score, delta, size = 'md' }: HealthBadgeProps) { } return ( - - - {score.toFixed(0)} + + {score.toFixed(0)} + {delta !== undefined && delta !== null && ( - = 0 ? 'text-emerald-400' : 'text-rose-400')}> + = 0 ? 'text-emerald-400' : 'text-rose-400' + )} + > {delta >= 0 ? `+${delta.toFixed(1)}` : delta.toFixed(1)} )} diff --git a/frontend/src/components/ui/ThemeToggle.tsx b/frontend/src/components/ui/ThemeToggle.tsx index 9383e40..524b9ec 100644 --- a/frontend/src/components/ui/ThemeToggle.tsx +++ b/frontend/src/components/ui/ThemeToggle.tsx @@ -31,7 +31,7 @@ export function ThemeToggle() { }, [theme]) const toggleTheme = () => { - setTheme(prev => (prev === 'light' ? 'dark' : 'light')) + setTheme((prev) => (prev === 'light' ? 'dark' : 'light')) } return ( @@ -50,21 +50,45 @@ export function ThemeToggle() { ) : ( - + - + )}
- - + + - + - + diff --git a/frontend/src/index.css b/frontend/src/index.css index 694502e..57aa8d9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -8,30 +8,30 @@ @layer base { :root { /* Brand Spatial Global Tokens */ - --color-brand-50: #F3F4F6; - --color-brand-100: #E5E7EB; - --color-brand-200: #D1D5DB; - --color-brand-300: #9CA3AF; - --color-brand-400: #A78BFA; - --color-brand-500: #8B5CF6; - --color-brand-600: #7C3AED; - --color-brand-700: #6D28D9; - --color-brand-800: #5B21B6; - --color-brand-900: #4C1D95; + --color-brand-50: #f3f4f6; + --color-brand-100: #e5e7eb; + --color-brand-200: #d1d5db; + --color-brand-300: #9ca3af; + --color-brand-400: #a78bfa; + --color-brand-500: #8b5cf6; + --color-brand-600: #7c3aed; + --color-brand-700: #6d28d9; + --color-brand-800: #5b21b6; + --color-brand-900: #4c1d95; /* Status Tokens */ - --color-healthy: #34D399; /* Emerald */ - --color-healthy-500: #10B981; - --color-warning: #FBBF24; /* Amber */ - --color-warning-500: #F59E0B; - --color-critical: #F87171; /* Coral Red */ - --color-critical-500: #EF4444; - --color-info: #60A5FA; /* Soft Icy Blue */ + --color-healthy: #34d399; /* Emerald */ + --color-healthy-500: #10b981; + --color-warning: #fbbf24; /* Amber */ + --color-warning-500: #f59e0b; + --color-critical: #f87171; /* Coral Red */ + --color-critical-500: #ef4444; + --color-info: #60a5fa; /* Soft Icy Blue */ /* Typography Hierarchy */ --font-display: 'Outfit', -apple-system, system-ui, sans-serif; - --font-body: 'Plus Jakarta Sans', -apple-system, system-ui, sans-serif; - --font-mono: 'JetBrains Mono', monospace; + --font-body: 'Plus Jakarta Sans', -apple-system, system-ui, sans-serif; + --font-mono: 'JetBrains Mono', monospace; /* Radii consistent with Apple Spatial UI */ --radius-sm: 8px; @@ -42,76 +42,86 @@ --radius-pill: 9999px; /* Base Layout Colors (Frosted Silver/White Light Theme by Default) */ - --color-base: #F4F5F8; - --color-surface: rgba(255, 255, 255, 0.45); - --color-elevated: rgba(255, 255, 255, 0.7); - --color-border: rgba(0, 0, 0, 0.06); + --color-base: #f4f5f8; + --color-surface: rgba(255, 255, 255, 0.45); + --color-elevated: rgba(255, 255, 255, 0.7); + --color-border: rgba(0, 0, 0, 0.06); --color-border-subtle: rgba(0, 0, 0, 0.03); - --color-primary: #111827; /* Near Black */ - --color-secondary: #374151; /* Dark Gray */ - --color-muted: #6B7280; /* System Gray */ - --color-mono: #4B5563; - --color-disabled: rgba(0, 0, 0, 0.02); + --color-primary: #111827; /* Near Black */ + --color-secondary: #374151; /* Dark Gray */ + --color-muted: #6b7280; /* System Gray */ + --color-mono: #4b5563; + --color-disabled: rgba(0, 0, 0, 0.02); - --color-brand: #7C3AED; /* Vibrant Violet Accent */ + --color-brand: #7c3aed; /* Vibrant Violet Accent */ /* Glassmorphism Variables (Light Mode) */ - --glass-bg: rgba(255, 255, 255, 0.45); - --glass-bg-hover: rgba(255, 255, 255, 0.65); - --glass-border: rgba(0, 0, 0, 0.06); - --glass-border-hover: rgba(0, 0, 0, 0.12); - --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8); - --glass-shadow-hover: 0 12px 40px rgba(0, 0, 0, 0.08), inset 0 1px 1.5px rgba(255, 255, 255, 0.95), 0 0 20px rgba(124, 58, 237, 0.06); - - --glass-bg-bright: rgba(255, 255, 255, 0.75); + --glass-bg: rgba(255, 255, 255, 0.45); + --glass-bg-hover: rgba(255, 255, 255, 0.65); + --glass-border: rgba(0, 0, 0, 0.06); + --glass-border-hover: rgba(0, 0, 0, 0.12); + --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8); + --glass-shadow-hover: + 0 12px 40px rgba(0, 0, 0, 0.08), inset 0 1px 1.5px rgba(255, 255, 255, 0.95), + 0 0 20px rgba(124, 58, 237, 0.06); + + --glass-bg-bright: rgba(255, 255, 255, 0.75); --glass-border-bright: rgba(0, 0, 0, 0.09); - --glass-shadow-bright: 0 8px 32px rgba(0, 0, 0, 0.04), inset 0 1px 1.5px rgba(255, 255, 255, 0.95); + --glass-shadow-bright: + 0 8px 32px rgba(0, 0, 0, 0.04), inset 0 1px 1.5px rgba(255, 255, 255, 0.95); - --glass-bg-dark: rgba(255, 255, 255, 0.85); - --glass-border-dark: rgba(0, 0, 0, 0.05); - --glass-shadow-dark: 0 12px 48px rgba(0, 0, 0, 0.06), inset 0 1px 0.5px rgba(255, 255, 255, 0.95); + --glass-bg-dark: rgba(255, 255, 255, 0.85); + --glass-border-dark: rgba(0, 0, 0, 0.05); + --glass-shadow-dark: + 0 12px 48px rgba(0, 0, 0, 0.06), inset 0 1px 0.5px rgba(255, 255, 255, 0.95); - --liquid-btn-bg: rgba(0, 0, 0, 0.03); - --liquid-btn-bg-hover: rgba(0, 0, 0, 0.06); - --liquid-btn-border: rgba(0, 0, 0, 0.06); + --liquid-btn-bg: rgba(0, 0, 0, 0.03); + --liquid-btn-bg-hover: rgba(0, 0, 0, 0.06); + --liquid-btn-border: rgba(0, 0, 0, 0.06); --liquid-btn-border-hover: rgba(0, 0, 0, 0.12); } .dark { - --color-base: #0A0B10; - --color-surface: rgba(255, 255, 255, 0.035); - --color-elevated: rgba(255, 255, 255, 0.07); - --color-border: rgba(255, 255, 255, 0.08); + --color-base: #0a0b10; + --color-surface: rgba(255, 255, 255, 0.035); + --color-elevated: rgba(255, 255, 255, 0.07); + --color-border: rgba(255, 255, 255, 0.08); --color-border-subtle: rgba(255, 255, 255, 0.05); - --color-primary: #F9FAFB; - --color-secondary: #E5E7EB; - --color-muted: #9CA3AF; - --color-mono: #D1D5DB; - --color-disabled: rgba(255, 255, 255, 0.02); + --color-primary: #f9fafb; + --color-secondary: #e5e7eb; + --color-muted: #9ca3af; + --color-mono: #d1d5db; + --color-disabled: rgba(255, 255, 255, 0.02); - --color-brand: #A78BFA; + --color-brand: #a78bfa; /* Glassmorphism Variables (Dark Mode) */ - --glass-bg: rgba(255, 255, 255, 0.035); - --glass-bg-hover: rgba(255, 255, 255, 0.06); - --glass-border: rgba(255, 255, 255, 0.08); - --glass-border-hover: rgba(255, 255, 255, 0.13); - --glass-shadow: 0 4px 30px rgba(0, 0, 0, 0.35), inset 0 1px 1px rgba(255, 255, 255, 0.06), inset 0 -1px 1px rgba(0, 0, 0, 0.2); - --glass-shadow-hover: 0 12px 40px rgba(0, 0, 0, 0.45), inset 0 1px 1.5px rgba(255, 255, 255, 0.12), 0 0 20px rgba(167, 139, 250, 0.05); - - --glass-bg-bright: rgba(255, 255, 255, 0.08); + --glass-bg: rgba(255, 255, 255, 0.035); + --glass-bg-hover: rgba(255, 255, 255, 0.06); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-border-hover: rgba(255, 255, 255, 0.13); + --glass-shadow: + 0 4px 30px rgba(0, 0, 0, 0.35), inset 0 1px 1px rgba(255, 255, 255, 0.06), + inset 0 -1px 1px rgba(0, 0, 0, 0.2); + --glass-shadow-hover: + 0 12px 40px rgba(0, 0, 0, 0.45), inset 0 1px 1.5px rgba(255, 255, 255, 0.12), + 0 0 20px rgba(167, 139, 250, 0.05); + + --glass-bg-bright: rgba(255, 255, 255, 0.08); --glass-border-bright: rgba(255, 255, 255, 0.14); - --glass-shadow-bright: 0 8px 32px rgba(0, 0, 0, 0.4), inset 0 1px 1.5px rgba(255, 255, 255, 0.12); + --glass-shadow-bright: + 0 8px 32px rgba(0, 0, 0, 0.4), inset 0 1px 1.5px rgba(255, 255, 255, 0.12); - --glass-bg-dark: rgba(10, 11, 16, 0.6); - --glass-border-dark: rgba(255, 255, 255, 0.05); - --glass-shadow-dark: 0 12px 48px rgba(0, 0, 0, 0.5), inset 0 1px 0.5px rgba(255, 255, 255, 0.03); + --glass-bg-dark: rgba(10, 11, 16, 0.6); + --glass-border-dark: rgba(255, 255, 255, 0.05); + --glass-shadow-dark: + 0 12px 48px rgba(0, 0, 0, 0.5), inset 0 1px 0.5px rgba(255, 255, 255, 0.03); - --liquid-btn-bg: rgba(255, 255, 255, 0.06); - --liquid-btn-bg-hover: rgba(255, 255, 255, 0.12); - --liquid-btn-border: rgba(255, 255, 255, 0.08); + --liquid-btn-bg: rgba(255, 255, 255, 0.06); + --liquid-btn-bg-hover: rgba(255, 255, 255, 0.12); + --liquid-btn-border: rgba(255, 255, 255, 0.08); --liquid-btn-border-hover: rgba(255, 255, 255, 0.18); } @@ -121,7 +131,9 @@ scroll-behavior: smooth; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - transition: background-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), color 0.4s cubic-bezier(0.16, 1, 0.3, 1); + transition: + background-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), + color 0.4s cubic-bezier(0.16, 1, 0.3, 1); } body { @@ -129,7 +141,9 @@ color: var(--color-primary); font-family: var(--font-body); overflow-x: hidden; - transition: background-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), color 0.4s cubic-bezier(0.16, 1, 0.3, 1); + transition: + background-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), + color 0.4s cubic-bezier(0.16, 1, 0.3, 1); } * { @@ -142,16 +156,16 @@ width: 6px; height: 6px; } - ::-webkit-scrollbar-track { - background: transparent; + ::-webkit-scrollbar-track { + background: transparent; } - ::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.08); - border-radius: 99px; + ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); + border-radius: 99px; transition: background-color 0.3s ease; } - ::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.18); + ::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.18); } /* Override default tailwind colors to support light mode dynamically */ @@ -195,7 +209,7 @@ html:not(.dark) .bg-white\/\[0\.06\] { background-color: rgba(0, 0, 0, 0.06) !important; } - + html:not(.dark) .border-white\/5 { border-color: rgba(0, 0, 0, 0.05) !important; } @@ -205,13 +219,13 @@ html:not(.dark) .border-white\/15 { border-color: rgba(0, 0, 0, 0.12) !important; } - - html:not(.dark) aside, + + html:not(.dark) aside, html:not(.dark) .bg-\[\#0a0b10\]\/40 { background-color: rgba(255, 255, 255, 0.5) !important; border-color: rgba(0, 0, 0, 0.06) !important; } - + html:not(.dark) .bg-\[\#07080d\] { background-color: var(--color-base) !important; } @@ -219,7 +233,7 @@ html:not(.dark) .bg-\[\#05060b\] { background-color: var(--color-surface) !important; } - + html:not(.dark) .text-white\/10 { color: rgba(0, 0, 0, 0.09) !important; } @@ -242,7 +256,10 @@ -webkit-backdrop-filter: blur(28px) saturate(120%); border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); - transition: background-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.4s cubic-bezier(0.16, 1, 0.3, 1); + transition: + background-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), + border-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), + box-shadow 0.4s cubic-bezier(0.16, 1, 0.3, 1); } .glass-panel:hover { @@ -265,16 +282,16 @@ backdrop-filter: blur(20px) saturate(140%); -webkit-backdrop-filter: blur(20px) saturate(140%); border: 1px solid rgba(255, 255, 255, 0.15) !important; - box-shadow: - 0 12px 40px rgba(0, 0, 0, 0.75), + box-shadow: + 0 12px 40px rgba(0, 0, 0, 0.75), inset 0 1px 1px rgba(255, 255, 255, 0.1), 0 0 25px rgba(167, 139, 250, 0.15); } html:not(.dark) .glass-tooltip { background: rgba(255, 255, 255, 0.95) !important; border-color: rgba(0, 0, 0, 0.1) !important; - box-shadow: - 0 12px 40px rgba(0, 0, 0, 0.08), + box-shadow: + 0 12px 40px rgba(0, 0, 0, 0.08), inset 0 1px 1px rgba(255, 255, 255, 0.9), 0 0 25px rgba(124, 58, 237, 0.06); } @@ -293,7 +310,7 @@ backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid var(--liquid-btn-border); - box-shadow: + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2), inset 0 1px 0.5px rgba(255, 255, 255, 0.1); color: var(--color-primary); @@ -304,7 +321,7 @@ background: var(--liquid-btn-bg-hover); border-color: var(--liquid-btn-border-hover); transform: translateY(-1px); - box-shadow: + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), inset 0 1px 1px rgba(255, 255, 255, 0.15); } @@ -346,18 +363,32 @@ } @keyframes floatSlow { - 0% { transform: translateY(0) scale(1); } - 100% { transform: translateY(-18px) scale(1.03); } + 0% { + transform: translateY(0) scale(1); + } + 100% { + transform: translateY(-18px) scale(1.03); + } } @keyframes floatMedium { - 0% { transform: translate(0, 0) scale(1.02); } - 100% { transform: translate(25px, -20px) scale(0.97); } + 0% { + transform: translate(0, 0) scale(1.02); + } + 100% { + transform: translate(25px, -20px) scale(0.97); + } } @keyframes glowPulse { - 0% { opacity: 0.35; filter: brightness(0.9); } - 100% { opacity: 0.75; filter: brightness(1.2); } + 0% { + opacity: 0.35; + filter: brightness(0.9); + } + 100% { + opacity: 0.75; + filter: brightness(1.2); + } } } @@ -370,9 +401,8 @@ } /* Ensure the force graph tooltips sit below floating sidebar components and don't capture pointer events */ -.graph-tooltip, .scene-tooltip { +.graph-tooltip, +.scene-tooltip { z-index: 30 !important; pointer-events: none !important; } - - diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index f043369..81c168c 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -22,12 +22,14 @@ describe('streamNarrative', () => { it('parses server-sent narrative chunks split across network reads', async () => { const chunks: NarrativeStreamChunk[] = [] - const fetchMock = vi.fn(async () => streamResponse([ - 'data: {"token":"The ","done":false}\n\n', - 'data: {"token":"risk","done":false}\n', - '\n', - 'data: {"done":true,"explanation":"The risk","tokens_total":4,"cost_usd":0.001,"cached":false,"model":"demo"}\n\n', - ])) + const fetchMock = vi.fn(async () => + streamResponse([ + 'data: {"token":"The ","done":false}\n\n', + 'data: {"token":"risk","done":false}\n', + '\n', + 'data: {"done":true,"explanation":"The risk","tokens_total":4,"cost_usd":0.001,"cached":false,"model":"demo"}\n\n', + ]) + ) vi.stubGlobal('fetch', fetchMock) await streamNarrative(7, 'abcdef123456', (chunk) => chunks.push(chunk)) @@ -50,7 +52,9 @@ describe('streamNarrative', () => { it('surfaces API error details for failed streams', async () => { vi.stubGlobal( 'fetch', - vi.fn(async () => new Response(JSON.stringify({ detail: 'LLM budget exceeded' }), { status: 429 })), + vi.fn( + async () => new Response(JSON.stringify({ detail: 'LLM budget exceeded' }), { status: 429 }) + ) ) await expect(streamNarrative(7, 'abcdef123456', vi.fn())).rejects.toThrow('LLM budget exceeded') diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index cf6ebb7..33aeeaf 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -68,7 +68,7 @@ export async function getHealthTimeline(repoId: string | number): Promise { return request(client.get(`/repos/${repoId}/commit/${sha}`)) } @@ -86,7 +86,7 @@ export async function getBusFactor(repoId: string | number): Promise { return request( client.get(`/repos/${repoId}/graph/diff`, { @@ -135,7 +135,7 @@ export async function cancelIngest(repoId: string | number): Promise void, + onChunk: (chunk: NarrativeStreamChunk) => void ): Promise { const response = await fetch(`${API_ROOT}/explain/stream`, { method: 'POST', diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 600f535..d05b760 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -6,12 +6,15 @@ export function cn(...inputs: ClassValue[]) { export function formatDate(iso: string): string { return new Date(iso).toLocaleDateString('en-US', { - month: 'short', day: 'numeric', year: 'numeric' + month: 'short', + day: 'numeric', + year: 'numeric', }) } export function formatDateShort(iso: string): string { return new Date(iso).toLocaleDateString('en-US', { - month: 'short', day: 'numeric' + month: 'short', + day: 'numeric', }) } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..2caec89 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,5 +6,5 @@ import App from './App.tsx' createRoot(document.getElementById('root')!).render( - , + ) diff --git a/frontend/src/pages/AnalyzePage.test.tsx b/frontend/src/pages/AnalyzePage.test.tsx index 75f08cc..e645d76 100644 --- a/frontend/src/pages/AnalyzePage.test.tsx +++ b/frontend/src/pages/AnalyzePage.test.tsx @@ -34,7 +34,7 @@ function renderAnalyzePage(source = new MockEventSource()) { Landing
} /> Dashboard
} /> - , + ) return source } diff --git a/frontend/src/pages/AnalyzePage.tsx b/frontend/src/pages/AnalyzePage.tsx index c47d1da..6b3aa06 100644 --- a/frontend/src/pages/AnalyzePage.tsx +++ b/frontend/src/pages/AnalyzePage.tsx @@ -44,11 +44,17 @@ export default function AnalyzePage() { setProgress(data) if (data.status === 'ready') { source.close() - getRepo(repoId).then((repo) => { - navigate(`/dashboard/${repo.repo_slug}`, { replace: true }) - }).catch((err) => { - setError(err instanceof Error ? err.message : 'Analysis completed, but repository metadata could not load.') - }) + getRepo(repoId) + .then((repo) => { + navigate(`/dashboard/${repo.repo_slug}`, { replace: true }) + }) + .catch((err) => { + setError( + err instanceof Error + ? err.message + : 'Analysis completed, but repository metadata could not load.' + ) + }) } if (data.status === 'error') { source.close() @@ -67,7 +73,9 @@ export default function AnalyzePage() { }, [repoId, navigate]) const currentStageIdx = progress.status === 'queued' ? 0 : stageIndex(progress.status) - const canCancel = Boolean(repoId && !error && !['ready', 'error', 'cancelled'].includes(progress.status)) + const canCancel = Boolean( + repoId && !error && !['ready', 'error', 'cancelled'].includes(progress.status) + ) async function handleCancel() { if (!repoId || isCancelling) return @@ -92,12 +100,24 @@ export default function AnalyzePage() {
- - + +
-

Analyzing Repository

+

+ Analyzing Repository +

{repoName}

@@ -108,8 +128,8 @@ export default function AnalyzePage() { const isDone = currentStageIdx > index || progress.status === 'ready' const isActive = currentStageIdx === index && progress.status !== 'ready' && !error return ( -
- + {isDone ? stage.done : stage.label} {isActive && progress.current > 0 && progress.total > 0 && ( @@ -142,13 +164,13 @@ export default function AnalyzePage() { {(isActive || isDone) && (
-
)} @@ -161,7 +183,9 @@ export default function AnalyzePage() { {progress.current_sha && (
ACTIVE SNAPSHOT - {progress.current_sha} + + {progress.current_sha} +
)} @@ -181,8 +205,8 @@ export default function AnalyzePage() { {error && (

{error}

- - - - + / - +
- {repo.name} + + {repo.name} +
@@ -116,8 +134,8 @@ export default function DashboardPage() {
-
- +
- { setSelected(commit) setIsSidebarOpen(false) - }} + }} />
@@ -186,27 +217,29 @@ export default function DashboardPage() { No analyzed commits are currently compiled for this repository workspace.
) : ( - )} {selected && (
- +
{selected.sha.slice(0, 12)} -

{selected.message || 'No commit message'}

+

+ {selected.message || 'No commit message'} +

-
{[ - { - label: 'Codebase Complexity', - value: selected.avg_complexity === 0 ? '-' : selected.avg_complexity.toFixed(1), - unit: selected.avg_complexity === 0 ? 'no code changed' : 'Avg cyclomatic score', - icon: + { + label: 'Codebase Complexity', + value: selected.avg_complexity === 0 ? '-' : selected.avg_complexity.toFixed(1), + unit: + selected.avg_complexity === 0 ? 'no code changed' : 'Avg cyclomatic score', + icon: , }, - { - label: 'Commit Churn', - value: `${selectedChurnPct.toFixed(0)}%`, + { + label: 'Commit Churn', + value: `${selectedChurnPct.toFixed(0)}%`, unit: `${selected.num_files_changed} modified components`, - icon: + icon: , }, - { - label: 'Minimum Bus Factor', - value: String(selected.bus_factor_min), + { + label: 'Minimum Bus Factor', + value: String(selected.bus_factor_min), unit: 'Crucial owners limit', - icon: + icon: , }, { label: 'Semantic Drift', value: `${(selected.subscores?.semantic_drift ?? selected.semantic_health_score ?? 100).toFixed(0)}`, unit: `${selected.avg_semantic_drift?.toFixed(2) ?? '0.00'} avg drift`, - badge: selected.semantic_drift_method === 'graphcodebert' ? 'GraphCodeBERT' : undefined, - icon: + badge: + selected.semantic_drift_method === 'graphcodebert' + ? 'GraphCodeBERT' + : undefined, + icon: , }, ].map((metric) => ( -
@@ -261,9 +298,7 @@ export default function DashboardPage() {
{metric.value}
-
- {metric.unit} -
+
{metric.unit}
))}
@@ -276,18 +311,25 @@ export default function DashboardPage() {
{selectedRiskReasons.map((reason) => ( -
+
{reason.label}
-
{reason.detail}
+
+ {reason.detail} +
- + {reason.severity}
@@ -308,10 +350,16 @@ export default function DashboardPage() {
{selectedPersistentHotspots.map((hotspot) => ( -
- {hotspot.path} +
+ + {hotspot.path} + - {hotspot.recent_commit_count} commits / cx {hotspot.complexity.toFixed(1)} + {hotspot.recent_commit_count} commits / cx{' '} + {hotspot.complexity.toFixed(1)}
))} @@ -332,9 +380,9 @@ export default function DashboardPage() { Could not construct software import dependency landscape.
) : ( - @@ -349,7 +397,7 @@ export default function DashboardPage() { ) : ( )} - + {repoId && }
diff --git a/frontend/src/pages/DemoPage.test.tsx b/frontend/src/pages/DemoPage.test.tsx index 0eead6d..ee03c8e 100644 --- a/frontend/src/pages/DemoPage.test.tsx +++ b/frontend/src/pages/DemoPage.test.tsx @@ -15,7 +15,7 @@ function renderDemoPage() { return render( - , + ) } diff --git a/frontend/src/pages/DemoPage.tsx b/frontend/src/pages/DemoPage.tsx index 0b84d6f..97fda02 100644 --- a/frontend/src/pages/DemoPage.tsx +++ b/frontend/src/pages/DemoPage.tsx @@ -14,7 +14,9 @@ export default function DemoPage() { try { const demoUrl = 'https://github.com/facebook/react' const response = await ingestRepo(demoUrl, 100) - navigate(`/analyze?repo_id=${response.repo_id}&name=${encodeURIComponent(demoUrl)}`, { replace: true }) + navigate(`/analyze?repo_id=${response.repo_id}&name=${encodeURIComponent(demoUrl)}`, { + replace: true, + }) } catch (err) { setError(err instanceof Error ? err.message : 'Could not start the demo analysis.') setLoading(false) @@ -36,12 +38,17 @@ export default function DemoPage() { )}
-

- {error || (loading ? 'Starting React demo analysis...' : 'Analyze facebook/react with a smaller demo-sized commit window.')} +

+ {error || + (loading + ? 'Starting React demo analysis...' + : 'Analyze facebook/react with a smaller demo-sized commit window.')}

-
- - GitHub @@ -143,17 +145,28 @@ export default function LandingPage() {

- Every commit has a story. + Every commit has a{' '} + + story. +

- Decipher architecture, complexity shifts, and knowledge dynamics directly from your codebase history. + Decipher architecture, complexity shifts, and knowledge dynamics directly from your + codebase history.

-
+
- +
{loading ? ( <> - - - + + + Parsing... @@ -218,42 +246,55 @@ export default function LandingPage() { )}
-
-
+
{[ { title: 'Health Timeline', desc: 'Replay commit activity visually and scrub through complexity, churn, and code risk dynamics over time.', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z', - glow: 'group-hover:border-indigo-500/30 group-hover:shadow-[0_0_30px_rgba(99,102,241,0.06)]' + glow: 'group-hover:border-indigo-500/30 group-hover:shadow-[0_0_30px_rgba(99,102,241,0.06)]', }, { title: 'Knowledge Graph', desc: 'Fly through 3D dependency streams to identify hidden import coupling and import risk structural flaws.', icon: 'M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7', - glow: 'group-hover:border-purple-500/30 group-hover:shadow-[0_0_30px_rgba(167,139,250,0.06)]' + glow: 'group-hover:border-purple-500/30 group-hover:shadow-[0_0_30px_rgba(167,139,250,0.06)]', }, { title: 'Bus Factor Index', desc: 'Audit critical files single-person dependencies to mitigate key-person risks before refactoring.', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z', - glow: 'group-hover:border-cyan-500/30 group-hover:shadow-[0_0_30px_rgba(6,182,212,0.06)]' - } + glow: 'group-hover:border-cyan-500/30 group-hover:shadow-[0_0_30px_rgba(6,182,212,0.06)]', + }, ].map((cap) => ( -
- - + +

{cap.title}

diff --git a/frontend/src/pages/NotFoundPage.tsx b/frontend/src/pages/NotFoundPage.tsx index 1674b0c..4431ee2 100644 --- a/frontend/src/pages/NotFoundPage.tsx +++ b/frontend/src/pages/NotFoundPage.tsx @@ -12,14 +12,19 @@ export default function NotFoundPage() {
-

404

-

Workspace Lost in Space

+

+ 404 +

+

+ Workspace Lost in Space +

- The requested coordinate snapshot index was not compiled or does not exist in the active computing grid. + The requested coordinate snapshot index was not compiled or does not exist in the active + computing grid.

-