From 9248a7c84606c326223e860d304d9698fb0f0c4c Mon Sep 17 00:00:00 2001 From: SamanPandey-in Date: Mon, 30 Mar 2026 01:34:33 +0530 Subject: [PATCH 01/18] fix: phase 2 audited gaps filled --- client/src/features/ai/components/AiPanel.jsx | 57 ++++- .../src/features/ai/components/QueryBar.jsx | 6 +- .../dashboard/pages/DashboardPage.jsx | 6 +- .../dashboard/services/dashboardService.js | 10 +- docs/Phase3/PHASE2_AUDIT.md | 199 ++++++++++++++++++ 5 files changed, 255 insertions(+), 23 deletions(-) create mode 100644 docs/Phase3/PHASE2_AUDIT.md diff --git a/client/src/features/ai/components/AiPanel.jsx b/client/src/features/ai/components/AiPanel.jsx index 057982e..5a2d512 100644 --- a/client/src/features/ai/components/AiPanel.jsx +++ b/client/src/features/ai/components/AiPanel.jsx @@ -1,21 +1,42 @@ -import React from 'react'; -import { useSelector } from 'react-redux'; +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { X, AlertTriangle } from 'lucide-react'; -import { selectAiExplainState, selectAiImpactState } from '../slices/aiSlice'; +import { + analyzeImpact, + explainNode, + selectAiExplainState, + selectAiImpactState, +} from '../slices/aiSlice'; export default function AiPanel({ nodeId, graph, onClose }) { - if (!nodeId || !graph?.[nodeId]) return null; + const dispatch = useDispatch(); + const jobId = useSelector((state) => state.graph.data?.jobId); + const explainState = useSelector(selectAiExplainState); + const impactState = useSelector(selectAiImpactState); + + const nodeData = nodeId ? graph?.[nodeId] : null; + + useEffect(() => { + if (!nodeId || !jobId) return; + + dispatch(explainNode({ jobId, filePath: nodeId, nodeLabel: nodeId })); + }, [dispatch, nodeId, jobId]); - const { deps = [], type, declarations = [] } = graph[nodeId]; + if (!nodeId || !nodeData) return null; + + const { deps = [], type, declarations = [], summary } = nodeData; const usedBy = Object.entries(graph) .filter(([, value]) => value.deps?.includes(nodeId)) .map(([file]) => file); - const explainState = useSelector(selectAiExplainState); - const impactState = useSelector(selectAiImpactState); - - const explanation = explainState?.data?.answer || explainState?.data?.explanation || null; + const explanation = explainState?.data?.answer || null; const impactedFiles = impactState?.data?.affectedFiles || []; + const isImpactLoading = impactState?.status === 'loading'; + + const handleSimulateImpact = () => { + if (!jobId || !nodeId) return; + dispatch(analyzeImpact({ jobId, filePath: nodeId })); + }; return (
@@ -36,6 +57,13 @@ export default function AiPanel({ nodeId, graph, onClose }) { Type: {type}

+ {summary && ( +
+

Summary

+

{summary}

+
+ )} + {declarations.length > 0 && (

@@ -80,6 +108,17 @@ export default function AiPanel({ nodeId, graph, onClose }) {

)} +
+ +
+ {impactedFiles.length > 0 && (

diff --git a/client/src/features/ai/components/QueryBar.jsx b/client/src/features/ai/components/QueryBar.jsx index d2ea23e..a4ce3b0 100644 --- a/client/src/features/ai/components/QueryBar.jsx +++ b/client/src/features/ai/components/QueryBar.jsx @@ -13,9 +13,9 @@ export default function QueryBar({ jobId }) { const inputRef = useRef(null); const { status, data, error } = queryState; - const isLoading = status === 'pending'; - const hasResult = data && status === 'fulfilled'; - const hasError = error && status === 'rejected'; + const isLoading = status === 'loading'; + const hasResult = data && status === 'succeeded'; + const hasError = error && status === 'failed'; const highlightCount = data?.highlightedFiles?.length || 0; // Auto-focus input when expanded diff --git a/client/src/features/dashboard/pages/DashboardPage.jsx b/client/src/features/dashboard/pages/DashboardPage.jsx index b384299..4c03412 100644 --- a/client/src/features/dashboard/pages/DashboardPage.jsx +++ b/client/src/features/dashboard/pages/DashboardPage.jsx @@ -631,11 +631,7 @@ export default function DashboardPage() { - ) : ( - - )} + ) : null}

{expandedRepos[repo.id] ? ( diff --git a/client/src/features/dashboard/services/dashboardService.js b/client/src/features/dashboard/services/dashboardService.js index 696c740..d28ae48 100644 --- a/client/src/features/dashboard/services/dashboardService.js +++ b/client/src/features/dashboard/services/dashboardService.js @@ -1,11 +1,9 @@ import axios from 'axios'; -const BASE_URL = import.meta.env.VITE_API_BASE_URL - ? `${import.meta.env.VITE_API_BASE_URL}/api` - : 'http://localhost:5000/api'; +const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || ''; const dashboardClient = axios.create({ - baseURL: BASE_URL, + baseURL: apiBaseUrl, withCredentials: true, headers: { 'Content-Type': 'application/json' }, }); @@ -103,7 +101,7 @@ const normalizePayload = (payload) => { export const dashboardService = { async getAnalyzedRepositories({ userId, page = 1, limit = 25 } = {}) { - const { data } = await dashboardClient.get('/repositories', { + const { data } = await dashboardClient.get('/api/repositories', { params: { page, limit, @@ -114,7 +112,7 @@ export const dashboardService = { }, async getRepositoryJobs({ repositoryId, page = 1, limit = 20 } = {}) { - const { data } = await dashboardClient.get(`/repositories/${repositoryId}/jobs`, { + const { data } = await dashboardClient.get(`/api/repositories/${repositoryId}/jobs`, { params: { page, limit }, }); diff --git a/docs/Phase3/PHASE2_AUDIT.md b/docs/Phase3/PHASE2_AUDIT.md new file mode 100644 index 0000000..c0d54f5 --- /dev/null +++ b/docs/Phase3/PHASE2_AUDIT.md @@ -0,0 +1,199 @@ +# Phase 2 Audit — CodeGraph AI + +**Audit date:** March 2026 +**Codebase:** `codegraph-ai-main__1_.zip` + +--- + +## Executive Summary + +Phase 2 is approximately **85% complete**. The entire backend — all 9 agents, the SupervisorAgent pipeline, BullMQ queue, PostgreSQL schema, Redis caching, and every API route — is fully implemented and production-grade. The client-side infrastructure (Redux slices, services, SSE streaming, component files) is also in place. What is missing is the **dispatch wiring inside AiPanel** and one **status string bug in QueryBar**, meaning the AI panel opens on node click but silently shows nothing, and the query loading spinner never activates. These are small gaps but they are the most visible parts of Phase 2 to a user. + +--- + +## What Is Complete + +### Server — fully done + +| Item | File | Status | +|---|---|---| +| PostgreSQL schema | `server/src/infrastructure/migrations/001_initial.sql` | ✅ Complete — all 7 tables including pgvector | +| DB + Redis connections | `server/src/infrastructure/connections.js` | ✅ pg Pool + ioredis singletons | +| Redis cache layer | `server/src/infrastructure/cache.js` | ✅ TTL jitter, versioning, pattern invalidation | +| BullMQ queue | `server/src/queue/analysisQueue.js` | ✅ Worker + `enqueueAnalysisJob` helper | +| BaseAgent | `server/src/agents/core/BaseAgent.js` | ✅ `buildResult()` contract | +| SupervisorAgent | `server/src/agents/core/SupervisorAgent.js` | ✅ Full pipeline, weighted confidence, retries | +| AuditLogger | `server/src/agents/core/AuditLogger.js` | ✅ SHA-256 input hash, writes to `agent_audit_log` | +| JobStatusEmitter | `server/src/agents/core/JobStatusEmitter.js` | ✅ Redis pub/sub for SSE | +| confidence.js | `server/src/agents/core/confidence.js` | ✅ Thresholds, weights, per-agent scoring functions | +| IngestionAgent | `server/src/agents/ingestion/IngestionAgent.js` | ✅ GitHub archive + local path handling | +| ScannerAgent | `server/src/agents/scanner/ScannerAgent.js` | ✅ File tree walk + language breakdown | +| ParserAgent | `server/src/agents/parser/ParserAgent.js` | ✅ Uses real `worker_threads`, pLimit concurrency | +| parseWorker.js | `server/src/agents/parser/parseWorker.js` | ✅ Babel AST, imports + declarations + metrics | +| GraphBuilderAgent | `server/src/agents/graph/GraphBuilderAgent.js` | ✅ Import resolution, Tarjan cycles, topology | +| EnrichmentAgent | `server/src/agents/enrichment/EnrichmentAgent.js` | ✅ GPT-4o-mini summaries, cheap fallback, Redis cache | +| EmbeddingAgent | `server/src/agents/embedding/EmbeddingAgent.js` | ✅ `text-embedding-3-small`, batched, pgvector | +| PersistenceAgent | `server/src/agents/persistence/PersistenceAgent.js` | ✅ Bulk unnest insert, savepoints, embeddings | +| QueryAgent | `server/src/agents/query/QueryAgent.js` | ✅ Vector similarity → rerank → LLM, saves to `saved_queries` | +| AnalysisAgent | `server/src/agents/analysis/AnalysisAgent.js` | ✅ Dead code + BFS impact analysis | +| `/api/jobs/:id/stream` | `server/src/api/jobs/routes/jobs.routes.js` | ✅ SSE with Redis pub/sub subscriber | +| `/api/graph/:jobId` | `server/src/api/graph/routes/graph.routes.js` | ✅ Loads from DB, Redis cache with TTL | +| `/api/ai/query` | `server/src/api/ai/routes/ai.routes.js` | ✅ QueryAgent + rate limiter | +| `/api/ai/impact` | `server/src/api/ai/routes/ai.routes.js` | ✅ AnalysisAgent + auth guard | +| `/api/repositories` | `server/src/api/repositories/routes/repositories.routes.js` | ✅ Paginated, LATERAL join, cached | +| `/api/repositories/:id/jobs` | same file | ✅ Job history per repo, cached | +| `analyze.controller.js` | `server/src/analyze/controllers/analyze.controller.js` | ✅ Async — enqueues job, returns `jobId` immediately | +| `app.js` | `server/app.js` | ✅ All routers registered | +| `docker-compose.yml` | root | ✅ pgvector image, Redis 7, backend with migrate script | +| `package.json` | `server/package.json` | ✅ bullmq, openai, pg, pgvector, ioredis all installed | +| `.env.example` | `server/.env.example` | ✅ All keys documented | + +### Client — mostly done + +| Item | File | Status | +|---|---|---| +| `aiSlice.js` | `client/src/features/ai/slices/aiSlice.js` | ✅ All 3 thunks, selectors, reset action | +| `aiService.js` | `client/src/features/ai/services/aiService.js` | ✅ queryGraph, explainNode, analyzeImpact | +| `aiReducer` in store | `client/src/app/store.js` | ✅ Registered | +| `JobProgressBar.jsx` | `client/src/features/jobs/components/JobProgressBar.jsx` | ✅ All stage labels + agent confidence pills | +| `graphSlice.js` | `client/src/features/graph/slices/graphSlice.js` | ✅ SSE polling, `loadSavedGraph` thunk, `updateAnalysisJob` | +| `graphService.js` | `client/src/features/graph/services/graphService.js` | ✅ `waitForJobCompletion` (EventSource), `getGraph` | +| `GraphView.jsx` | `client/src/features/graph/components/GraphView.jsx` | ✅ Highlight + dead code styling wired from Redux | +| `GraphPage.jsx` | `client/src/features/graph/pages/GraphPage.jsx` | ✅ QueryBar shown, `loadSavedGraph` via URL `?jobId=` | +| `AnalyzePage.jsx` | `client/src/features/graph/pages/AnalyzePage.jsx` | ✅ `JobProgressBar` shown during loading | +| `dashboardSlice.js` | `client/src/features/dashboard/slices/dashboardSlice.js` | ✅ `fetchAnalyzedRepositories`, `fetchRepositoryJobs` | +| `dashboardService.js` | `client/src/features/dashboard/services/dashboardService.js` | ✅ Hits `/api/repositories` + `/api/repositories/:id/jobs` | + +--- + +## What Is Missing — Phase 2 Completion Gaps + +### Gap 1 — AiPanel never dispatches `explainNode` (critical UX break) + +**File:** `client/src/features/ai/components/AiPanel.jsx` + +The panel reads from `selectAiExplainState` and `selectAiImpactState` but there is no `useDispatch` call and no `useEffect` that fires when `nodeId` changes. The result: clicking any node opens the panel showing only static data (type, declarations, deps, usedBy). The "AI Explanation" section only renders if `explainState.data` happens to be populated from a previous QueryBar search — which is coincidental. + +**What needs to be added:** + +```jsx +import { useDispatch, useSelector } from 'react-redux'; +import { useEffect } from 'react'; +import { explainNode, analyzeImpact } from '../slices/aiSlice'; + +export default function AiPanel({ nodeId, graph, onClose }) { + const dispatch = useDispatch(); + const jobId = useSelector((state) => state.graph.data?.jobId); + + // Auto-fetch explanation when selected node changes + useEffect(() => { + if (!nodeId || !jobId) return; + dispatch(explainNode({ jobId, filePath: nodeId, nodeLabel: nodeId })); + }, [nodeId, jobId, dispatch]); + + // ... rest of component +``` + +Also needs a "Simulate change impact" button that dispatches `analyzeImpact`: + +```jsx + +``` + +--- + +### Gap 2 — QueryBar loading state never activates (bug) + +**File:** `client/src/features/ai/components/QueryBar.jsx`, line 22 + +```js +// Current — WRONG +const isLoading = status === 'pending'; + +// Correct — matches what aiSlice sets +const isLoading = status === 'loading'; +``` + +The `aiSlice.js` `extraReducers` sets `state.query.status = 'loading'` on `.pending`. The QueryBar checks for `'pending'`. These strings never match, so the ask button spinner and disabled state never fire during an in-flight request. + +--- + +### Gap 3 — No dedicated `/api/ai/explain` endpoint + +**Files:** `server/src/api/ai/routes/ai.routes.js` + +The current flow works but is approximate: `aiService.explainNode()` constructs a question string and sends it to `/api/ai/query`. This means: + +- The explanation is a generic NLQ answer, not a structured `{ purpose, keyFunctions, dependencies, risks }` object. +- The `EnrichmentAgent` already stored a one-line `summary` in `graph_nodes.summary`, which is returned in the graph payload and available at `graph[nodeId].summary`. The AiPanel could display this summary directly from Redux state without any extra API call — no endpoint needed. + +**Simplest fix:** In `AiPanel.jsx`, display the pre-stored summary directly: + +```jsx +const nodeData = graph[nodeId]; +const enrichedSummary = nodeData?.summary; // already in Redux from getGraph + +// Render it immediately, no loading state needed: +{enrichedSummary && ( +
+

Summary

+

{enrichedSummary}

+
+)} +``` + +The `explainNode` dispatch (Gap 1) then serves as a deeper "ask AI about this file" enrichment on top. + +--- + +### Gap 4 — Dashboard shows a "pending" placeholder card + +**File:** `client/src/features/dashboard/pages/DashboardPage.jsx`, ~line 493 + +There is a `CardTitle` with text "Database history integration pending" visible in certain state branches. The backend endpoint `/api/repositories` is fully implemented and the `dashboardService` correctly calls it. This placeholder appears to render when `status === 'failed'` with a `NOT_READY` error code, which the `dashboardSlice` sets when the response is 404 or 501. + +Since the endpoint exists, this should not trigger — but verify `VITE_API_BASE_URL` is set correctly in `client/.env` (it defaults to `'http://localhost:5000/api'` in `dashboardService.js`, which differs from the graphService which uses `''` as base and appends full paths). If the API base URL is misconfigured this hits 404 and shows the placeholder. + +**Fix:** Standardise `VITE_API_BASE_URL` across all services. The `dashboardService` uses `http://localhost:5000/api` as fallback while `aiService` and `graphService` use `''` (relative). Align them all to use relative paths or set `VITE_API_BASE_URL=http://localhost:5000` consistently. + +--- + +### Gap 5 — `explainNode` result structure mismatch in AiPanel + +**File:** `client/src/features/ai/components/AiPanel.jsx` + +```js +const explanation = explainState?.data?.answer || explainState?.data?.explanation || null; +``` + +The `QueryAgent` returns `{ answer, highlightedFiles, confidence }`. So `explainState.data.answer` will work once Gap 1 is fixed. However `explainState.data.explanation` does not exist in the response schema — it's a dead fallback. This is cosmetically harmless but confirms the explain flow was designed for a structured response that was never implemented server-side. + +--- + +## Gap Fix Priority + +| Priority | Gap | Time to fix | +|---|---|---| +| P0 | Gap 2 — QueryBar `'pending'` → `'loading'` | 2 minutes | +| P0 | Gap 1 — AiPanel `useEffect` + `analyzeImpact` button | 30 minutes | +| P1 | Gap 3 — Display `graph[nodeId].summary` directly in AiPanel | 15 minutes | +| P2 | Gap 4 — Dashboard API base URL alignment | 10 minutes | +| P3 | Gap 5 — Clean up dead `explanation` fallback key | 5 minutes | + +Total to complete Phase 2: **~1 hour of targeted client-side changes.** + +--- + +## Phase 2 Completion Checklist + +- [x] `AiPanel.jsx`: Add `useDispatch`, `useEffect` to auto-call `explainNode` on `nodeId` change +- [x] `AiPanel.jsx`: Add "Simulate change" button that dispatches `analyzeImpact` +- [x] `AiPanel.jsx`: Display `graph[nodeId].summary` as instant pre-loaded enrichment summary +- [x] `QueryBar.jsx`: Fix `isLoading = status === 'loading'` (not `'pending'`) +- [x] `client/.env` / `dashboardService.js`: Align base URL to `''` (relative) across all services +- [x] `AiPanel.jsx`: Remove dead `.explanation` fallback key From 1828d118f0effe34a8e46cfb1e76002c1367d845 Mon Sep 17 00:00:00 2001 From: SamanPandey-in Date: Mon, 30 Mar 2026 02:08:16 +0530 Subject: [PATCH 02/18] feat: Streaming text in AiPanel --- .agents/skills/nodejs-best-practices/SKILL.md | 338 +++++ .claude/skills/nodejs-best-practices/SKILL.md | 338 +++++ client/src/features/ai/components/AiPanel.jsx | 138 +- client/src/features/ai/services/aiService.js | 109 ++ docs/Phase3/PHASE3_GUIDE.md | 1112 +++++++++++++++++ server/src/api/ai/routes/ai.routes.js | 197 ++- skills-lock.json | 5 + 7 files changed, 2187 insertions(+), 50 deletions(-) create mode 100644 .agents/skills/nodejs-best-practices/SKILL.md create mode 100644 .claude/skills/nodejs-best-practices/SKILL.md create mode 100644 docs/Phase3/PHASE3_GUIDE.md diff --git a/.agents/skills/nodejs-best-practices/SKILL.md b/.agents/skills/nodejs-best-practices/SKILL.md new file mode 100644 index 0000000..b92ac38 --- /dev/null +++ b/.agents/skills/nodejs-best-practices/SKILL.md @@ -0,0 +1,338 @@ +--- +name: nodejs-best-practices +description: "Node.js development principles and decision-making. Framework selection, async patterns, security, and architecture. Teaches thinking, not copying." +risk: unknown +source: community +date_added: "2026-02-27" +--- + +# Node.js Best Practices + +> Principles and decision-making for Node.js development in 2025. +> **Learn to THINK, not memorize code patterns.** + +## When to Use +Use this skill when making Node.js architecture decisions, choosing frameworks, designing async patterns, or applying security and deployment best practices. + +--- + +## ⚠️ How to Use This Skill + +This skill teaches **decision-making principles**, not fixed code to copy. + +- ASK user for preferences when unclear +- Choose framework/pattern based on CONTEXT +- Don't default to same solution every time + +--- + +## 1. Framework Selection (2025) + +### Decision Tree + +``` +What are you building? +│ +├── Edge/Serverless (Cloudflare, Vercel) +│ └── Hono (zero-dependency, ultra-fast cold starts) +│ +├── High Performance API +│ └── Fastify (2-3x faster than Express) +│ +├── Enterprise/Team familiarity +│ └── NestJS (structured, DI, decorators) +│ +├── Legacy/Stable/Maximum ecosystem +│ └── Express (mature, most middleware) +│ +└── Full-stack with frontend + └── Next.js API Routes or tRPC +``` + +### Comparison Principles + +| Factor | Hono | Fastify | Express | +|--------|------|---------|---------| +| **Best for** | Edge, serverless | Performance | Legacy, learning | +| **Cold start** | Fastest | Fast | Moderate | +| **Ecosystem** | Growing | Good | Largest | +| **TypeScript** | Native | Excellent | Good | +| **Learning curve** | Low | Medium | Low | + +### Selection Questions to Ask: +1. What's the deployment target? +2. Is cold start time critical? +3. Does team have existing experience? +4. Is there legacy code to maintain? + +--- + +## 2. Runtime Considerations (2025) + +### Native TypeScript + +``` +Node.js 22+: --experimental-strip-types +├── Run .ts files directly +├── No build step needed for simple projects +└── Consider for: scripts, simple APIs +``` + +### Module System Decision + +``` +ESM (import/export) +├── Modern standard +├── Better tree-shaking +├── Async module loading +└── Use for: new projects + +CommonJS (require) +├── Legacy compatibility +├── More npm packages support +└── Use for: existing codebases, some edge cases +``` + +### Runtime Selection + +| Runtime | Best For | +|---------|----------| +| **Node.js** | General purpose, largest ecosystem | +| **Bun** | Performance, built-in bundler | +| **Deno** | Security-first, built-in TypeScript | + +--- + +## 3. Architecture Principles + +### Layered Structure Concept + +``` +Request Flow: +│ +├── Controller/Route Layer +│ ├── Handles HTTP specifics +│ ├── Input validation at boundary +│ └── Calls service layer +│ +├── Service Layer +│ ├── Business logic +│ ├── Framework-agnostic +│ └── Calls repository layer +│ +└── Repository Layer + ├── Data access only + ├── Database queries + └── ORM interactions +``` + +### Why This Matters: +- **Testability**: Mock layers independently +- **Flexibility**: Swap database without touching business logic +- **Clarity**: Each layer has single responsibility + +### When to Simplify: +- Small scripts → Single file OK +- Prototypes → Less structure acceptable +- Always ask: "Will this grow?" + +--- + +## 4. Error Handling Principles + +### Centralized Error Handling + +``` +Pattern: +├── Create custom error classes +├── Throw from any layer +├── Catch at top level (middleware) +└── Format consistent response +``` + +### Error Response Philosophy + +``` +Client gets: +├── Appropriate HTTP status +├── Error code for programmatic handling +├── User-friendly message +└── NO internal details (security!) + +Logs get: +├── Full stack trace +├── Request context +├── User ID (if applicable) +└── Timestamp +``` + +### Status Code Selection + +| Situation | Status | When | +|-----------|--------|------| +| Bad input | 400 | Client sent invalid data | +| No auth | 401 | Missing or invalid credentials | +| No permission | 403 | Valid auth, but not allowed | +| Not found | 404 | Resource doesn't exist | +| Conflict | 409 | Duplicate or state conflict | +| Validation | 422 | Schema valid but business rules fail | +| Server error | 500 | Our fault, log everything | + +--- + +## 5. Async Patterns Principles + +### When to Use Each + +| Pattern | Use When | +|---------|----------| +| `async/await` | Sequential async operations | +| `Promise.all` | Parallel independent operations | +| `Promise.allSettled` | Parallel where some can fail | +| `Promise.race` | Timeout or first response wins | + +### Event Loop Awareness + +``` +I/O-bound (async helps): +├── Database queries +├── HTTP requests +├── File system +└── Network operations + +CPU-bound (async doesn't help): +├── Crypto operations +├── Image processing +├── Complex calculations +└── → Use worker threads or offload +``` + +### Avoiding Event Loop Blocking + +- Never use sync methods in production (fs.readFileSync, etc.) +- Offload CPU-intensive work +- Use streaming for large data + +--- + +## 6. Validation Principles + +### Validate at Boundaries + +``` +Where to validate: +├── API entry point (request body/params) +├── Before database operations +├── External data (API responses, file uploads) +└── Environment variables (startup) +``` + +### Validation Library Selection + +| Library | Best For | +|---------|----------| +| **Zod** | TypeScript first, inference | +| **Valibot** | Smaller bundle (tree-shakeable) | +| **ArkType** | Performance critical | +| **Yup** | Existing React Form usage | + +### Validation Philosophy + +- Fail fast: Validate early +- Be specific: Clear error messages +- Don't trust: Even "internal" data + +--- + +## 7. Security Principles + +### Security Checklist (Not Code) + +- [ ] **Input validation**: All inputs validated +- [ ] **Parameterized queries**: No string concatenation for SQL +- [ ] **Password hashing**: bcrypt or argon2 +- [ ] **JWT verification**: Always verify signature and expiry +- [ ] **Rate limiting**: Protect from abuse +- [ ] **Security headers**: Helmet.js or equivalent +- [ ] **HTTPS**: Everywhere in production +- [ ] **CORS**: Properly configured +- [ ] **Secrets**: Environment variables only +- [ ] **Dependencies**: Regularly audited + +### Security Mindset + +``` +Trust nothing: +├── Query params → validate +├── Request body → validate +├── Headers → verify +├── Cookies → validate +├── File uploads → scan +└── External APIs → validate response +``` + +--- + +## 8. Testing Principles + +### Test Strategy Selection + +| Type | Purpose | Tools | +|------|---------|-------| +| **Unit** | Business logic | node:test, Vitest | +| **Integration** | API endpoints | Supertest | +| **E2E** | Full flows | Playwright | + +### What to Test (Priorities) + +1. **Critical paths**: Auth, payments, core business +2. **Edge cases**: Empty inputs, boundaries +3. **Error handling**: What happens when things fail? +4. **Not worth testing**: Framework code, trivial getters + +### Built-in Test Runner (Node.js 22+) + +``` +node --test src/**/*.test.ts +├── No external dependency +├── Good coverage reporting +└── Watch mode available +``` + +--- + +## 10. Anti-Patterns to Avoid + +### ❌ DON'T: +- Use Express for new edge projects (use Hono) +- Use sync methods in production code +- Put business logic in controllers +- Skip input validation +- Hardcode secrets +- Trust external data without validation +- Block event loop with CPU work + +### ✅ DO: +- Choose framework based on context +- Ask user for preferences when unclear +- Use layered architecture for growing projects +- Validate all inputs +- Use environment variables for secrets +- Profile before optimizing + +--- + +## 11. Decision Checklist + +Before implementing: + +- [ ] **Asked user about stack preference?** +- [ ] **Chosen framework for THIS context?** (not just default) +- [ ] **Considered deployment target?** +- [ ] **Planned error handling strategy?** +- [ ] **Identified validation points?** +- [ ] **Considered security requirements?** + +--- + +> **Remember**: Node.js best practices are about decision-making, not memorizing patterns. Every project deserves fresh consideration based on its requirements. diff --git a/.claude/skills/nodejs-best-practices/SKILL.md b/.claude/skills/nodejs-best-practices/SKILL.md new file mode 100644 index 0000000..b92ac38 --- /dev/null +++ b/.claude/skills/nodejs-best-practices/SKILL.md @@ -0,0 +1,338 @@ +--- +name: nodejs-best-practices +description: "Node.js development principles and decision-making. Framework selection, async patterns, security, and architecture. Teaches thinking, not copying." +risk: unknown +source: community +date_added: "2026-02-27" +--- + +# Node.js Best Practices + +> Principles and decision-making for Node.js development in 2025. +> **Learn to THINK, not memorize code patterns.** + +## When to Use +Use this skill when making Node.js architecture decisions, choosing frameworks, designing async patterns, or applying security and deployment best practices. + +--- + +## ⚠️ How to Use This Skill + +This skill teaches **decision-making principles**, not fixed code to copy. + +- ASK user for preferences when unclear +- Choose framework/pattern based on CONTEXT +- Don't default to same solution every time + +--- + +## 1. Framework Selection (2025) + +### Decision Tree + +``` +What are you building? +│ +├── Edge/Serverless (Cloudflare, Vercel) +│ └── Hono (zero-dependency, ultra-fast cold starts) +│ +├── High Performance API +│ └── Fastify (2-3x faster than Express) +│ +├── Enterprise/Team familiarity +│ └── NestJS (structured, DI, decorators) +│ +├── Legacy/Stable/Maximum ecosystem +│ └── Express (mature, most middleware) +│ +└── Full-stack with frontend + └── Next.js API Routes or tRPC +``` + +### Comparison Principles + +| Factor | Hono | Fastify | Express | +|--------|------|---------|---------| +| **Best for** | Edge, serverless | Performance | Legacy, learning | +| **Cold start** | Fastest | Fast | Moderate | +| **Ecosystem** | Growing | Good | Largest | +| **TypeScript** | Native | Excellent | Good | +| **Learning curve** | Low | Medium | Low | + +### Selection Questions to Ask: +1. What's the deployment target? +2. Is cold start time critical? +3. Does team have existing experience? +4. Is there legacy code to maintain? + +--- + +## 2. Runtime Considerations (2025) + +### Native TypeScript + +``` +Node.js 22+: --experimental-strip-types +├── Run .ts files directly +├── No build step needed for simple projects +└── Consider for: scripts, simple APIs +``` + +### Module System Decision + +``` +ESM (import/export) +├── Modern standard +├── Better tree-shaking +├── Async module loading +└── Use for: new projects + +CommonJS (require) +├── Legacy compatibility +├── More npm packages support +└── Use for: existing codebases, some edge cases +``` + +### Runtime Selection + +| Runtime | Best For | +|---------|----------| +| **Node.js** | General purpose, largest ecosystem | +| **Bun** | Performance, built-in bundler | +| **Deno** | Security-first, built-in TypeScript | + +--- + +## 3. Architecture Principles + +### Layered Structure Concept + +``` +Request Flow: +│ +├── Controller/Route Layer +│ ├── Handles HTTP specifics +│ ├── Input validation at boundary +│ └── Calls service layer +│ +├── Service Layer +│ ├── Business logic +│ ├── Framework-agnostic +│ └── Calls repository layer +│ +└── Repository Layer + ├── Data access only + ├── Database queries + └── ORM interactions +``` + +### Why This Matters: +- **Testability**: Mock layers independently +- **Flexibility**: Swap database without touching business logic +- **Clarity**: Each layer has single responsibility + +### When to Simplify: +- Small scripts → Single file OK +- Prototypes → Less structure acceptable +- Always ask: "Will this grow?" + +--- + +## 4. Error Handling Principles + +### Centralized Error Handling + +``` +Pattern: +├── Create custom error classes +├── Throw from any layer +├── Catch at top level (middleware) +└── Format consistent response +``` + +### Error Response Philosophy + +``` +Client gets: +├── Appropriate HTTP status +├── Error code for programmatic handling +├── User-friendly message +└── NO internal details (security!) + +Logs get: +├── Full stack trace +├── Request context +├── User ID (if applicable) +└── Timestamp +``` + +### Status Code Selection + +| Situation | Status | When | +|-----------|--------|------| +| Bad input | 400 | Client sent invalid data | +| No auth | 401 | Missing or invalid credentials | +| No permission | 403 | Valid auth, but not allowed | +| Not found | 404 | Resource doesn't exist | +| Conflict | 409 | Duplicate or state conflict | +| Validation | 422 | Schema valid but business rules fail | +| Server error | 500 | Our fault, log everything | + +--- + +## 5. Async Patterns Principles + +### When to Use Each + +| Pattern | Use When | +|---------|----------| +| `async/await` | Sequential async operations | +| `Promise.all` | Parallel independent operations | +| `Promise.allSettled` | Parallel where some can fail | +| `Promise.race` | Timeout or first response wins | + +### Event Loop Awareness + +``` +I/O-bound (async helps): +├── Database queries +├── HTTP requests +├── File system +└── Network operations + +CPU-bound (async doesn't help): +├── Crypto operations +├── Image processing +├── Complex calculations +└── → Use worker threads or offload +``` + +### Avoiding Event Loop Blocking + +- Never use sync methods in production (fs.readFileSync, etc.) +- Offload CPU-intensive work +- Use streaming for large data + +--- + +## 6. Validation Principles + +### Validate at Boundaries + +``` +Where to validate: +├── API entry point (request body/params) +├── Before database operations +├── External data (API responses, file uploads) +└── Environment variables (startup) +``` + +### Validation Library Selection + +| Library | Best For | +|---------|----------| +| **Zod** | TypeScript first, inference | +| **Valibot** | Smaller bundle (tree-shakeable) | +| **ArkType** | Performance critical | +| **Yup** | Existing React Form usage | + +### Validation Philosophy + +- Fail fast: Validate early +- Be specific: Clear error messages +- Don't trust: Even "internal" data + +--- + +## 7. Security Principles + +### Security Checklist (Not Code) + +- [ ] **Input validation**: All inputs validated +- [ ] **Parameterized queries**: No string concatenation for SQL +- [ ] **Password hashing**: bcrypt or argon2 +- [ ] **JWT verification**: Always verify signature and expiry +- [ ] **Rate limiting**: Protect from abuse +- [ ] **Security headers**: Helmet.js or equivalent +- [ ] **HTTPS**: Everywhere in production +- [ ] **CORS**: Properly configured +- [ ] **Secrets**: Environment variables only +- [ ] **Dependencies**: Regularly audited + +### Security Mindset + +``` +Trust nothing: +├── Query params → validate +├── Request body → validate +├── Headers → verify +├── Cookies → validate +├── File uploads → scan +└── External APIs → validate response +``` + +--- + +## 8. Testing Principles + +### Test Strategy Selection + +| Type | Purpose | Tools | +|------|---------|-------| +| **Unit** | Business logic | node:test, Vitest | +| **Integration** | API endpoints | Supertest | +| **E2E** | Full flows | Playwright | + +### What to Test (Priorities) + +1. **Critical paths**: Auth, payments, core business +2. **Edge cases**: Empty inputs, boundaries +3. **Error handling**: What happens when things fail? +4. **Not worth testing**: Framework code, trivial getters + +### Built-in Test Runner (Node.js 22+) + +``` +node --test src/**/*.test.ts +├── No external dependency +├── Good coverage reporting +└── Watch mode available +``` + +--- + +## 10. Anti-Patterns to Avoid + +### ❌ DON'T: +- Use Express for new edge projects (use Hono) +- Use sync methods in production code +- Put business logic in controllers +- Skip input validation +- Hardcode secrets +- Trust external data without validation +- Block event loop with CPU work + +### ✅ DO: +- Choose framework based on context +- Ask user for preferences when unclear +- Use layered architecture for growing projects +- Validate all inputs +- Use environment variables for secrets +- Profile before optimizing + +--- + +## 11. Decision Checklist + +Before implementing: + +- [ ] **Asked user about stack preference?** +- [ ] **Chosen framework for THIS context?** (not just default) +- [ ] **Considered deployment target?** +- [ ] **Planned error handling strategy?** +- [ ] **Identified validation points?** +- [ ] **Considered security requirements?** + +--- + +> **Remember**: Node.js best practices are about decision-making, not memorizing patterns. Every project deserves fresh consideration based on its requirements. diff --git a/client/src/features/ai/components/AiPanel.jsx b/client/src/features/ai/components/AiPanel.jsx index 5a2d512..d5048c6 100644 --- a/client/src/features/ai/components/AiPanel.jsx +++ b/client/src/features/ai/components/AiPanel.jsx @@ -1,26 +1,67 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { X, AlertTriangle } from 'lucide-react'; +import { X, AlertTriangle, Loader2, Zap } from 'lucide-react'; import { analyzeImpact, - explainNode, - selectAiExplainState, selectAiImpactState, } from '../slices/aiSlice'; +import { selectGraphData } from '../../graph/slices/graphSlice'; +import { aiService } from '../services/aiService'; export default function AiPanel({ nodeId, graph, onClose }) { const dispatch = useDispatch(); - const jobId = useSelector((state) => state.graph.data?.jobId); - const explainState = useSelector(selectAiExplainState); + const graphData = useSelector(selectGraphData); const impactState = useSelector(selectAiImpactState); + const jobId = graphData?.jobId; + const [streamedText, setStreamedText] = useState(''); + const [isStreaming, setIsStreaming] = useState(false); + const [streamError, setStreamError] = useState(''); const nodeData = nodeId ? graph?.[nodeId] : null; useEffect(() => { - if (!nodeId || !jobId) return; - - dispatch(explainNode({ jobId, filePath: nodeId, nodeLabel: nodeId })); - }, [dispatch, nodeId, jobId]); + if (!nodeId || !jobId) { + setStreamedText(''); + setIsStreaming(false); + setStreamError(''); + return; + } + + let isCancelled = false; + const controller = new AbortController(); + + setStreamedText(''); + setIsStreaming(true); + setStreamError(''); + + aiService + .streamExplain({ + question: `Explain the file ${nodeId} and include its purpose, key functions, dependencies, and risks.`, + jobId, + signal: controller.signal, + onChunk: (text) => { + if (isCancelled) return; + setStreamedText((prev) => prev + text); + }, + onDone: () => { + if (isCancelled) return; + setIsStreaming(false); + }, + onError: (error) => { + if (isCancelled) return; + setStreamError(error?.message || 'Failed to load explanation'); + setIsStreaming(false); + }, + }) + .catch(() => { + // Errors are handled in onError callback. + }); + + return () => { + isCancelled = true; + controller.abort(); + }; + }, [nodeId, jobId]); if (!nodeId || !nodeData) return null; @@ -29,9 +70,8 @@ export default function AiPanel({ nodeId, graph, onClose }) { .filter(([, value]) => value.deps?.includes(nodeId)) .map(([file]) => file); - const explanation = explainState?.data?.answer || null; const impactedFiles = impactState?.data?.affectedFiles || []; - const isImpactLoading = impactState?.status === 'loading'; + const isImpacting = impactState?.status === 'loading'; const handleSimulateImpact = () => { if (!jobId || !nodeId) return; @@ -57,13 +97,33 @@ export default function AiPanel({ nodeId, graph, onClose }) { Type: {type}

- {summary && ( + {summary && !streamedText && !isStreaming && !streamError && (

Summary

{summary}

)} +
+

+ AI Explanation +

+ {isStreaming && ( +
+ + Analyzing... +
+ )} + {streamError && ( +

+ {streamError} +

+ )} + {streamedText && ( +

{streamedText}

+ )} +
+ {declarations.length > 0 && (

@@ -79,12 +139,29 @@ export default function AiPanel({ nodeId, graph, onClose }) {

)} - {explanation && ( -
-

AI Explanation

-

{explanation}

+
+
+

Impact Analysis

+
- )} + + {impactedFiles.length > 0 && ( +
+
    + {impactedFiles.map((file) => ( +
  • {file}
  • + ))} +
+
+ )} +
{deps.length > 0 && (
@@ -107,31 +184,6 @@ export default function AiPanel({ nodeId, graph, onClose }) {
)} - -
- -
- - {impactedFiles.length > 0 && ( -
-

- - Impacted Files ({impactedFiles.length}) -

-
    - {impactedFiles.map((file) => ( -
  • {file}
  • - ))} -
-
- )}
); } diff --git a/client/src/features/ai/services/aiService.js b/client/src/features/ai/services/aiService.js index 1586347..b068382 100644 --- a/client/src/features/ai/services/aiService.js +++ b/client/src/features/ai/services/aiService.js @@ -12,6 +12,18 @@ function normalizeText(value) { return String(value || '').trim(); } +function resolveApiUrl(pathname) { + const trimmedBase = apiBaseUrl.trim(); + + if (!trimmedBase) return pathname; + + if (/^https?:\/\//i.test(trimmedBase)) { + return new URL(pathname, trimmedBase).toString(); + } + + return `${trimmedBase.replace(/\/$/, '')}${pathname}`; +} + function buildExplainQuestion({ filePath, nodeLabel, question }) { const customQuestion = normalizeText(question); if (customQuestion) return customQuestion; @@ -77,4 +89,101 @@ export const aiService = { return data; }, + + async streamExplain({ question, jobId, onChunk, onDone, onError, signal } = {}) { + const normalizedQuestion = normalizeText(question); + const normalizedJobId = normalizeText(jobId); + + if (!normalizedQuestion || !normalizedJobId) { + throw new Error('streamExplain requires question and jobId.'); + } + + const url = resolveApiUrl('/api/ai/explain/stream'); + const response = await fetch(url, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ question: normalizedQuestion, jobId: normalizedJobId }), + signal, + }); + + if (!response.ok) { + let message = `Streaming request failed with status ${response.status}.`; + + try { + const payload = await response.json(); + if (payload?.error) message = payload.error; + } catch { + // Ignore JSON parsing failures and keep the fallback message. + } + + const error = new Error(message); + onError?.(error); + throw error; + } + + if (!response.body) { + const error = new Error('Streaming response body is not available.'); + onError?.(error); + throw error; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + + const payload = line.slice(6).trim(); + if (!payload) continue; + + if (payload === '[DONE]') { + onDone?.(); + return; + } + + try { + const parsed = JSON.parse(payload); + if (parsed?.error) { + const error = new Error(parsed.error); + onError?.(error); + throw error; + } + + if (parsed?.text) { + onChunk?.(parsed.text); + } + } catch (error) { + if (error instanceof SyntaxError) { + // Ignore malformed stream chunks and continue receiving valid chunks. + continue; + } + + throw error; + } + } + } + + onDone?.(); + } catch (error) { + if (error?.name === 'AbortError') { + return; + } + + onError?.(error); + throw error; + } finally { + reader.releaseLock(); + } + }, }; diff --git a/docs/Phase3/PHASE3_GUIDE.md b/docs/Phase3/PHASE3_GUIDE.md new file mode 100644 index 0000000..ad7c3c0 --- /dev/null +++ b/docs/Phase3/PHASE3_GUIDE.md @@ -0,0 +1,1112 @@ +# CodeGraph AI — Phase 3 Implementation Guide + +## What Phase 3 Is + +Phase 2 gave you a working agentic pipeline with AI explanations, NLQ, dead code detection, and impact analysis. Phase 3 is the step from "impressive demo" to "product people pay for." It has four pillars: + +1. **Intelligence depth** — function-level graph, streaming AI, multi-language parsing +2. **User product** — saved queries UI, query history, re-analyze, starred repos +3. **Collaboration** — shareable graph links, GitHub PR integration, team workspaces +4. **Production hardening** — test suite, error monitoring, CI/CD, plan enforcement + +Build in this order. Each section is independent. + +--- + +## Section 1 — Complete Phase 2 First (1 hour) + +Before starting Phase 3, close the five open gaps from the audit. These are not Phase 3 work — they are Phase 2 bugs that make the AI panel silent. + +### 1.1 Fix QueryBar loading state + +**File:** `client/src/features/ai/components/QueryBar.jsx` + +Change line 22: +```js +// Before +const isLoading = status === 'pending'; + +// After +const isLoading = status === 'loading'; +``` + +### 1.2 Wire AiPanel dispatches + +**File:** `client/src/features/ai/components/AiPanel.jsx` + +Replace the entire file content: + +```jsx +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { X, AlertTriangle, Loader2, Zap } from 'lucide-react'; +import { + explainNode, + analyzeImpact, + selectAiExplainState, + selectAiImpactState, +} from '../slices/aiSlice'; +import { selectGraphData } from '../../graph/slices/graphSlice'; + +export default function AiPanel({ nodeId, graph, onClose }) { + const dispatch = useDispatch(); + const graphData = useSelector(selectGraphData); + const explainState = useSelector(selectAiExplainState); + const impactState = useSelector(selectAiImpactState); + + const jobId = graphData?.jobId; + + // Auto-fetch explanation when selected node changes + useEffect(() => { + if (!nodeId || !jobId) return; + dispatch(explainNode({ jobId, filePath: nodeId, nodeLabel: nodeId })); + }, [nodeId, jobId, dispatch]); + + if (!nodeId || !graph?.[nodeId]) return null; + + const { deps = [], type, declarations = [], summary } = graph[nodeId]; + const usedBy = Object.entries(graph) + .filter(([, value]) => value.deps?.includes(nodeId)) + .map(([file]) => file); + + const explanation = explainState?.data?.answer || null; + const isExplaining = explainState?.status === 'loading'; + const explainError = explainState?.status === 'failed'; + + const impactedFiles = impactState?.data?.affectedFiles || []; + const isImpacting = impactState?.status === 'loading'; + + return ( +
+
+ {nodeId} + +
+ +

+ Type: {type} +

+ + {/* Pre-loaded enrichment summary (from EnrichmentAgent, instant) */} + {summary && !explanation && !isExplaining && ( +
+

Summary

+

{summary}

+
+ )} + + {/* AI Explanation (fetched on node click) */} +
+

+ AI Explanation +

+ {isExplaining && ( +
+ + Analyzing... +
+ )} + {explainError && ( +

+ Failed to load explanation +

+ )} + {explanation && !isExplaining && ( +

{explanation}

+ )} +
+ + {/* Declarations */} + {declarations.length > 0 && ( +
+

+ Declarations ({declarations.length}) +

+
    + {declarations.map((d) => ( +
  • + {d.name} +
  • + ))} +
+
+ )} + + {/* Impact analysis */} +
+
+

Impact Analysis

+ +
+ {impactedFiles.length > 0 && ( +
+
    + {impactedFiles.map((file) => ( +
  • {file}
  • + ))} +
+
+ )} +
+ + {/* Deps + Used By */} + {deps.length > 0 && ( +
+

Imports ({deps.length})

+
    + {deps.map((dep) =>
  • {dep}
  • )} +
+
+ )} + {usedBy.length > 0 && ( +
+

Used by ({usedBy.length})

+
    + {usedBy.map((file) =>
  • {file}
  • )} +
+
+ )} +
+ ); +} +``` + +### 1.3 Align API base URL + +**File:** `client/src/features/dashboard/services/dashboardService.js` + +Change line 4–7: +```js +// Before +const BASE_URL = import.meta.env.VITE_API_BASE_URL + ? `${import.meta.env.VITE_API_BASE_URL}/api` + : 'http://localhost:5000/api'; + +// After — matches graphService and aiService pattern +const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || ''; +``` + +Then update the axios instance: +```js +const dashboardClient = axios.create({ + baseURL: apiBaseUrl, + withCredentials: true, + headers: { 'Content-Type': 'application/json' }, +}); +``` + +And update the service methods to use full paths: +```js +// getAnalyzedRepositories +const { data } = await dashboardClient.get('/api/repositories', { params: { page, limit } }); + +// getRepositoryJobs +const { data } = await dashboardClient.get(`/api/repositories/${repositoryId}/jobs`, { params: { page, limit } }); +``` + +--- + +## Section 2 — Function-Level Graph Expansion + +Currently every node is a file. Phase 3 lets users click a file node to "expand" it into its constituent functions/classes as child nodes. This is the most visually impressive Phase 3 feature. + +### 2.1 GraphBuilderAgent — function node output + +**File:** `server/src/agents/graph/GraphBuilderAgent.js` + +Extend the graph output to include function-level nodes in a separate map: + +```js +// Add to graph output: +functionNodes: { + 'src/auth/authService.js': [ + { name: 'login', kind: 'function', calls: ['verifyCredentials', 'createToken'] }, + { name: 'logout', kind: 'function', calls: [] }, + ] +} +``` + +The `parseWorker.js` already extracts `declarations` per file. Extend it to also record which other declaration names are called inside each function body by doing a second walk of the function's body AST node. + +### 2.2 Store function nodes in DB + +**Migration:** `server/src/infrastructure/migrations/002_function_nodes.sql` + +```sql +CREATE TABLE function_nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_id UUID NOT NULL REFERENCES analysis_jobs(id) ON DELETE CASCADE, + file_path TEXT NOT NULL, + name TEXT NOT NULL, + kind TEXT NOT NULL, -- function | class | arrow + calls JSONB DEFAULT '[]', -- names of other functions called + loc INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (job_id, file_path, name) +); +CREATE INDEX idx_fn_nodes_job_file ON function_nodes(job_id, file_path); +``` + +Add an endpoint: +``` +GET /api/graph/:jobId/functions/:filePath +→ [{ name, kind, calls, loc }] +``` + +### 2.3 GraphView — expandable nodes + +**File:** `client/src/features/graph/components/GraphView.jsx` + +Add a double-click handler that fetches and renders function sub-nodes: + +```jsx +const onNodeDoubleClick = useCallback(async (_e, node) => { + if (expandedNodes.has(node.id)) return; // already expanded + const fns = await graphService.getFunctionNodes(jobId, node.id); + // Add function nodes as children in React Flow + setNodes(prev => [...prev, ...fns.map(fn => ({ + id: `${node.id}::${fn.name}`, + data: { label: fn.name, kind: fn.kind }, + position: { x: node.position.x + 50, y: node.position.y + 50 + fns.indexOf(fn) * 40 }, + parentNode: node.id, + style: { fontSize: 10, padding: '2px 6px', borderRadius: 4 }, + }))]); + setExpandedNodes(prev => new Set([...prev, node.id])); +}, [expandedNodes, jobId]); +``` + +--- + +## Section 3 — Streaming AI Explanations + +Currently the explain call blocks until the full response arrives. Phase 3 streams tokens from OpenAI so users see text appearing as it generates. + +### 3.1 Server — streaming endpoint + +**New file:** `server/src/api/ai/routes/ai.routes.js` — add route: + +```js +router.post('/explain/stream', async (req, res, next) => { + const userId = getAuthUserId(req); + if (!userId) return res.status(401).json({ error: 'Authentication required.' }); + + const { question, jobId } = req.body; + if (!question || !jobId) return res.status(400).json({ error: 'question and jobId are required.' }); + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.flushHeaders(); + + try { + const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + const stream = await openai.chat.completions.stream({ + model: process.env.OPENAI_MODEL || 'gpt-4o-mini', + max_tokens: 500, + messages: [{ role: 'user', content: question }], + }); + + for await (const chunk of stream) { + const text = chunk.choices[0]?.delta?.content || ''; + if (text) res.write(`data: ${JSON.stringify({ text })}\n\n`); + } + + res.write('data: [DONE]\n\n'); + res.end(); + } catch (err) { + res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`); + res.end(); + } +}); +``` + +### 3.2 Client — streaming aiService method + +**File:** `client/src/features/ai/services/aiService.js` + +```js +streamExplain({ question, jobId, onChunk, onDone, onError }) { + const url = `${apiBaseUrl}/api/ai/explain/stream`; + const body = JSON.stringify({ question, jobId }); + + fetch(url, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body, + }).then(async (res) => { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const payload = line.slice(6); + if (payload === '[DONE]') { onDone?.(); return; } + try { + const { text, error } = JSON.parse(payload); + if (error) { onError?.(error); return; } + if (text) onChunk?.(text); + } catch {} + } + } + }).catch(onError); +}, +``` + +### 3.3 AiPanel — streaming UI state + +Add a local `streamedText` state to AiPanel that accumulates chunks: + +```jsx +const [streamedText, setStreamedText] = useState(''); +const [isStreaming, setIsStreaming] = useState(false); + +useEffect(() => { + if (!nodeId || !jobId) return; + setStreamedText(''); + setIsStreaming(true); + + aiService.streamExplain({ + question: `Explain the file ${nodeId} — its purpose, key functions, dependencies, and risks.`, + jobId, + onChunk: (text) => setStreamedText(prev => prev + text), + onDone: () => setIsStreaming(false), + onError: () => setIsStreaming(false), + }); +}, [nodeId, jobId]); +``` + +--- + +## Section 4 — Multi-Language Parser Support + +The current parser only handles JS/TS/JSX/TSX via Babel. Phase 3 adds Python and Go. + +### 4.1 ScannerAgent — extend allowed extensions + +**File:** `server/src/agents/scanner/ScannerAgent.js` + +```js +const ALLOWED_EXTENSIONS = new Set([ + '.js', '.ts', '.jsx', '.tsx', // existing + '.py', // Python + '.go', // Go +]); +``` + +### 4.2 Language router in ParserAgent + +**File:** `server/src/agents/parser/ParserAgent.js` + +```js +_parseInWorker(filePath, relativePath) { + const ext = path.extname(filePath).toLowerCase(); + const workerFile = ext === '.py' ? './pythonWorker.js' + : ext === '.go' ? './goWorker.js' + : './parseWorker.js'; + + return new Promise((resolve) => { + const worker = new Worker(new URL(workerFile, import.meta.url), { + workerData: { filePath, relativePath }, + }); + worker.once('message', resolve); + worker.once('error', (err) => resolve({ + relativePath, imports: [], declarations: [], metrics: {}, parseError: err.message + })); + }); +} +``` + +### 4.3 Python worker + +**New file:** `server/src/agents/parser/pythonWorker.js` + +Python imports are much simpler to parse with regex than with a full AST (avoiding a native module dependency): + +```js +import { readFile } from 'fs/promises'; +import { parentPort, workerData } from 'worker_threads'; + +const { filePath, relativePath } = workerData; + +async function run() { + const code = await readFile(filePath, 'utf8'); + const lines = code.split('\n'); + const loc = lines.length; + + const imports = []; + const declarations = []; + const seenDecl = new Set(); + + for (const line of lines) { + // import foo, from foo import bar, from . import baz + const imp = line.match(/^(?:from\s+([\w.]+)\s+)?import\s+([\w,\s*]+)/); + if (imp) imports.push(imp[1] || imp[2].split(',')[0].trim()); + + // def foo( and class Foo( + const fn = line.match(/^(?:async\s+)?def\s+(\w+)\s*\(/); + if (fn && !seenDecl.has(fn[1])) { declarations.push({ name: fn[1], kind: 'function' }); seenDecl.add(fn[1]); } + + const cls = line.match(/^class\s+(\w+)[\s:(]/); + if (cls && !seenDecl.has(cls[1])) { declarations.push({ name: cls[1], kind: 'class' }); seenDecl.add(cls[1]); } + } + + parentPort.postMessage({ relativePath, imports, declarations, metrics: { loc }, parseError: null }); +} + +run().catch((err) => parentPort.postMessage({ + relativePath, imports: [], declarations: [], metrics: {}, parseError: err.message +})); +``` + +--- + +## Section 5 — Saved Queries UI + +The `saved_queries` table already exists and the `QueryAgent` writes to it. Phase 3 surfaces this history in the UI. + +### 5.1 Server — saved queries endpoint + +**New route in** `server/src/api/ai/routes/ai.routes.js`: + +```js +// GET /api/ai/queries?jobId=...&page=1&limit=20 +router.get('/queries', async (req, res, next) => { + const userId = getAuthUserId(req); + if (!userId) return res.status(401).json({ error: 'Authentication required.' }); + + const jobId = String(req.query?.jobId || '').trim(); + const page = Math.max(1, parseInt(req.query?.page) || 1); + const limit = Math.min(50, parseInt(req.query?.limit) || 20); + const offset = (page - 1) * limit; + + try { + const result = await pgPool.query( + `SELECT id, question, answer, highlights, confidence, created_at + FROM saved_queries + WHERE user_id = $1 ${jobId ? 'AND job_id = $2' : ''} + ORDER BY created_at DESC + LIMIT ${jobId ? '$3' : '$2'} OFFSET ${jobId ? '$4' : '$3'}`, + jobId ? [userId, jobId, limit, offset] : [userId, limit, offset] + ); + + return res.json({ queries: result.rows, page, limit }); + } catch (err) { + return next(err); + } +}); +``` + +### 5.2 Client — query history panel + +**New file:** `client/src/features/ai/components/QueryHistory.jsx` + +A slide-in list of previous queries per repo. Clicking one re-runs it via `dispatch(queryGraph(...))` and highlights the same files. Show it as a collapsible section below the QueryBar in `GraphPage.jsx`. + +```jsx +export default function QueryHistory({ jobId }) { + const [queries, setQueries] = useState([]); + const dispatch = useDispatch(); + + useEffect(() => { + if (!jobId) return; + fetch(`/api/ai/queries?jobId=${jobId}`, { credentials: 'include' }) + .then(r => r.json()) + .then(data => setQueries(data.queries || [])); + }, [jobId]); + + if (queries.length === 0) return null; + + return ( +
+

Recent queries

+
    + {queries.slice(0, 5).map(q => ( +
  • + +
  • + ))} +
+
+ ); +} +``` + +--- + +## Section 6 — Dashboard Re-Analyze + Starred Repos + +### 6.1 Re-analyze from Dashboard + +**File:** `client/src/features/dashboard/pages/DashboardPage.jsx` + +Add a re-analyze action to each repo card. It reads the last scan config from the repo record and dispatches a new analysis: + +```jsx +// In the repo card action buttons: + +``` + +### 6.2 Star a repository + +**Server:** Add `PATCH /api/repositories/:id/star` that toggles `is_starred` in the `repositories` table. + +**Client:** Add a star icon button to each repo card in DashboardPage. Starred repos float to the top of the list. + +```js +// server route: +router.patch('/:id/star', async (req, res, next) => { + const authUser = getAuthUser(req); + if (!authUser?.id) return res.status(401).json({ error: 'Authentication required.' }); + + const userId = await resolveDatabaseUserId(authUser); + const { id } = req.params; + + const result = await pgPool.query( + `UPDATE repositories + SET is_starred = NOT is_starred + WHERE id = $1 AND owner_id = $2 + RETURNING id, is_starred`, + [id, userId] + ); + + if (result.rowCount === 0) return res.status(404).json({ error: 'Repository not found.' }); + return res.json(result.rows[0]); +}); +``` + +--- + +## Section 7 — Shareable Graph Links + +Currently graphs are private to the session. Phase 3 adds public/unlisted share links. + +### 7.1 DB + +**Migration:** `server/src/infrastructure/migrations/003_share_tokens.sql` + +```sql +CREATE TABLE graph_shares ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_id UUID NOT NULL REFERENCES analysis_jobs(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, -- random 32-char URL-safe token + visibility TEXT NOT NULL DEFAULT 'unlisted', -- unlisted | public + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_shares_token ON graph_shares(token); +``` + +### 7.2 Server + +``` +POST /api/graph/:jobId/share → { shareUrl: 'https://.../?share=TOKEN' } +GET /api/share/:token → graph data (no auth required if unlisted) +``` + +```js +// POST /api/graph/:jobId/share +import crypto from 'crypto'; + +router.post('/:jobId/share', async (req, res, next) => { + const token = crypto.randomBytes(24).toString('base64url'); + await pgPool.query( + `INSERT INTO graph_shares (job_id, token) VALUES ($1, $2)`, + [req.params.jobId, token] + ); + const shareUrl = `${process.env.CLIENT_URL}/?share=${token}`; + return res.json({ shareUrl, token }); +}); + +// GET /api/share/:token (no auth) +router.get('/share/:token', async (req, res, next) => { + const share = await pgPool.query( + `SELECT gn.job_id FROM graph_shares gn WHERE gn.token = $1 + AND (gn.expires_at IS NULL OR gn.expires_at > NOW())`, + [req.params.token] + ); + if (share.rowCount === 0) return res.status(404).json({ error: 'Share link not found or expired.' }); + // Load graph same as /api/graph/:jobId +}); +``` + +### 7.3 Client — share button in GraphToolbar + +**File:** `client/src/features/graph/components/GraphToolbar.jsx` + +Add a share button that calls the API and copies the URL to clipboard: + +```jsx +const handleShare = async () => { + const { shareUrl } = await graphService.shareGraph(jobId); + await navigator.clipboard.writeText(shareUrl); + toast('Share link copied to clipboard'); +}; +``` + +--- + +## Section 8 — GitHub PR Integration + +When a pull request is opened, automatically analyze the diff and post a comment showing which files in the graph are impacted. + +### 8.1 Webhook endpoint + +**New file:** `server/src/api/webhooks/github.webhook.js` + +```js +import crypto from 'crypto'; +import { Router } from 'express'; +import { pgPool } from '../../infrastructure/connections.js'; +import { enqueueAnalysisJob } from '../../queue/analysisQueue.js'; + +const router = Router(); + +function verifySignature(payload, signature, secret) { + const expected = `sha256=${crypto.createHmac('sha256', secret).update(payload).digest('hex')}`; + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); +} + +router.post('/github', express.raw({ type: 'application/json' }), async (req, res) => { + const sig = req.headers['x-hub-signature-256']; + if (!verifySignature(req.body, sig, process.env.GITHUB_WEBHOOK_SECRET)) { + return res.status(401).send('Invalid signature'); + } + + const event = req.headers['x-github-event']; + const payload = JSON.parse(req.body); + + if (event === 'pull_request' && ['opened', 'synchronize'].includes(payload.action)) { + const { owner, name: repo } = payload.repository; + const branch = payload.pull_request.head.ref; + + // Find the repository record by owner/name + const repoResult = await pgPool.query( + `SELECT id, owner_id FROM repositories + WHERE github_owner = $1 AND github_repo = $2 + LIMIT 1`, + [owner, repo] + ); + + if (repoResult.rowCount > 0) { + const { id: repositoryId, owner_id: userId } = repoResult.rows[0]; + const jobResult = await pgPool.query( + `INSERT INTO analysis_jobs (repository_id, user_id, branch, status) + VALUES ($1, $2, $3, 'queued') RETURNING id`, + [repositoryId, userId, branch] + ); + const jobId = jobResult.rows[0].id; + await enqueueAnalysisJob({ + jobId, + input: { source: 'github', github: { owner, repo, branch }, repositoryId, userId }, + }); + } + } + + return res.status(200).send('OK'); +}); +``` + +Register in `app.js`: +```js +import webhookRouter from './src/api/webhooks/github.webhook.js'; +app.use('/api/webhooks', webhookRouter); +``` + +Add env var: +``` +GITHUB_WEBHOOK_SECRET=your_webhook_secret +``` + +--- + +## Section 9 — Test Suite + +### 9.1 Install test dependencies + +```bash +cd server +npm install --save-dev vitest @vitest/coverage-v8 supertest +``` + +### 9.2 Test structure + +``` +server/ +└── src/ + └── agents/ + ├── core/__tests__/ + │ ├── SupervisorAgent.test.js + │ └── confidence.test.js + ├── parser/__tests__/ + │ └── ParserAgent.test.js + └── graph/__tests__/ + └── GraphBuilderAgent.test.js +``` + +### 9.3 Key tests to write + +**`confidence.test.js`** — verify all scoring formulas: +```js +import { describe, it, expect } from 'vitest'; +import { scoreParser, scoreEnrichment, computeOverallConfidence } from '../confidence.js'; + +describe('scoreParser', () => { + it('returns 1.0 when all files parse successfully', () => { + expect(scoreParser({ totalAttempted: 100, successCount: 100, failedCount: 0 })).toBe(1); + }); + + it('penalises high failure rate', () => { + const score = scoreParser({ totalAttempted: 100, successCount: 70, failedCount: 30 }); + expect(score).toBeLessThan(0.75); + }); + + it('returns 0 when all files fail', () => { + expect(scoreParser({ totalAttempted: 10, successCount: 0, failedCount: 10 })).toBe(0); + }); +}); + +describe('computeOverallConfidence', () => { + it('weights parser at 0.25 and penalises low parser score', () => { + const trace = [ + { agentId: 'parser-agent', confidence: 0.3 }, + { agentId: 'graph-builder-agent', confidence: 0.95 }, + { agentId: 'persistence-agent', confidence: 1.0 }, + ]; + const score = computeOverallConfidence(trace); + expect(score).toBeLessThan(0.65); // low parser drags it down + }); +}); +``` + +**`SupervisorAgent.test.js`** — mock agents and verify retry + abort: +```js +import { describe, it, expect, vi } from 'vitest'; +import { SupervisorAgent } from '../SupervisorAgent.js'; + +const mockAgent = (confidence, status = 'success') => ({ + agentId: 'test-agent', + maxRetries: 2, + timeoutMs: 5000, + process: vi.fn().mockResolvedValue({ + agentId: 'test-agent', + jobId: 'test-job', + status, + confidence, + data: { extractedPath: '/tmp/test', repoMeta: {} }, + errors: [], + warnings: [], + metrics: {}, + processingTimeMs: 10, + retryCount: 0, + }), + buildResult: vi.fn(), +}); + +describe('SupervisorAgent._decide', () => { + const supervisor = new SupervisorAgent({}); + + it('returns PROCEED for high confidence', () => { + expect(supervisor._decide(0.9)).toBe('PROCEED'); + }); + + it('returns PROCEED_WARN for medium confidence', () => { + expect(supervisor._decide(0.7)).toBe('PROCEED_WARN'); + }); + + it('returns RETRY for low confidence', () => { + expect(supervisor._decide(0.5)).toBe('RETRY'); + }); + + it('returns ABORT for critical confidence', () => { + expect(supervisor._decide(0.2)).toBe('ABORT'); + }); +}); +``` + +### 9.4 `vitest.config.js` + +```js +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/agents/**/*.js'], + exclude: ['**/__tests__/**'], + thresholds: { lines: 70, functions: 70, branches: 60 }, + }, + }, +}); +``` + +--- + +## Section 10 — Production Hardening + +### 10.1 Error monitoring — Sentry + +```bash +cd server && npm install @sentry/node @sentry/tracing +cd client && npm install @sentry/react @sentry/tracing +``` + +**Server:** `server/index.js` + +```js +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: process.env.NODE_ENV, + tracesSampleRate: 0.1, +}); + +// Add before error handler in app.js: +app.use(Sentry.Handlers.errorHandler()); +``` + +**Client:** `client/src/main.jsx` + +```jsx +import * as Sentry from '@sentry/react'; + +Sentry.init({ + dsn: import.meta.env.VITE_SENTRY_DSN, + environment: import.meta.env.MODE, + tracesSampleRate: 0.1, +}); +``` + +### 10.2 GitHub Actions CI + +**New file:** `.github/workflows/ci.yml` + +```yaml +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + server: + runs-on: ubuntu-latest + services: + postgres: + image: ankane/pgvector + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: codegraph_test + ports: ['5432:5432'] + redis: + image: redis:7 + ports: ['6379:6379'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '20' } + - run: cd server && npm ci + - run: cd server && DATABASE_URL=postgres://postgres:postgres@localhost:5432/codegraph_test npm run migrate + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/codegraph_test + - run: cd server && npm run test:coverage + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/codegraph_test + REDIS_URL: redis://localhost:6379 + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + JWT_SECRET: test_secret + + client: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '20' } + - run: cd client && npm ci + - run: cd client && npm run build +``` + +### 10.3 Plan enforcement + +The `users.plan` column already exists. Add middleware to gate AI features: + +**New file:** `server/src/middleware/planGuard.middleware.js` + +```js +import { pgPool } from '../infrastructure/connections.js'; + +const PLAN_LIMITS = { + free: { reposPerMonth: 3, aiQueriesPerDay: 10 }, + pro: { reposPerMonth: Infinity, aiQueriesPerDay: 200 }, + team: { reposPerMonth: Infinity, aiQueriesPerDay: 1000 }, +}; + +export function requirePlan(...allowedPlans) { + return async (req, res, next) => { + const userId = req.userId; // set by auth middleware + if (!userId) return res.status(401).json({ error: 'Authentication required.' }); + + const result = await pgPool.query('SELECT plan FROM users WHERE id = $1', [userId]); + const plan = result.rows[0]?.plan || 'free'; + + if (!allowedPlans.includes(plan)) { + return res.status(403).json({ + error: 'This feature requires a higher plan.', + currentPlan: plan, + requiredPlans: allowedPlans, + upgradeUrl: '/settings/billing', + }); + } + + req.userPlan = plan; + req.planLimits = PLAN_LIMITS[plan]; + return next(); + }; +} +``` + +Apply to AI routes: +```js +// In ai.routes.js +import { requirePlan } from '../../../middleware/planGuard.middleware.js'; + +router.post('/query', aiLimiter, requirePlan('pro', 'team'), async (req, res, next) => { ... }); +``` + +### 10.4 Rate limit by user (not IP) + +The current AI rate limiter uses IP. Replace with user ID for accuracy: + +```js +const aiLimiter = rateLimit({ + windowMs: 60 * 1000, + max: Number(process.env.AI_RATE_LIMIT_PER_MINUTE || 30), + keyGenerator: (req) => { + // Use user ID from JWT if available, fall back to IP + const token = req.cookies?.token || req.headers.authorization?.replace('Bearer ', ''); + if (token && process.env.JWT_SECRET) { + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + if (decoded?.id) return `user:${decoded.id}`; + } catch {} + } + return req.ip; + }, +}); +``` + +--- + +## Phase 3 Build Order Summary + +| Week | Focus | Outcome | +|---|---|---| +| Week 1 | Section 1 (Phase 2 gaps) | AI panel fully works on node click - Done | +| Week 1 | Section 3 (streaming explanations) | Streaming text in AiPanel - Done | +| Week 2 | Section 2 (function-level graph) | Double-click to expand file nodes | +| Week 2 | Section 5 (saved queries UI) | Query history visible in graph view | +| Week 3 | Section 4 (multi-language) | Python/Go repos parse correctly | +| Week 3 | Section 6 (dashboard improvements) | Re-analyze + starred repos | +| Week 4 | Section 7 (shareable links) | Share button in toolbar | +| Week 4 | Section 9 (test suite) | 70%+ coverage on agents | +| Week 5 | Section 8 (PR integration) | GitHub webhook auto-analyzes PRs | +| Week 5 | Section 10 (production hardening) | Sentry, CI, plan gates | + +--- + +## New Environment Variables for Phase 3 + +Add to `server/.env`: + +```bash +# Phase 3 additions +GITHUB_WEBHOOK_SECRET=your_webhook_secret_here +SENTRY_DSN=https://...@sentry.io/... + +# Plan enforcement +DEFAULT_USER_PLAN=free +AI_QUERIES_PER_DAY_FREE=10 +AI_QUERIES_PER_DAY_PRO=200 + +# Streaming +OPENAI_STREAM_ENABLED=true +``` + +Add to `client/.env`: +```bash +VITE_SENTRY_DSN=https://...@sentry.io/... +VITE_SHARE_BASE_URL=https://yourdomain.com +``` + +--- + +## New Files Created in Phase 3 + +``` +server/ +├── src/ +│ ├── agents/ +│ │ └── parser/ +│ │ └── pythonWorker.js ← Section 4 +│ ├── api/ +│ │ ├── ai/routes/ai.routes.js ← extend: streaming + query history +│ │ ├── graph/routes/graph.routes.js ← extend: share + function nodes +│ │ └── webhooks/ +│ │ └── github.webhook.js ← Section 8 +│ ├── middleware/ +│ │ └── planGuard.middleware.js ← Section 10 +│ └── infrastructure/ +│ └── migrations/ +│ ├── 002_function_nodes.sql ← Section 2 +│ └── 003_share_tokens.sql ← Section 7 +│ +client/ +└── src/ + └── features/ + └── ai/ + └── components/ + └── QueryHistory.jsx ← Section 5 + +.github/ +└── workflows/ + └── ci.yml ← Section 10 +``` diff --git a/server/src/api/ai/routes/ai.routes.js b/server/src/api/ai/routes/ai.routes.js index baccbe3..ea1d0cb 100644 --- a/server/src/api/ai/routes/ai.routes.js +++ b/server/src/api/ai/routes/ai.routes.js @@ -1,11 +1,15 @@ import { Router } from 'express'; import jwt from 'jsonwebtoken'; import rateLimit from 'express-rate-limit'; +import OpenAI from 'openai'; import { QueryAgent } from '../../../agents/query/QueryAgent.js'; import { AnalysisAgent } from '../../../agents/analysis/AnalysisAgent.js'; import { pgPool, redisClient } from '../../../infrastructure/connections.js'; const router = Router(); +const openaiClient = process.env.OPENAI_API_KEY + ? new OpenAI({ apiKey: process.env.OPENAI_API_KEY }) + : null; const aiLimiter = rateLimit({ windowMs: 60 * 1000, @@ -15,18 +19,82 @@ const aiLimiter = rateLimit({ message: { error: 'Too many AI requests. Please wait a moment and try again.' }, }); -function getAuthUserId(req) { +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function getAuthUser(req) { const token = req.cookies?.token || req.headers.authorization?.replace('Bearer ', ''); if (!token || !process.env.JWT_SECRET) return null; try { - const decoded = jwt.verify(token, process.env.JWT_SECRET); - return decoded?.id || null; + return jwt.verify(token, process.env.JWT_SECRET); } catch { return null; } } +function isUuid(value) { + return UUID_REGEX.test(String(value || '')); +} + +async function resolveDatabaseUserId(authUser) { + const authId = String(authUser?.id || '').trim(); + if (!authId) return null; + + if (isUuid(authId)) { + const existing = await pgPool.query( + ` + SELECT id + FROM users + WHERE id = $1 + LIMIT 1 + `, + [authId], + ); + + if (existing.rowCount > 0) return existing.rows[0].id; + + const inserted = await pgPool.query( + ` + INSERT INTO users (id, github_id, username, email, avatar_url) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + `, + [ + authId, + null, + authUser?.username || 'unknown-user', + authUser?.email || null, + authUser?.avatar || null, + ], + ); + + return inserted.rows[0]?.id || null; + } + + const upserted = await pgPool.query( + ` + INSERT INTO users (github_id, username, email, avatar_url) + VALUES ($1, $2, $3, $4) + ON CONFLICT (github_id) + DO UPDATE + SET username = COALESCE(EXCLUDED.username, users.username), + email = COALESCE(EXCLUDED.email, users.email), + avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url), + updated_at = NOW() + RETURNING id + `, + [ + authId, + authUser?.username || `github-${authId}`, + authUser?.email || null, + authUser?.avatar || null, + ], + ); + + return upserted.rows[0]?.id || null; +} + function toGraphFromRows(nodeRows = [], edgeRows = []) { const depsBySource = new Map(); @@ -53,8 +121,8 @@ function toGraphFromRows(nodeRows = [], edgeRows = []) { router.use(aiLimiter); router.post('/query', async (req, res, next) => { - const userId = getAuthUserId(req); - if (!userId) { + const authUser = getAuthUser(req); + if (!authUser?.id) { return res.status(401).json({ error: 'Authentication required.' }); } @@ -66,6 +134,11 @@ router.post('/query', async (req, res, next) => { } try { + const userId = await resolveDatabaseUserId(authUser); + if (!userId) { + return res.status(500).json({ error: 'Failed to resolve authenticated user.' }); + } + const agent = new QueryAgent({ db: pgPool, redis: redisClient }); const result = await agent.process({ question, jobId, userId }, { jobId }); @@ -82,9 +155,119 @@ router.post('/query', async (req, res, next) => { } }); +router.post('/explain/stream', async (req, res, next) => { + const authUser = getAuthUser(req); + if (!authUser?.id) { + return res.status(401).json({ error: 'Authentication required.' }); + } + + const question = String(req.body?.question || '').trim(); + const jobId = String(req.body?.jobId || '').trim(); + + if (!question || !jobId) { + return res.status(400).json({ error: 'question and jobId are required.' }); + } + + if (!openaiClient) { + return res.status(503).json({ error: 'OpenAI is not configured for streaming.' }); + } + + let clientClosed = false; + let stream = null; + + const closeStream = () => { + if (typeof stream?.abort === 'function') { + stream.abort(); + } + + if (typeof stream?.controller?.abort === 'function') { + stream.controller.abort(); + } + }; + + const writeEvent = (payload) => { + if (clientClosed || res.writableEnded) return; + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }; + + req.on('close', () => { + clientClosed = true; + closeStream(); + }); + + try { + const userId = await resolveDatabaseUserId(authUser); + if (!userId) { + return res.status(500).json({ error: 'Failed to resolve authenticated user.' }); + } + + const ownership = await pgPool.query( + ` + SELECT 1 + FROM analysis_jobs + WHERE id = $1 AND user_id = $2 + LIMIT 1 + `, + [jobId, userId], + ); + + if (ownership.rowCount === 0) { + return res.status(404).json({ error: 'Analysis job not found for this user.' }); + } + + res.status(200); + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + if (typeof res.flushHeaders === 'function') { + res.flushHeaders(); + } + + stream = await openaiClient.chat.completions.stream({ + model: process.env.OPENAI_MODEL || 'gpt-4o-mini', + max_tokens: 500, + messages: [ + { + role: 'user', + content: question, + }, + ], + }); + + for await (const chunk of stream) { + if (clientClosed) break; + + const text = chunk?.choices?.[0]?.delta?.content || ''; + if (text) { + writeEvent({ text }); + } + } + + if (!clientClosed) { + res.write('data: [DONE]\n\n'); + res.end(); + } + + return undefined; + } catch (error) { + closeStream(); + + if (res.headersSent) { + if (!clientClosed && !res.writableEnded) { + writeEvent({ error: error.message || 'Streaming failed.' }); + res.end(); + } + return undefined; + } + + return next(error); + } +}); + router.post('/impact', async (req, res, next) => { - const userId = getAuthUserId(req); - if (!userId) { + const authUser = getAuthUser(req); + if (!authUser?.id) { return res.status(401).json({ error: 'Authentication required.' }); } diff --git a/skills-lock.json b/skills-lock.json index fe958f1..7428048 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -16,6 +16,11 @@ "sourceType": "github", "computedHash": "516bd2154eb843a8240e43d5b285229129853114ad7075a5e141e1c08e408c84" }, + "nodejs-best-practices": { + "source": "sickn33/antigravity-awesome-skills", + "sourceType": "github", + "computedHash": "9f8d3f268624c4757f4039617942d3e8ddcf420b9295cb943bea3bf9feb37e9d" + }, "redis-best-practices": { "source": "mindrally/skills", "sourceType": "github", From 26d10573d5a9da6adea1b7458180c5a89ed60fc0 Mon Sep 17 00:00:00 2001 From: SamanPandey-in Date: Mon, 30 Mar 2026 09:27:47 +0530 Subject: [PATCH 03/18] feat: Function-Level Graph Expansion for expandable function nodes --- .../features/graph/components/GraphView.jsx | 101 +++++++++++++- .../features/graph/services/graphService.js | 9 ++ server/package.json | 2 +- server/src/agents/core/SupervisorAgent.js | 1 + server/src/agents/graph/GraphBuilderAgent.js | 5 +- server/src/agents/parser/parseWorker.js | 127 +++++++++++++++++- .../agents/persistence/PersistenceAgent.js | 66 ++++++++- server/src/api/graph/routes/graph.routes.js | 47 +++++++ .../migrations/002_function_nodes.sql | 13 ++ 9 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 server/src/infrastructure/migrations/002_function_nodes.sql diff --git a/client/src/features/graph/components/GraphView.jsx b/client/src/features/graph/components/GraphView.jsx index 2325591..b29c6c9 100644 --- a/client/src/features/graph/components/GraphView.jsx +++ b/client/src/features/graph/components/GraphView.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import ReactFlow, { Background, @@ -43,6 +43,7 @@ import { selectGraphData, } from '../slices/graphSlice'; import { selectDeadFiles, selectHighlightedNodeIds } from '../../ai/slices/aiSlice'; +import { graphService } from '../services/graphService'; const THEME_COLORS = { dark: { @@ -70,6 +71,19 @@ const THEME_TEXT = { light: '#1A1A1A', }; +const FUNCTION_NODE_STYLE = { + dark: { + bg: '#111827', + border: '#6B7280', + text: '#E5E7EB', + }, + light: { + bg: '#FFFFFF', + border: '#9CA3AF', + text: '#111827', + }, +}; + function getTypeColors(theme) { return THEME_COLORS[theme] || THEME_COLORS.dark; } @@ -154,6 +168,7 @@ export default function GraphView() { const deadFiles = useSelector(selectDeadFiles); const themeMode = useSelector(selectThemeMode); const graph = rawData?.graph ?? EMPTY_GRAPH; + const jobId = rawData?.jobId || null; const emptyMessage = rawData?.message || 'No JS/TS files found in the selected directory.'; @@ -164,10 +179,12 @@ export default function GraphView() { const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const expandedNodesRef = useRef(new Set()); useEffect(() => { setNodes(initialNodes); setEdges(initialEdges); + expandedNodesRef.current = new Set(); }, [initialEdges, initialNodes, setEdges, setNodes]); const onConnect = useCallback( @@ -176,8 +193,85 @@ export default function GraphView() { ); const onNodeClick = useCallback( - (_e, node) => dispatch(selectNode(node.id)), - [dispatch], + (_e, node) => { + if (!graph[node.id]) return; + dispatch(selectNode(node.id)); + }, + [dispatch, graph], + ); + + const onNodeDoubleClick = useCallback( + async (_event, node) => { + if (!jobId || !graph[node.id]) return; + if (expandedNodesRef.current.has(node.id)) return; + + try { + const functionDeclarations = await graphService.getFunctionNodes(jobId, node.id); + + const baseStyle = FUNCTION_NODE_STYLE[themeMode] || FUNCTION_NODE_STYLE.dark; + const createdNodes = []; + const createdEdges = []; + + setNodes((previousNodes) => { + const existingIds = new Set(previousNodes.map((existingNode) => existingNode.id)); + + functionDeclarations.forEach((fn, index) => { + if (!fn?.name) return; + + const childId = `${node.id}::${fn.name}`; + if (existingIds.has(childId)) return; + existingIds.add(childId); + + createdNodes.push({ + id: childId, + data: { + label: fn.name, + kind: fn.kind || 'function', + }, + position: { + x: node.position.x + 56, + y: node.position.y + 56 + index * 36, + }, + draggable: true, + style: { + background: baseStyle.bg, + border: `1px solid ${baseStyle.border}`, + borderRadius: 6, + color: baseStyle.text, + fontSize: 10, + padding: '3px 7px', + maxWidth: 160, + }, + }); + + createdEdges.push({ + id: `${node.id}>${childId}`, + source: node.id, + target: childId, + animated: false, + style: { stroke: baseStyle.border, strokeWidth: 1 }, + }); + }); + + if (createdNodes.length === 0) return previousNodes; + return [...previousNodes, ...createdNodes]; + }); + + if (createdEdges.length > 0) { + setEdges((previousEdges) => { + const existingIds = new Set(previousEdges.map((edge) => edge.id)); + const dedupedEdges = createdEdges.filter((edge) => !existingIds.has(edge.id)); + if (dedupedEdges.length === 0) return previousEdges; + return [...previousEdges, ...dedupedEdges]; + }); + } + + expandedNodesRef.current.add(node.id); + } catch (error) { + console.error('Failed to load function nodes:', error); + } + }, + [graph, jobId, setEdges, setNodes, themeMode], ); if (nodes.length === 0) { @@ -197,6 +291,7 @@ export default function GraphView() { onEdgesChange={onEdgesChange} onConnect={onConnect} onNodeClick={onNodeClick} + onNodeDoubleClick={onNodeDoubleClick} fitView style={{ background: 'transparent' }} > diff --git a/client/src/features/graph/services/graphService.js b/client/src/features/graph/services/graphService.js index d184e3e..212a7cd 100644 --- a/client/src/features/graph/services/graphService.js +++ b/client/src/features/graph/services/graphService.js @@ -88,6 +88,15 @@ export const graphService = { return data; }, + getFunctionNodes: async (jobId, filePath) => { + if (!jobId) throw new Error('jobId is required to fetch function nodes.'); + if (!filePath) throw new Error('filePath is required to fetch function nodes.'); + + const encodedFilePath = encodeURIComponent(filePath); + const { data } = await graphClient.get(`/api/graph/${jobId}/functions/${encodedFilePath}`); + return Array.isArray(data) ? data : []; + }, + validateLocalPath: async (projectPath) => { const { data } = await graphClient.post('/api/analyze/local/validate', { path: projectPath.trim(), diff --git a/server/package.json b/server/package.json index 814b22d..1b2b12e 100644 --- a/server/package.json +++ b/server/package.json @@ -9,7 +9,7 @@ "scripts": { "start": "node index.js", "dev": "nodemon index.js", - "migrate": "psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/001_initial.sql || true", + "migrate": "psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/001_initial.sql || true; psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/002_function_nodes.sql || true", "db:migrate": "npm run migrate" }, "dependencies": { diff --git a/server/src/agents/core/SupervisorAgent.js b/server/src/agents/core/SupervisorAgent.js index c1c46c4..4e282cc 100644 --- a/server/src/agents/core/SupervisorAgent.js +++ b/server/src/agents/core/SupervisorAgent.js @@ -112,6 +112,7 @@ export class SupervisorAgent { repositoryId: input?.repositoryId, graph: pipelineData.graph, edges: pipelineData.edges, + functionNodes: pipelineData.functionNodes, enriched: pipelineData.enriched, embeddings: pipelineData.embeddings, topology: pipelineData.topology, diff --git a/server/src/agents/graph/GraphBuilderAgent.js b/server/src/agents/graph/GraphBuilderAgent.js index a54c20d..3bd2193 100644 --- a/server/src/agents/graph/GraphBuilderAgent.js +++ b/server/src/agents/graph/GraphBuilderAgent.js @@ -118,6 +118,7 @@ export class GraphBuilderAgent extends BaseAgent { } const graph = {}; + const functionNodes = {}; const adjacency = new Map(); const reverse = new Map(); const edges = []; @@ -171,6 +172,8 @@ export class GraphBuilderAgent extends BaseAgent { }, }; + functionNodes[source] = Array.isArray(parsed.functionNodes) ? parsed.functionNodes : []; + adjacency.set(source, deps); if (!reverse.has(source)) reverse.set(source, []); @@ -219,7 +222,7 @@ export class GraphBuilderAgent extends BaseAgent { jobId: context?.jobId, status: 'success', confidence, - data: { graph, edges, topology }, + data: { graph, edges, topology, functionNodes }, errors, warnings, metrics: { diff --git a/server/src/agents/parser/parseWorker.js b/server/src/agents/parser/parseWorker.js index 823aae7..2377f40 100644 --- a/server/src/agents/parser/parseWorker.js +++ b/server/src/agents/parser/parseWorker.js @@ -29,10 +29,78 @@ function pushDeclaration(declarations, seen, name, kind) { declarations.push({ name, kind }); } +function declarationNameFromNode(node) { + if (node?.type === 'Identifier') return node.name; + return null; +} + +function collectCallsInNode(node, declarationNames, selfName = null) { + if (!node) return []; + + const calls = new Set(); + + walk(node, (current) => { + let calledName = null; + + if (current.type === 'CallExpression' || current.type === 'OptionalCallExpression') { + if (current.callee?.type === 'Identifier') { + calledName = current.callee.name; + } else if ( + current.callee?.type === 'MemberExpression' && + !current.callee.computed && + current.callee.property?.type === 'Identifier' + ) { + calledName = current.callee.property.name; + } + } + + if ( + !calledName && + current.type === 'NewExpression' && + current.callee?.type === 'Identifier' + ) { + calledName = current.callee.name; + } + + if (!calledName) return; + if (!declarationNames.has(calledName)) return; + if (selfName && calledName === selfName) return; + + calls.add(calledName); + }); + + return [...calls]; +} + +function declarationLoc(node) { + const start = node?.loc?.start?.line; + const end = node?.loc?.end?.line; + + if (!Number.isFinite(start) || !Number.isFinite(end)) return null; + return Math.max(1, end - start + 1); +} + +function pushFunctionNode(functionNodes, seenNames, declarationNames, { name, kind, body, locNode }) { + if (!name) return; + if (seenNames.has(name)) return; + + seenNames.add(name); + + functionNodes.push({ + name, + kind, + calls: collectCallsInNode(body, declarationNames, name), + loc: declarationLoc(locNode), + }); +} + function extractFromAst(ast) { const imports = []; const declarations = []; const seenDecl = new Set(); + const declarationNames = new Set(); + const functionNodes = []; + const seenFunctionNames = new Set(); walk(ast, (node) => { if (node.type === 'ImportDeclaration' && typeof node.source?.value === 'string') { @@ -58,26 +126,80 @@ function extractFromAst(ast) { if (node.type === 'FunctionDeclaration' && node.id?.name) { pushDeclaration(declarations, seenDecl, node.id.name, 'function'); + declarationNames.add(node.id.name); } if (node.type === 'ClassDeclaration' && node.id?.name) { pushDeclaration(declarations, seenDecl, node.id.name, 'class'); + declarationNames.add(node.id.name); } if (node.type === 'VariableDeclarator' && node.id?.type === 'Identifier') { pushDeclaration(declarations, seenDecl, node.id.name, 'variable'); + declarationNames.add(node.id.name); } if (node.type === 'TSInterfaceDeclaration' && node.id?.name) { pushDeclaration(declarations, seenDecl, node.id.name, 'interface'); + declarationNames.add(node.id.name); } if (node.type === 'TSTypeAliasDeclaration' && node.id?.name) { pushDeclaration(declarations, seenDecl, node.id.name, 'type'); + declarationNames.add(node.id.name); + } + }); + + walk(ast, (node) => { + if (node.type === 'FunctionDeclaration' && node.id?.name) { + pushFunctionNode(functionNodes, seenFunctionNames, declarationNames, { + name: node.id.name, + kind: 'function', + body: node.body, + locNode: node, + }); + return; + } + + if (node.type === 'ClassDeclaration' && node.id?.name) { + pushFunctionNode(functionNodes, seenFunctionNames, declarationNames, { + name: node.id.name, + kind: 'class', + body: node.body, + locNode: node, + }); + return; + } + + if (node.type !== 'VariableDeclarator') return; + + const name = declarationNameFromNode(node.id); + if (!name) return; + + const init = node.init; + if (!init) return; + + if (init.type === 'ArrowFunctionExpression') { + pushFunctionNode(functionNodes, seenFunctionNames, declarationNames, { + name, + kind: 'arrow', + body: init.body, + locNode: init.loc ? init : node, + }); + return; + } + + if (init.type === 'FunctionExpression') { + pushFunctionNode(functionNodes, seenFunctionNames, declarationNames, { + name, + kind: 'function', + body: init.body, + locNode: init.loc ? init : node, + }); } }); - return { imports, declarations }; + return { imports, declarations, functionNodes }; } async function run() { @@ -90,12 +212,13 @@ async function run() { plugins: ['typescript', 'jsx', 'decorators-legacy', 'classProperties', 'dynamicImport'], }); - const { imports, declarations } = extractFromAst(ast); + const { imports, declarations, functionNodes } = extractFromAst(ast); return { relativePath, imports, declarations, + functionNodes, metrics: { loc: code.split(/\r?\n/).length, importCount: imports.length, diff --git a/server/src/agents/persistence/PersistenceAgent.js b/server/src/agents/persistence/PersistenceAgent.js index 8fea241..031d55f 100644 --- a/server/src/agents/persistence/PersistenceAgent.js +++ b/server/src/agents/persistence/PersistenceAgent.js @@ -36,6 +36,7 @@ export class PersistenceAgent extends BaseAgent { const jobId = input?.jobId || context?.jobId; const graph = input?.graph || {}; const edges = Array.isArray(input?.edges) ? input.edges : []; + const functionNodes = input?.functionNodes || {}; const embeddings = input?.embeddings || {}; const enriched = input?.enriched || {}; const topology = input?.topology || {}; @@ -95,7 +96,31 @@ export class PersistenceAgent extends BaseAgent { embeddingVectors.push(vectorLiteral); } - const recordsAttempted = nodePaths.length + edgeSourcePaths.length + embeddingPaths.length; + const functionNodePaths = []; + const functionNodeNames = []; + const functionNodeKinds = []; + const functionNodeCalls = []; + const functionNodeLocs = []; + + for (const [filePath, declarations] of Object.entries(functionNodes)) { + if (!Array.isArray(declarations)) continue; + + for (const declaration of declarations) { + if (!declaration?.name) continue; + + functionNodePaths.push(filePath); + functionNodeNames.push(declaration.name); + functionNodeKinds.push(declaration.kind || 'function'); + functionNodeCalls.push(toJson(Array.isArray(declaration.calls) ? declaration.calls : [], [])); + functionNodeLocs.push(Number.isFinite(declaration.loc) ? Number(declaration.loc) : null); + } + } + + const recordsAttempted = + nodePaths.length + + edgeSourcePaths.length + + embeddingPaths.length + + functionNodePaths.length; let recordsWritten = 0; let client; @@ -193,6 +218,44 @@ export class PersistenceAgent extends BaseAgent { recordsWritten += embeddingResult.rowCount || 0; } + await client.query('SAVEPOINT after_embeddings'); + + if (functionNodePaths.length > 0) { + const functionNodeResult = await client.query( + ` + INSERT INTO function_nodes ( + job_id, + file_path, + name, + kind, + calls, + loc + ) + SELECT + $1, + unnest($2::text[]), + unnest($3::text[]), + unnest($4::text[]), + unnest($5::jsonb[]), + unnest($6::integer[]) + ON CONFLICT (job_id, file_path, name) DO UPDATE + SET kind = EXCLUDED.kind, + calls = EXCLUDED.calls, + loc = EXCLUDED.loc + `, + [ + jobId, + functionNodePaths, + functionNodeNames, + functionNodeKinds, + functionNodeCalls, + functionNodeLocs, + ], + ); + + recordsWritten += functionNodeResult.rowCount || 0; + } + await client.query('COMMIT'); const confidence = scorePersistence({ @@ -209,6 +272,7 @@ export class PersistenceAgent extends BaseAgent { nodes: nodePaths.length, edges: edgeSourcePaths.length, embeddings: embeddingPaths.length, + functionNodes: functionNodePaths.length, }, durationMs: Date.now() - start, }, diff --git a/server/src/api/graph/routes/graph.routes.js b/server/src/api/graph/routes/graph.routes.js index c65f88d..a10dc3c 100644 --- a/server/src/api/graph/routes/graph.routes.js +++ b/server/src/api/graph/routes/graph.routes.js @@ -9,6 +9,53 @@ import { const router = Router(); +router.get('/:jobId/functions/*filePath', async (req, res, next) => { + const { jobId } = req.params; + const wildcardPath = req.params?.filePath; + const rawFilePath = Array.isArray(wildcardPath) + ? wildcardPath.join('/') + : String(wildcardPath || '').trim(); + + if (!jobId) { + return res.status(400).json({ error: 'jobId is required.' }); + } + + if (!rawFilePath) { + return res.status(400).json({ error: 'filePath is required.' }); + } + + let filePath = rawFilePath; + + try { + filePath = decodeURIComponent(rawFilePath); + } catch { + filePath = rawFilePath; + } + + try { + const result = await pgPool.query( + ` + SELECT name, kind, calls, loc + FROM function_nodes + WHERE job_id = $1 AND file_path = $2 + ORDER BY name ASC + `, + [jobId, filePath], + ); + + return res.status(200).json( + result.rows.map((row) => ({ + name: row.name, + kind: row.kind, + calls: Array.isArray(row.calls) ? row.calls : [], + loc: Number.isFinite(row.loc) ? row.loc : null, + })), + ); + } catch (error) { + return next(error); + } +}); + router.get('/:jobId', async (req, res, next) => { const { jobId } = req.params; diff --git a/server/src/infrastructure/migrations/002_function_nodes.sql b/server/src/infrastructure/migrations/002_function_nodes.sql new file mode 100644 index 0000000..b416e56 --- /dev/null +++ b/server/src/infrastructure/migrations/002_function_nodes.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS function_nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_id UUID NOT NULL REFERENCES analysis_jobs(id) ON DELETE CASCADE, + file_path TEXT NOT NULL, + name TEXT NOT NULL, + kind TEXT NOT NULL, + calls JSONB NOT NULL DEFAULT '[]', + loc INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (job_id, file_path, name) +); + +CREATE INDEX IF NOT EXISTS idx_fn_nodes_job_file ON function_nodes(job_id, file_path); From beac55c506d14a4c363d2283f44462cf838b354b Mon Sep 17 00:00:00 2001 From: SamanPandey-in Date: Mon, 30 Mar 2026 09:52:09 +0530 Subject: [PATCH 04/18] feat: saved Queries UI implemented in GraphPage --- .../features/ai/components/QueryHistory.jsx | 193 ++++++++++++++++++ client/src/features/ai/index.js | 1 + client/src/features/ai/services/aiService.js | 17 ++ client/src/features/graph/pages/GraphPage.jsx | 3 +- docs/Phase3/PHASE3_GUIDE.md | 4 +- server/package.json | 4 +- server/src/api/ai/routes/ai.routes.js | 69 +++++++ server/test/ai.queries.test.js | 127 ++++++++++++ 8 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 client/src/features/ai/components/QueryHistory.jsx create mode 100644 server/test/ai.queries.test.js diff --git a/client/src/features/ai/components/QueryHistory.jsx b/client/src/features/ai/components/QueryHistory.jsx new file mode 100644 index 0000000..2abc625 --- /dev/null +++ b/client/src/features/ai/components/QueryHistory.jsx @@ -0,0 +1,193 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { ChevronDown, History, Loader2, RotateCw } from 'lucide-react'; +import { queryGraph } from '../slices/aiSlice'; +import { aiService } from '../services/aiService'; + +const HISTORY_LIMIT = 5; + +function formatRelativeDate(value) { + if (!value) return null; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + + const now = Date.now(); + const diffMs = now - date.getTime(); + const diffMinutes = Math.floor(diffMs / (60 * 1000)); + + if (diffMinutes < 1) return 'just now'; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString(); +} + +export default function QueryHistory({ jobId }) { + const dispatch = useDispatch(); + const [isOpen, setIsOpen] = useState(false); + const [queries, setQueries] = useState([]); + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(''); + + const hasQueries = queries.length > 0; + const isLoading = status === 'loading'; + + useEffect(() => { + let cancelled = false; + + async function run() { + if (!jobId) { + if (!cancelled) { + setQueries([]); + setStatus('idle'); + setError(''); + setIsOpen(false); + } + return; + } + + setStatus('loading'); + setError(''); + + try { + const data = await aiService.getQueryHistory({ + jobId, + page: 1, + limit: HISTORY_LIMIT, + }); + + if (cancelled) return; + + setQueries(data.queries || []); + setStatus('succeeded'); + if ((data.queries || []).length === 0) { + setIsOpen(false); + } + } catch (loadError) { + if (cancelled) return; + + setQueries([]); + setStatus('failed'); + setError( + loadError?.response?.data?.error || + loadError?.message || + 'Failed to load query history.', + ); + } + } + + run(); + + return () => { + cancelled = true; + }; + }, [jobId]); + + const visibleQueries = useMemo(() => queries.slice(0, HISTORY_LIMIT), [queries]); + + if (!jobId) return null; + + return ( +
+ + + {isOpen && ( +
+ {error && ( +

{error}

+ )} + + {!error && !isLoading && visibleQueries.length === 0 && ( +

No saved queries for this analysis yet.

+ )} + + {!error && visibleQueries.length > 0 && ( +
    + {visibleQueries.map((queryItem) => ( +
  • + +
  • + ))} +
+ )} + + {!error && queries.length > HISTORY_LIMIT && ( +

+ Showing most recent {HISTORY_LIMIT} queries. +

+ )} + + {!error && !isLoading && ( + + )} +
+ )} +
+ ); +} diff --git a/client/src/features/ai/index.js b/client/src/features/ai/index.js index 3d5f8bc..65ebe2e 100644 --- a/client/src/features/ai/index.js +++ b/client/src/features/ai/index.js @@ -13,4 +13,5 @@ export { export { aiService } from './services/aiService'; export { default as QueryBar } from './components/QueryBar'; +export { default as QueryHistory } from './components/QueryHistory'; export { default as AiPanel } from './components/AiPanel'; diff --git a/client/src/features/ai/services/aiService.js b/client/src/features/ai/services/aiService.js index b068382..df616d6 100644 --- a/client/src/features/ai/services/aiService.js +++ b/client/src/features/ai/services/aiService.js @@ -57,6 +57,23 @@ export const aiService = { }); }, + async getQueryHistory({ jobId, page = 1, limit = 20 } = {}) { + const params = { + page: Math.max(1, Number.parseInt(page, 10) || 1), + limit: Math.min(50, Math.max(1, Number.parseInt(limit, 10) || 20)), + }; + + const normalizedJobId = normalizeText(jobId); + if (normalizedJobId) params.jobId = normalizedJobId; + + const { data } = await aiClient.get('/api/ai/queries', { params }); + return { + queries: Array.isArray(data?.queries) ? data.queries : [], + page: Number.isFinite(data?.page) ? data.page : params.page, + limit: Number.isFinite(data?.limit) ? data.limit : params.limit, + }; + }, + async explainNode({ jobId, filePath, nodeLabel, question }) { const normalizedJobId = normalizeText(jobId); if (!normalizedJobId) { diff --git a/client/src/features/graph/pages/GraphPage.jsx b/client/src/features/graph/pages/GraphPage.jsx index 7627ff6..1afc62d 100644 --- a/client/src/features/graph/pages/GraphPage.jsx +++ b/client/src/features/graph/pages/GraphPage.jsx @@ -11,7 +11,7 @@ import { selectGraphError, selectGraphStatus, } from '../slices/graphSlice'; -import { QueryBar } from '../../ai'; +import { QueryBar, QueryHistory } from '../../ai'; function toFiniteNumber(value) { const numberValue = Number(value); @@ -91,6 +91,7 @@ export default function GraphPage() {
+
diff --git a/docs/Phase3/PHASE3_GUIDE.md b/docs/Phase3/PHASE3_GUIDE.md index ad7c3c0..5d60ad1 100644 --- a/docs/Phase3/PHASE3_GUIDE.md +++ b/docs/Phase3/PHASE3_GUIDE.md @@ -1042,8 +1042,8 @@ const aiLimiter = rateLimit({ |---|---|---| | Week 1 | Section 1 (Phase 2 gaps) | AI panel fully works on node click - Done | | Week 1 | Section 3 (streaming explanations) | Streaming text in AiPanel - Done | -| Week 2 | Section 2 (function-level graph) | Double-click to expand file nodes | -| Week 2 | Section 5 (saved queries UI) | Query history visible in graph view | +| Week 2 | Section 2 (function-level graph) | Double-click to expand file nodes - Done | +| Week 2 | Section 5 (saved queries UI) | Query history visible in graph view - Done| | Week 3 | Section 4 (multi-language) | Python/Go repos parse correctly | | Week 3 | Section 6 (dashboard improvements) | Re-analyze + starred repos | | Week 4 | Section 7 (shareable links) | Share button in toolbar | diff --git a/server/package.json b/server/package.json index 1b2b12e..9292f76 100644 --- a/server/package.json +++ b/server/package.json @@ -10,7 +10,9 @@ "start": "node index.js", "dev": "nodemon index.js", "migrate": "psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/001_initial.sql || true; psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/002_function_nodes.sql || true", - "db:migrate": "npm run migrate" + "db:migrate": "npm run migrate", + "test": "node --test \"test/**/*.test.js\"", + "test:ai-queries": "node --test test/ai.queries.test.js" }, "dependencies": { "@babel/parser": "^7.23.6", diff --git a/server/src/api/ai/routes/ai.routes.js b/server/src/api/ai/routes/ai.routes.js index ea1d0cb..b14a5e4 100644 --- a/server/src/api/ai/routes/ai.routes.js +++ b/server/src/api/ai/routes/ai.routes.js @@ -120,6 +120,75 @@ function toGraphFromRows(nodeRows = [], edgeRows = []) { router.use(aiLimiter); +router.get('/queries', async (req, res, next) => { + const authUser = getAuthUser(req); + if (!authUser?.id) { + return res.status(401).json({ error: 'Authentication required.' }); + } + + const jobId = String(req.query?.jobId || '').trim(); + const page = Math.max(1, Number.parseInt(req.query?.page, 10) || 1); + const limit = Math.min(50, Math.max(1, Number.parseInt(req.query?.limit, 10) || 20)); + const offset = (page - 1) * limit; + + try { + const userId = await resolveDatabaseUserId(authUser); + if (!userId) { + return res.status(500).json({ error: 'Failed to resolve authenticated user.' }); + } + + if (jobId) { + const ownership = await pgPool.query( + ` + SELECT 1 + FROM analysis_jobs + WHERE id = $1 AND user_id = $2 + LIMIT 1 + `, + [jobId, userId], + ); + + if (ownership.rowCount === 0) { + return res.status(404).json({ error: 'Analysis job not found for this user.' }); + } + } + + const queryText = jobId + ? ` + SELECT id, question, answer, highlights, confidence, created_at + FROM saved_queries + WHERE user_id = $1 AND job_id = $2 + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + ` + : ` + SELECT id, question, answer, highlights, confidence, created_at + FROM saved_queries + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + `; + + const params = jobId ? [userId, jobId, limit, offset] : [userId, limit, offset]; + const result = await pgPool.query(queryText, params); + + return res.status(200).json({ + queries: result.rows.map((row) => ({ + id: row.id, + question: row.question, + answer: row.answer, + highlights: Array.isArray(row.highlights) ? row.highlights : [], + confidence: row.confidence || null, + createdAt: row.created_at, + })), + page, + limit, + }); + } catch (error) { + return next(error); + } +}); + router.post('/query', async (req, res, next) => { const authUser = getAuthUser(req); if (!authUser?.id) { diff --git a/server/test/ai.queries.test.js b/server/test/ai.queries.test.js new file mode 100644 index 0000000..d93b3bf --- /dev/null +++ b/server/test/ai.queries.test.js @@ -0,0 +1,127 @@ +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import jwt from 'jsonwebtoken'; + +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'; +process.env.DATABASE_URL = + process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5433/codegraph'; +process.env.REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379'; + +let app; +let pgPool; +let redisClient; +let server; +let baseUrl; + +before(async () => { + ({ default: app } = await import('../app.js')); + ({ pgPool, redisClient } = await import('../src/infrastructure/connections.js')); + + await new Promise((resolve) => { + server = app.listen(0, resolve); + }); + + const address = server.address(); + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +after(async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) return reject(error); + return resolve(); + }); + }); + + await redisClient.quit(); + await pgPool.end(); +}); + +test('GET /api/ai/queries requires authentication', async () => { + const response = await fetch(`${baseUrl}/api/ai/queries`); + assert.equal(response.status, 401); + + const payload = await response.json(); + assert.equal(payload.error, 'Authentication required.'); +}); + +test('GET /api/ai/queries returns paginated history for authenticated owner and job', async () => { + const userId = '8bb61d2f-0655-4db0-8c12-02dbf8b9e101'; + const repositoryId = '6b11f568-473f-4974-a14d-ad3f15ff53bf'; + const jobId = 'c77a0f11-208a-4c8d-a7dd-e525f9685f70'; + + const token = jwt.sign( + { + id: userId, + username: 'integration-user', + email: 'integration@example.com', + }, + process.env.JWT_SECRET, + { expiresIn: '1h' }, + ); + + await pgPool.query( + ` + INSERT INTO users (id, username, email) + VALUES ($1, $2, $3) + ON CONFLICT (id) DO NOTHING + `, + [userId, 'integration-user', 'integration@example.com'], + ); + + await pgPool.query( + ` + INSERT INTO repositories (id, owner_id, source, full_name) + VALUES ($1, $2, 'local', 'integration/repo') + ON CONFLICT (owner_id, full_name) DO UPDATE + SET full_name = EXCLUDED.full_name + `, + [repositoryId, userId], + ); + + await pgPool.query( + ` + INSERT INTO analysis_jobs (id, repository_id, user_id, status) + VALUES ($1, $2, $3, 'completed') + ON CONFLICT (id) DO NOTHING + `, + [jobId, repositoryId, userId], + ); + + await pgPool.query( + ` + INSERT INTO saved_queries (user_id, job_id, question, answer, highlights, confidence, created_at) + VALUES + ($1, $2, 'How is auth wired?', 'Auth explanation', '["src/auth/index.js"]'::jsonb, 'high', NOW() - INTERVAL '10 minutes'), + ($1, $2, 'Which files depend on graph?', 'Dependency answer', '["src/features/graph/GraphView.jsx"]'::jsonb, 'medium', NOW() - INTERVAL '2 minutes') + `, + [userId, jobId], + ); + + try { + const response = await fetch( + `${baseUrl}/api/ai/queries?jobId=${encodeURIComponent(jobId)}&page=1&limit=20`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + assert.equal(response.status, 200); + + const payload = await response.json(); + assert.equal(payload.page, 1); + assert.equal(payload.limit, 20); + assert.equal(Array.isArray(payload.queries), true); + assert.equal(payload.queries.length, 2); + assert.equal(payload.queries[0].question, 'Which files depend on graph?'); + assert.equal(payload.queries[1].question, 'How is auth wired?'); + assert.deepEqual(payload.queries[0].highlights, ['src/features/graph/GraphView.jsx']); + } finally { + await pgPool.query('DELETE FROM saved_queries WHERE user_id = $1 AND job_id = $2', [userId, jobId]); + await pgPool.query('DELETE FROM analysis_jobs WHERE id = $1', [jobId]); + await pgPool.query('DELETE FROM repositories WHERE id = $1', [repositoryId]); + await pgPool.query('DELETE FROM users WHERE id = $1', [userId]); + } +}); From a55b604acb8cac9c02c123439a66a8a0e868bf54 Mon Sep 17 00:00:00 2001 From: SamanPandey-in Date: Mon, 30 Mar 2026 10:10:43 +0530 Subject: [PATCH 05/18] feat: added go and python parsing support --- server/src/agents/graph/GraphBuilderAgent.js | 2 +- server/src/agents/parser/ParserAgent.js | 15 ++- server/src/agents/parser/goWorker.js | 107 ++++++++++++++++++ server/src/agents/parser/pythonWorker.js | 112 +++++++++++++++++++ server/src/agents/scanner/ScannerAgent.js | 9 +- server/test/parser.multilang.test.js | 88 +++++++++++++++ 6 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 server/src/agents/parser/goWorker.js create mode 100644 server/src/agents/parser/pythonWorker.js create mode 100644 server/test/parser.multilang.test.js diff --git a/server/src/agents/graph/GraphBuilderAgent.js b/server/src/agents/graph/GraphBuilderAgent.js index 3bd2193..8c6d987 100644 --- a/server/src/agents/graph/GraphBuilderAgent.js +++ b/server/src/agents/graph/GraphBuilderAgent.js @@ -3,7 +3,7 @@ import { existsSync } from 'fs'; import { BaseAgent } from '../core/BaseAgent.js'; import { scoreGraphBuilder } from '../core/confidence.js'; -const RESOLVE_EXTS = ['.js', '.ts', '.jsx', '.tsx']; +const RESOLVE_EXTS = ['.js', '.ts', '.jsx', '.tsx', '.py', '.go']; function inferFileType(relPath) { const normalized = relPath.replace(/\\/g, '/').toLowerCase(); diff --git a/server/src/agents/parser/ParserAgent.js b/server/src/agents/parser/ParserAgent.js index b450ddd..b5393f6 100644 --- a/server/src/agents/parser/ParserAgent.js +++ b/server/src/agents/parser/ParserAgent.js @@ -15,6 +15,10 @@ function parseConcurrency() { return Math.max(1, os.cpus().length - 1); } +function buildWorkerExecArgv() { + return []; +} + export class ParserAgent extends BaseAgent { agentId = 'parser-agent'; maxRetries = 2; @@ -103,10 +107,17 @@ export class ParserAgent extends BaseAgent { } _parseInWorker(filePath, relativePath) { + const ext = path.extname(filePath).toLowerCase(); + const workerFile = ext === '.py' + ? './pythonWorker.js' + : ext === '.go' + ? './goWorker.js' + : './parseWorker.js'; + return new Promise((resolve) => { - const worker = new Worker(new URL('./parseWorker.js', import.meta.url), { + const worker = new Worker(new URL(workerFile, import.meta.url), { workerData: { filePath, relativePath }, - execArgv: process.execArgv.filter((arg) => !String(arg).startsWith('--input-type')), + execArgv: buildWorkerExecArgv(), }); worker.once('message', (result) => { diff --git a/server/src/agents/parser/goWorker.js b/server/src/agents/parser/goWorker.js new file mode 100644 index 0000000..a2f8081 --- /dev/null +++ b/server/src/agents/parser/goWorker.js @@ -0,0 +1,107 @@ +import { readFile } from 'fs/promises'; +import { parentPort, workerData } from 'worker_threads'; + +const { filePath, relativePath } = workerData; + +function uniquePush(target, seen, value) { + if (!value || seen.has(value)) return; + seen.add(value); + target.push(value); +} + +function pushDeclaration(target, seen, name, kind) { + if (!name || seen.has(name)) return; + seen.add(name); + target.push({ name, kind }); +} + +function extractImports(code) { + const imports = []; + const seen = new Set(); + + const importBlockRegex = /import\s*\(([^)]*)\)/gms; + let blockMatch; + while ((blockMatch = importBlockRegex.exec(code)) !== null) { + const block = blockMatch[1] || ''; + const quoted = block.match(/"([^"]+)"/g) || []; + + for (const entry of quoted) { + uniquePush(imports, seen, entry.replaceAll('"', '')); + } + } + + const singleImportRegex = /^\s*import\s+(?:[\w.]+\s+)?"([^"]+)"/gm; + let singleMatch; + while ((singleMatch = singleImportRegex.exec(code)) !== null) { + uniquePush(imports, seen, singleMatch[1]); + } + + return imports; +} + +function extractDeclarations(lines) { + const declarations = []; + const seen = new Set(); + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith('//')) continue; + + const functionMatch = line.match(/^func\s+(?:\([^)]*\)\s*)?(\w+)\s*\(/); + if (functionMatch) { + pushDeclaration(declarations, seen, functionMatch[1], 'function'); + continue; + } + + const structMatch = line.match(/^type\s+(\w+)\s+struct\b/); + if (structMatch) { + pushDeclaration(declarations, seen, structMatch[1], 'struct'); + continue; + } + + const interfaceMatch = line.match(/^type\s+(\w+)\s+interface\b/); + if (interfaceMatch) { + pushDeclaration(declarations, seen, interfaceMatch[1], 'interface'); + continue; + } + + const typeAliasMatch = line.match(/^type\s+(\w+)\s+[\w\[\]*]+/); + if (typeAliasMatch) { + pushDeclaration(declarations, seen, typeAliasMatch[1], 'type'); + } + } + + return declarations; +} + +async function run() { + const code = await readFile(filePath, 'utf8'); + const lines = code.split(/\r?\n/); + + const imports = extractImports(code); + const declarations = extractDeclarations(lines); + + parentPort.postMessage({ + relativePath, + imports, + declarations, + functionNodes: [], + metrics: { + loc: lines.length, + importCount: imports.length, + declarationCount: declarations.length, + }, + parseError: null, + }); +} + +run().catch((error) => { + parentPort.postMessage({ + relativePath, + imports: [], + declarations: [], + functionNodes: [], + metrics: {}, + parseError: error.message, + }); +}); diff --git a/server/src/agents/parser/pythonWorker.js b/server/src/agents/parser/pythonWorker.js new file mode 100644 index 0000000..625c69d --- /dev/null +++ b/server/src/agents/parser/pythonWorker.js @@ -0,0 +1,112 @@ +import { readFile } from 'fs/promises'; +import { parentPort, workerData } from 'worker_threads'; + +const { filePath, relativePath } = workerData; + +function pushDeclaration(declarations, seen, name, kind) { + if (!name || seen.has(name)) return; + seen.add(name); + declarations.push({ name, kind }); +} + +function normalizeImportTarget(target) { + const normalized = String(target || '').trim(); + if (!normalized) return null; + + if (!normalized.startsWith('.')) return normalized; + + const leadingDots = normalized.match(/^\.+/)?.[0]?.length || 0; + const suffix = normalized.slice(leadingDots).replace(/\./g, '/'); + + if (leadingDots <= 1) { + return suffix ? `./${suffix}` : './'; + } + + const parentPrefix = '../'.repeat(leadingDots - 1); + return suffix ? `${parentPrefix}${suffix}` : parentPrefix; +} + +function extractImports(lines) { + const imports = []; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + + const fromImport = line.match(/^from\s+([.\w]+)\s+import\s+(.+)$/); + if (fromImport) { + const target = normalizeImportTarget(fromImport[1]); + if (target) imports.push(target); + continue; + } + + const directImport = line.match(/^import\s+(.+)$/); + if (!directImport) continue; + + const firstSpecifier = directImport[1] + .split(',')[0] + .trim() + .split(/\s+as\s+/i)[0] + .trim(); + + const target = normalizeImportTarget(firstSpecifier); + if (target) imports.push(target); + } + + return imports; +} + +function extractDeclarations(lines) { + const declarations = []; + const seen = new Set(); + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + + const functionMatch = line.match(/^(?:async\s+)?def\s+(\w+)\s*\(/); + if (functionMatch) { + pushDeclaration(declarations, seen, functionMatch[1], 'function'); + continue; + } + + const classMatch = line.match(/^class\s+(\w+)[\s:(]/); + if (classMatch) { + pushDeclaration(declarations, seen, classMatch[1], 'class'); + } + } + + return declarations; +} + +async function run() { + const code = await readFile(filePath, 'utf8'); + const lines = code.split(/\r?\n/); + + const imports = extractImports(lines); + const declarations = extractDeclarations(lines); + + parentPort.postMessage({ + relativePath, + imports, + declarations, + functionNodes: [], + metrics: { + loc: lines.length, + importCount: imports.length, + declarationCount: declarations.length, + }, + parseError: null, + }); +} + +run().catch((error) => { + parentPort.postMessage({ + relativePath, + imports: [], + declarations: [], + functionNodes: [], + metrics: {}, + parseError: error.message, + }); +}); diff --git a/server/src/agents/scanner/ScannerAgent.js b/server/src/agents/scanner/ScannerAgent.js index 0551c00..0912b84 100644 --- a/server/src/agents/scanner/ScannerAgent.js +++ b/server/src/agents/scanner/ScannerAgent.js @@ -16,7 +16,14 @@ const DEFAULT_SKIP_DIRS = new Set([ '.vercel', ]); -const ALLOWED_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']); +const ALLOWED_EXTENSIONS = new Set([ + '.js', + '.ts', + '.jsx', + '.tsx', + '.py', + '.go', +]); function normalizeRelative(filePath, rootDir) { return path.relative(rootDir, filePath).replace(/\\/g, '/'); diff --git a/server/test/parser.multilang.test.js b/server/test/parser.multilang.test.js new file mode 100644 index 0000000..682fad6 --- /dev/null +++ b/server/test/parser.multilang.test.js @@ -0,0 +1,88 @@ +import { after, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile, mkdir } from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { ParserAgent } from '../src/agents/parser/ParserAgent.js'; + +const tempDirs = []; + +after(async () => { + for (const dir of tempDirs) { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('ParserAgent parses Python and Go files via language workers', async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'codegraph-parser-')); + tempDirs.push(rootDir); + + const pyPath = path.join(rootDir, 'service.py'); + const goPath = path.join(rootDir, 'service.go'); + + await mkdir(path.join(rootDir, 'pkg'), { recursive: true }); + + await writeFile( + pyPath, + [ + 'from .pkg import auth', + 'import requests', + '', + 'class AuthService:', + ' pass', + '', + 'async def login(user):', + ' return user', + ].join('\n'), + 'utf8', + ); + + await writeFile( + goPath, + [ + 'package service', + '', + 'import (', + ' "fmt"', + ' alias "net/http"', + ')', + '', + 'type Service struct {}', + '', + 'func (s Service) Handle() {', + ' fmt.Println("ok")', + '}', + ].join('\n'), + 'utf8', + ); + + const parser = new ParserAgent(); + + const result = await parser.process( + { + extractedPath: rootDir, + manifest: [ + { absolutePath: pyPath, relativePath: 'service.py' }, + { absolutePath: goPath, relativePath: 'service.go' }, + ], + }, + { jobId: 'test-job' }, + ); + + assert.equal(result.status, 'success'); + assert.equal(result.data.parsedFiles.length, 2); + + const pyResult = result.data.parsedFiles.find((file) => file.relativePath === 'service.py'); + assert.ok(pyResult); + assert.equal(pyResult.parseError, null); + assert.deepEqual(pyResult.imports, ['./pkg', 'requests']); + assert.equal(pyResult.declarations.some((entry) => entry.name === 'login' && entry.kind === 'function'), true); + assert.equal(pyResult.declarations.some((entry) => entry.name === 'AuthService' && entry.kind === 'class'), true); + + const goResult = result.data.parsedFiles.find((file) => file.relativePath === 'service.go'); + assert.ok(goResult); + assert.equal(goResult.parseError, null); + assert.deepEqual(goResult.imports, ['fmt', 'net/http']); + assert.equal(goResult.declarations.some((entry) => entry.name === 'Handle' && entry.kind === 'function'), true); + assert.equal(goResult.declarations.some((entry) => entry.name === 'Service' && entry.kind === 'struct'), true); +}); From a40567cdcde699729517b1a5c05192faf4ee6578 Mon Sep 17 00:00:00 2001 From: SamanPandey-in Date: Mon, 30 Mar 2026 12:25:06 +0530 Subject: [PATCH 06/18] feat: Dashboard Re-Analyze + Starred Repos working --- client/src/features/dashboard/index.js | 1 + .../dashboard/pages/DashboardPage.jsx | 83 ++++++++++++++++++- .../dashboard/services/dashboardService.js | 9 ++ .../dashboard/slices/dashboardSlice.js | 43 ++++++++++ .../features/graph/components/AnalyzeForm.jsx | 42 ++++++++++ docs/Phase3/PHASE3_GUIDE.md | 6 +- .../routes/repositories.routes.js | 62 +++++++++++++- 7 files changed, 241 insertions(+), 5 deletions(-) diff --git a/client/src/features/dashboard/index.js b/client/src/features/dashboard/index.js index a827480..dd9c992 100644 --- a/client/src/features/dashboard/index.js +++ b/client/src/features/dashboard/index.js @@ -3,6 +3,7 @@ export { default as DashboardPage } from './pages/DashboardPage'; export { fetchAnalyzedRepositories, fetchRepositoryJobs, + toggleRepositoryStar, selectDashboardStatus, selectDashboardError, selectAnalyzedRepositories, diff --git a/client/src/features/dashboard/pages/DashboardPage.jsx b/client/src/features/dashboard/pages/DashboardPage.jsx index 4c03412..bfdacf6 100644 --- a/client/src/features/dashboard/pages/DashboardPage.jsx +++ b/client/src/features/dashboard/pages/DashboardPage.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { Link, useSearchParams } from 'react-router-dom'; +import { Link, useSearchParams, useNavigate } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { Network, @@ -15,6 +15,8 @@ import { ChevronDown, ChevronUp, Loader2, + Star, + RotateCcw, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -37,12 +39,14 @@ import { useAuth } from '@/features/auth/context/AuthContext'; import { fetchAnalyzedRepositories, fetchRepositoryJobs, + toggleRepositoryStar, selectAnalyzedRepositories, selectDashboardError, selectRepositoryJobsById, selectDashboardStatus, selectDashboardSummary, } from '../index'; +import { analyzeCodebase } from '@/features/graph/slices/graphSlice'; const QUICK_ACTIONS = [ { @@ -162,6 +166,7 @@ function RepositoryListSkeleton() { export default function DashboardPage() { const [searchParams, setSearchParams] = useSearchParams(); const dispatch = useDispatch(); + const navigate = useNavigate(); const { user } = useAuth(); const [sortBy, setSortBy] = useState(() => parseSortFromQuery(searchParams.get('sort')), @@ -171,6 +176,8 @@ export default function DashboardPage() { ); const [searchTerm, setSearchTerm] = useState(() => searchParams.get('q') || ''); const [expandedRepos, setExpandedRepos] = useState({}); + const [starringRepoId, setStarringRepoId] = useState(null); + const [reanalyzingRepoId, setReanalyzingRepoId] = useState(null); const status = useSelector(selectDashboardStatus); const error = useSelector(selectDashboardError); @@ -266,6 +273,10 @@ export default function DashboardPage() { }); return filtered.toSorted((a, b) => { + if (a.isStarred !== b.isStarred) { + return a.isStarred ? -1 : 1; + } + if (sortBy === 'oldest') { return getAnalysisTime(a) - getAnalysisTime(b); } @@ -341,6 +352,46 @@ export default function DashboardPage() { }; }; + const handleToggleStar = async (repoId, e) => { + e?.preventDefault(); + setStarringRepoId(repoId); + try { + await dispatch(toggleRepositoryStar({ repositoryId: repoId })).unwrap(); + } catch (error) { + console.error('Failed to toggle star:', error); + } finally { + setStarringRepoId(null); + } + }; + + const handleReanalyze = (repo, e) => { + e?.preventDefault(); + e?.stopPropagation(); + setReanalyzingRepoId(repo.id); + + const config = + repo.source === 'local' + ? { + source: 'local', + localPath: repo.fullName, + } + : { + source: 'github', + github: { + mode: + repo.githubMode || + (repo.sourceCategory === 'github-public' ? 'public' : 'owned'), + owner: repo.owner, + repo: repo.name, + branch: repo.branch || 'main', + }, + }; + + dispatch(analyzeCodebase(config)); + navigate('/graph'); + setReanalyzingRepoId(null); + }; + return (
@@ -615,6 +666,36 @@ export default function DashboardPage() {
+ + + + + ) : null} + + + ))} +
+ + {currentPlan !== 'free' && ( +
+ +
+ )} +
+ ); +} +``` + +Add to `client/.env.example`: +```bash +VITE_STRIPE_PRICE_PRO=price_... +VITE_STRIPE_PRICE_TEAM=price_... +``` + +Register in `client/src/App.jsx` routes: +```jsx +} /> +``` + +--- + +### Section P4-2: Team Workspaces + +#### P4-2.1 Schema + +**File:** `server/src/infrastructure/migrations/006_teams.sql` + +```sql +CREATE TABLE teams ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + owner_id UUID NOT NULL REFERENCES users(id), + plan TEXT NOT NULL DEFAULT 'team', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE team_members ( + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', -- owner | admin | member + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (team_id, user_id) +); + +CREATE TABLE team_repositories ( + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + PRIMARY KEY (team_id, repository_id) +); +``` + +#### P4-2.2 Shared repo visibility + +When `repositories.owner_id` refers to a team member, all members of that team can see the repo in their dashboard. Add a `team_id` column to `repositories`: + +```sql +ALTER TABLE repositories ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id); +``` + +Modify the `GET /api/repositories` query to include repos from the user's team: + +```sql +WHERE r.owner_id = $1 + OR r.team_id IN (SELECT team_id FROM team_members WHERE user_id = $1) +``` + +#### P4-2.3 Team invite endpoint + +``` +POST /api/teams/:teamId/invite → { inviteToken } +GET /api/teams/join/:token → joins team, redirects to dashboard +GET /api/teams/:teamId/members → lists members with roles +PATCH /api/teams/:teamId/members/:userId → change role +DELETE /api/teams/:teamId/members/:userId → remove +``` + +--- + +### Section P4-3: Refactor Intelligence + +#### P4-3.1 Complexity heatmap endpoint + +**New route in** `server/src/api/graph/routes/graph.routes.js`: + +```js +// GET /api/graph/:jobId/heatmap +// Returns nodes sorted by complexity: cyclomatic complexity × inDegree (fan-in) +router.get('/:jobId/heatmap', async (req, res, next) => { + try { + const result = await pgPool.query( + `SELECT file_path, file_type, metrics, + (metrics->>'inDegree')::int * COALESCE((metrics->>'complexity')::numeric, 1) AS risk_score + FROM graph_nodes + WHERE job_id = $1 + ORDER BY risk_score DESC + LIMIT 50`, + [req.params.jobId], + ); + + return res.json({ + hotspots: result.rows.map(row => ({ + filePath: row.file_path, + type: row.file_type, + riskScore: parseFloat(row.risk_score) || 0, + inDegree: row.metrics?.inDegree || 0, + loc: row.metrics?.loc || 0, + })), + }); + } catch (err) { + return next(err); + } +}); +``` + +#### P4-3.2 AI refactor suggestions endpoint + +**New route in** `server/src/api/ai/routes/ai.routes.js`: + +```js +// POST /api/ai/suggest-refactor +// Body: { jobId, filePath } +// Returns: structured refactor recommendations for a high-complexity file +router.post('/suggest-refactor', requirePlan('pro', 'team'), async (req, res, next) => { + const { jobId, filePath } = req.body; + if (!jobId || !filePath) return res.status(400).json({ error: 'jobId and filePath are required.' }); + + try { + // Load node data + const nodeResult = await pgPool.query( + `SELECT file_path, file_type, declarations, metrics, summary + FROM graph_nodes WHERE job_id = $1 AND file_path = $2`, + [jobId, filePath], + ); + if (nodeResult.rowCount === 0) return res.status(404).json({ error: 'File not found.' }); + + const node = nodeResult.rows[0]; + const prompt = `You are a senior software architect reviewing a file in a dependency graph analysis. + +File: ${node.file_path} +Type: ${node.file_type} +Lines of code: ${node.metrics?.loc || 'unknown'} +In-degree (files that import this): ${node.metrics?.inDegree || 0} +Out-degree (files this imports): ${node.metrics?.outDegree || 0} +Exports: ${(node.declarations || []).map(d => d.name).join(', ') || 'none'} +Summary: ${node.summary || 'no summary available'} + +Respond with a JSON object: +{ + "concerns": ["list of specific architectural concerns"], + "suggestions": ["list of concrete refactoring steps"], + "priority": "high | medium | low", + "estimatedEffort": "hours estimate as a string, e.g. '2–4 hours'" +} +Only respond with the JSON object.`; + + const response = await openaiClient.chat.completions.create({ + model: process.env.OPENAI_MODEL || 'gpt-4o-mini', + max_tokens: 400, + temperature: 0.2, + messages: [{ role: 'user', content: prompt }], + }); + + let result; + try { + result = JSON.parse(response.choices[0].message.content.trim()); + } catch { + result = { concerns: [], suggestions: [response.choices[0].message.content], priority: 'medium', estimatedEffort: 'unknown' }; + } + + return res.json({ filePath, ...result }); + } catch (err) { + return next(err); + } +}); +``` + +#### P4-3.3 Client — Heatmap view in GraphToolbar + +Add a toggle in `GraphToolbar.jsx` to switch between normal graph view and heatmap overlay. When enabled, the heatmap endpoint is called and node colours are overridden by risk score (green → yellow → red). + +```jsx +// In GraphToolbar, add state: +const [heatmapMode, setHeatmapMode] = useState(false); + +// Pass to GraphView via Redux or prop: + +``` + +In `GraphView.jsx`, when `heatmapMode` is true, override node colour based on `metrics.inDegree * metrics.loc`: + +```js +function riskToColor(inDegree = 0, loc = 0) { + const score = inDegree * (loc / 100); + if (score > 20) return '#ef4444'; // red + if (score > 8) return '#f59e0b'; // amber + return '#22c55e'; // green +} +``` + +--- + +### Section P4-4: GitHub Checks API (PR Status Checks) + +Instead of (or in addition to) posting a comment, report CodeGraph impact analysis as a GitHub Checks status. This shows a green/red/neutral badge directly on the PR. + +#### P4-4.1 Server + +Add to `GitHubPRService.js`: + +```js +async createCheckRun(owner, repo, sha, { conclusion, title, summary, detailsUrl }) { + if (!this.isConfigured()) return; + + const response = await this.client.post(`/repos/${owner}/${repo}/check-runs`, { + name: 'CodeGraph Impact Analysis', + head_sha: sha, + status: 'completed', + conclusion, // 'success' | 'failure' | 'neutral' + details_url: detailsUrl, + output: { title, summary }, + }); + + return response.data; +} +``` + +In `_tryPostPRComment()` in SupervisorAgent, after posting the comment, also create a check run: + +```js +const sha = input?.github?.headSha; // add this to webhook payload +if (sha) { + const conclusion = impactedFiles.size > 10 ? 'failure' : 'neutral'; + await GitHubPRService.createCheckRun(owner, repo, sha, { + conclusion, + title: `${impactedFiles.size} files potentially impacted`, + summary: `${changedFiles.length} changed files affect ${impactedFiles.size} dependent files.`, + detailsUrl: graphUrl, + }); +} +``` + +Update the webhook to also capture `head_sha`: + +```js +// In github.webhook.js: +const headSha = payload?.pull_request?.head?.sha; +// Pass in input: { ...github: { owner, repo, branch, prNumber, prTitle, headSha } } +``` + +--- + +### Section P4-5: VS Code Extension + +The VS Code extension brings the graph directly into the editor, letting developers see dependencies, impact, and AI explanations without leaving their IDE. + +#### P4-5.1 Bootstrap the extension + +```bash +npm install -g yo generator-code +yo code +# Choose: New Extension (TypeScript) +# Name: codegraph-ai +# Display name: CodeGraph AI +``` + +#### P4-5.2 Extension structure + +``` +vscode-extension/ +├── src/ +│ ├── extension.ts ← activate(), register commands +│ ├── GraphPanel.ts ← WebviewPanel showing React graph +│ ├── HoverProvider.ts ← shows summary + deps on hover +│ └── ApiClient.ts ← talks to CodeGraph backend +├── package.json ← extension manifest, contributes +└── README.md +``` + +#### P4-5.3 Core extension code + +**File:** `vscode-extension/src/extension.ts` + +```typescript +import * as vscode from 'vscode'; +import { GraphPanel } from './GraphPanel'; +import { HoverProvider } from './HoverProvider'; +import { ApiClient } from './ApiClient'; + +export function activate(context: vscode.ExtensionContext) { + const apiClient = new ApiClient( + vscode.workspace.getConfiguration('codegraphAi').get('serverUrl') || 'http://localhost:5000', + vscode.workspace.getConfiguration('codegraphAi').get('apiToken') || '' + ); + + // Command: Open graph for current workspace + context.subscriptions.push( + vscode.commands.registerCommand('codegraphAi.openGraph', async () => { + const repoPath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!repoPath) { + vscode.window.showErrorMessage('No workspace folder open.'); + return; + } + GraphPanel.createOrShow(context.extensionUri, apiClient, repoPath); + }) + ); + + // Hover: show file summary + dep count + context.subscriptions.push( + vscode.languages.registerHoverProvider( + ['javascript', 'typescript', 'javascriptreact', 'typescriptreact', 'python', 'go'], + new HoverProvider(apiClient) + ) + ); +} + +export function deactivate() {} +``` + +**File:** `vscode-extension/src/HoverProvider.ts` + +```typescript +import * as vscode from 'vscode'; +import { ApiClient } from './ApiClient'; + +export class HoverProvider implements vscode.HoverProvider { + constructor(private api: ApiClient) {} + + async provideHover(document: vscode.TextDocument): Promise { + const jobId = this.api.currentJobId; + if (!jobId) return null; + + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + const relativePath = document.uri.fsPath.replace(workspaceRoot + '/', ''); + + try { + const graph = await this.api.getGraph(jobId); + const node = graph?.graph?.[relativePath]; + if (!node) return null; + + const markdown = new vscode.MarkdownString(); + markdown.isTrusted = true; + markdown.appendMarkdown(`**CodeGraph AI** — \`${relativePath}\`\n\n`); + if (node.summary) markdown.appendMarkdown(`${node.summary}\n\n`); + markdown.appendMarkdown(`- **Deps:** ${node.deps?.length || 0} `); + markdown.appendMarkdown(`**Used by:** ${Object.values(graph.graph).filter((n: any) => n.deps?.includes(relativePath)).length}\n\n`); + markdown.appendMarkdown(`[Open in Graph](command:codegraphAi.openGraph)`); + + return new vscode.Hover(markdown); + } catch { + return null; + } + } +} +``` + +**File:** `vscode-extension/package.json` — key fields: + +```json +{ + "contributes": { + "commands": [ + { "command": "codegraphAi.openGraph", "title": "CodeGraph AI: Open Graph" } + ], + "configuration": { + "title": "CodeGraph AI", + "properties": { + "codegraphAi.serverUrl": { + "type": "string", + "default": "http://localhost:5000", + "description": "CodeGraph AI server URL" + }, + "codegraphAi.apiToken": { + "type": "string", + "default": "", + "description": "JWT token for authentication" + } + } + } + }, + "activationEvents": ["workspaceContains:**/*.{js,ts,jsx,tsx,py,go}"] +} +``` + +--- + +### Phase 4 Build Sequence + +| Sprint | Section | Duration | What ships | +|---|---|---|---| +| 1 | P4-1 Stripe | 3 days | Checkout, billing page, webhook, plan sync | +| 2 | Phase 3 Bug Fixes 1–8 | 1 day | All 8 bugs resolved, PR flow working | +| 2 | P4-4 GitHub Checks | 1 day | Green/red check on PRs | +| 3 | P4-2 Teams | 4 days | Team schema, invite flow, shared repos | +| 4 | P4-3 Refactor Intel | 2 days | Heatmap endpoint + toggle + AI suggestions | +| 5 | P4-5 VS Code Extension | 5 days | Hover provider, graph WebviewPanel | + +--- + +### New Env Variables Added in Phase 4 + +**`server/.env`:** +```bash +STRIPE_SECRET_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... +STRIPE_PRICE_PRO_MONTHLY=price_... +STRIPE_PRICE_TEAM_MONTHLY=price_... +``` + +**`client/.env`:** +```bash +VITE_STRIPE_PRICE_PRO=price_... +VITE_STRIPE_PRICE_TEAM=price_... +``` + +**`vscode-extension/.vscodeignore` and `package.json`:** +Publish to the VS Code Marketplace via `vsce package && vsce publish`. + +--- + +### New Files Summary + +``` +server/ +└── src/ + ├── api/ + │ └── billing/ + │ ├── billing.routes.js ← checkout + portal + │ └── stripe.webhook.js ← plan sync from Stripe events + └── infrastructure/migrations/ + ├── 004_analysis_jobs_metadata.sql ← adds metadata col + audit_logs + ├── 005_billing.sql ← subscriptions + usage_events + └── 006_teams.sql ← teams + members + team_repos + +client/ +└── src/features/settings/ + └── pages/BillingPage.jsx ← billing UI + +vscode-extension/ ← new root folder +├── src/ +│ ├── extension.ts +│ ├── GraphPanel.ts +│ ├── HoverProvider.ts +│ └── ApiClient.ts +└── package.json +``` diff --git a/server/.env.example b/server/.env.example index bc9491e..fc6d393 100644 --- a/server/.env.example +++ b/server/.env.example @@ -35,9 +35,7 @@ GITHUB_WEBHOOK_SECRET=your_github_webhook_secret_here # Generate at: https://github.com/settings/tokens GITHUB_TOKEN=ghp_your_github_personal_access_token_here -# =============================== -# CORS -# =============================== +# The public URL of the client app (used for share links, PR comments, etc.) CLIENT_URL=http://localhost:5173 # =============================== diff --git a/server/src/api/ai/routes/ai.routes.js b/server/src/api/ai/routes/ai.routes.js index 12483d6..0927901 100644 --- a/server/src/api/ai/routes/ai.routes.js +++ b/server/src/api/ai/routes/ai.routes.js @@ -5,7 +5,6 @@ import OpenAI from 'openai'; import { QueryAgent } from '../../../agents/query/QueryAgent.js'; import { AnalysisAgent } from '../../../agents/analysis/AnalysisAgent.js'; import { pgPool, redisClient } from '../../../infrastructure/connections.js'; -import { requirePlan } from '../../../middleware/planGuard.middleware.js'; const router = Router(); const openaiClient = process.env.OPENAI_API_KEY @@ -206,7 +205,7 @@ router.get('/queries', async (req, res, next) => { } }); -router.post('/query', requirePlan('pro', 'team'), async (req, res, next) => { +router.post('/query', async (req, res, next) => { const authUser = getAuthUser(req); if (!authUser?.id) { return res.status(401).json({ error: 'Authentication required.' }); diff --git a/server/src/api/graph/routes/graph.routes.js b/server/src/api/graph/routes/graph.routes.js index 9e8bb99..a54b68a 100644 --- a/server/src/api/graph/routes/graph.routes.js +++ b/server/src/api/graph/routes/graph.routes.js @@ -8,9 +8,7 @@ const router = Router(); const SHARE_VISIBILITY = new Set(['unlisted', 'public']); function buildShareUrl(token) { - const baseUrl = - String(process.env.VITE_SHARE_BASE_URL || process.env.CLIENT_URL || '').trim() || - 'http://localhost:5173'; + const baseUrl = String(process.env.CLIENT_URL || 'http://localhost:5173').trim(); try { const url = new URL('/graph', baseUrl); diff --git a/server/src/infrastructure/migrations/001_initial.sql b/server/src/infrastructure/migrations/001_initial.sql index 1a730a2..ba3f7c5 100644 --- a/server/src/infrastructure/migrations/001_initial.sql +++ b/server/src/infrastructure/migrations/001_initial.sql @@ -7,7 +7,7 @@ CREATE TABLE users ( username TEXT NOT NULL, email TEXT, avatar_url TEXT, - plan TEXT NOT NULL DEFAULT 'free', -- free | pro | team + plan TEXT NOT NULL DEFAULT 'free', -- all features currently available on free created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); diff --git a/server/src/middleware/planGuard.middleware.js b/server/src/middleware/planGuard.middleware.js index 5434f1d..13512a6 100644 --- a/server/src/middleware/planGuard.middleware.js +++ b/server/src/middleware/planGuard.middleware.js @@ -5,9 +5,7 @@ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const PLAN_LIMITS = { - free: { reposPerMonth: 3, aiQueriesPerDay: 10 }, - pro: { reposPerMonth: Number.POSITIVE_INFINITY, aiQueriesPerDay: 200 }, - team: { reposPerMonth: Number.POSITIVE_INFINITY, aiQueriesPerDay: 1000 }, + free: { reposPerMonth: Number.POSITIVE_INFINITY, aiQueriesPerDay: Number.POSITIVE_INFINITY }, }; function isUuid(value) { @@ -83,9 +81,7 @@ async function resolveDatabaseUserId(authUser) { return upserted.rows[0]?.id || null; } -export function requirePlan(...allowedPlans) { - const required = new Set(allowedPlans.map((plan) => String(plan || '').trim().toLowerCase())); - +export function requirePlan(..._allowedPlans) { return async (req, res, next) => { try { const authUser = getAuthUser(req); @@ -98,29 +94,8 @@ export function requirePlan(...allowedPlans) { return res.status(500).json({ error: 'Failed to resolve authenticated user.' }); } - const result = await pgPool.query( - ` - SELECT plan - FROM users - WHERE id = $1 - LIMIT 1 - `, - [userId], - ); - - const currentPlan = String(result.rows[0]?.plan || 'free').toLowerCase(); - - if (required.size > 0 && !required.has(currentPlan)) { - return res.status(403).json({ - error: 'This feature requires a higher plan.', - currentPlan, - requiredPlans: [...required], - upgradeUrl: '/settings/billing', - }); - } - - req.userPlan = currentPlan; - req.planLimits = PLAN_LIMITS[currentPlan] || PLAN_LIMITS.free; + req.userPlan = 'free'; + req.planLimits = PLAN_LIMITS.free; req.userId = userId; return next(); diff --git a/server/src/services/ImpactAnalysisService.js b/server/src/services/ImpactAnalysisService.js index 3aef8ef..4c9442e 100644 --- a/server/src/services/ImpactAnalysisService.js +++ b/server/src/services/ImpactAnalysisService.js @@ -1,67 +1,37 @@ import { pgPool } from '../infrastructure/connections.js'; -/** - * Impact Analysis Service - * Analyzes code graph to determine which files are impacted by changes - */ - class ImpactAnalysisService { - /** - * Find all files impacted by changed files - * Traverses the dependency graph to find files that depend on changed files - * - * @param {string} jobId - Analysis job ID - * @param {Array} changedFiles - Array of file paths that changed - * @param {number} maxDepth - Maximum dependency depth to traverse (default: 3) - * @returns {Promise<{impactedFiles: Set, depth: number}>} - */ async findImpactedFiles(jobId, changedFiles, maxDepth = 3) { if (!jobId || changedFiles.length === 0) { return { impactedFiles: new Set(), depth: 0 }; } - const impactedFiles = new Set(); - const visited = new Set(changedFiles); - let currentLevel = changedFiles; - let depth = 0; - try { - // Fetch the entire graph for this job - const graphResult = await pgPool.query( + // Build reverse adjacency: target_path -> [source files that import it] + const edgeResult = await pgPool.query( ` - SELECT relativePath, dependencies - FROM graph_nodes - WHERE jobId = $1 + SELECT source_path, target_path + FROM graph_edges + WHERE job_id = $1 `, [jobId], ); - if (graphResult.rowCount === 0) { - return { impactedFiles: new Set(), depth: 0 }; + const reverseMap = new Map(); + for (const row of edgeResult.rows) { + if (!reverseMap.has(row.target_path)) reverseMap.set(row.target_path, []); + reverseMap.get(row.target_path).push(row.source_path); } - // Build an adjacency map: file -> files that depend on it - const dependencyMap = new Map(); - const allNodes = graphResult.rows; + const impactedFiles = new Set(); + const visited = new Set(changedFiles); + let currentLevel = changedFiles; + let depth = 0; - for (const node of allNodes) { - const deps = node.dependencies || []; - for (const dep of deps) { - if (!dependencyMap.has(dep)) { - dependencyMap.set(dep, []); - } - dependencyMap.get(dep).push(node.relativePath); - } - } - - // BFS traversal: find all files that depend on changed files while (currentLevel.length > 0 && depth < maxDepth) { const nextLevel = []; - for (const file of currentLevel) { - const dependents = dependencyMap.get(file) || []; - - for (const dependent of dependents) { + for (const dependent of reverseMap.get(file) || []) { if (!visited.has(dependent)) { visited.add(dependent); impactedFiles.add(dependent); @@ -69,25 +39,17 @@ class ImpactAnalysisService { } } } - currentLevel = nextLevel; depth++; } return { impactedFiles, depth }; } catch (err) { - console.error('Failed to find impacted files:', err); + console.error('[ImpactAnalysisService] findImpactedFiles failed:', err.message); return { impactedFiles: new Set(), depth: 0 }; } } - /** - * Analyze which files are safe to change (no dependents) - * - * @param {string} jobId - Analysis job ID - * @param {Array} changedFiles - Array of file paths that changed - * @returns {Promise<{safeFiles: Array, riskyFiles: Array}>} - */ async analyzeChangeRisk(jobId, changedFiles) { if (!jobId || changedFiles.length === 0) { return { safeFiles: [], riskyFiles: [] }; @@ -96,68 +58,33 @@ class ImpactAnalysisService { try { const result = await pgPool.query( ` - SELECT relativePath, - (SELECT COUNT(*) FROM graph_nodes gn2 WHERE $1::text[] && gn2.dependencies AND gn2.jobId = $2) as dependentCount - FROM graph_nodes - WHERE jobId = $2 AND relativePath = ANY($1) + SELECT gn.file_path, + COUNT(ge.source_path) AS dependent_count + FROM graph_nodes gn + LEFT JOIN graph_edges ge ON ge.target_path = gn.file_path AND ge.job_id = gn.job_id + WHERE gn.job_id = $1 AND gn.file_path = ANY($2::text[]) + GROUP BY gn.file_path `, - [changedFiles, jobId], + [jobId, changedFiles], ); const safeFiles = []; const riskyFiles = []; for (const row of result.rows) { - if (row.dependentCount === 0) { - safeFiles.push(row.relativePath); + if (parseInt(row.dependent_count, 10) === 0) { + safeFiles.push(row.file_path); } else { - riskyFiles.push(row.relativePath); + riskyFiles.push(row.file_path); } } return { safeFiles, riskyFiles }; } catch (err) { - console.error('Failed to analyze change risk:', err); + console.error('[ImpactAnalysisService] analyzeChangeRisk failed:', err.message); return { safeFiles: [], riskyFiles: [] }; } } - - /** - * Get circular dependencies for changed files - * Useful for identifying refactoring risks - * - * @param {string} jobId - Analysis job ID - * @param {Array} changedFiles - Array of file paths that changed - * @returns {Promise>>} Array of circular dependency paths - */ - async findCircularDependencies(jobId, changedFiles) { - if (!jobId || changedFiles.length === 0) { - return []; - } - - try { - const result = await pgPool.query( - ` - SELECT cirularDeps - FROM graph_nodes - WHERE jobId = $1 AND relativePath = ANY($2) AND cirularDeps IS NOT NULL - `, - [jobId, changedFiles], - ); - - const cycles = []; - for (const row of result.rows) { - if (Array.isArray(row.circularDeps)) { - cycles.push(...row.circularDeps); - } - } - - return cycles; - } catch (err) { - console.error('Failed to find circular dependencies:', err); - return []; - } - } } export default new ImpactAnalysisService(); From cc85ccdcac3148a735e164ce769e3fcd05e4f04a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:18:31 +0000 Subject: [PATCH 15/18] fix: apply all review feedback - mock injection, route patterns, auth, body parsing scope, CI, Docker Agent-Logs-Url: https://github.com/SamanPandey-in/codegraph-ai/sessions/e644ac39-fbd1-4ebc-8735-041f6eb88851 Co-authored-by: SamanPandey-in <171229634+SamanPandey-in@users.noreply.github.com> --- .github/workflows/ci.yml | 10 +- client/src/features/graph/pages/GraphPage.jsx | 7 +- docker-compose.yml | 2 +- server/app.js | 1 - server/package.json | 2 +- server/src/api/graph/routes/graph.routes.js | 40 ++- server/src/api/webhooks/github.webhook.js | 3 +- server/src/api/webhooks/pr-comment.routes.js | 299 +++++++++--------- server/src/services/GitHubPRService.js | 22 +- server/test/pr-comment.test.js | 100 +++++- 10 files changed, 307 insertions(+), 179 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f59d46..b09712d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: working-directory: server env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/codegraph_test - - name: Run tests + - name: Run Vitest unit tests run: npm run test:coverage working-directory: server env: @@ -42,6 +42,14 @@ jobs: REDIS_URL: redis://localhost:6379 OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} JWT_SECRET: test_secret + - name: Run Node integration tests + run: npm test + working-directory: server + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/codegraph_test + REDIS_URL: redis://localhost:6379 + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + JWT_SECRET: test_secret client: runs-on: ubuntu-latest diff --git a/client/src/features/graph/pages/GraphPage.jsx b/client/src/features/graph/pages/GraphPage.jsx index f6a5935..584b346 100644 --- a/client/src/features/graph/pages/GraphPage.jsx +++ b/client/src/features/graph/pages/GraphPage.jsx @@ -41,12 +41,17 @@ export default function GraphPage() { useEffect(() => { if (!shareToken) return; + + const isCurrentGraphShared = data?.rootDir === `shared:${shareToken}`; + if (isCurrentGraphShared) return; + if (data?.jobId && !String(data.jobId).startsWith('shared:')) { // Avoid replacing an existing private graph without explicit confirmation. if (!window.confirm('Load shared graph? This will replace your current view.')) return; } + dispatch(loadSharedGraph({ token: shareToken })); - }, [data?.jobId, dispatch, shareToken]); + }, [data?.rootDir, dispatch, shareToken]); useEffect(() => { if (shareToken) return; diff --git a/docker-compose.yml b/docker-compose.yml index 5bb9766..1a14cdc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,7 @@ services: - /app/node_modules command: sh -c " until pg_isready -h postgres -p 5432; do sleep 1; done; - npm install; + if [ ! -d node_modules ]; then npm install; fi; npm run migrate; npm run dev " diff --git a/server/app.js b/server/app.js index 216a9ae..ae7d42e 100644 --- a/server/app.js +++ b/server/app.js @@ -38,7 +38,6 @@ app.use( ); app.use(cookieParser()); -app.use('/api/webhooks/github', express.raw({ type: 'application/json' })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(requestLogger); diff --git a/server/package.json b/server/package.json index 9a027eb..e42c2ad 100644 --- a/server/package.json +++ b/server/package.json @@ -9,7 +9,7 @@ "scripts": { "start": "node index.js", "dev": "nodemon index.js", - "migrate": "psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/001_initial.sql || true; psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/002_function_nodes.sql || true; psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/003_share_tokens.sql || true; psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/004_analysis_jobs_metadata.sql || true", + "migrate": "psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/001_initial.sql && psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/002_function_nodes.sql && psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/003_share_tokens.sql && psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/004_analysis_jobs_metadata.sql", "db:migrate": "npm run migrate", "test": "node --test \"test/**/*.test.js\"", "test:ai-queries": "node --test test/ai.queries.test.js", diff --git a/server/src/api/graph/routes/graph.routes.js b/server/src/api/graph/routes/graph.routes.js index a54b68a..5a76970 100644 --- a/server/src/api/graph/routes/graph.routes.js +++ b/server/src/api/graph/routes/graph.routes.js @@ -1,5 +1,6 @@ import { Router } from 'express'; import crypto from 'node:crypto'; +import jwt from 'jsonwebtoken'; import { pgPool } from '../../../infrastructure/connections.js'; import { loadGraphPayloadByJobId } from '../services/graphPayload.service.js'; @@ -19,12 +20,21 @@ function buildShareUrl(token) { } } -router.get('/:jobId/functions/*filePath', async (req, res, next) => { +function getAuthUser(req) { + const token = req.cookies?.token || req.headers.authorization?.replace('Bearer ', ''); + if (!token || !process.env.JWT_SECRET) return null; + + try { + return jwt.verify(token, process.env.JWT_SECRET); + } catch { + return null; + } +} + +router.get('/:jobId/functions/*', async (req, res, next) => { const { jobId } = req.params; - const wildcardPath = req.params?.filePath; - const rawFilePath = Array.isArray(wildcardPath) - ? wildcardPath.join('/') - : String(wildcardPath || '').trim(); + const wildcardPath = req.params[0]; + const rawFilePath = String(wildcardPath || '').trim(); if (!jobId) { return res.status(400).json({ error: 'jobId is required.' }); @@ -67,6 +77,11 @@ router.get('/:jobId/functions/*filePath', async (req, res, next) => { }); router.post('/:jobId/share', async (req, res, next) => { + const authUser = getAuthUser(req); + if (!authUser) { + return res.status(401).json({ error: 'Authentication required.' }); + } + const { jobId } = req.params; const visibility = String(req.body?.visibility || 'unlisted').trim().toLowerCase(); const expiresAtInput = req.body?.expiresAt; @@ -91,6 +106,21 @@ router.post('/:jobId/share', async (req, res, next) => { const token = crypto.randomBytes(24).toString('base64url'); try { + // Verify the job belongs to the authenticated user + const jobCheck = await pgPool.query( + ` + SELECT id + FROM analysis_jobs + WHERE id = $1 AND user_id = $2 + LIMIT 1 + `, + [jobId, authUser.id], + ); + + if (jobCheck.rowCount === 0) { + return res.status(404).json({ error: 'Analysis job not found.' }); + } + const inserted = await pgPool.query( ` INSERT INTO graph_shares (job_id, token, visibility, expires_at) diff --git a/server/src/api/webhooks/github.webhook.js b/server/src/api/webhooks/github.webhook.js index 7750622..a856f0d 100644 --- a/server/src/api/webhooks/github.webhook.js +++ b/server/src/api/webhooks/github.webhook.js @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import express from 'express'; import { Router } from 'express'; import { pgPool } from '../../infrastructure/connections.js'; import { enqueueAnalysisJob } from '../../queue/analysisQueue.js'; @@ -41,7 +42,7 @@ function logWebhookEvent(level, message, context = {}) { } } -router.post('/github', async (req, res, next) => { +router.post('/github', express.raw({ type: 'application/json' }), async (req, res, next) => { const startTime = Date.now(); const signature = req.headers['x-github-signature-256']; const event = String(req.headers['x-github-event'] || '').trim(); diff --git a/server/src/api/webhooks/pr-comment.routes.js b/server/src/api/webhooks/pr-comment.routes.js index 5a4784b..930660b 100644 --- a/server/src/api/webhooks/pr-comment.routes.js +++ b/server/src/api/webhooks/pr-comment.routes.js @@ -3,169 +3,180 @@ import { pgPool } from '../../infrastructure/connections.js'; import GitHubPRService from '../../services/GitHubPRService.js'; import ImpactAnalysisService from '../../services/ImpactAnalysisService.js'; -const router = Router(); - /** - * POST /api/webhooks/github/pr-comment - * Post impact analysis comment to a PR after analysis completes - * - * This is called by the analysis pipeline after SupervisorAgent finishes. - * It fetches the PR diff, identifies changed files, finds impacted graph files, - * and posts a comment with the impact analysis. + * Factory that builds the PR-comment router with injectable dependencies. + * When called without arguments it falls back to the production singletons. */ -router.post('/pr-comment', async (req, res, next) => { - const { jobId } = req.body; - - if (!jobId) { - return res.status(400).json({ error: 'jobId is required' }); - } - - try { - // Fetch job metadata and PR info - const jobResult = await pgPool.query( - ` - SELECT aj.id, aj.status, aj.branch, - r.id as repositoryId, r.github_owner, r.github_repo, - aj.metadata ->> 'prNumber' as prNumber, - aj.metadata ->> 'prTitle' as prTitle - FROM analysis_jobs aj - JOIN repositories r ON aj.repository_id = r.id - WHERE aj.id = $1 - `, - [jobId], - ); - - if (jobResult.rowCount === 0) { - return res.status(404).json({ error: 'Job not found' }); - } - - const job = jobResult.rows[0]; - const { github_owner: owner, github_repo: repo, prNumber } = job; - - // Only post comments for GitHub PRs - if (!owner || !repo || !prNumber) { - return res.status(200).json({ message: 'Not a GitHub PR, skipping comment' }); - } - - // Check if GitHub token is configured - if (!GitHubPRService.isConfigured()) { - console.warn('GitHub token not configured, skipping PR comment'); - return res.status(200).json({ message: 'GitHub token not configured' }); +export function createPrCommentRouter({ + db = pgPool, + gitHubPRService = GitHubPRService, +} = {}) { + const router = Router(); + + /** + * POST /api/webhooks/github/pr-comment + * Post impact analysis comment to a PR after analysis completes + * + * This is called by the analysis pipeline after SupervisorAgent finishes. + * It fetches the PR diff, identifies changed files, finds impacted graph files, + * and posts a comment with the impact analysis. + */ + router.post('/pr-comment', async (req, res, next) => { + const { jobId } = req.body; + + if (!jobId) { + return res.status(400).json({ error: 'jobId is required' }); } - // Get PR diff - let diff; try { - diff = await GitHubPRService.getPRDiff(owner, repo, parseInt(prNumber, 10)); - } catch (err) { - console.error('Failed to fetch PR diff:', err.message); - return res.status(200).json({ message: 'Failed to fetch PR diff', error: err.message }); - } + // Fetch job metadata and PR info + const jobResult = await db.query( + ` + SELECT aj.id, aj.status, aj.branch, + r.id as repositoryId, r.github_owner, r.github_repo, + aj.metadata ->> 'prNumber' as prNumber, + aj.metadata ->> 'prTitle' as prTitle + FROM analysis_jobs aj + JOIN repositories r ON aj.repository_id = r.id + WHERE aj.id = $1 + `, + [jobId], + ); + + if (jobResult.rowCount === 0) { + return res.status(404).json({ error: 'Job not found' }); + } - // Parse changed files from diff - const changedFiles = GitHubPRService.parseDiff(diff).map((f) => f.file); + const job = jobResult.rows[0]; + const { github_owner: owner, github_repo: repo, prNumber } = job; - if (changedFiles.length === 0) { - console.log('No changed files found in diff'); - return res.status(200).json({ message: 'No changed files in diff' }); - } + // Only post comments for GitHub PRs + if (!owner || !repo || !prNumber) { + return res.status(200).json({ message: 'Not a GitHub PR, skipping comment' }); + } - // Find impacted files in code graph - const { impactedFiles: impactedSet, depth } = await ImpactAnalysisService.findImpactedFiles( - jobId, - changedFiles, - 3, // max depth - ); + // Check if GitHub token is configured + if (!gitHubPRService.isConfigured()) { + console.warn('GitHub token not configured, skipping PR comment'); + return res.status(200).json({ message: 'GitHub token not configured' }); + } - const impactedFiles = Array.from(impactedSet).sort(); + // Get PR diff + let diff; + try { + diff = await gitHubPRService.getPRDiff(owner, repo, parseInt(prNumber, 10)); + } catch (err) { + console.error('Failed to fetch PR diff:', err.message); + return res.status(200).json({ message: 'Failed to fetch PR diff', error: err.message }); + } - // Format impact comment - const graphUrl = `${process.env.CLIENT_URL || 'http://localhost:5173'}/?jobId=${jobId}`; - const comment = GitHubPRService.formatImpactComment(changedFiles, impactedFiles, graphUrl); + // Parse changed files from diff + const changedFiles = gitHubPRService.parseDiff(diff).map((f) => f.file); - // Check if comment already exists - let existingComment; - try { - existingComment = await GitHubPRService.findExistingComment(owner, repo, parseInt(prNumber, 10)); - } catch (err) { - console.error('Failed to find existing comment:', err.message); - } + if (changedFiles.length === 0) { + console.log('No changed files found in diff'); + return res.status(200).json({ message: 'No changed files in diff' }); + } - // Post or update comment - let result; - try { - if (existingComment) { - result = await GitHubPRService.updatePRComment(owner, repo, existingComment.id, comment); - console.log(`Updated PR comment #${existingComment.id} on ${owner}/${repo}#${prNumber}`); - } else { - result = await GitHubPRService.postPRComment(owner, repo, parseInt(prNumber, 10), comment); - console.log(`Posted PR comment on ${owner}/${repo}#${prNumber}`); + // Find impacted files in code graph + const { impactedFiles: impactedSet, depth } = await ImpactAnalysisService.findImpactedFiles( + jobId, + changedFiles, + 3, // max depth + ); + + const impactedFiles = Array.from(impactedSet).sort(); + + // Format impact comment + const graphUrl = `${process.env.CLIENT_URL || 'http://localhost:5173'}/?jobId=${jobId}`; + const comment = gitHubPRService.formatImpactComment(changedFiles, impactedFiles, graphUrl); + + // Check if comment already exists + let existingComment; + try { + existingComment = await gitHubPRService.findExistingComment(owner, repo, parseInt(prNumber, 10)); + } catch (err) { + console.error('Failed to find existing comment:', err.message); + } + + // Post or update comment + let result; + try { + if (existingComment) { + result = await gitHubPRService.updatePRComment(owner, repo, existingComment.id, comment); + console.log(`Updated PR comment #${existingComment.id} on ${owner}/${repo}#${prNumber}`); + } else { + result = await gitHubPRService.postPRComment(owner, repo, parseInt(prNumber, 10), comment); + console.log(`Posted PR comment on ${owner}/${repo}#${prNumber}`); + } + } catch (err) { + console.error('Failed to post/update PR comment:', err.message); + return res.status(200).json({ + message: 'Analysis complete but failed to post comment', + error: err.message, + }); } - } catch (err) { - console.error('Failed to post/update PR comment:', err.message); - return res.status(200).json({ - message: 'Analysis complete but failed to post comment', - error: err.message, + + // Log the event + await db.query( + ` + INSERT INTO audit_logs (job_id, event_type, message, metadata) + VALUES ($1, $2, $3, $4) + `, + [ + jobId, + 'pr_comment_posted', + `Posted impact analysis comment to ${owner}/${repo}#${prNumber}`, + JSON.stringify({ + commentUrl: result.url, + changedFilesCount: changedFiles.length, + impactedFilesCount: impactedFiles.length, + analysisDepth: depth, + }), + ], + ); + + return res.json({ + success: true, + commentUrl: result.url, + changedFiles: changedFiles.length, + impactedFiles: impactedFiles.length, }); + } catch (error) { + console.error('PR comment posting failed:', error); + return next(error); } + }); - // Log the event - await pgPool.query( - ` - INSERT INTO audit_logs (job_id, event_type, message, metadata) - VALUES ($1, $2, $3, $4) - `, - [ - jobId, - 'pr_comment_posted', - `Posted impact analysis comment to ${owner}/${repo}#${prNumber}`, - JSON.stringify({ - commentUrl: result.url, - changedFilesCount: changedFiles.length, - impactedFilesCount: impactedFiles.length, - analysisDepth: depth, - }), - ], - ); - - return res.json({ - success: true, - commentUrl: result.url, - changedFiles: changedFiles.length, - impactedFiles: impactedFiles.length, - }); - } catch (error) { - console.error('PR comment posting failed:', error); - return next(error); - } -}); + /** + * GET /api/webhooks/github/pr-status/:prNumber + * Check if comment has been posted for a PR + */ + router.get('/pr-status/:prNumber', async (req, res, next) => { + const { prNumber } = req.params; + const { owner, repo } = req.query; -/** - * GET /api/webhooks/github/pr-status/:prNumber - * Check if comment has been posted for a PR - */ -router.get('/pr-status/:prNumber', async (req, res, next) => { - const { prNumber } = req.params; - const { owner, repo } = req.query; + if (!owner || !repo || !prNumber) { + return res.status(400).json({ error: 'owner, repo, and prNumber are required' }); + } - if (!owner || !repo || !prNumber) { - return res.status(400).json({ error: 'owner, repo, and prNumber are required' }); - } + try { + if (!gitHubPRService.isConfigured()) { + return res.status(503).json({ error: 'GitHub token not configured' }); + } - try { - if (!GitHubPRService.isConfigured()) { - return res.status(503).json({ error: 'GitHub token not configured' }); - } + const existing = await gitHubPRService.findExistingComment(owner, repo, parseInt(prNumber, 10)); - const existing = await GitHubPRService.findExistingComment(owner, repo, parseInt(prNumber, 10)); + return res.json({ + hasComment: !!existing, + commentId: existing?.id || null, + }); + } catch (error) { + return next(error); + } + }); - return res.json({ - hasComment: !!existing, - commentId: existing?.id || null, - }); - } catch (error) { - return next(error); - } -}); + return router; +} -export default router; +export default createPrCommentRouter(); diff --git a/server/src/services/GitHubPRService.js b/server/src/services/GitHubPRService.js index a49b286..62a2b45 100644 --- a/server/src/services/GitHubPRService.js +++ b/server/src/services/GitHubPRService.js @@ -59,19 +59,26 @@ class GitHubPRService { const changedFiles = []; const lines = diff.split('\n'); - for (const line of lines) { + for (let i = 0; i < lines.length; i++) { // Matches: "diff --git a/path/file.js b/path/file.js" - const match = line.match(/^diff --git a\/(.*?) b\/(.*?)$/); + const match = lines[i].match(/^diff --git a\/(.*?) b\/(.*?)$/); if (!match) continue; const filePath = match[2]; - // Determine status from next lines + // Determine status by scanning forward until the next "diff --git" header + // to avoid misidentifying files when multiple files have status markers let status = 'modified'; - if (diff.includes(`new file mode`) && diff.lastIndexOf(`new file mode`) > diff.indexOf(line)) { - status = 'added'; - } else if (diff.includes(`deleted file mode`) && diff.lastIndexOf(`deleted file mode`) > diff.indexOf(line)) { - status = 'deleted'; + for (let j = i + 1; j < lines.length; j++) { + if (lines[j].startsWith('diff --git ')) break; + if (lines[j].startsWith('new file mode')) { + status = 'added'; + break; + } + if (lines[j].startsWith('deleted file mode')) { + status = 'deleted'; + break; + } } changedFiles.push({ @@ -223,4 +230,5 @@ ${impactedList} } } +export { GitHubPRService }; export default new GitHubPRService(); diff --git a/server/test/pr-comment.test.js b/server/test/pr-comment.test.js index 6b30d64..9f29421 100644 --- a/server/test/pr-comment.test.js +++ b/server/test/pr-comment.test.js @@ -1,9 +1,9 @@ -import { describe, it, before, after } from 'node:test'; +import { describe, it, before } from 'node:test'; import assert from 'node:assert/strict'; import express from 'express'; import request from 'supertest'; -import prCommentRouter from '../src/api/webhooks/pr-comment.routes.js'; -import GitHubPRService from '../src/services/GitHubPRService.js'; +import { createPrCommentRouter } from '../src/api/webhooks/pr-comment.routes.js'; +import { GitHubPRService } from '../src/services/GitHubPRService.js'; // Mock dependencies const mockPgPool = { @@ -27,6 +27,23 @@ const mockPgPool = { ], }; } + if (params[0] === 'non-github-job') { + return { + rowCount: 1, + rows: [ + { + id: 'non-github-job', + status: 'complete', + branch: 'main', + repositoryId: 'repo-456', + github_owner: null, + github_repo: null, + prNumber: null, + prTitle: null, + }, + ], + }; + } return { rowCount: 0, rows: [] }; } @@ -48,7 +65,7 @@ describe('PR Comment Posting', () => { app = express(); app.use(express.json()); - app.use('/api/webhooks/github', prCommentRouter); + app.use('/api/webhooks/github', createPrCommentRouter({ db: mockPgPool })); }); describe('POST /api/webhooks/github/pr-comment', () => { @@ -81,7 +98,16 @@ describe('PR Comment Posting', () => { const oldToken = process.env.GITHUB_TOKEN; delete process.env.GITHUB_TOKEN; - const response = await request(app) + // Create a service instance without a token to simulate missing token + const noTokenService = new GitHubPRService(); + const testApp = express(); + testApp.use(express.json()); + testApp.use( + '/api/webhooks/github', + createPrCommentRouter({ db: mockPgPool, gitHubPRService: noTokenService }), + ); + + const response = await request(testApp) .post('/api/webhooks/github/pr-comment') .send({ jobId: 'valid-job-id' }); @@ -106,7 +132,15 @@ describe('PR Comment Posting', () => { const oldToken = process.env.GITHUB_TOKEN; delete process.env.GITHUB_TOKEN; - const response = await request(app) + const noTokenService = new GitHubPRService(); + const testApp = express(); + testApp.use(express.json()); + testApp.use( + '/api/webhooks/github', + createPrCommentRouter({ db: mockPgPool, gitHubPRService: noTokenService }), + ); + + const response = await request(testApp) .get('/api/webhooks/github/pr-status/42') .query({ owner: 'myorg', repo: 'myrepo' }); @@ -120,6 +154,7 @@ describe('PR Comment Posting', () => { describe('GitHubPRService', () => { describe('parseDiff', () => { it('extracts changed files from diff', () => { + const service = new GitHubPRService(); const diff = `diff --git a/src/app.js b/src/app.js index 1234567..abcdefg 100644 --- a/src/app.js @@ -139,34 +174,64 @@ index 0000000..1234567 +}; `; - const files = GitHubPRService.parseDiff(diff); + const files = service.parseDiff(diff); assert.equal(files.length, 2); assert.ok(files.some((f) => f.file === 'src/app.js')); assert.ok(files.some((f) => f.file === 'src/config.js')); }); + it('correctly labels added vs modified files', () => { + const service = new GitHubPRService(); + const diff = `diff --git a/src/app.js b/src/app.js +index 1234567..abcdefg 100644 +--- a/src/app.js ++++ b/src/app.js +@@ -1,5 +1,6 @@ + const express = require('express'); ++const newLib = require('new-lib'); + +diff --git a/src/config.js b/src/config.js +new file mode 100644 +index 0000000..1234567 +--- /dev/null ++++ b/src/config.js +@@ -0,0 +1,3 @@ ++module.exports = {}; +`; + + const files = service.parseDiff(diff); + const appFile = files.find((f) => f.file === 'src/app.js'); + const configFile = files.find((f) => f.file === 'src/config.js'); + + assert.equal(appFile.status, 'modified'); + assert.equal(configFile.status, 'added'); + }); + it('returns empty array for empty diff', () => { - const files = GitHubPRService.parseDiff(''); + const service = new GitHubPRService(); + const files = service.parseDiff(''); assert.equal(files.length, 0); }); it('handles diffs without file changes', () => { + const service = new GitHubPRService(); const diff = ` Some text without proper diff format `; - const files = GitHubPRService.parseDiff(diff); + const files = service.parseDiff(diff); assert.equal(files.length, 0); }); }); describe('formatImpactComment', () => { it('formats impact comment with changed and impacted files', () => { + const service = new GitHubPRService(); const changed = ['src/auth.js', 'src/config.js']; const impacted = ['src/api.js', 'src/middleware.js', 'src/controllers/user.js']; const graphUrl = 'http://localhost:5173/?jobId=123'; - const comment = GitHubPRService.formatImpactComment(changed, impacted, graphUrl); + const comment = service.formatImpactComment(changed, impacted, graphUrl); assert.match(comment, /CodeGraph Impact Analysis/); assert.match(comment, /Changed Files \(2\)/); @@ -177,31 +242,34 @@ Some text without proper diff format }); it('handles empty impacted files list', () => { + const service = new GitHubPRService(); const changed = ['src/util.js']; const impacted = []; const graphUrl = 'http://localhost:5173/?jobId=123'; - const comment = GitHubPRService.formatImpactComment(changed, impacted, graphUrl); + const comment = service.formatImpactComment(changed, impacted, graphUrl); assert.match(comment, /isolated change/i); }); it('truncates large file lists', () => { + const service = new GitHubPRService(); const changed = Array.from({ length: 30 }, (_, i) => `file${i}.js`); const impacted = Array.from({ length: 30 }, (_, i) => `impacted${i}.js`); const graphUrl = 'http://localhost:5173/?jobId=123'; - const comment = GitHubPRService.formatImpactComment(changed, impacted, graphUrl); + const comment = service.formatImpactComment(changed, impacted, graphUrl); assert.match(comment, /and \d+ more/); // Should have "and X more" }); it('includes timestamp in comment', () => { + const service = new GitHubPRService(); const changed = ['src/app.js']; const impacted = []; const graphUrl = 'http://localhost:5173/?jobId=123'; - const comment = GitHubPRService.formatImpactComment(changed, impacted, graphUrl); + const comment = service.formatImpactComment(changed, impacted, graphUrl); assert.match(comment, /\d{4}-\d{2}-\d{2}T/); // ISO date format }); @@ -211,10 +279,8 @@ Some text without proper diff format it('detects when GitHub token is configured', () => { const oldToken = process.env.GITHUB_TOKEN; process.env.GITHUB_TOKEN = 'test-token'; - - const service = require('../src/services/GitHubPRService.js').default; + const service = new GitHubPRService(); assert.equal(service.isConfigured(), true); - process.env.GITHUB_TOKEN = oldToken; }); @@ -222,7 +288,7 @@ Some text without proper diff format const oldToken = process.env.GITHUB_TOKEN; delete process.env.GITHUB_TOKEN; - const service = require('../src/services/GitHubPRService.js').default; + const service = new GitHubPRService(); assert.equal(service.isConfigured(), false); process.env.GITHUB_TOKEN = oldToken; From d863a2273b1e7ef05dced3d12d9831f35f24ad29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:25:08 +0000 Subject: [PATCH 16/18] fix: add rate limiting to all affected routes to address CodeQL alerts Agent-Logs-Url: https://github.com/SamanPandey-in/codegraph-ai/sessions/e644ac39-fbd1-4ebc-8735-041f6eb88851 Co-authored-by: SamanPandey-in <171229634+SamanPandey-in@users.noreply.github.com> --- server/src/api/graph/routes/graph.routes.js | 21 ++++++++++++++++++-- server/src/api/webhooks/github.webhook.js | 11 +++++++++- server/src/api/webhooks/pr-comment.routes.js | 11 +++++++++- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/server/src/api/graph/routes/graph.routes.js b/server/src/api/graph/routes/graph.routes.js index 5a76970..1ddfa8d 100644 --- a/server/src/api/graph/routes/graph.routes.js +++ b/server/src/api/graph/routes/graph.routes.js @@ -1,6 +1,7 @@ import { Router } from 'express'; import crypto from 'node:crypto'; import jwt from 'jsonwebtoken'; +import rateLimit from 'express-rate-limit'; import { pgPool } from '../../../infrastructure/connections.js'; import { loadGraphPayloadByJobId } from '../services/graphPayload.service.js'; @@ -8,6 +9,22 @@ const router = Router(); const SHARE_VISIBILITY = new Set(['unlisted', 'public']); +const shareLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many share requests. Please try again later.' }, +}); + +const functionNodesLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 120, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests. Please try again later.' }, +}); + function buildShareUrl(token) { const baseUrl = String(process.env.CLIENT_URL || 'http://localhost:5173').trim(); @@ -31,7 +48,7 @@ function getAuthUser(req) { } } -router.get('/:jobId/functions/*', async (req, res, next) => { +router.get('/:jobId/functions/*', functionNodesLimiter, async (req, res, next) => { const { jobId } = req.params; const wildcardPath = req.params[0]; const rawFilePath = String(wildcardPath || '').trim(); @@ -76,7 +93,7 @@ router.get('/:jobId/functions/*', async (req, res, next) => { } }); -router.post('/:jobId/share', async (req, res, next) => { +router.post('/:jobId/share', shareLimiter, async (req, res, next) => { const authUser = getAuthUser(req); if (!authUser) { return res.status(401).json({ error: 'Authentication required.' }); diff --git a/server/src/api/webhooks/github.webhook.js b/server/src/api/webhooks/github.webhook.js index a856f0d..21bb6d4 100644 --- a/server/src/api/webhooks/github.webhook.js +++ b/server/src/api/webhooks/github.webhook.js @@ -1,11 +1,20 @@ import crypto from 'node:crypto'; import express from 'express'; import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; import { pgPool } from '../../infrastructure/connections.js'; import { enqueueAnalysisJob } from '../../queue/analysisQueue.js'; const router = Router(); +const webhookLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many webhook requests.' }, +}); + function timingSafeCompare(a, b) { const left = Buffer.from(String(a || '')); const right = Buffer.from(String(b || '')); @@ -42,7 +51,7 @@ function logWebhookEvent(level, message, context = {}) { } } -router.post('/github', express.raw({ type: 'application/json' }), async (req, res, next) => { +router.post('/github', webhookLimiter, express.raw({ type: 'application/json' }), async (req, res, next) => { const startTime = Date.now(); const signature = req.headers['x-github-signature-256']; const event = String(req.headers['x-github-event'] || '').trim(); diff --git a/server/src/api/webhooks/pr-comment.routes.js b/server/src/api/webhooks/pr-comment.routes.js index 930660b..79e25aa 100644 --- a/server/src/api/webhooks/pr-comment.routes.js +++ b/server/src/api/webhooks/pr-comment.routes.js @@ -1,8 +1,17 @@ import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; import { pgPool } from '../../infrastructure/connections.js'; import GitHubPRService from '../../services/GitHubPRService.js'; import ImpactAnalysisService from '../../services/ImpactAnalysisService.js'; +const prCommentLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many PR comment requests. Please try again later.' }, +}); + /** * Factory that builds the PR-comment router with injectable dependencies. * When called without arguments it falls back to the production singletons. @@ -21,7 +30,7 @@ export function createPrCommentRouter({ * It fetches the PR diff, identifies changed files, finds impacted graph files, * and posts a comment with the impact analysis. */ - router.post('/pr-comment', async (req, res, next) => { + router.post('/pr-comment', prCommentLimiter, async (req, res, next) => { const { jobId } = req.body; if (!jobId) { From cebad2083dfde522deb8c3eb58bf43408af2ca6b Mon Sep 17 00:00:00 2001 From: SamanPandey-in Date: Mon, 30 Mar 2026 19:07:41 +0530 Subject: [PATCH 17/18] fix: jobId route path for graph --- server/src/api/graph/routes/graph.routes.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/api/graph/routes/graph.routes.js b/server/src/api/graph/routes/graph.routes.js index 1ddfa8d..087fda7 100644 --- a/server/src/api/graph/routes/graph.routes.js +++ b/server/src/api/graph/routes/graph.routes.js @@ -48,9 +48,9 @@ function getAuthUser(req) { } } -router.get('/:jobId/functions/*', functionNodesLimiter, async (req, res, next) => { +router.get('/:jobId/functions/*filePath', functionNodesLimiter, async (req, res, next) => { const { jobId } = req.params; - const wildcardPath = req.params[0]; + const wildcardPath = req.params.filePath; const rawFilePath = String(wildcardPath || '').trim(); if (!jobId) { From a96bffcdbe3a697c9fadbfad305e4d4492e978ee Mon Sep 17 00:00:00 2001 From: SamanPandey-in Date: Mon, 30 Mar 2026 19:37:35 +0530 Subject: [PATCH 18/18] fix CI server --- server/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/package.json b/server/package.json index e42c2ad..55fbbc0 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,7 @@ "dev": "nodemon index.js", "migrate": "psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/001_initial.sql && psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/002_function_nodes.sql && psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/003_share_tokens.sql && psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ./src/infrastructure/migrations/004_analysis_jobs_metadata.sql", "db:migrate": "npm run migrate", - "test": "node --test \"test/**/*.test.js\"", + "test": "node --test test/ai.queries.test.js test/github.webhook.test.js test/parser.multilang.test.js test/pr-comment.test.js", "test:ai-queries": "node --test test/ai.queries.test.js", "test:unit": "vitest run --configLoader native --pool threads", "test:coverage": "vitest run --coverage --configLoader native --pool threads"