diff --git a/.claude/skills/gitnexus/debugging/SKILL.md b/.claude/skills/gitnexus/debugging/SKILL.md index 883ff25b76..b18d2392fb 100644 --- a/.claude/skills/gitnexus/debugging/SKILL.md +++ b/.claude/skills/gitnexus/debugging/SKILL.md @@ -7,10 +7,11 @@ description: Trace bugs through call chains using knowledge graph ## Quick Start ``` -0. If "Index is stale" → gitnexus_analyze({}) -1. gitnexus_search({query}) → Find code related to error -2. gitnexus_explore({name, type: "symbol"}) → Get callers and callees -3. READ gitnexus://process/{name} → Trace execution flow +0. READ gitnexus://repos → Discover indexed repos +1. If "Index is stale" → gitnexus_analyze({repo: "my-app"}) +2. gitnexus_search({query: "...", repo: "my-app"}) → Find code related to error +3. gitnexus_explore({name, type: "symbol", repo: "my-app"}) → Get callers and callees +4. READ gitnexus://repo/my-app/process/{name} → Trace execution flow ``` ## When to Use @@ -22,18 +23,19 @@ description: Trace bugs through call chains using knowledge graph ## Workflow Checklist ``` Bug Investigation: +- [ ] READ gitnexus://repos to find the right repo - [ ] Understand the symptom (error message, behavior) - [ ] gitnexus_search to find related code - [ ] Identify the suspect function - [ ] gitnexus_explore to see callers/callees -- [ ] READ gitnexus://process/{name} if suspect is in a process -- [ ] READ gitnexus://schema for Cypher query help +- [ ] READ gitnexus://repo/{name}/process/{name} if suspect is in a process +- [ ] READ gitnexus://repo/{name}/schema for Cypher query help - [ ] gitnexus_cypher for custom traces ``` ## Resource Reference -### gitnexus://schema +### gitnexus://repo/{name}/schema Graph schema for writing Cypher queries: ```yaml nodes: [Function, Class, Method, File, Community, Process] @@ -44,7 +46,7 @@ example_queries: RETURN caller.name ``` -### gitnexus://process/{name} +### gitnexus://repo/{name}/process/{processName} Trace execution flow to find where bug might occur: ```yaml name: CheckoutFlow @@ -60,13 +62,13 @@ trace: ### gitnexus_search Find code related to error or symptom: ``` -gitnexus_search({query: "payment validation error", depth: "full"}) +gitnexus_search({query: "payment validation error", depth: "full", repo: "my-app"}) ``` ### gitnexus_explore Get symbol context: ``` -gitnexus_explore({name: "validatePayment", type: "symbol"}) +gitnexus_explore({name: "validatePayment", type: "symbol", repo: "my-app"}) → Callers: processCheckout, webhookHandler → Callees: verifyCard, fetchRates ``` @@ -82,13 +84,13 @@ RETURN [n IN nodes(path) | n.name] AS chain ## Example: "Payment endpoint returns 500 intermittently" ``` -1. gitnexus_search({query: "payment error handling"}) +1. gitnexus_search({query: "payment error handling", repo: "my-app"}) → validatePayment, handlePaymentError, PaymentException -2. gitnexus_explore({name: "validatePayment", type: "symbol"}) +2. gitnexus_explore({name: "validatePayment", type: "symbol", repo: "my-app"}) → Callees: verifyCard, fetchRates (external API!) -3. READ gitnexus://process/CheckoutFlow +3. READ gitnexus://repo/my-app/process/CheckoutFlow → Step 3: validatePayment → calls external API 4. Root cause: fetchRates calls external API without proper timeout diff --git a/.claude/skills/gitnexus/exploring/SKILL.md b/.claude/skills/gitnexus/exploring/SKILL.md index 52beb6909d..46e69bb2aa 100644 --- a/.claude/skills/gitnexus/exploring/SKILL.md +++ b/.claude/skills/gitnexus/exploring/SKILL.md @@ -7,10 +7,11 @@ description: Navigate unfamiliar code using GitNexus knowledge graph ## Quick Start ``` -0. If "Index is stale" → gitnexus_analyze({}) -1. READ gitnexus://context → Get codebase overview (~150 tokens) -2. READ gitnexus://clusters → See all functional clusters -3. READ gitnexus://cluster/{name} → Deep dive on specific cluster +0. READ gitnexus://repos → Discover indexed repos (use repo param if multiple) +1. If "Index is stale" → gitnexus_analyze({repo: "my-app"}) +2. READ gitnexus://repo/{name}/context → Get codebase overview (~150 tokens) +3. READ gitnexus://repo/{name}/clusters → See all functional clusters +4. READ gitnexus://repo/{name}/cluster/{name} → Deep dive on specific cluster ``` ## When to Use @@ -22,17 +23,28 @@ description: Navigate unfamiliar code using GitNexus knowledge graph ## Workflow Checklist ``` Exploration Progress: -- [ ] READ gitnexus://context for codebase overview -- [ ] READ gitnexus://clusters to list all clusters +- [ ] READ gitnexus://repos to discover available repos +- [ ] READ gitnexus://repo/{name}/context for codebase overview +- [ ] READ gitnexus://repo/{name}/clusters to list all clusters - [ ] Identify the relevant cluster by name -- [ ] READ gitnexus://cluster/{name} for cluster details +- [ ] READ gitnexus://repo/{name}/cluster/{name} for cluster details - [ ] Use gitnexus_explore for specific symbols ``` ## Resource Reference -### gitnexus://context -Codebase overview. **Read first.** +### gitnexus://repos +Discover all indexed repositories. **Read first.** +```yaml +repos: + - name: "my-app" + path: "/home/user/my-app" + files: 42 + symbols: 918 +``` + +### gitnexus://repo/{name}/context +Codebase overview for a specific repo. ```yaml project: my-app stats: @@ -40,11 +52,10 @@ stats: symbols: 918 clusters: 12 processes: 45 -tools_available: [search, explore, impact, overview, cypher] -resources_available: [clusters, processes, cluster/{name}, process/{name}] +tools_available: [list_repos, search, explore, impact, overview, cypher] ``` -### gitnexus://clusters +### gitnexus://repo/{name}/clusters All functional clusters with cohesion scores. ```yaml clusters: @@ -56,7 +67,7 @@ clusters: cohesion: 88% ``` -### gitnexus://cluster/{name} +### gitnexus://repo/{name}/cluster/{clusterName} Members of a specific cluster. ```yaml name: Auth @@ -68,7 +79,7 @@ members: file: src/auth/validator.ts ``` -### gitnexus://process/{name} +### gitnexus://repo/{name}/process/{processName} Full execution trace. ```yaml name: LoginFlow @@ -84,7 +95,7 @@ steps: ### gitnexus_explore For detailed symbol context with callers/callees: ``` -gitnexus_explore({name: "validateUser", type: "symbol"}) +gitnexus_explore({name: "validateUser", type: "symbol", repo: "my-app"}) → Callers: loginHandler, apiMiddleware → Callees: checkToken, getUserById ``` @@ -92,21 +103,24 @@ gitnexus_explore({name: "validateUser", type: "symbol"}) ### gitnexus_search For finding code by query: ``` -gitnexus_search({query: "payment validation", depth: "full"}) +gitnexus_search({query: "payment validation", depth: "full", repo: "my-app"}) ``` ## Example: "How does payment processing work?" ``` -1. READ gitnexus://context +1. READ gitnexus://repos + → Repos: my-app (918 symbols) + +2. READ gitnexus://repo/my-app/context → 918 symbols, 12 clusters -2. READ gitnexus://clusters +3. READ gitnexus://repo/my-app/clusters → Clusters: Auth, Payment, Database, API... -3. READ gitnexus://cluster/Payment +4. READ gitnexus://repo/my-app/cluster/Payment → Members: processPayment, validateCard, PaymentService -4. READ gitnexus://process/CheckoutFlow +5. READ gitnexus://repo/my-app/process/CheckoutFlow → handleCheckout → validateCart → processPayment → sendConfirmation ``` diff --git a/.claude/skills/gitnexus/impact-analysis/SKILL.md b/.claude/skills/gitnexus/impact-analysis/SKILL.md index dbbffc3124..32a3ea0c2a 100644 --- a/.claude/skills/gitnexus/impact-analysis/SKILL.md +++ b/.claude/skills/gitnexus/impact-analysis/SKILL.md @@ -7,10 +7,11 @@ description: Analyze blast radius before making code changes ## Quick Start ``` -0. If "Index is stale" → gitnexus_analyze({}) -1. gitnexus_impact({target, direction: "upstream"}) → What depends on this -2. READ gitnexus://clusters → Check affected areas -3. READ gitnexus://processes → Affected execution flows +0. READ gitnexus://repos → Discover indexed repos +1. If "Index is stale" → gitnexus_analyze({repo: "my-app"}) +2. gitnexus_impact({target, direction: "upstream", repo: "my-app"}) → What depends on this +3. READ gitnexus://repo/my-app/clusters → Check affected areas +4. READ gitnexus://repo/my-app/processes → Affected execution flows ``` ## When to Use @@ -30,8 +31,9 @@ description: Analyze blast radius before making code changes ## Workflow Checklist ``` Impact Analysis: -- [ ] gitnexus_impact(target, "upstream") to find dependents -- [ ] READ gitnexus://clusters to understand affected areas +- [ ] READ gitnexus://repos to find the right repo +- [ ] gitnexus_impact(target, "upstream", repo) to find dependents +- [ ] READ gitnexus://repo/{name}/clusters to understand affected areas - [ ] Check high-confidence (>0.8) dependencies first - [ ] Count affected clusters (cross-cutting = higher risk) - [ ] If >10 processes affected, consider splitting change @@ -39,7 +41,7 @@ Impact Analysis: ## Resource Reference -### gitnexus://clusters +### gitnexus://repo/{name}/clusters Check which clusters might be affected: ```yaml clusters: @@ -49,7 +51,7 @@ clusters: symbols: 32 ``` -### gitnexus://processes +### gitnexus://repo/{name}/processes Find which processes touch the target: ```yaml processes: @@ -67,7 +69,8 @@ gitnexus_impact({ target: "validateUser", direction: "upstream", minConfidence: 0.8, - maxDepth: 3 + maxDepth: 3, + repo: "my-app" }) → d=1 (WILL BREAK): @@ -103,11 +106,11 @@ Before Committing: ## Example: "What breaks if I change validateUser?" ``` -1. gitnexus_impact({target: "validateUser", direction: "upstream"}) +1. gitnexus_impact({target: "validateUser", direction: "upstream", repo: "my-app"}) → d=1: loginHandler, apiMiddleware → d=2: authRouter, sessionManager -2. READ gitnexus://clusters +2. READ gitnexus://repo/my-app/clusters → Auth and API clusters affected 3. Decision: 2 direct callers, 2 clusters = MEDIUM risk diff --git a/.claude/skills/gitnexus/refactoring/SKILL.md b/.claude/skills/gitnexus/refactoring/SKILL.md index 1ea1c545b8..1f9b71604d 100644 --- a/.claude/skills/gitnexus/refactoring/SKILL.md +++ b/.claude/skills/gitnexus/refactoring/SKILL.md @@ -7,10 +7,11 @@ description: Plan safe refactors using blast radius and dependency mapping ## Quick Start ``` -0. If "Index is stale" → gitnexus_analyze({}) -1. gitnexus_impact({target, direction: "upstream"}) → Map all dependents -2. READ gitnexus://schema → Understand graph structure -3. gitnexus_cypher → Find all references +0. READ gitnexus://repos → Discover indexed repos +1. If "Index is stale" → gitnexus_analyze({repo: "my-app"}) +2. gitnexus_impact({target, direction: "upstream", repo: "my-app"}) → Map all dependents +3. READ gitnexus://repo/my-app/schema → Understand graph structure +4. gitnexus_cypher({query: "...", repo: "my-app"}) → Find all references ``` ## When to Use @@ -24,8 +25,8 @@ description: Plan safe refactors using blast radius and dependency mapping ### Rename Symbol ``` Rename Refactoring: -- [ ] gitnexus_impact(oldName, "upstream") — find all callers -- [ ] gitnexus_search(oldName) — find string literals +- [ ] gitnexus_impact({target: oldName, direction: "upstream", repo: "my-app"}) — find all callers +- [ ] gitnexus_search({query: oldName, repo: "my-app"}) — find string literals - [ ] Check for reflection/dynamic references - [ ] Update in order: interface → implementation → usages - [ ] Run tests for affected processes @@ -34,9 +35,9 @@ Rename Refactoring: ### Extract Module ``` Extract Module: -- [ ] gitnexus_explore(target, "symbol") — map dependencies -- [ ] gitnexus_impact(target, "upstream") — find callers -- [ ] READ gitnexus://cluster/{name} — check cohesion +- [ ] gitnexus_explore({name: target, type: "symbol", repo: "my-app"}) — map dependencies +- [ ] gitnexus_impact({target, direction: "upstream", repo: "my-app"}) — find callers +- [ ] READ gitnexus://repo/my-app/cluster/{name} — check cohesion - [ ] Define new module interface - [ ] Update imports across affected files ``` @@ -44,7 +45,7 @@ Extract Module: ### Split Function ``` Split Function: -- [ ] gitnexus_explore(target, "symbol") — understand callees +- [ ] gitnexus_explore({name: target, type: "symbol", repo: "my-app"}) — understand callees - [ ] Group related logic - [ ] gitnexus_impact — verify callers won't break - [ ] Create new functions @@ -53,7 +54,7 @@ Split Function: ## Resource Reference -### gitnexus://schema +### gitnexus://repo/{name}/schema Graph structure for Cypher queries: ```yaml nodes: [Function, Class, Method, Community, Process] @@ -65,7 +66,7 @@ example_queries: RETURN caller.name ``` -### gitnexus://cluster/{name} +### gitnexus://repo/{name}/cluster/{clusterName} Check if extraction preserves cohesion: ```yaml name: Payment @@ -91,13 +92,13 @@ RETURN importer.name, importer.filePath ## Example: Safely Rename `validateUser` to `authenticateUser` ``` -1. gitnexus_impact({target: "validateUser", direction: "upstream"}) +1. gitnexus_impact({target: "validateUser", direction: "upstream", repo: "my-app"}) → loginHandler, apiMiddleware, testUtils -2. gitnexus_search({query: "validateUser"}) +2. gitnexus_search({query: "validateUser", repo: "my-app"}) → Found in: config.json (dynamic reference!) -3. READ gitnexus://processes +3. READ gitnexus://repo/my-app/processes → LoginFlow, TokenRefresh, APIGateway 4. Plan update order: diff --git a/AGENTS.md b/AGENTS.md index a4add62be9..053c0ece69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,63 +7,71 @@ This project uses GitNexus MCP for code intelligence. See .gitnexus/RULES.md for # GitNexus MCP -This project is indexed by GitNexus, providing AI agents with deep code intelligence. +This project is indexed as **GitnexusV2** by GitNexus, providing AI agents with deep code intelligence. ## Project: GitnexusV2 | Metric | Count | |--------|-------| -| Files | 146 | -| Symbols | 907 | -| Relationships | 2306 | -| Communities | 278 | -| Processes | 75 | +| Files | 147 | +| Symbols | 940 | +| Relationships | 2298 | +| Clusters | 13 | +| Processes | 67 | + +> **Staleness:** If the index is out of date, run `gitnexus_analyze({repo: "GitnexusV2"})` to refresh. The `gitnexus://repo/GitnexusV2/context` resource will warn you when the index is stale. ## Quick Start ``` -1. READ gitnexus://context → Get codebase overview (~150 tokens) -2. READ gitnexus://clusters → See all functional clusters -3. READ gitnexus://cluster/{name} → Deep dive on specific cluster -4. gitnexus_search(query) → Find code by query +1. READ gitnexus://repos → Discover all indexed repos +2. READ gitnexus://repo/GitnexusV2/context → Get codebase overview (~150 tokens) +3. READ gitnexus://repo/GitnexusV2/clusters → See all functional clusters +4. gitnexus_search({query: "...", repo: "GitnexusV2"}) → Find code by query ``` ## Available Resources | Resource | Purpose | |----------|---------| -| `gitnexus://context` | Codebase stats, tools, and resources overview | -| `gitnexus://clusters` | All clusters with symbol counts and cohesion | -| `gitnexus://cluster/{name}` | Cluster members and details | -| `gitnexus://processes` | All execution flows with types | -| `gitnexus://process/{name}` | Full process trace with steps | -| `gitnexus://schema` | Graph schema for Cypher queries | +| `gitnexus://repos` | List all indexed repositories | +| `gitnexus://repo/GitnexusV2/context` | Codebase stats, tools, and resources overview | +| `gitnexus://repo/GitnexusV2/clusters` | All clusters with symbol counts and cohesion | +| `gitnexus://repo/GitnexusV2/cluster/{name}` | Cluster members and details | +| `gitnexus://repo/GitnexusV2/processes` | All execution flows with types | +| `gitnexus://repo/GitnexusV2/process/{name}` | Full process trace with steps | +| `gitnexus://repo/GitnexusV2/schema` | Graph schema for Cypher queries | ## Available Tools | Tool | Purpose | When to Use | |------|---------|-------------| +| `list_repos` | Discover indexed repos | First step with multiple repos | | `search` | Semantic + keyword search | Finding code by query | | `overview` | List clusters & processes | Understanding architecture | | `explore` | Deep dive on symbol/cluster/process | Detailed investigation | | `impact` | Blast radius analysis | Before making changes | | `cypher` | Raw graph queries | Complex analysis | +| `analyze` | Re-index repository | When index is stale or after major code changes | + +> **Multi-repo:** When multiple repos are indexed, pass `repo: "GitnexusV2"` to target this project. ## Workflow Examples ### Exploring the Codebase ``` -READ gitnexus://context → Stats and overview -READ gitnexus://clusters → Find relevant cluster -READ gitnexus://cluster/Auth → Explore Auth cluster -gitnexus_explore("validateUser", "symbol") → Detailed symbol info +READ gitnexus://repos → Discover repos +READ gitnexus://repo/GitnexusV2/context → Stats and overview (check for staleness) +READ gitnexus://repo/GitnexusV2/clusters → Find relevant cluster by name +READ gitnexus://repo/GitnexusV2/cluster/{name} → See members of that cluster +gitnexus_explore({name: "", type: "symbol", repo: "GitnexusV2"}) ``` ### Planning a Change ``` -gitnexus_impact("UserService", "upstream") → See what breaks -READ gitnexus://processes → Check affected flows -gitnexus_explore("LoginFlow", "process") → Trace execution +gitnexus_search({query: "", repo: "GitnexusV2"}) +gitnexus_impact({target: "", direction: "upstream", repo: "GitnexusV2"}) +READ gitnexus://repo/GitnexusV2/processes → Check affected execution flows ``` ## Graph Schema diff --git a/CLAUDE.md b/CLAUDE.md index 5ca1b0b2d8..6e9550ae3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,63 +1,71 @@ # GitNexus MCP -This project is indexed by GitNexus, providing AI agents with deep code intelligence. +This project is indexed as **GitnexusV2** by GitNexus, providing AI agents with deep code intelligence. ## Project: GitnexusV2 | Metric | Count | |--------|-------| -| Files | 146 | -| Symbols | 907 | -| Relationships | 2306 | -| Communities | 278 | -| Processes | 75 | +| Files | 147 | +| Symbols | 940 | +| Relationships | 2298 | +| Clusters | 13 | +| Processes | 67 | + +> **Staleness:** If the index is out of date, run `gitnexus_analyze({repo: "GitnexusV2"})` to refresh. The `gitnexus://repo/GitnexusV2/context` resource will warn you when the index is stale. ## Quick Start ``` -1. READ gitnexus://context → Get codebase overview (~150 tokens) -2. READ gitnexus://clusters → See all functional clusters -3. READ gitnexus://cluster/{name} → Deep dive on specific cluster -4. gitnexus_search(query) → Find code by query +1. READ gitnexus://repos → Discover all indexed repos +2. READ gitnexus://repo/GitnexusV2/context → Get codebase overview (~150 tokens) +3. READ gitnexus://repo/GitnexusV2/clusters → See all functional clusters +4. gitnexus_search({query: "...", repo: "GitnexusV2"}) → Find code by query ``` ## Available Resources | Resource | Purpose | |----------|---------| -| `gitnexus://context` | Codebase stats, tools, and resources overview | -| `gitnexus://clusters` | All clusters with symbol counts and cohesion | -| `gitnexus://cluster/{name}` | Cluster members and details | -| `gitnexus://processes` | All execution flows with types | -| `gitnexus://process/{name}` | Full process trace with steps | -| `gitnexus://schema` | Graph schema for Cypher queries | +| `gitnexus://repos` | List all indexed repositories | +| `gitnexus://repo/GitnexusV2/context` | Codebase stats, tools, and resources overview | +| `gitnexus://repo/GitnexusV2/clusters` | All clusters with symbol counts and cohesion | +| `gitnexus://repo/GitnexusV2/cluster/{name}` | Cluster members and details | +| `gitnexus://repo/GitnexusV2/processes` | All execution flows with types | +| `gitnexus://repo/GitnexusV2/process/{name}` | Full process trace with steps | +| `gitnexus://repo/GitnexusV2/schema` | Graph schema for Cypher queries | ## Available Tools | Tool | Purpose | When to Use | |------|---------|-------------| +| `list_repos` | Discover indexed repos | First step with multiple repos | | `search` | Semantic + keyword search | Finding code by query | | `overview` | List clusters & processes | Understanding architecture | | `explore` | Deep dive on symbol/cluster/process | Detailed investigation | | `impact` | Blast radius analysis | Before making changes | | `cypher` | Raw graph queries | Complex analysis | +| `analyze` | Re-index repository | When index is stale or after major code changes | + +> **Multi-repo:** When multiple repos are indexed, pass `repo: "GitnexusV2"` to target this project. ## Workflow Examples ### Exploring the Codebase ``` -READ gitnexus://context → Stats and overview -READ gitnexus://clusters → Find relevant cluster -READ gitnexus://cluster/Auth → Explore Auth cluster -gitnexus_explore("validateUser", "symbol") → Detailed symbol info +READ gitnexus://repos → Discover repos +READ gitnexus://repo/GitnexusV2/context → Stats and overview (check for staleness) +READ gitnexus://repo/GitnexusV2/clusters → Find relevant cluster by name +READ gitnexus://repo/GitnexusV2/cluster/{name} → See members of that cluster +gitnexus_explore({name: "", type: "symbol", repo: "GitnexusV2"}) ``` ### Planning a Change ``` -gitnexus_impact("UserService", "upstream") → See what breaks -READ gitnexus://processes → Check affected flows -gitnexus_explore("LoginFlow", "process") → Trace execution +gitnexus_search({query: "", repo: "GitnexusV2"}) +gitnexus_impact({target: "", direction: "upstream", repo: "GitnexusV2"}) +READ gitnexus://repo/GitnexusV2/processes → Check affected execution flows ``` ## Graph Schema diff --git a/README.md b/README.md index 89eec7ecd1..7927e24a01 100644 --- a/README.md +++ b/README.md @@ -1,183 +1,361 @@ -# GitNexus V2 +# GitNexus -**Zero-Server, Graph-Based Code Intelligence Engine** -Works fully in-browser through WebAssembly. (DB engine, Embeddings model, AST parsing, all happens inside browser) +**Building git for agent context.** +Indexes any codebase into a knowledge graph — every dependency, call chain, cluster, and execution flow — then exposes it through smart tools so AI agents never miss code. + +[![npm version](https://img.shields.io/npm/v/gitnexus.svg)](https://www.npmjs.com/package/gitnexus) +[![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg)](https://polyformproject.org/licenses/noncommercial/1.0.0/) https://github.com/user-attachments/assets/abfd0300-0aae-4296-b8d3-8b72ed882433 -https://gitnexus.vercel.app -Being client sided, it costs me zero to deploy, so you can use it for free :-) (would love a ⭐ though) +> *Like DeepWiki, but deeper.* DeepWiki helps you *understand* code. GitNexus lets you *analyze* it — because a knowledge graph tracks every relationship, not just descriptions. -> *Like DeepWiki, but deeper.* 😉 - -DeepWiki helps you *understand* code. GitNexus lets you *analyze* it—because a knowledge graph tracks every dependency, call chain, and relationship. - -That's the difference between: -- "What does this function do?" → *understanding* -- "What breaks if I change this function?" → *analysis* - -**Core Innovation: Precomputed Relational Intelligence** - -Most AI coding tools give the LLM raw data and hope it figures out relationships. GitNexus **precomputes structure at index time**—clustering related code, tracing execution flows, scoring edge confidence—so tools return *decision-ready context*. This means: -- 🎯 **Reliability**: LLM can't miss context—it's already in the tool response -- ⚡ **Token efficiency**: No 10-query chains to understand one function -- 🤖 **Model democratization**: Smaller LLMs work because tools do the heavy lifting - -**Quick tech jargon:** -- **Smart Tools**: 7 graph-aware tools with built-in cluster/process context -- **Leiden Clustering**: Automatic detection of functional code communities -- **Process Detection**: Entry point tracing via BFS with framework-aware scoring -- **Confidence Scoring**: Every CALLS edge rated 0-1 (import-resolved vs fuzzy guess) -- **Hybrid Search**: BM25 + Semantic + 1-hop graph expansion via Cypher -- **Full WASM Stack**: Tree-sitter parsing + KuzuDB graph database, all in-browser -- **9 Languages**: TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust - -**What you can do:** - -| Capability | Description | -|------------|-------------| -| **Codebase-wide audits** | Find layer violations, forbidden dependencies | -| **Blast radius analysis** | See every function affected by a change (with confidence) | -| **Dead code detection** | Identify orphaned nodes with zero incoming calls | -| **Dependency tracing** | Follow import chains across the entire codebase | -| **Process exploration** | Trace execution flows from API handlers to data layer | -| **Cluster navigation** | Explore code by functional area, not just file structure | -| **AI analyses with citations** | Ask questions, analyze, get answers with `[[file:line]]` proof | - -**100% client-side.** Your code never leaves your browser. - -gitnexus_img +**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with goliath models. --- -## 🔍 The Problem with AI Coding Tools +## Two Ways to Use GitNexus -Tools like **Cursor**, **Claude Code**, **Cline**, **Roo Code**, and **Windsurf** are powerful—but they share a fundamental limitation: **they don't truly know your codebase structure**. +| | **CLI + MCP** | **Web UI** | +|---|---|---| +| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser | +| **For** | Daily development with Cursor, Claude Code, Windsurf, OpenCode | Quick exploration, demos, one-off analysis | +| **Scale** | Full repos, any size | Limited by browser memory (~5k files) | +| **Install** | `npm install -g gitnexus` | No install — [gitnexus.vercel.app](https://gitnexus.vercel.app) | +| **Storage** | KuzuDB native (fast, persistent) | KuzuDB WASM (in-memory, per session) | +| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM | +| **Privacy** | Everything local, no network | Everything in-browser, no server | + +--- + +## CLI + MCP (recommended) + +The CLI indexes your repository and runs an MCP server that gives AI agents deep codebase awareness. + +### Quick Start + +```bash +# Install +npm install -g gitnexus + +# One-time setup: configures MCP for Cursor, Claude Code, OpenCode +gitnexus setup + +# Index your repo (run from repo root) +gitnexus analyze + +# That's it! Open your editor — the MCP server starts automatically. +``` + +Or without installing globally: + +```bash +npx gitnexus setup # one-time +npx gitnexus analyze # per repo +``` + +### MCP Setup + +The `gitnexus setup` command auto-detects your editors and writes the correct MCP config. You only need to run it once. + +If you prefer manual configuration: + +**Cursor** (`~/.cursor/mcp.json` — global, works for all projects): + +```json +{ + "mcpServers": { + "gitnexus": { + "command": "npx", + "args": ["-y", "gitnexus@latest", "mcp"] + } + } +} +``` + +**Claude Code:** + +```bash +claude mcp add gitnexus -- npx -y gitnexus@latest mcp +``` + +**OpenCode** (`~/.config/opencode/config.json`): + +```json +{ + "mcp": { + "gitnexus": { + "command": "npx", + "args": ["-y", "gitnexus@latest", "mcp"] + } + } +} +``` + +### CLI Commands + +```bash +gitnexus setup # Configure MCP for your editors (one-time) +gitnexus analyze [path] # Index a repository (or update stale index) +gitnexus analyze --force # Force full re-index +gitnexus mcp # Start MCP server (stdio) — serves all indexed repos +gitnexus serve # Start HTTP server for web UI connection +gitnexus list # List all indexed repositories +gitnexus status # Show index status for current repo +gitnexus clean # Delete index for current repo +gitnexus clean --all # Delete all indexes +``` + +### What Your AI Agent Gets + +**7 tools** exposed via MCP: + +| Tool | What It Does | `repo` Param | +|------|-------------|--------------| +| `list_repos` | Discover all indexed repositories | — | +| `search` | Hybrid search (BM25 + semantic) with cluster context | Optional | +| `overview` | List all clusters and processes | Optional | +| `explore` | Deep dive on a symbol, cluster, or process | Optional | +| `impact` | Blast radius analysis with confidence filtering | Optional | +| `cypher` | Raw Cypher graph queries | Optional | +| `analyze` | Index or re-index a repository | Optional | + +> When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `search({query: "auth", repo: "my-app"})`. + +**Resources** for instant context: + +| Resource | Purpose | +|----------|---------| +| `gitnexus://repos` | List all indexed repositories (read this first) | +| `gitnexus://repo/{name}/context` | Codebase stats and overview | +| `gitnexus://repo/{name}/clusters` | All functional clusters with cohesion scores | +| `gitnexus://repo/{name}/cluster/{name}` | Cluster members and details | +| `gitnexus://repo/{name}/processes` | All execution flows | +| `gitnexus://repo/{name}/process/{name}` | Full process trace with steps | +| `gitnexus://repo/{name}/schema` | Graph schema for Cypher queries | + +**4 agent skills** installed to `.claude/skills/` automatically: + +- **Exploring** — Navigate unfamiliar code using the knowledge graph +- **Debugging** — Trace bugs through call chains +- **Impact Analysis** — Analyze blast radius before changes +- **Refactoring** — Plan safe refactors using dependency mapping + +--- + +## Multi-Repo MCP Architecture + +GitNexus uses a **global registry** so one MCP server can serve multiple indexed repos. No per-project MCP config needed — set it up once and it works everywhere. + +```mermaid +flowchart TD + subgraph CLI [CLI Commands] + Setup["gitnexus setup"] + Analyze["gitnexus analyze"] + Clean["gitnexus clean"] + List["gitnexus list"] + end + + subgraph Registry ["~/.gitnexus/"] + RegFile["registry.json"] + end + + subgraph Repos [Project Repos] + RepoA[".gitnexus/ in repo A"] + RepoB[".gitnexus/ in repo B"] + end + + subgraph MCP [MCP Server] + Server["server.ts"] + Backend["LocalBackend"] + Pool["Connection Pool"] + ConnA["KuzuDB conn A"] + ConnB["KuzuDB conn B"] + end + + Setup -->|"writes global MCP config"| CursorConfig["~/.cursor/mcp.json"] + Analyze -->|"registers repo"| RegFile + Analyze -->|"stores index"| RepoA + Clean -->|"unregisters repo"| RegFile + List -->|"reads"| RegFile + Server -->|"reads registry"| RegFile + Server --> Backend + Backend --> Pool + Pool -->|"lazy open"| ConnA + Pool -->|"lazy open"| ConnB + ConnA -->|"queries"| RepoA + ConnB -->|"queries"| RepoB +``` + +**How it works:** Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. KuzuDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). If only one repo is indexed, the `repo` parameter is optional on all tools — agents don't need to change anything. + +--- + +## Web UI (browser-based) + +A fully client-side graph explorer and AI chat. No server, no install — your code never leaves the browser. + +**Try it now:** [gitnexus.vercel.app](https://gitnexus.vercel.app) — drag & drop a ZIP and start exploring. + +gitnexus_img + +Or run locally: + +```bash +git clone https://github.com/abhigyanpatwari/gitnexus.git +cd gitnexus/gitnexus-web +npm install +npm run dev +``` + +The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, KuzuDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos. + +--- + +## The Problem GitNexus Solves + +Tools like **Cursor**, **Claude Code**, **Cline**, **Roo Code**, and **Windsurf** are powerful — but they don't truly know your codebase structure. **What happens:** -1. AI edits `UserService.validate()` +1. AI edits `UserService.validate()` 2. Doesn't know 47 functions depend on its return type -3. **Breaking changes ship** 💥 +3. **Breaking changes ship** -### The Solution: Precomputed Graph Intelligence +### Traditional Graph RAG vs GitNexus -Traditional Graph RAG gives the LLM raw edges and hopes it explores enough. GitNexus precomputes structure so tools return complete context in one call: +Traditional approaches give the LLM raw graph edges and hope it explores enough. GitNexus **precomputes structure at index time** — clustering, tracing, scoring — so tools return complete context in one call: ```mermaid flowchart TB - subgraph Traditional["❌ Traditional Graph RAG"] + subgraph Traditional["Traditional Graph RAG"] direction TB U1["User: What depends on UserService?"] U1 --> LLM1["LLM receives raw graph"] LLM1 --> Q1["Query 1: Find callers"] - Q1 --> R1["47 node IDs returned"] - R1 --> Q2["Query 2: What files are these?"] - Q2 --> R2["12 file paths"] - R2 --> Q3["Query 3: Filter out tests?"] - Q3 --> R3["8 production files"] - R3 --> Q4["Query 4: Which are high-risk?"] - Q4 --> THINK["LLM interprets..."] - THINK --> OUT1["Answer after 4+ queries"] + Q1 --> Q2["Query 2: What files?"] + Q2 --> Q3["Query 3: Filter tests?"] + Q3 --> Q4["Query 4: High-risk?"] + Q4 --> OUT1["Answer after 4+ queries"] end - subgraph GitNexus["✅ GitNexus Smart Tools"] + subgraph GN["GitNexus Smart Tools"] direction TB U2["User: What depends on UserService?"] U2 --> TOOL["impact UserService upstream"] TOOL --> PRECOMP["Pre-structured response: - • 8 production callers - • Grouped: Auth 3, Payment 2, API 3 - • All 90%+ confidence - • 5 in LoginFlow process"] + 8 callers, 3 clusters, all 90%+ confidence"] PRECOMP --> OUT2["Complete answer, 1 query"] end ``` -**Current state:** GitNexus is a standalone tool—a better DeepWiki that's 100% client-side with graph-powered analysis. +**Core innovation: Precomputed Relational Intelligence** -**MCP Integration:** GitNexus also runs as an MCP server (`gitnexus-mcp`) so tools like Cursor and Claude Code can query it for accurate context. - -git clone https://github.com/abhigyanpatwari/gitnexus.git -cd gitnexus -npm install -npm run dev - -Open http://localhost:5173, drag & drop a ZIP of your codebase, and start exploring. +- **Reliability** — LLM can't miss context, it's already in the tool response +- **Token efficiency** — No 10-query chains to understand one function +- **Model democratization** — Smaller LLMs work because tools do the heavy lifting --- -## 🏗️ Indexing Architecture +## How Indexing Works -Seven-phase indexing: **Structure** → **Parse** → **Imports** → **Calls** → **Heritage** → **Communities** → **Processes**. +Seven-phase pipeline that builds a complete knowledge graph: ```mermaid flowchart TD - subgraph P1["Phase 1: Extract (0-15%)"] - E1[Decompress ZIP] --> E2[Collect file paths] + subgraph P1["Phase 1: Structure (0-15%)"] + S1[Walk file tree] --> S2[Create CONTAINS edges] end - - subgraph P2["Phase 2: Structure (15-30%)"] - S1[Build folder tree] --> S2[Create CONTAINS edges] - end - - subgraph P3["Phase 3: Parse (30-55%)"] - PA1[Load Tree-sitter WASM] --> PA2[Generate ASTs] - PA2 --> PA3[Extract symbols] + + subgraph P2["Phase 2: Parse (15-40%)"] + PA1[Load Tree-sitter parsers] --> PA2[Generate ASTs] + PA2 --> PA3[Extract functions, classes, methods] PA3 --> PA4[Populate Symbol Table] end - - subgraph P4["Phase 4: Imports (55-65%)"] - I1[Find import statements] --> I2[Resolve paths] + + subgraph P3["Phase 3: Imports (40-55%)"] + I1[Find import statements] --> I2[Language-aware resolution] I2 --> I3[Create IMPORTS edges] end - - subgraph P5["Phase 5: Calls + Heritage (65-80%)"] + + subgraph P4["Phase 4: Calls + Heritage (55-75%)"] C1[Find function calls] --> C2[Resolve via Symbol Table] C2 --> C3[Create CALLS edges with confidence] C3 --> H1[Find extends/implements] H1 --> H2[Create EXTENDS/IMPLEMENTS edges] end - - subgraph P6["Phase 6: Communities (80-90%)"] + + subgraph P5["Phase 5: Communities (75-85%)"] CM1[Build CALLS graph] --> CM2[Run Leiden algorithm] CM2 --> CM3[Calculate cohesion scores] CM3 --> CM4[Generate heuristic labels] CM4 --> CM5[Create MEMBER_OF edges] end - - subgraph P7["Phase 7: Processes (90-100%)"] + + subgraph P6["Phase 6: Processes (85-95%)"] PR1[Score entry points] --> PR2[BFS trace via CALLS] PR2 --> PR3[Detect cross-community flows] PR3 --> PR4[Create STEP_IN_PROCESS edges] end - + + subgraph P7["Phase 7: Embeddings (95-100%)"] + EM1[Generate embeddings] --> EM2[Build HNSW vector index] + EM2 --> EM3[Build BM25 full-text index] + end + P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7 - P7 --> DB[(KuzuDB WASM)] - DB --> READY[Graph Ready!] + P7 --> DB[(KuzuDB)] + DB --> READY[Graph Ready] ``` -### Symbol Table: Dual HashMap +### Supported Languages -Resolution strategy for function calls (produces **confidence scores**): +TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust + +### Language-Aware Import Resolution + +GitNexus doesn't just string-match import paths. It understands language-specific module systems: + +| Language | What's Resolved | +|----------|----------------| +| **TypeScript** | Path aliases from `tsconfig.json` (e.g. `@/lib/auth` → `src/lib/auth`) | +| **Rust** | Module paths (`crate::auth::validate`, `super::utils`, `self::handler`) | +| **Java** | Wildcard imports (`com.example.*`) and static imports | +| **Go** | Module paths via `go.mod`, internal package resolution | +| **C/C++** | Relative includes, system include detection | + +### Confidence Scoring on CALLS + +Every function call edge includes a trust score: + +| Confidence | Reason | Meaning | +|------------|--------|---------| +| 0.90 | `import-resolved` | Target found in imported file | +| 0.85 | `same-file` | Target defined in same file | +| 0.50 | `fuzzy-global` (1 match) | Single global match by name | +| 0.30 | `fuzzy-global` (N matches) | Multiple matches, first picked | + +The `impact` tool uses `minConfidence` to filter out guesses and return only reliable results. + +### Symbol Table: Dual HashMap ```mermaid flowchart TD CALL["Found call: validateUser"] --> CHECK1{"In Import Map?"} - CHECK1 -->|Yes| FOUND1["✅ Import-resolved (90%)"] + CHECK1 -->|Yes| FOUND1["Import-resolved (90%)"] CHECK1 -->|No| CHECK2{"In Current File?"} - CHECK2 -->|Yes| FOUND2["✅ Same-file (85%)"] + CHECK2 -->|Yes| FOUND2["Same-file (85%)"] CHECK2 -->|No| CHECK3{"Global Search"} - CHECK3 -->|1 match| FOUND3["⚠️ Fuzzy single (50%)"] - CHECK3 -->|N matches| FOUND4["⚠️ Fuzzy multiple (30%)"] + CHECK3 -->|1 match| FOUND3["Fuzzy single (50%)"] + CHECK3 -->|N matches| FOUND4["Fuzzy multiple (30%)"] CHECK3 -->|Not Found| SKIP["Skip - unresolved"] - + FOUND1 & FOUND2 & FOUND3 & FOUND4 --> EDGE["Create CALLS edge with confidence"] ``` ### Community Detection (Leiden Algorithm) -Groups related code by analyzing CALLS edge density: +Groups related code into functional clusters by analyzing CALLS edge density: ```mermaid flowchart LR @@ -191,7 +369,7 @@ flowchart LR COHESION --> MEMBER["MEMBER_OF edges"] ``` -**Why it matters:** Instead of "this function is in `/src/auth/validate.ts`", the agent knows "this function is in the **Authentication** cluster with 23 other related symbols." +Instead of "this function is in `/src/auth/validate.ts`", the agent knows "this function is in the **Authentication** cluster with 23 other related symbols." ### Process Detection (Entry Point Tracing) @@ -200,14 +378,14 @@ Finds execution flows by tracing from entry points: ```mermaid flowchart TD FUNCS[All Functions/Methods] --> SCORE["Score entry point likelihood"] - + subgraph Scoring["Entry Point Scoring"] BASE["Call ratio: callees/(callers+1)"] - EXPORT["× 2.0 if exported"] - NAME["× 1.5 if handle*/on*/Controller"] - FW["× 3.0 if in /routes/ or /handlers/"] + EXPORT["x 2.0 if exported"] + NAME["x 1.5 if handle*/on*/Controller"] + FW["x 3.0 if in /routes/ or /handlers/"] end - + SCORE --> Scoring Scoring --> TOP["Top candidates"] TOP --> BFS["BFS trace via CALLS (max 10 hops)"] @@ -222,40 +400,25 @@ flowchart TD - Spring: `/controllers/`, `*Controller.java` - And more for Go, Rust, C#... -### Background Embeddings - -```mermaid -flowchart LR - subgraph BG["Background (Non-blocking)"] - M1[Load snowflake-arctic-embed-xs] --> M2[Initialize WebGPU/WASM] - M2 --> E1[Batch embed nodes] - E1 --> E2[INSERT into CodeEmbedding table] - E2 --> V1[Create HNSW Vector Index] - V1 --> B1[Build BM25 Index] - end - - BG --> AI[AI Search Ready!] -``` - -User can explore the graph during embedding. AI features unlock when complete. - --- -## 📊 Graph Schema +## Graph Schema ### Node Types | Label | Description | Key Properties | |-------|-------------|----------------| | `Folder` | Directory | `name`, `filePath` | -| `File` | Source file | `name`, `filePath`, `language` | +| `File` | Source file | `name`, `filePath`, `language`, `content` | | `Function` | Function def | `name`, `filePath`, `startLine`, `endLine`, `isExported` | -| `Class` | Class def | `name`, `filePath`, `startLine`, `endLine` | -| `Interface` | Interface def | `name`, `filePath`, `startLine`, `endLine` | -| `Method` | Class method | `name`, `filePath`, `startLine`, `endLine` | -| `Community` | Functional cluster | `label`, `cohesion`, `symbolCount`, `description` | +| `Class` | Class def | `name`, `filePath`, `startLine`, `endLine`, `isExported` | +| `Interface` | Interface def | `name`, `filePath`, `startLine`, `endLine`, `isExported` | +| `Method` | Class method | `name`, `filePath`, `startLine`, `endLine`, `isExported` | +| `Community` | Functional cluster | `label`, `heuristicLabel`, `cohesion`, `symbolCount` | | `Process` | Execution flow | `label`, `processType`, `stepCount`, `entryPointId` | +Plus language-specific nodes: `Struct`, `Enum`, `Trait`, `Impl`, `TypeAlias`, `Namespace`, `Record`, `Delegate`, `Annotation`, `Constructor`, `Template`, `Module` and more. + ### Relationship Table: `CodeRelation` Single edge table with `type` property: @@ -269,60 +432,74 @@ Single edge table with `type` property: | `EXTENDS` | Class | Class | — | | `IMPLEMENTS` | Class | Interface | — | | `MEMBER_OF` | Symbol | Community | — | -| `STEP_IN_PROCESS` | Symbol | Process | `step` (1-indexed position) | - -### Confidence Scores on CALLS - -Every CALLS edge includes trust metadata: - -| Confidence | Reason | Meaning | -|------------|--------|---------| -| 0.90 | `import-resolved` | Target found in imported file | -| 0.85 | `same-file` | Target defined in same file | -| 0.50 | `fuzzy-global` (1 match) | Single global match by name | -| 0.30 | `fuzzy-global` (N matches) | Multiple matches, first picked | - -**Why it matters:** The `impact` tool filters by `minConfidence` (default 0.7) to exclude guesses. +| `STEP_IN_PROCESS` | Symbol | Process | `step` (1-indexed) | --- -## 🛠️ Agent Tools Architecture +## Tool Examples -The LangChain ReAct agent has **7 tools** for code exploration. These tools **use precomputed structure** (clusters, processes, confidence) to return rich context. +### Impact Analysis -### Tool 1: `search` — Hybrid Search with Process Grouping +``` +gitnexus_impact({target: "UserService", direction: "upstream", minConfidence: 0.8, repo: "my-app"}) -Combines **BM25** (keyword) + **Semantic** (vector) + **1-hop expansion** + **process context**: +TARGET: Class UserService (src/services/user.ts) -```mermaid -flowchart TD - Q["Query: auth middleware"] --> HYBRID["Hybrid Search (BM25 + Semantic)"] - HYBRID --> RRF["Reciprocal Rank Fusion"] - RRF --> TOP["Top K Results"] - - TOP --> ENRICH["For each result:"] - ENRICH --> HOP["1-hop connections + confidence"] - ENRICH --> CLUSTER["Cluster membership"] - ENRICH --> PROC["Process participation"] - - HOP & CLUSTER & PROC --> GROUP["Group by process"] - GROUP --> OUT["Structured output: - PROCESS: LoginFlow (3 matches) - [1] Function: validateUser (step 2/7) - Cluster: Authentication - Connections: ←[CALLS 90%] handleLogin"] +UPSTREAM (what depends on this): + Depth 1 (direct callers): + handleLogin [CALLS 90%] → src/api/auth.ts:45 + handleRegister [CALLS 90%] → src/api/auth.ts:78 + UserController [CALLS 85%] → src/controllers/user.ts:12 + Depth 2: + authRouter [IMPORTS] → src/routes/auth.ts + +8 files affected, 3 clusters touched ``` -Each result includes not just *what matches*, but *where it fits* in the codebase structure. +Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests` ---- +### Search with Cluster Context -### Tool 2: `cypher` — Raw Graph Queries +``` +gitnexus_search({query: "authentication middleware", repo: "my-app"}) -Execute Cypher directly. Supports `{{QUERY_VECTOR}}` auto-embedding: +Results: + [1] Function: validateUser + File: src/auth/validate.ts + Cluster: Authentication + Score: 0.042 (hybrid: bm25 + semantic) + [2] Function: checkSession + File: src/auth/session.ts + Cluster: Authentication + Score: 0.038 +``` + +Each result includes the functional cluster it belongs to and optional relationship connections with `depth=full`. + +### Explore a Process + +``` +gitnexus_explore({name: "LoginFlow", type: "process", repo: "my-app"}) + +PROCESS: LoginFlow +Type: cross_community | Steps: 7 + +TRACE: + 1. handleLogin (API) + 2. validateUser (Authentication) + 3. checkRateLimit (RateLimiting) + 4. hashPassword (Authentication) + 5. createSession (Authentication) + 6. storeSession (Database) + 7. generateToken (Authentication) + +CLUSTERS TOUCHED: API, Authentication, RateLimiting, Database +``` + +### Cypher Queries ```cypher --- Find what calls auth functions in the Authentication cluster +-- Find what calls auth functions with high confidence MATCH (c:Community {label: 'Authentication'})<-[:CodeRelation {type: 'MEMBER_OF'}]-(fn) MATCH (caller)-[r:CodeRelation {type: 'CALLS'}]->(fn) WHERE r.confidence > 0.8 @@ -332,216 +509,63 @@ ORDER BY r.confidence DESC --- -### Tool 3: `grep` — Regex Pattern Matching +## Tech Stack -For exact strings, error codes, TODOs: - -``` -grep TODO|FIXME --fileFilter=.ts -→ src/auth/validate.ts:42: // TODO: Add rate limiting -``` +| Layer | CLI | Web | +|-------|-----|-----| +| **Runtime** | Node.js (native) | Browser (WASM) | +| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM | +| **Database** | KuzuDB native | KuzuDB WASM | +| **Embeddings** | HuggingFace transformers | transformers.js (WebGPU/WASM) | +| **Search** | BM25 + semantic + RRF | BM25 + semantic + RRF | +| **Agent Interface** | MCP (stdio) | LangChain ReAct agent | +| **Visualization** | — | Sigma.js + Graphology (WebGL) | +| **Frontend** | — | React 18, TypeScript, Vite, Tailwind v4 | +| **Clustering** | Graphology + Leiden | Graphology + Leiden | +| **Concurrency** | Node.js async | Web Workers + Comlink | --- -### Tool 4: `read` — Smart File Reader - -Fuzzy path matching with suggestions if not found. - ---- - -### Tool 5: `overview` — Codebase Map - -Returns the full structural overview in one call: - -``` -CLUSTERS (12 total): -| Cluster | Symbols | Cohesion | Description | -| Authentication| 23 | 0.82 | Login, session, JWT handling | -| Database | 18 | 0.76 | Query builders, connection pool | -... - -PROCESSES (8 total): -| Process | Steps | Type | Clusters | -| LoginFlow | 7 | cross_community | 3 | -| PaymentProcessing | 5 | intra_community | 1 | -... - -CLUSTER DEPENDENCIES: -- Authentication -> Database (12 calls) -- API -> Authentication (8 calls) -``` - ---- - -### Tool 6: `explore` — Deep Dive - -Accepts a **symbol**, **cluster**, or **process** name and returns detailed info: - -**For a symbol:** -``` -SYMBOL: Function validateUser -File: src/auth/validate.ts -Cluster: Authentication — Login and session management - -PROCESSES: -- LoginFlow (step 2/7) -- SessionRefresh (step 1/4) - -CONNECTIONS: --[CALLS 90%]-> hashPassword --[CALLS 85%]-> checkRateLimit -<-[CALLS 90%]- handleLogin -<-[CALLS 85%]- refreshSession -``` - -**For a process:** -``` -PROCESS: LoginFlow -Type: cross_community -Steps: 7 - -TRACE: -1. handleLogin (API) -2. validateUser (Authentication) -3. checkRateLimit (RateLimiting) -4. hashPassword (Authentication) -5. createSession (Authentication) -6. storeSession (Database) -7. generateToken (Authentication) - -CLUSTERS TOUCHED: API, Authentication, RateLimiting, Database -``` - ---- - -### Tool 7: `impact` — Blast Radius Analysis - -Answers "what breaks if I change X?" or "what does X depend on?": - -``` -impact UserService upstream --maxDepth=3 --minConfidence=0.8 - -TARGET: Class UserService (src/services/user.ts) - -UPSTREAM (what depends on this): -Depth 1 (direct callers): - • handleLogin [CALLS 90%] → src/api/auth.ts:45 - • handleRegister [CALLS 90%] → src/api/auth.ts:78 - • UserController [CALLS 85%] → src/controllers/user.ts:12 - -Depth 2: - • authRouter [IMPORTS] → src/routes/auth.ts - • (3 more...) - -Summary: 8 production files affected, 3 clusters touched -``` - -**Key features:** -- `upstream` = what calls this (breakage risk) -- `downstream` = what this depends on -- `minConfidence` = filter out fuzzy matches (default 0.7) -- `includeTests` = false by default - ---- - -## 💡 Key Discovery: Unified Vector + Graph - -KuzuDB supports **native vector indexing (HNSW)**, so we do semantic + graph in **one Cypher query**: - -```cypher -CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', $queryVector, 20) -YIELD node AS emb, distance -WITH emb, distance WHERE distance < 0.4 -MATCH (n:Function {id: emb.nodeId})<-[:CodeRelation {type: 'CALLS'}]-(caller) -MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) -RETURN n.name, caller.name, c.label, distance -ORDER BY distance -``` - -**Why this matters:** -- 🎯 **Single query execution** — No round-trips between systems -- 📊 **Built-in relevance ranking** — Distance IS the score -- ⚡ **No separate vector DB** — One database, one query language - ---- - -## ⚡ Technical Improvements - -### Sigma.js + WebGL -- V1: D3.js, choked at ~3k nodes -- V2: Sigma.js + GPU rendering, smooth at 10k+ - -### Dual HashMap Symbol Table -- V1: Trie (prefix tree) - clever but slow -- V2: File-scoped + Global hashmaps - **~2x speedup** - -### LRU AST Cache -- Tree-sitter ASTs live in WASM memory -- LRU cache (50 slots) with `tree.delete()` for cleanup - -### ForceAtlas2 in Web Worker -- Layout algorithm runs off main thread -- UI stays responsive during graph positioning - ---- - -## 🚧 Roadmap +## Roadmap ### Actively Building -- [ ] **LLM Cluster Enrichment** - Semantic names via LLM API -- [ ] **AST Decorator Detection** - Parse @Controller, @Get, etc. -- [ ] **Multi-Repo Support** - Analyze multiple repos together -- [ ] **External Neo4j Connection** - Use hosted graph DB +- [ ] **LLM Cluster Enrichment** — Semantic cluster names via LLM API +- [ ] **AST Decorator Detection** — Parse @Controller, @Get, etc. +- [ ] **Diff-Aware Impact** — Show blast radius for uncommitted changes -### Recently Completed ✅ +### Recently Completed -- [x] **MCP Support** - `gitnexus-mcp` package for tool integration -- [x] **Community Detection** - Leiden algorithm for functional clustering -- [x] **Process Detection** - Entry point tracing with framework awareness -- [x] **9 Language Support** - Java, C, C++, C#, Go, Rust added -- [x] **Confidence Scoring** - Trust levels on CALLS edges -- [x] **7 Smart Tools** - overview, explore, impact added -- [x] **Ollama Support** - Local LLM integration -- [x] **Blast Radius Tool** - `impact` for dependency analysis -- [x] Graph RAG Agent with streaming -- [x] Browser embeddings (snowflake-arctic-embed-xs, 22M params) -- [x] Vector index with HNSW in KuzuDB -- [x] Hybrid search (BM25 + semantic + RRF) -- [x] Grounded citations (`[[file:line]]` format) -- [x] Multiple LLM providers (OpenAI, Azure, Gemini, Anthropic, Ollama) +- [x] **Multi-Repo MCP** — Global registry + lazy connection pool, one MCP server for all repos +- [x] **Zero-Config Setup** — `gitnexus setup` auto-configures Cursor, Claude Code, OpenCode +- [x] **Unified CLI + MCP** — `npm install -g gitnexus` for indexing and MCP server +- [x] **Language-Aware Imports** — TS path aliases, Rust modules, Java wildcards, Go packages +- [x] **Community Detection** — Leiden algorithm for functional clustering +- [x] **Process Detection** — Entry point tracing with framework awareness +- [x] **9 Language Support** — TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust +- [x] **Confidence Scoring** — Trust levels on CALLS edges (0.3-0.9) +- [x] **Blast Radius Tool** — `impact` with minConfidence, relationTypes, includeTests +- [x] **Hybrid Search** — BM25 + semantic + Reciprocal Rank Fusion + cluster context +- [x] **Browser Embeddings** — snowflake-arctic-embed-xs (22M params) +- [x] **Vector Index** — HNSW in KuzuDB for semantic search +- [x] **Grounded Citations** — `[[file:line]]` format in AI responses +- [x] **Multiple LLM Providers** — OpenAI, Azure, Gemini, Anthropic, Ollama --- -## 🛠 Tech Stack +## Security & Privacy -| Layer | Technology | -|-------|------------| -| **Frontend** | React 18, TypeScript, Vite, Tailwind v4 | -| **Visualization** | Sigma.js, Graphology, ForceAtlas2 (WebGL) | -| **Parsing** | Tree-sitter WASM (9 languages) | -| **Database** | KuzuDB WASM (graph + vector HNSW) | -| **Clustering** | Graphology + Leiden algorithm | -| **Embeddings** | transformers.js, snowflake-arctic-embed-xs (22M) | -| **AI** | LangChain ReAct agent, streaming | -| **Concurrency** | Web Workers + Comlink | +- **CLI**: Everything runs locally on your machine. No network calls. Index stored in `.gitnexus/` (gitignored). Global registry at `~/.gitnexus/` stores only paths and metadata. +- **Web**: Everything runs in your browser. No code uploaded to any server. API keys stored in localStorage only. +- Open source — audit the code yourself. --- -## 🔐 Security & Privacy +## Acknowledgments -- All processing happens in your browser -- No code uploaded to any server -- API keys stored in localStorage only -- Open source—audit the code yourself - ---- - -## 🙏 Acknowledgments - -- [Tree-sitter](https://tree-sitter.github.io/) - AST parsing -- [KuzuDB](https://kuzudb.com/) - Embedded graph database with vector support -- [Sigma.js](https://www.sigmajs.org/) - WebGL graph rendering -- [transformers.js](https://huggingface.co/docs/transformers.js) - Browser ML -- [LangChain](https://langchain.com/) - Agent orchestration -- [Graphology](https://graphology.github.io/) - Graph data structures + Leiden +- [Tree-sitter](https://tree-sitter.github.io/) — AST parsing +- [KuzuDB](https://kuzudb.com/) — Embedded graph database with vector support +- [Sigma.js](https://www.sigmajs.org/) — WebGL graph rendering +- [transformers.js](https://huggingface.co/docs/transformers.js) — Browser ML +- [Graphology](https://graphology.github.io/) — Graph data structures + Leiden +- [MCP](https://modelcontextprotocol.io/) — Model Context Protocol diff --git a/gitnexus-web/src/components/StatusBar.tsx b/gitnexus-web/src/components/StatusBar.tsx index fbc4f53ad8..3240a008a3 100644 --- a/gitnexus-web/src/components/StatusBar.tsx +++ b/gitnexus-web/src/components/StatusBar.tsx @@ -1,3 +1,4 @@ +import { Heart } from 'lucide-react'; import { useAppState } from '../hooks/useAppState'; export const StatusBar = () => { @@ -44,6 +45,20 @@ export const StatusBar = () => { )} + {/* Center - Sponsor */} + + + Sponsor + + need to buy some API credits to run SWE-bench 😅 + + + {/* Right - Stats */}
{graph && ( diff --git a/gitnexus/.npmignore b/gitnexus/.npmignore new file mode 100644 index 0000000000..6a4cfb118b --- /dev/null +++ b/gitnexus/.npmignore @@ -0,0 +1,17 @@ +# Source (dist/ is the compiled output) +src/ +tsconfig.json + +# Dev files +*.ts +!dist/**/*.d.ts +.git/ +.gitignore +node_modules/ + +# Package lock (consumers use their own) +package-lock.json + +# IDE +.vscode/ +.idea/ diff --git a/gitnexus/README.md b/gitnexus/README.md new file mode 100644 index 0000000000..4219303b1c --- /dev/null +++ b/gitnexus/README.md @@ -0,0 +1,196 @@ +# GitNexus + +**Graph-powered code intelligence for AI agents.** Index any codebase into a knowledge graph, then query it via MCP or CLI. + +Works with **Cursor**, **Claude Code**, **Windsurf**, **Cline**, **OpenCode**, and any MCP-compatible tool. + +[![npm version](https://img.shields.io/npm/v/gitnexus.svg)](https://www.npmjs.com/package/gitnexus) +[![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg)](https://polyformproject.org/licenses/noncommercial/1.0.0/) + +--- + +## Why? + +AI coding tools don't understand your codebase structure. They edit a function without knowing 47 other functions depend on it. GitNexus fixes this by **precomputing every dependency, call chain, and relationship** into a queryable graph. + +**Three commands to give your AI agent full codebase awareness.** + +## Quick Start + +```bash +# Install +npm install -g gitnexus + +# One-time: configure MCP for your editors +gitnexus setup + +# Index your repository (run from repo root) +gitnexus analyze + +# Done! Open your editor — MCP connects automatically. +``` + +Or without installing globally: + +```bash +npx gitnexus setup # one-time +npx gitnexus analyze # per repo +``` + +The `setup` command auto-detects Cursor, Claude Code, and OpenCode, then writes the correct global MCP config. You only run it once. + +## MCP Setup (manual) + +If you prefer to configure manually instead of using `gitnexus setup`: + +### Cursor / Windsurf + +Add to `~/.cursor/mcp.json` (global — works for all projects): + +```json +{ + "mcpServers": { + "gitnexus": { + "command": "npx", + "args": ["-y", "gitnexus@latest", "mcp"] + } + } +} +``` + +### Claude Code + +```bash +claude mcp add gitnexus -- npx -y gitnexus@latest mcp +``` + +### OpenCode + +Add to `~/.config/opencode/config.json`: + +```json +{ + "mcp": { + "gitnexus": { + "command": "npx", + "args": ["-y", "gitnexus@latest", "mcp"] + } + } +} +``` + +## What It Does + +GitNexus indexes your codebase through 7 phases: + +1. **Structure** — File/folder tree +2. **Parse** — AST extraction via Tree-sitter (9 languages) +3. **Imports** — Resolve import paths (including TS path aliases, Rust modules, Java wildcards, Go packages) +4. **Calls** — Function call resolution with confidence scoring (0.3-0.9) +5. **Heritage** — Class extends/implements chains +6. **Communities** — Leiden algorithm clusters related code into functional groups +7. **Processes** — Entry point detection and execution flow tracing + +The result is a **KuzuDB graph database** stored locally in `.gitnexus/` with full-text search and semantic embeddings. + +## MCP Tools + +Your AI agent gets these tools automatically: + +| Tool | What It Does | `repo` Param | +|------|-------------|--------------| +| `list_repos` | Discover all indexed repositories | — | +| `search` | Hybrid search (BM25 + semantic) with cluster context | Optional | +| `overview` | List all clusters and processes | Optional | +| `explore` | Deep dive on a symbol, cluster, or process | Optional | +| `impact` | Blast radius analysis | Optional | +| `cypher` | Raw Cypher graph queries | Optional | +| `analyze` | Index or re-index a repository | Optional | + +> With one indexed repo, the `repo` param is optional. With multiple, specify which: `search({query: "auth", repo: "my-app"})`. + +## MCP Resources + +| Resource | Purpose | +|----------|---------| +| `gitnexus://repos` | List all indexed repositories (read first) | +| `gitnexus://repo/{name}/context` | Codebase stats and overview | +| `gitnexus://repo/{name}/clusters` | All functional clusters | +| `gitnexus://repo/{name}/cluster/{name}` | Cluster members and details | +| `gitnexus://repo/{name}/processes` | All execution flows | +| `gitnexus://repo/{name}/process/{name}` | Full process trace | +| `gitnexus://repo/{name}/schema` | Graph schema for Cypher queries | + +## CLI Commands + +```bash +gitnexus setup # Configure MCP for your editors (one-time) +gitnexus analyze [path] # Index a repository (or update stale index) +gitnexus analyze --force # Force full re-index +gitnexus mcp # Start MCP server (stdio) — serves all indexed repos +gitnexus serve # Start HTTP server for web UI +gitnexus list # List all indexed repositories +gitnexus status # Show index status for current repo +gitnexus clean # Delete index for current repo +gitnexus clean --all # Delete all indexes +``` + +## Multi-Repo Support + +GitNexus supports indexing multiple repositories. Each `gitnexus analyze` registers the repo in a global registry (`~/.gitnexus/registry.json`). The MCP server serves all indexed repos automatically with lazy KuzuDB connections. + +## Supported Languages + +TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust + +## How Impact Analysis Works + +``` +gitnexus_impact({target: "UserService", direction: "upstream", repo: "my-app"}) + +TARGET: Class UserService (src/services/user.ts) + +UPSTREAM (what depends on this): + Depth 1 (direct callers): + handleLogin [CALLS 90%] → src/api/auth.ts:45 + handleRegister [CALLS 90%] → src/api/auth.ts:78 + Depth 2: + authRouter [IMPORTS] → src/routes/auth.ts + +8 files affected, 3 clusters touched +``` + +Options: `maxDepth`, `minConfidence`, `relationTypes`, `includeTests` + +## Agent Skills + +GitNexus ships with skill files that teach AI agents how to use the tools effectively: + +- **Exploring** — Navigate unfamiliar code using the knowledge graph +- **Debugging** — Trace bugs through call chains +- **Impact Analysis** — Analyze blast radius before changes +- **Refactoring** — Plan safe refactors using dependency mapping + +These are installed automatically to `.claude/skills/` when you run `gitnexus analyze`. + +## Requirements + +- Node.js >= 18 +- Git repository (uses git for commit tracking) + +## Privacy + +- All processing happens locally on your machine +- No code is sent to any server +- Index stored in `.gitnexus/` inside your repo (gitignored) +- Global registry at `~/.gitnexus/` stores only paths and metadata + +## Web UI + +GitNexus also has a browser-based UI at [gitnexus.vercel.app](https://gitnexus.vercel.app) — 100% client-side, your code never leaves the browser. + +## License + +[PolyForm Noncommercial 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0/) + +Free for non-commercial use. Contact for commercial licensing. diff --git a/gitnexus/package.json b/gitnexus/package.json index 6afd6147c8..d7b0718c99 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,12 +1,33 @@ { "name": "gitnexus", - "version": "0.1.0", - "description": "GitNexus local CLI and MCP server", + "version": "1.1.6", + "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", + "homepage": "https://github.com/abhigyanpatwari/GitNexus#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/abhigyanpatwari/GitNexus.git", + "directory": "gitnexus" + }, + "bugs": { + "url": "https://github.com/abhigyanpatwari/GitNexus/issues" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "code-intelligence", + "knowledge-graph", + "cursor", + "claude", + "ai-agent", + "gitnexus", + "static-analysis", + "codebase-indexing" + ], "type": "module", "bin": { - "gitnexus": "./dist/cli/index.js" + "gitnexus": "dist/cli/index.js" }, "files": [ "dist", diff --git a/gitnexus/skills/debugging.md b/gitnexus/skills/debugging.md index 883ff25b76..c2d00ba3fa 100644 --- a/gitnexus/skills/debugging.md +++ b/gitnexus/skills/debugging.md @@ -7,10 +7,11 @@ description: Trace bugs through call chains using knowledge graph ## Quick Start ``` -0. If "Index is stale" → gitnexus_analyze({}) -1. gitnexus_search({query}) → Find code related to error -2. gitnexus_explore({name, type: "symbol"}) → Get callers and callees -3. READ gitnexus://process/{name} → Trace execution flow +0. READ gitnexus://repos → Discover indexed repos +1. If "Index is stale" → run `npx gitnexus analyze` in terminal +2. gitnexus_search({query: "...", repo: "my-app"}) → Find code related to error +3. gitnexus_explore({name, type: "symbol", repo: "my-app"}) → Get callers and callees +4. READ gitnexus://repo/my-app/process/{name} → Trace execution flow ``` ## When to Use @@ -22,18 +23,19 @@ description: Trace bugs through call chains using knowledge graph ## Workflow Checklist ``` Bug Investigation: +- [ ] READ gitnexus://repos to find the right repo - [ ] Understand the symptom (error message, behavior) - [ ] gitnexus_search to find related code - [ ] Identify the suspect function - [ ] gitnexus_explore to see callers/callees -- [ ] READ gitnexus://process/{name} if suspect is in a process -- [ ] READ gitnexus://schema for Cypher query help +- [ ] READ gitnexus://repo/{name}/process/{name} if suspect is in a process +- [ ] READ gitnexus://repo/{name}/schema for Cypher query help - [ ] gitnexus_cypher for custom traces ``` ## Resource Reference -### gitnexus://schema +### gitnexus://repo/{name}/schema Graph schema for writing Cypher queries: ```yaml nodes: [Function, Class, Method, File, Community, Process] @@ -44,7 +46,7 @@ example_queries: RETURN caller.name ``` -### gitnexus://process/{name} +### gitnexus://repo/{name}/process/{processName} Trace execution flow to find where bug might occur: ```yaml name: CheckoutFlow @@ -60,13 +62,13 @@ trace: ### gitnexus_search Find code related to error or symptom: ``` -gitnexus_search({query: "payment validation error", depth: "full"}) +gitnexus_search({query: "payment validation error", depth: "full", repo: "my-app"}) ``` ### gitnexus_explore Get symbol context: ``` -gitnexus_explore({name: "validatePayment", type: "symbol"}) +gitnexus_explore({name: "validatePayment", type: "symbol", repo: "my-app"}) → Callers: processCheckout, webhookHandler → Callees: verifyCard, fetchRates ``` @@ -82,13 +84,13 @@ RETURN [n IN nodes(path) | n.name] AS chain ## Example: "Payment endpoint returns 500 intermittently" ``` -1. gitnexus_search({query: "payment error handling"}) +1. gitnexus_search({query: "payment error handling", repo: "my-app"}) → validatePayment, handlePaymentError, PaymentException -2. gitnexus_explore({name: "validatePayment", type: "symbol"}) +2. gitnexus_explore({name: "validatePayment", type: "symbol", repo: "my-app"}) → Callees: verifyCard, fetchRates (external API!) -3. READ gitnexus://process/CheckoutFlow +3. READ gitnexus://repo/my-app/process/CheckoutFlow → Step 3: validatePayment → calls external API 4. Root cause: fetchRates calls external API without proper timeout diff --git a/gitnexus/skills/exploring.md b/gitnexus/skills/exploring.md index 52beb6909d..cffd6c3984 100644 --- a/gitnexus/skills/exploring.md +++ b/gitnexus/skills/exploring.md @@ -7,10 +7,11 @@ description: Navigate unfamiliar code using GitNexus knowledge graph ## Quick Start ``` -0. If "Index is stale" → gitnexus_analyze({}) -1. READ gitnexus://context → Get codebase overview (~150 tokens) -2. READ gitnexus://clusters → See all functional clusters -3. READ gitnexus://cluster/{name} → Deep dive on specific cluster +0. READ gitnexus://repos → Discover indexed repos (use repo param if multiple) +1. If "Index is stale" → run `npx gitnexus analyze` in terminal +2. READ gitnexus://repo/{name}/context → Get codebase overview (~150 tokens) +3. READ gitnexus://repo/{name}/clusters → See all functional clusters +4. READ gitnexus://repo/{name}/cluster/{name} → Deep dive on specific cluster ``` ## When to Use @@ -22,17 +23,28 @@ description: Navigate unfamiliar code using GitNexus knowledge graph ## Workflow Checklist ``` Exploration Progress: -- [ ] READ gitnexus://context for codebase overview -- [ ] READ gitnexus://clusters to list all clusters +- [ ] READ gitnexus://repos to discover available repos +- [ ] READ gitnexus://repo/{name}/context for codebase overview +- [ ] READ gitnexus://repo/{name}/clusters to list all clusters - [ ] Identify the relevant cluster by name -- [ ] READ gitnexus://cluster/{name} for cluster details +- [ ] READ gitnexus://repo/{name}/cluster/{name} for cluster details - [ ] Use gitnexus_explore for specific symbols ``` ## Resource Reference -### gitnexus://context -Codebase overview. **Read first.** +### gitnexus://repos +Discover all indexed repositories. **Read first.** +```yaml +repos: + - name: "my-app" + path: "/home/user/my-app" + files: 42 + symbols: 918 +``` + +### gitnexus://repo/{name}/context +Codebase overview for a specific repo. ```yaml project: my-app stats: @@ -40,11 +52,10 @@ stats: symbols: 918 clusters: 12 processes: 45 -tools_available: [search, explore, impact, overview, cypher] -resources_available: [clusters, processes, cluster/{name}, process/{name}] +tools_available: [list_repos, search, explore, impact, overview, cypher] ``` -### gitnexus://clusters +### gitnexus://repo/{name}/clusters All functional clusters with cohesion scores. ```yaml clusters: @@ -56,7 +67,7 @@ clusters: cohesion: 88% ``` -### gitnexus://cluster/{name} +### gitnexus://repo/{name}/cluster/{clusterName} Members of a specific cluster. ```yaml name: Auth @@ -68,7 +79,7 @@ members: file: src/auth/validator.ts ``` -### gitnexus://process/{name} +### gitnexus://repo/{name}/process/{processName} Full execution trace. ```yaml name: LoginFlow @@ -84,7 +95,7 @@ steps: ### gitnexus_explore For detailed symbol context with callers/callees: ``` -gitnexus_explore({name: "validateUser", type: "symbol"}) +gitnexus_explore({name: "validateUser", type: "symbol", repo: "my-app"}) → Callers: loginHandler, apiMiddleware → Callees: checkToken, getUserById ``` @@ -92,21 +103,24 @@ gitnexus_explore({name: "validateUser", type: "symbol"}) ### gitnexus_search For finding code by query: ``` -gitnexus_search({query: "payment validation", depth: "full"}) +gitnexus_search({query: "payment validation", depth: "full", repo: "my-app"}) ``` ## Example: "How does payment processing work?" ``` -1. READ gitnexus://context +1. READ gitnexus://repos + → Repos: my-app (918 symbols) + +2. READ gitnexus://repo/my-app/context → 918 symbols, 12 clusters -2. READ gitnexus://clusters +3. READ gitnexus://repo/my-app/clusters → Clusters: Auth, Payment, Database, API... -3. READ gitnexus://cluster/Payment +4. READ gitnexus://repo/my-app/cluster/Payment → Members: processPayment, validateCard, PaymentService -4. READ gitnexus://process/CheckoutFlow +5. READ gitnexus://repo/my-app/process/CheckoutFlow → handleCheckout → validateCart → processPayment → sendConfirmation ``` diff --git a/gitnexus/skills/impact-analysis.md b/gitnexus/skills/impact-analysis.md index dbbffc3124..2c283b33c5 100644 --- a/gitnexus/skills/impact-analysis.md +++ b/gitnexus/skills/impact-analysis.md @@ -7,10 +7,11 @@ description: Analyze blast radius before making code changes ## Quick Start ``` -0. If "Index is stale" → gitnexus_analyze({}) -1. gitnexus_impact({target, direction: "upstream"}) → What depends on this -2. READ gitnexus://clusters → Check affected areas -3. READ gitnexus://processes → Affected execution flows +0. READ gitnexus://repos → Discover indexed repos +1. If "Index is stale" → run `npx gitnexus analyze` in terminal +2. gitnexus_impact({target, direction: "upstream", repo: "my-app"}) → What depends on this +3. READ gitnexus://repo/my-app/clusters → Check affected areas +4. READ gitnexus://repo/my-app/processes → Affected execution flows ``` ## When to Use @@ -30,8 +31,9 @@ description: Analyze blast radius before making code changes ## Workflow Checklist ``` Impact Analysis: -- [ ] gitnexus_impact(target, "upstream") to find dependents -- [ ] READ gitnexus://clusters to understand affected areas +- [ ] READ gitnexus://repos to find the right repo +- [ ] gitnexus_impact(target, "upstream", repo) to find dependents +- [ ] READ gitnexus://repo/{name}/clusters to understand affected areas - [ ] Check high-confidence (>0.8) dependencies first - [ ] Count affected clusters (cross-cutting = higher risk) - [ ] If >10 processes affected, consider splitting change @@ -39,7 +41,7 @@ Impact Analysis: ## Resource Reference -### gitnexus://clusters +### gitnexus://repo/{name}/clusters Check which clusters might be affected: ```yaml clusters: @@ -49,7 +51,7 @@ clusters: symbols: 32 ``` -### gitnexus://processes +### gitnexus://repo/{name}/processes Find which processes touch the target: ```yaml processes: @@ -67,7 +69,8 @@ gitnexus_impact({ target: "validateUser", direction: "upstream", minConfidence: 0.8, - maxDepth: 3 + maxDepth: 3, + repo: "my-app" }) → d=1 (WILL BREAK): @@ -103,11 +106,11 @@ Before Committing: ## Example: "What breaks if I change validateUser?" ``` -1. gitnexus_impact({target: "validateUser", direction: "upstream"}) +1. gitnexus_impact({target: "validateUser", direction: "upstream", repo: "my-app"}) → d=1: loginHandler, apiMiddleware → d=2: authRouter, sessionManager -2. READ gitnexus://clusters +2. READ gitnexus://repo/my-app/clusters → Auth and API clusters affected 3. Decision: 2 direct callers, 2 clusters = MEDIUM risk diff --git a/gitnexus/skills/refactoring.md b/gitnexus/skills/refactoring.md index 1ea1c545b8..53d9e37dde 100644 --- a/gitnexus/skills/refactoring.md +++ b/gitnexus/skills/refactoring.md @@ -7,10 +7,11 @@ description: Plan safe refactors using blast radius and dependency mapping ## Quick Start ``` -0. If "Index is stale" → gitnexus_analyze({}) -1. gitnexus_impact({target, direction: "upstream"}) → Map all dependents -2. READ gitnexus://schema → Understand graph structure -3. gitnexus_cypher → Find all references +0. READ gitnexus://repos → Discover indexed repos +1. If "Index is stale" → run `npx gitnexus analyze` in terminal +2. gitnexus_impact({target, direction: "upstream", repo: "my-app"}) → Map all dependents +3. READ gitnexus://repo/my-app/schema → Understand graph structure +4. gitnexus_cypher({query: "...", repo: "my-app"}) → Find all references ``` ## When to Use @@ -24,8 +25,8 @@ description: Plan safe refactors using blast radius and dependency mapping ### Rename Symbol ``` Rename Refactoring: -- [ ] gitnexus_impact(oldName, "upstream") — find all callers -- [ ] gitnexus_search(oldName) — find string literals +- [ ] gitnexus_impact({target: oldName, direction: "upstream", repo: "my-app"}) — find all callers +- [ ] gitnexus_search({query: oldName, repo: "my-app"}) — find string literals - [ ] Check for reflection/dynamic references - [ ] Update in order: interface → implementation → usages - [ ] Run tests for affected processes @@ -34,9 +35,9 @@ Rename Refactoring: ### Extract Module ``` Extract Module: -- [ ] gitnexus_explore(target, "symbol") — map dependencies -- [ ] gitnexus_impact(target, "upstream") — find callers -- [ ] READ gitnexus://cluster/{name} — check cohesion +- [ ] gitnexus_explore({name: target, type: "symbol", repo: "my-app"}) — map dependencies +- [ ] gitnexus_impact({target, direction: "upstream", repo: "my-app"}) — find callers +- [ ] READ gitnexus://repo/my-app/cluster/{name} — check cohesion - [ ] Define new module interface - [ ] Update imports across affected files ``` @@ -44,7 +45,7 @@ Extract Module: ### Split Function ``` Split Function: -- [ ] gitnexus_explore(target, "symbol") — understand callees +- [ ] gitnexus_explore({name: target, type: "symbol", repo: "my-app"}) — understand callees - [ ] Group related logic - [ ] gitnexus_impact — verify callers won't break - [ ] Create new functions @@ -53,7 +54,7 @@ Split Function: ## Resource Reference -### gitnexus://schema +### gitnexus://repo/{name}/schema Graph structure for Cypher queries: ```yaml nodes: [Function, Class, Method, Community, Process] @@ -65,7 +66,7 @@ example_queries: RETURN caller.name ``` -### gitnexus://cluster/{name} +### gitnexus://repo/{name}/cluster/{clusterName} Check if extraction preserves cohesion: ```yaml name: Payment @@ -91,13 +92,13 @@ RETURN importer.name, importer.filePath ## Example: Safely Rename `validateUser` to `authenticateUser` ``` -1. gitnexus_impact({target: "validateUser", direction: "upstream"}) +1. gitnexus_impact({target: "validateUser", direction: "upstream", repo: "my-app"}) → loginHandler, apiMiddleware, testUtils -2. gitnexus_search({query: "validateUser"}) +2. gitnexus_search({query: "validateUser", repo: "my-app"}) → Found in: config.json (dynamic reference!) -3. READ gitnexus://processes +3. READ gitnexus://repo/my-app/processes → LoginFlow, TokenRefresh, APIGateway 4. Plan update order: diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index dcc8f30871..c0d427afbe 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -19,6 +19,7 @@ interface RepoStats { nodes?: number; edges?: number; communities?: number; + clusters?: number; // Aggregated cluster count (what tools show) processes?: number; } @@ -29,10 +30,11 @@ const GITNEXUS_END_MARKER = ''; * Generate the full GitNexus context content (resources-first approach) */ function generateGitNexusContent(projectName: string, stats: RepoStats): string { + const clusterCount = stats.clusters || stats.communities || 0; return `${GITNEXUS_START_MARKER} # GitNexus MCP -This project is indexed by GitNexus, providing AI agents with deep code intelligence. +This project is indexed as **${projectName}** by GitNexus, providing AI agents with deep code intelligence. ## Project: ${projectName} @@ -41,54 +43,63 @@ This project is indexed by GitNexus, providing AI agents with deep code intellig | Files | ${stats.files || 0} | | Symbols | ${stats.nodes || 0} | | Relationships | ${stats.edges || 0} | -| Communities | ${stats.communities || 0} | +| Clusters | ${clusterCount} | | Processes | ${stats.processes || 0} | +> **Staleness:** If the index is out of date, run \`npx gitnexus analyze\` in the terminal to refresh. The \`gitnexus://repo/${projectName}/context\` resource will warn you when the index is stale. + ## Quick Start \`\`\` -1. READ gitnexus://context → Get codebase overview (~150 tokens) -2. READ gitnexus://clusters → See all functional clusters -3. READ gitnexus://cluster/{name} → Deep dive on specific cluster -4. gitnexus_search(query) → Find code by query +1. READ gitnexus://repos → Discover all indexed repos +2. READ gitnexus://repo/${projectName}/context → Get codebase overview (~150 tokens) +3. READ gitnexus://repo/${projectName}/clusters → See all functional clusters +4. gitnexus_search({query: "...", repo: "${projectName}"}) → Find code by query \`\`\` ## Available Resources | Resource | Purpose | |----------|---------| -| \`gitnexus://context\` | Codebase stats, tools, and resources overview | -| \`gitnexus://clusters\` | All clusters with symbol counts and cohesion | -| \`gitnexus://cluster/{name}\` | Cluster members and details | -| \`gitnexus://processes\` | All execution flows with types | -| \`gitnexus://process/{name}\` | Full process trace with steps | -| \`gitnexus://schema\` | Graph schema for Cypher queries | +| \`gitnexus://repos\` | List all indexed repositories | +| \`gitnexus://repo/${projectName}/context\` | Codebase stats, tools, and resources overview | +| \`gitnexus://repo/${projectName}/clusters\` | All clusters with symbol counts and cohesion | +| \`gitnexus://repo/${projectName}/cluster/{name}\` | Cluster members and details | +| \`gitnexus://repo/${projectName}/processes\` | All execution flows with types | +| \`gitnexus://repo/${projectName}/process/{name}\` | Full process trace with steps | +| \`gitnexus://repo/${projectName}/schema\` | Graph schema for Cypher queries | ## Available Tools | Tool | Purpose | When to Use | |------|---------|-------------| +| \`list_repos\` | Discover indexed repos | First step with multiple repos | | \`search\` | Semantic + keyword search | Finding code by query | | \`overview\` | List clusters & processes | Understanding architecture | | \`explore\` | Deep dive on symbol/cluster/process | Detailed investigation | | \`impact\` | Blast radius analysis | Before making changes | | \`cypher\` | Raw graph queries | Complex analysis | +> **Re-indexing:** To refresh a stale index, run \`npx gitnexus analyze\` in the terminal. Use \`--force\` only to rebuild from scratch. This is a CLI command, not an MCP tool. + +> **Multi-repo:** When multiple repos are indexed, pass \`repo: "${projectName}"\` to target this project. + ## Workflow Examples ### Exploring the Codebase \`\`\` -READ gitnexus://context → Stats and overview -READ gitnexus://clusters → Find relevant cluster -READ gitnexus://cluster/Auth → Explore Auth cluster -gitnexus_explore("validateUser", "symbol") → Detailed symbol info +READ gitnexus://repos → Discover repos +READ gitnexus://repo/${projectName}/context → Stats and overview (check for staleness) +READ gitnexus://repo/${projectName}/clusters → Find relevant cluster by name +READ gitnexus://repo/${projectName}/cluster/{name} → See members of that cluster +gitnexus_explore({name: "", type: "symbol", repo: "${projectName}"}) \`\`\` ### Planning a Change \`\`\` -gitnexus_impact("UserService", "upstream") → See what breaks -READ gitnexus://processes → Check affected flows -gitnexus_explore("LoginFlow", "process") → Trace execution +gitnexus_search({query: "", repo: "${projectName}"}) +gitnexus_impact({target: "", direction: "upstream", repo: "${projectName}"}) +READ gitnexus://repo/${projectName}/processes → Check affected execution flows \`\`\` ## Graph Schema diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 8f4532e588..3abf4a31a7 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -9,9 +9,10 @@ import ora from 'ora'; import { runPipelineFromRepo } from '../core/ingestion/pipeline.js'; import { initKuzu, loadGraphToKuzu, getKuzuStats, executeQuery, executeWithReusedStatement, closeKuzu, createFTSIndex } from '../core/kuzu/kuzu-adapter.js'; import { runEmbeddingPipeline } from '../core/embeddings/embedding-pipeline.js'; -import { getStoragePaths, saveMeta, loadMeta, addToGitignore } from '../storage/repo-manager.js'; +import { getStoragePaths, saveMeta, loadMeta, addToGitignore, registerRepo, getGlobalRegistryPath } from '../storage/repo-manager.js'; import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js'; import { generateAIContextFiles } from './ai-context.js'; +import fs from 'fs/promises'; export interface AnalyzeOptions { force?: boolean; @@ -84,6 +85,7 @@ export const analyzeCommand = async ( await createFTSIndex('Function', 'function_fts', ['name', 'content']); await createFTSIndex('Class', 'class_fts', ['name', 'content']); await createFTSIndex('Method', 'method_fts', ['name', 'content']); + await createFTSIndex('Interface', 'interface_fts', ['name', 'content']); } catch (e: any) { // FTS index creation may fail if tables are empty (no data for that type) console.error('Note: Some FTS indexes may not have been created:', e.message); @@ -103,7 +105,7 @@ export const analyzeCommand = async ( // Save metadata const stats = await getKuzuStats(); - await saveMeta(storagePath, { + const meta = { repoPath, lastCommit: currentCommit, indexedAt: new Date().toISOString(), @@ -114,18 +116,35 @@ export const analyzeCommand = async ( communities: pipelineResult.communityResult?.stats.totalCommunities, processes: pipelineResult.processResult?.stats.totalProcesses, }, - }); + }; + await saveMeta(storagePath, meta); + + // Register in global registry + await registerRepo(repoPath, meta); // Add .gitnexus to .gitignore await addToGitignore(repoPath); // Generate AI context files const projectName = path.basename(repoPath); + // Compute aggregated cluster count (grouped by heuristicLabel, >=5 symbols) + // This matches the aggregation logic in local-backend.ts for tool output consistency. + let aggregatedClusterCount = 0; + if (pipelineResult.communityResult?.communities) { + const groups = new Map(); + for (const c of pipelineResult.communityResult.communities) { + const label = c.heuristicLabel || c.label || 'Unknown'; + groups.set(label, (groups.get(label) || 0) + c.symbolCount); + } + aggregatedClusterCount = Array.from(groups.values()).filter(count => count >= 5).length; + } + const aiContext = await generateAIContextFiles(repoPath, storagePath, projectName, { files: pipelineResult.fileContents.size, nodes: stats.nodes, edges: stats.edges, communities: pipelineResult.communityResult?.stats.totalCommunities, + clusters: aggregatedClusterCount, processes: pipelineResult.processResult?.stats.totalProcesses, }); @@ -140,4 +159,13 @@ export const analyzeCommand = async ( if (aiContext.files.length > 0) { console.log(` AI Context: ${aiContext.files.join(', ')}`); } + + // Hint about setup if it hasn't been run + try { + await fs.access(getGlobalRegistryPath()); + } catch { + // Registry didn't exist before this run — suggest setup + console.log(''); + console.log(' Tip: Run `gitnexus setup` to configure MCP for your editor.'); + } }; diff --git a/gitnexus/src/cli/clean.ts b/gitnexus/src/cli/clean.ts index 40214333f9..edf57409d4 100644 --- a/gitnexus/src/cli/clean.ts +++ b/gitnexus/src/cli/clean.ts @@ -2,12 +2,43 @@ * Clean Command * * Removes the .gitnexus index from the current repository. + * Also unregisters it from the global registry. */ import fs from 'fs/promises'; -import { findRepo, getStoragePath } from '../storage/repo-manager.js'; +import { findRepo, unregisterRepo, listRegisteredRepos } from '../storage/repo-manager.js'; -export const cleanCommand = async (options?: { force?: boolean }) => { +export const cleanCommand = async (options?: { force?: boolean; all?: boolean }) => { + // --all flag: clean all indexed repos + if (options?.all) { + if (!options?.force) { + const entries = await listRegisteredRepos(); + if (entries.length === 0) { + console.log('No indexed repositories found.'); + return; + } + console.log(`This will delete GitNexus indexes for ${entries.length} repo(s):`); + for (const entry of entries) { + console.log(` - ${entry.name} (${entry.path})`); + } + console.log('\nRun with --force to confirm deletion.'); + return; + } + + const entries = await listRegisteredRepos(); + for (const entry of entries) { + try { + await fs.rm(entry.storagePath, { recursive: true, force: true }); + await unregisterRepo(entry.path); + console.log(`Deleted: ${entry.name} (${entry.storagePath})`); + } catch (err) { + console.error(`Failed to delete ${entry.name}:`, err); + } + } + return; + } + + // Default: clean current repo const cwd = process.cwd(); const repo = await findRepo(cwd); @@ -19,7 +50,7 @@ export const cleanCommand = async (options?: { force?: boolean }) => { const repoName = repo.repoPath.split(/[/\\]/).pop() || repo.repoPath; if (!options?.force) { - console.log(`⚠️ This will delete the GitNexus index for: ${repoName}`); + console.log(`This will delete the GitNexus index for: ${repoName}`); console.log(` Path: ${repo.storagePath}`); console.log('\nRun with --force to confirm deletion.'); return; @@ -27,7 +58,8 @@ export const cleanCommand = async (options?: { force?: boolean }) => { try { await fs.rm(repo.storagePath, { recursive: true, force: true }); - console.log(`🗑️ Deleted: ${repo.storagePath}`); + await unregisterRepo(repo.repoPath); + console.log(`Deleted: ${repo.storagePath}`); } catch (err) { console.error('Failed to delete:', err); } diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 7d094dd852..956307d3e6 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -6,12 +6,18 @@ import { listCommand } from './list.js'; import { statusCommand } from './status.js'; import { mcpCommand } from './mcp.js'; import { cleanCommand } from './clean.js'; +import { setupCommand } from './setup.js'; const program = new Command(); program .name('gitnexus') .description('GitNexus local CLI and MCP server') - .version('0.1.0'); + .version('1.1.1'); + +program + .command('setup') + .description('One-time setup: configure MCP for Cursor, Claude Code, OpenCode') + .action(setupCommand); program .command('analyze [path]') @@ -28,12 +34,12 @@ program program .command('mcp') - .description('Start MCP server (stdio)') + .description('Start MCP server (stdio) — serves all indexed repos') .action(mcpCommand); program .command('list') - .description('List indexed repositories') + .description('List all indexed repositories') .action(listCommand); program @@ -45,9 +51,7 @@ program .command('clean') .description('Delete GitNexus index for current repo') .option('-f, --force', 'Skip confirmation prompt') + .option('--all', 'Clean all indexed repos') .action(cleanCommand); program.parse(process.argv); - - - diff --git a/gitnexus/src/cli/list.ts b/gitnexus/src/cli/list.ts index c034e68cb1..6f4771609e 100644 --- a/gitnexus/src/cli/list.ts +++ b/gitnexus/src/cli/list.ts @@ -1,31 +1,34 @@ /** * List Command * - * Shows info about the indexed repo in the current directory. + * Shows all indexed repositories from the global registry. */ -import path from 'path'; -import { findRepo } from '../storage/repo-manager.js'; +import { listRegisteredRepos } from '../storage/repo-manager.js'; export const listCommand = async () => { - const cwd = process.cwd(); - const repo = await findRepo(cwd); + const entries = await listRegisteredRepos({ validate: true }); - if (!repo) { - console.log('No indexed repository found in this directory.'); - console.log('Run `gitnexus analyze` to index your codebase.'); + if (entries.length === 0) { + console.log('No indexed repositories found.'); + console.log('Run `gitnexus analyze` in a git repo to index it.'); return; } - const stats = repo.meta.stats || {}; - const repoName = repo.repoPath.split(/[/\\]/).pop() || repo.repoPath; - const indexedDate = new Date(repo.meta.indexedAt).toLocaleString(); + console.log(`\n Indexed Repositories (${entries.length})\n`); - console.log(`\n📁 ${repoName}`); - console.log(` Path: ${repo.repoPath}`); - console.log(` Indexed: ${indexedDate}`); - console.log(` Stats: ${stats.files ?? 0} files, ${stats.nodes ?? 0} nodes, ${stats.edges ?? 0} edges`); - console.log(` Commit: ${repo.meta.lastCommit?.slice(0, 7) || 'unknown'}`); - if (stats.communities) console.log(` Communities: ${stats.communities}`); - if (stats.processes) console.log(` Processes: ${stats.processes}`); + for (const entry of entries) { + const indexedDate = new Date(entry.indexedAt).toLocaleString(); + const stats = entry.stats || {}; + const commitShort = entry.lastCommit?.slice(0, 7) || 'unknown'; + + console.log(` ${entry.name}`); + console.log(` Path: ${entry.path}`); + console.log(` Indexed: ${indexedDate}`); + console.log(` Commit: ${commitShort}`); + console.log(` Stats: ${stats.files ?? 0} files, ${stats.nodes ?? 0} symbols, ${stats.edges ?? 0} edges`); + if (stats.communities) console.log(` Clusters: ${stats.communities}`); + if (stats.processes) console.log(` Processes: ${stats.processes}`); + console.log(''); + } }; diff --git a/gitnexus/src/cli/mcp.ts b/gitnexus/src/cli/mcp.ts index 36c85b4d29..e4152d6069 100644 --- a/gitnexus/src/cli/mcp.ts +++ b/gitnexus/src/cli/mcp.ts @@ -1,96 +1,43 @@ /** * MCP Command * - * Starts the MCP server in standalone mode using local .gitnexus/ index. - * Auto-detects repository by searching for .gitnexus/ folder. + * Starts the MCP server in standalone mode. + * Loads all indexed repos from the global registry. + * No longer depends on cwd — works from any directory. */ -import path from 'path'; -import fs from 'fs/promises'; import { startMCPServer } from '../mcp/server.js'; -import { LocalBackend, findRepo } from '../mcp/local/local-backend.js'; - -/** - * Get candidate paths to search for .gitnexus/ folder - */ -function getCandidatePaths(): string[] { - const candidates: string[] = []; - - // 1. Explicit override (highest priority) - if (process.env.GITNEXUS_CWD) { - candidates.push(process.env.GITNEXUS_CWD); - } - - // 2. Current working directory - candidates.push(process.cwd()); - - // 3. VS Code workspace folders (if available via env) - if (process.env.VSCODE_WORKSPACE_FOLDER) { - candidates.push(process.env.VSCODE_WORKSPACE_FOLDER); - } - - // Deduplicate while preserving order - return [...new Set(candidates.map(p => path.resolve(p)))]; -} - -/** - * Find a git repository root by walking up the directory tree - */ -async function findGitRoot(startPath: string): Promise { - let current = path.resolve(startPath); - const root = path.parse(current).root; - - while (current !== root) { - try { - const gitPath = path.join(current, '.git'); - const stat = await fs.stat(gitPath); - if (stat.isDirectory()) return current; - } catch {} - current = path.dirname(current); - } - return null; -} +import { LocalBackend } from '../mcp/local/local-backend.js'; +import { listRegisteredRepos } from '../storage/repo-manager.js'; export const mcpCommand = async () => { - // Try multiple candidate paths to find .gitnexus/ - const candidates = getCandidatePaths(); - - for (const candidate of candidates) { - const repo = await findRepo(candidate); - if (repo) { - const local = new LocalBackend(); - await local.init(candidate); - console.error(`GitNexus: Found index at ${repo.storagePath}`); - - // Start MCP server (no watcher daemon - staleness hints guide LLM to call analyze) - await startMCPServer(local); - return; - } + // Load all registered repos + const entries = await listRegisteredRepos({ validate: true }); + + if (entries.length === 0) { + console.error(''); + console.error(' GitNexus: No indexed repositories found.'); + console.error(''); + console.error(' To get started:'); + console.error(' 1. cd into a git repository'); + console.error(' 2. Run: gitnexus analyze'); + console.error(' 3. Restart your editor'); + console.error(''); + process.exit(1); } - - // No index found - give helpful error message - for (const candidate of candidates) { - const gitRoot = await findGitRoot(candidate); - if (gitRoot) { - console.error(''); - console.error('╔════════════════════════════════════════════════════╗'); - console.error('║ GitNexus: Repository Not Indexed ║'); - console.error('╠════════════════════════════════════════════════════╣'); - console.error(`║ Found git repo: ${gitRoot.slice(0, 35).padEnd(35)} ║`); - console.error('║ ║'); - console.error('║ To enable AI code understanding, run: ║'); - console.error('║ ║'); - console.error('║ npx gitnexus analyze ║'); - console.error('║ ║'); - console.error('║ Then restart your IDE. ║'); - console.error('╚════════════════════════════════════════════════════╝'); - console.error(''); - process.exit(1); - } + + // Initialize multi-repo backend from registry + const backend = new LocalBackend(); + const ok = await backend.init(); + + if (!ok) { + console.error('GitNexus: Failed to initialize backend from registry.'); + process.exit(1); } - - // No git repo found - console.error('GitNexus: No git repository found.'); - console.error(`Searched: ${candidates.join(', ')}`); - process.exit(1); + + const repoNames = backend.listRepos().map(r => r.name); + console.error(`GitNexus: MCP server starting with ${repoNames.length} repo(s): ${repoNames.join(', ')}`); + + // Start MCP server (serves all repos) + await startMCPServer(backend); }; diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts new file mode 100644 index 0000000000..8715eb79c2 --- /dev/null +++ b/gitnexus/src/cli/setup.ts @@ -0,0 +1,190 @@ +/** + * Setup Command + * + * One-time global MCP configuration writer. + * Detects installed AI editors and writes the appropriate MCP config + * so the GitNexus MCP server is available in all projects. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { getGlobalDir } from '../storage/repo-manager.js'; + +interface SetupResult { + configured: string[]; + skipped: string[]; + errors: string[]; +} + +/** + * The MCP server entry for all editors + */ +function getMcpEntry() { + return { + command: 'npx', + args: ['-y', 'gitnexus@latest', 'mcp'], + }; +} + +/** + * Merge gitnexus entry into an existing MCP config JSON object. + * Returns the updated config. + */ +function mergeMcpConfig(existing: any): any { + if (!existing || typeof existing !== 'object') { + existing = {}; + } + if (!existing.mcpServers || typeof existing.mcpServers !== 'object') { + existing.mcpServers = {}; + } + existing.mcpServers.gitnexus = getMcpEntry(); + return existing; +} + +/** + * Try to read a JSON file, returning null if it doesn't exist or is invalid. + */ +async function readJsonFile(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(raw); + } catch { + return null; + } +} + +/** + * Write JSON to a file, creating parent directories if needed. + */ +async function writeJsonFile(filePath: string, data: any): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +/** + * Check if a directory exists + */ +async function dirExists(dirPath: string): Promise { + try { + const stat = await fs.stat(dirPath); + return stat.isDirectory(); + } catch { + return false; + } +} + +// ─── Editor-specific setup ───────────────────────────────────────── + +async function setupCursor(result: SetupResult): Promise { + const cursorDir = path.join(os.homedir(), '.cursor'); + if (!(await dirExists(cursorDir))) { + result.skipped.push('Cursor (not installed)'); + return; + } + + const mcpPath = path.join(cursorDir, 'mcp.json'); + try { + const existing = await readJsonFile(mcpPath); + const updated = mergeMcpConfig(existing); + await writeJsonFile(mcpPath, updated); + result.configured.push('Cursor'); + } catch (err: any) { + result.errors.push(`Cursor: ${err.message}`); + } +} + +async function setupClaudeCode(result: SetupResult): Promise { + // Claude Code uses `claude mcp add` — we just print the command + // Check for common Claude Code indicators + const claudeDir = path.join(os.homedir(), '.claude'); + const hasClaude = await dirExists(claudeDir); + + if (!hasClaude) { + result.skipped.push('Claude Code (not installed)'); + return; + } + + // Claude Code uses a JSON settings file at ~/.claude.json or claude mcp add + console.log(''); + console.log(' Claude Code detected. Run this command to add GitNexus:'); + console.log(''); + console.log(' claude mcp add gitnexus -- npx -y gitnexus mcp'); + console.log(''); + result.configured.push('Claude Code (manual step printed)'); +} + +async function setupOpenCode(result: SetupResult): Promise { + const opencodeDir = path.join(os.homedir(), '.config', 'opencode'); + if (!(await dirExists(opencodeDir))) { + result.skipped.push('OpenCode (not installed)'); + return; + } + + const configPath = path.join(opencodeDir, 'config.json'); + try { + const existing = await readJsonFile(configPath); + const config = existing || {}; + if (!config.mcp) config.mcp = {}; + config.mcp.gitnexus = getMcpEntry(); + await writeJsonFile(configPath, config); + result.configured.push('OpenCode'); + } catch (err: any) { + result.errors.push(`OpenCode: ${err.message}`); + } +} + +// ─── Main command ────────────────────────────────────────────────── + +export const setupCommand = async () => { + console.log(''); + console.log(' GitNexus Setup'); + console.log(' =============='); + console.log(''); + + // Ensure global directory exists + const globalDir = getGlobalDir(); + await fs.mkdir(globalDir, { recursive: true }); + + const result: SetupResult = { + configured: [], + skipped: [], + errors: [], + }; + + // Detect and configure each editor + await setupCursor(result); + await setupClaudeCode(result); + await setupOpenCode(result); + + // Print results + if (result.configured.length > 0) { + console.log(' Configured:'); + for (const name of result.configured) { + console.log(` + ${name}`); + } + } + + if (result.skipped.length > 0) { + console.log(''); + console.log(' Skipped:'); + for (const name of result.skipped) { + console.log(` - ${name}`); + } + } + + if (result.errors.length > 0) { + console.log(''); + console.log(' Errors:'); + for (const err of result.errors) { + console.log(` ! ${err}`); + } + } + + console.log(''); + console.log(' Next steps:'); + console.log(' 1. cd into any git repo'); + console.log(' 2. Run: gitnexus analyze'); + console.log(' 3. Open the repo in your editor — MCP is ready!'); + console.log(''); +}; diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index d8d97da99d..4cb4073b00 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -1,3 +1,5 @@ +import fs from 'fs/promises'; +import path from 'path'; import { KnowledgeGraph } from '../graph/types.js'; import { ASTCache } from './ast-cache.js'; import Parser from 'tree-sitter'; @@ -5,6 +7,7 @@ import { loadParser, loadLanguage } from '../tree-sitter/parser-loader.js'; import { LANGUAGE_QUERIES } from './tree-sitter-queries.js'; import { generateId } from '../../lib/utils.js'; import { getLanguageFromFilename, yieldToEventLoop } from './utils.js'; +import { SupportedLanguages } from '../../config/supported-languages.js'; const isDev = process.env.NODE_ENV !== 'production'; @@ -14,21 +17,217 @@ export type ImportMap = Map>; export const createImportMap = (): ImportMap => new Map(); -// Helper: Resolve import paths (relative and absolute/package-style) +// ============================================================================ +// LANGUAGE-SPECIFIC CONFIG +// ============================================================================ + +/** TypeScript path alias config parsed from tsconfig.json */ +interface TsconfigPaths { + /** Map of alias prefix -> target prefix (e.g., "@/" -> "src/") */ + aliases: Map; + /** Base URL for path resolution (relative to repo root) */ + baseUrl: string; +} + +/** Go module config parsed from go.mod */ +interface GoModuleConfig { + /** Module path (e.g., "github.com/user/repo") */ + modulePath: string; +} + +/** + * Parse tsconfig.json to extract path aliases. + * Tries tsconfig.json, tsconfig.app.json, tsconfig.base.json in order. + */ +async function loadTsconfigPaths(repoRoot: string): Promise { + const candidates = ['tsconfig.json', 'tsconfig.app.json', 'tsconfig.base.json']; + + for (const filename of candidates) { + try { + const tsconfigPath = path.join(repoRoot, filename); + const raw = await fs.readFile(tsconfigPath, 'utf-8'); + // Strip JSON comments (// and /* */ style) for robustness + const stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); + const tsconfig = JSON.parse(stripped); + const compilerOptions = tsconfig.compilerOptions; + if (!compilerOptions?.paths) continue; + + const baseUrl = compilerOptions.baseUrl || '.'; + const aliases = new Map(); + + for (const [pattern, targets] of Object.entries(compilerOptions.paths)) { + if (!Array.isArray(targets) || targets.length === 0) continue; + const target = targets[0] as string; + + // Convert glob patterns: "@/*" -> "@/", "src/*" -> "src/" + const aliasPrefix = pattern.endsWith('/*') ? pattern.slice(0, -1) : pattern; + const targetPrefix = target.endsWith('/*') ? target.slice(0, -1) : target; + + aliases.set(aliasPrefix, targetPrefix); + } + + if (aliases.size > 0) { + if (isDev) { + console.log(`📦 Loaded ${aliases.size} path aliases from ${filename}`); + } + return { aliases, baseUrl }; + } + } catch { + // File doesn't exist or isn't valid JSON - try next + } + } + + return null; +} + +/** + * Parse go.mod to extract module path. + */ +async function loadGoModulePath(repoRoot: string): Promise { + try { + const goModPath = path.join(repoRoot, 'go.mod'); + const content = await fs.readFile(goModPath, 'utf-8'); + const match = content.match(/^module\s+(\S+)/m); + if (match) { + if (isDev) { + console.log(`📦 Loaded Go module path: ${match[1]}`); + } + return { modulePath: match[1] }; + } + } catch { + // No go.mod + } + return null; +} + +// ============================================================================ +// IMPORT PATH RESOLUTION +// ============================================================================ + +/** All file extensions to try during resolution */ +const EXTENSIONS = [ + '', + // TypeScript/JavaScript + '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts', '/index.jsx', '/index.js', + // Python + '.py', '/__init__.py', + // Java + '.java', + // C/C++ + '.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.hh', + // C# + '.cs', + // Go + '.go', + // Rust + '.rs', '/mod.rs', +]; + +/** + * Try to match a path (with extensions) against the known file set. + * Returns the matched file path or null. + */ +function tryResolveWithExtensions( + basePath: string, + allFiles: Set, +): string | null { + for (const ext of EXTENSIONS) { + const candidate = basePath + ext; + if (allFiles.has(candidate)) return candidate; + } + return null; +} + +/** + * Suffix-based resolution: try progressively shorter suffixes against all files. + * Used for package-style imports (Java, Python, etc.). + */ +function suffixResolve( + pathParts: string[], + normalizedFileList: string[], + allFileList: string[], +): string | null { + for (let i = 0; i < pathParts.length; i++) { + const suffix = pathParts.slice(i).join('/'); + for (const ext of EXTENSIONS) { + const suffixWithExt = suffix + ext; + const suffixPattern = '/' + suffixWithExt; + const matchIdx = normalizedFileList.findIndex(filePath => + filePath.endsWith(suffixPattern) || filePath.toLowerCase().endsWith(suffixPattern.toLowerCase()) + ); + if (matchIdx !== -1) { + return allFileList[matchIdx]; + } + } + } + return null; +} + +/** + * Resolve an import path to a file path in the repository. + * + * Language-specific preprocessing is applied before the generic resolution: + * - TypeScript/JavaScript: rewrites tsconfig path aliases + * - Rust: converts crate::/super::/self:: to relative paths + * + * Java wildcards and Go package imports are handled separately in processImports + * because they resolve to multiple files. + */ const resolveImportPath = ( - currentFile: string, - importPath: string, + currentFile: string, + importPath: string, allFiles: Set, allFileList: string[], - resolveCache: Map + normalizedFileList: string[], + resolveCache: Map, + language: SupportedLanguages, + tsconfigPaths: TsconfigPaths | null, ): string | null => { const cacheKey = `${currentFile}::${importPath}`; if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey) ?? null; - // 1. Resolve '..' and '.' for relative imports + const cache = (result: string | null): string | null => { + resolveCache.set(cacheKey, result); + return result; + }; + + // ---- TypeScript/JavaScript: rewrite path aliases ---- + if ( + (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) && + tsconfigPaths && + !importPath.startsWith('.') + ) { + for (const [aliasPrefix, targetPrefix] of tsconfigPaths.aliases) { + if (importPath.startsWith(aliasPrefix)) { + const remainder = importPath.slice(aliasPrefix.length); + // Build the rewritten path relative to baseUrl + const rewritten = tsconfigPaths.baseUrl === '.' + ? targetPrefix + remainder + : tsconfigPaths.baseUrl + '/' + targetPrefix + remainder; + + // Try direct resolution from repo root + const resolved = tryResolveWithExtensions(rewritten, allFiles); + if (resolved) return cache(resolved); + + // Try suffix matching as fallback + const parts = rewritten.split('/').filter(Boolean); + const suffixResult = suffixResolve(parts, normalizedFileList, allFileList); + if (suffixResult) return cache(suffixResult); + } + } + } + + // ---- Rust: convert module path syntax to file paths ---- + if (language === SupportedLanguages.Rust) { + const rustResult = resolveRustImport(currentFile, importPath, allFiles); + if (rustResult) return cache(rustResult); + // Fall through to generic resolution if Rust-specific didn't match + } + + // ---- Generic relative import resolution (./ and ../) ---- const currentDir = currentFile.split('/').slice(0, -1); const parts = importPath.split('/'); - + for (const part of parts) { if (part === '.') continue; if (part === '..') { @@ -37,44 +236,18 @@ const resolveImportPath = ( currentDir.push(part); } } - + const basePath = currentDir.join('/'); - // 2. Try extensions for all supported languages - const extensions = [ - '', - // TypeScript/JavaScript - '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts', '/index.jsx', '/index.js', - // Python - '.py', '/__init__.py', - // Java - '.java', - // C/C++ - '.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.hh', - // C# - '.cs', - // Go - '.go', - // Rust - '.rs', '/mod.rs' - ]; - if (importPath.startsWith('.')) { - for (const ext of extensions) { - const candidate = basePath + ext; - if (allFiles.has(candidate)) { - resolveCache.set(cacheKey, candidate); - return candidate; - } - } - resolveCache.set(cacheKey, null); - return null; + const resolved = tryResolveWithExtensions(basePath, allFiles); + return cache(resolved); } - // 3. Handle absolute/package imports (Java, Go, Python, etc.) + // ---- Generic package/absolute import resolution (suffix matching) ---- + // Java wildcards are handled in processImports, not here if (importPath.endsWith('.*')) { - resolveCache.set(cacheKey, null); - return null; + return cache(null); } const pathLike = importPath.includes('/') @@ -82,48 +255,242 @@ const resolveImportPath = ( : importPath.replace(/\./g, '/'); const pathParts = pathLike.split('/').filter(Boolean); - // Normalize all file paths to forward slashes for matching - const normalizedFileList = allFileList.map(p => p.replace(/\\/g, '/')); + const resolved = suffixResolve(pathParts, normalizedFileList, allFileList); + return cache(resolved); +}; - for (let i = 0; i < pathParts.length; i++) { - const suffix = pathParts.slice(i).join('/'); - for (const ext of extensions) { - const suffixWithExt = suffix + ext; - // Require path separator before match to avoid false positives like "View.java" matching "RootView.java" - const suffixPattern = '/' + suffixWithExt; - const matchIdx = normalizedFileList.findIndex(filePath => - filePath.endsWith(suffixPattern) || filePath.toLowerCase().endsWith(suffixPattern.toLowerCase()) - ); - if (matchIdx !== -1) { - const match = allFileList[matchIdx]; - resolveCache.set(cacheKey, match); - return match; +// ============================================================================ +// RUST MODULE RESOLUTION +// ============================================================================ + +/** + * Resolve Rust use-path to a file. + * Handles crate::, super::, self:: prefixes and :: path separators. + */ +function resolveRustImport( + currentFile: string, + importPath: string, + allFiles: Set, +): string | null { + let rustPath: string; + + if (importPath.startsWith('crate::')) { + // crate:: resolves from src/ directory (standard Rust layout) + rustPath = importPath.slice(7).replace(/::/g, '/'); + + // Try from src/ (standard layout) + const fromSrc = tryRustModulePath('src/' + rustPath, allFiles); + if (fromSrc) return fromSrc; + + // Try from repo root (non-standard) + const fromRoot = tryRustModulePath(rustPath, allFiles); + if (fromRoot) return fromRoot; + + return null; + } + + if (importPath.startsWith('super::')) { + // super:: = parent directory of current file's module + const currentDir = currentFile.split('/').slice(0, -1); + currentDir.pop(); // Go up one level for super:: + rustPath = importPath.slice(7).replace(/::/g, '/'); + const fullPath = [...currentDir, rustPath].join('/'); + return tryRustModulePath(fullPath, allFiles); + } + + if (importPath.startsWith('self::')) { + // self:: = current module's directory + const currentDir = currentFile.split('/').slice(0, -1); + rustPath = importPath.slice(6).replace(/::/g, '/'); + const fullPath = [...currentDir, rustPath].join('/'); + return tryRustModulePath(fullPath, allFiles); + } + + // Bare path without prefix (e.g., from a use in a nested module) + // Convert :: to / and try suffix matching + if (importPath.includes('::')) { + rustPath = importPath.replace(/::/g, '/'); + return tryRustModulePath(rustPath, allFiles); + } + + return null; +} + +/** + * Try to resolve a Rust module path to a file. + * Tries: path.rs, path/mod.rs, and with the last segment stripped + * (last segment might be a symbol name, not a module). + */ +function tryRustModulePath(modulePath: string, allFiles: Set): string | null { + // Try direct: path.rs + if (allFiles.has(modulePath + '.rs')) return modulePath + '.rs'; + // Try directory: path/mod.rs + if (allFiles.has(modulePath + '/mod.rs')) return modulePath + '/mod.rs'; + // Try path/lib.rs (for crate root) + if (allFiles.has(modulePath + '/lib.rs')) return modulePath + '/lib.rs'; + + // The last segment might be a symbol (function, struct, etc.), not a module. + // Strip it and try again. + const lastSlash = modulePath.lastIndexOf('/'); + if (lastSlash > 0) { + const parentPath = modulePath.substring(0, lastSlash); + if (allFiles.has(parentPath + '.rs')) return parentPath + '.rs'; + if (allFiles.has(parentPath + '/mod.rs')) return parentPath + '/mod.rs'; + } + + return null; +} + +// ============================================================================ +// JAVA MULTI-FILE RESOLUTION +// ============================================================================ + +/** + * Resolve a Java wildcard import (com.example.*) to all matching .java files. + * Returns an array of file paths. + */ +function resolveJavaWildcard( + importPath: string, + normalizedFileList: string[], + allFileList: string[], +): string[] { + // "com.example.util.*" -> "com/example/util" + const packagePath = importPath.slice(0, -2).replace(/\./g, '/'); + const packageSuffix = '/' + packagePath + '/'; + + const matches: string[] = []; + for (let i = 0; i < normalizedFileList.length; i++) { + const normalized = normalizedFileList[i]; + if (normalized.includes(packageSuffix) && normalized.endsWith('.java')) { + // Ensure the file is directly in the package (not a subdirectory) + const afterPackage = normalized.substring(normalized.indexOf(packageSuffix) + packageSuffix.length); + if (!afterPackage.includes('/')) { + matches.push(allFileList[i]); + } + } + } + return matches; +} + +/** + * Try to resolve a Java static import by stripping the member name. + * "com.example.Constants.VALUE" -> resolve "com.example.Constants" + */ +function resolveJavaStaticImport( + importPath: string, + normalizedFileList: string[], + allFileList: string[], +): string | null { + // Static imports look like: com.example.Constants.VALUE or com.example.Constants.* + // The last segment is a member name (field/method) if it starts with lowercase or is ALL_CAPS + const segments = importPath.split('.'); + if (segments.length < 3) return null; + + const lastSeg = segments[segments.length - 1]; + // If last segment is a wildcard or ALL_CAPS constant or starts with lowercase, strip it + if (lastSeg === '*' || /^[a-z]/.test(lastSeg) || /^[A-Z_]+$/.test(lastSeg)) { + const classPath = segments.slice(0, -1).join('/'); + const classSuffix = '/' + classPath + '.java'; + for (let i = 0; i < normalizedFileList.length; i++) { + if (normalizedFileList[i].endsWith(classSuffix) || + normalizedFileList[i].toLowerCase().endsWith(classSuffix.toLowerCase())) { + return allFileList[i]; } } } - // Unresolved imports (external packages, SDK imports) are expected - don't log - resolveCache.set(cacheKey, null); return null; -}; +} + +// ============================================================================ +// GO PACKAGE RESOLUTION +// ============================================================================ + +/** + * Resolve a Go internal package import to all .go files in the package directory. + * Returns an array of file paths. + */ +function resolveGoPackage( + importPath: string, + goModule: GoModuleConfig, + normalizedFileList: string[], + allFileList: string[], +): string[] { + if (!importPath.startsWith(goModule.modulePath)) return []; + + // Strip module path to get relative package path + const relativePkg = importPath.slice(goModule.modulePath.length + 1); // e.g., "internal/auth" + if (!relativePkg) return []; + + const pkgSuffix = '/' + relativePkg + '/'; + const matches: string[] = []; + + for (let i = 0; i < normalizedFileList.length; i++) { + const normalized = normalizedFileList[i]; + // File must be directly in the package directory (not a subdirectory) + if (normalized.includes(pkgSuffix) && normalized.endsWith('.go') && !normalized.endsWith('_test.go')) { + const afterPkg = normalized.substring(normalized.indexOf(pkgSuffix) + pkgSuffix.length); + if (!afterPkg.includes('/')) { + matches.push(allFileList[i]); + } + } + } + + return matches; +} + +// ============================================================================ +// MAIN IMPORT PROCESSOR +// ============================================================================ export const processImports = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], astCache: ASTCache, importMap: ImportMap, - onProgress?: (current: number, total: number) => void + onProgress?: (current: number, total: number) => void, + repoRoot?: string, ) => { // Create a Set of all file paths for fast lookup during resolution const allFilePaths = new Set(files.map(f => f.path)); const parser = await loadParser(); const resolveCache = new Map(); const allFileList = files.map(f => f.path); - + // Pre-compute normalized file list once (forward slashes) + const normalizedFileList = allFileList.map(p => p.replace(/\\/g, '/')); + // Track import statistics let totalImportsFound = 0; let totalImportsResolved = 0; + // Load language-specific configs once before the file loop + const effectiveRoot = repoRoot || ''; + const tsconfigPaths = await loadTsconfigPaths(effectiveRoot); + const goModule = await loadGoModulePath(effectiveRoot); + + // Helper: add an IMPORTS edge + update import map + const addImportEdge = (filePath: string, resolvedPath: string) => { + const sourceId = generateId('File', filePath); + const targetId = generateId('File', resolvedPath); + const relId = generateId('IMPORTS', `${filePath}->${resolvedPath}`); + + totalImportsResolved++; + + graph.addRelationship({ + id: relId, + sourceId, + targetId, + type: 'IMPORTS', + confidence: 1.0, + reason: '', + }); + + if (!importMap.has(filePath)) { + importMap.set(filePath, new Set()); + } + importMap.get(filePath)!.add(resolvedPath); + }; + for (let i = 0; i < files.length; i++) { const file = files[i]; onProgress?.(i + 1, files.length); @@ -132,7 +499,7 @@ export const processImports = async ( // 1. Check language support first const language = getLanguageFromFilename(file.path); if (!language) continue; - + const queryStr = LANGUAGE_QUERIES[language]; if (!queryStr) continue; @@ -142,14 +509,11 @@ export const processImports = async ( // 3. Get AST (Try Cache First) let tree = astCache.get(file.path); let wasReparsed = false; - + if (!tree) { - // Cache Miss: Re-parse (slower, but necessary if evicted) - // Use larger bufferSize for files > 32KB try { tree = parser.parse(file.content, undefined, { bufferSize: 1024 * 256 }); } catch (parseError) { - // Skip files that can't be parsed continue; } wasReparsed = true; @@ -158,13 +522,10 @@ export const processImports = async ( let query; let matches; try { - const language = parser.getLanguage(); - query = new Parser.Query(language, queryStr); + const lang = parser.getLanguage(); + query = new Parser.Query(lang, queryStr); matches = query.matches(tree.rootNode); - - // Removed verbose Java import logging } catch (queryError: any) { - // Only log query errors in development mode if (isDev) { console.group(`🔴 Query Error: ${file.path}`); console.log('Language:', language); @@ -175,7 +536,7 @@ export const processImports = async ( console.log('AST has errors:', tree.rootNode?.hasError); console.groupEnd(); } - + if (wasReparsed) (tree as any).delete?.(); continue; } @@ -193,44 +554,55 @@ export const processImports = async ( return; } - // Clean path (remove quotes) - const rawImportPath = sourceNode.text.replace(/['"]/g, ''); + // Clean path (remove quotes and angle brackets for C/C++ includes) + const rawImportPath = sourceNode.text.replace(/['"<>]/g, ''); totalImportsFound++; - - // Removed verbose per-import logging - - // Resolve to actual file in the system + + // ---- Java: handle wildcards and static imports specially ---- + if (language === SupportedLanguages.Java) { + if (rawImportPath.endsWith('.*')) { + const matchedFiles = resolveJavaWildcard(rawImportPath, normalizedFileList, allFileList); + for (const matchedFile of matchedFiles) { + addImportEdge(file.path, matchedFile); + } + return; // skip single-file resolution + } + + // Try static import resolution (strip member name) + const staticResolved = resolveJavaStaticImport(rawImportPath, normalizedFileList, allFileList); + if (staticResolved) { + addImportEdge(file.path, staticResolved); + return; + } + // Fall through to normal resolution for regular Java imports + } + + // ---- Go: handle package-level imports ---- + if (language === SupportedLanguages.Go && goModule && rawImportPath.startsWith(goModule.modulePath)) { + const pkgFiles = resolveGoPackage(rawImportPath, goModule, normalizedFileList, allFileList); + if (pkgFiles.length > 0) { + for (const pkgFile of pkgFiles) { + addImportEdge(file.path, pkgFile); + } + return; // skip single-file resolution + } + // Fall through if no files found (package might be external) + } + + // ---- Standard single-file resolution ---- const resolvedPath = resolveImportPath( file.path, rawImportPath, allFilePaths, allFileList, - resolveCache + normalizedFileList, + resolveCache, + language, + tsconfigPaths, ); if (resolvedPath) { - // A. Update Graph (File -> IMPORTS -> File) - const sourceId = generateId('File', file.path); - const targetId = generateId('File', resolvedPath); - const relId = generateId('IMPORTS', `${file.path}->${resolvedPath}`); - - totalImportsResolved++; - - graph.addRelationship({ - id: relId, - sourceId, - targetId, - type: 'IMPORTS', - confidence: 1.0, - reason: '', - }); - - // B. Update Import Map (For Pass 4) - // Store all resolved import paths for this file - if (!importMap.has(file.path)) { - importMap.set(file.path, new Set()); - } - importMap.get(file.path)!.add(resolvedPath); + addImportEdge(file.path, resolvedPath); } } }); @@ -240,10 +612,8 @@ export const processImports = async ( (tree as any).delete?.(); } } - + if (isDev) { console.log(`📊 Import processing complete: ${totalImportsResolved}/${totalImportsFound} imports resolved to graph edges`); } }; - - diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 45b087db73..b5e6fc53c7 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -105,7 +105,7 @@ export const runPipelineFromRepo = async ( message: 'Resolving imports...', stats: { filesProcessed: current, totalFiles: total, nodesCreated: graph.nodeCount }, }); - }); + }, repoPath); if (isDev) { const importsCount = graph.relationships.filter(r => r.type === 'IMPORTS').length; @@ -199,6 +199,10 @@ export const runPipelineFromRepo = async ( stats: { filesProcessed: files.length, totalFiles: files.length, nodesCreated: graph.nodeCount }, }); + // Dynamic process cap based on codebase size + const symbolCount = graph.nodes.filter(n => n.label !== 'File').length; + const dynamicMaxProcesses = Math.max(20, Math.min(300, Math.round(symbolCount / 10))); + const processResult = await processProcesses( graph, communityResult.memberships, @@ -210,7 +214,8 @@ export const runPipelineFromRepo = async ( message, stats: { filesProcessed: files.length, totalFiles: files.length, nodesCreated: graph.nodeCount }, }); - } + }, + { maxProcesses: dynamicMaxProcesses, minSteps: 3 } ); if (isDev) { diff --git a/gitnexus/src/core/ingestion/process-processor.ts b/gitnexus/src/core/ingestion/process-processor.ts index ccb1bf1cc0..805ea9304b 100644 --- a/gitnexus/src/core/ingestion/process-processor.ts +++ b/gitnexus/src/core/ingestion/process-processor.ts @@ -31,7 +31,7 @@ const DEFAULT_CONFIG: ProcessDetectionConfig = { maxTraceDepth: 10, maxBranching: 4, maxProcesses: 75, - minSteps: 2, + minSteps: 3, // 3+ steps = genuine multi-hop flow (2-step is just "A calls B") }; // ============================================================================ @@ -119,11 +119,16 @@ export const processProcesses = async ( onProgress?.(`Found ${allTraces.length} traces, deduplicating...`, 60); - // Step 3: Deduplicate similar traces + // Step 3: Deduplicate similar traces (subset removal) const uniqueTraces = deduplicateTraces(allTraces); + // Step 3b: Deduplicate by entry+terminal pair (keep longest path per pair) + const endpointDeduped = deduplicateByEndpoints(uniqueTraces); + + onProgress?.(`Deduped ${uniqueTraces.length} → ${endpointDeduped.length} unique endpoint pairs`, 70); + // Step 4: Limit to max processes (prioritize longer traces) - const limitedTraces = uniqueTraces + const limitedTraces = endpointDeduped .sort((a, b) => b.length - a.length) .slice(0, cfg.maxProcesses); @@ -206,11 +211,18 @@ export const processProcesses = async ( type AdjacencyList = Map; +/** + * Minimum edge confidence for process tracing. + * Filters out ambiguous fuzzy-global matches (0.3) that cause + * traces to jump across unrelated code areas. + */ +const MIN_TRACE_CONFIDENCE = 0.5; + const buildCallsGraph = (graph: KnowledgeGraph): AdjacencyList => { const adj = new Map(); graph.relationships.forEach(rel => { - if (rel.type === 'CALLS') { + if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) { if (!adj.has(rel.sourceId)) { adj.set(rel.sourceId, []); } @@ -225,7 +237,7 @@ const buildReverseCallsGraph = (graph: KnowledgeGraph): AdjacencyList => { const adj = new Map(); graph.relationships.forEach(rel => { - if (rel.type === 'CALLS') { + if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) { if (!adj.has(rel.targetId)) { adj.set(rel.targetId, []); } @@ -397,6 +409,31 @@ const deduplicateTraces = (traces: string[][]): string[][] => { return unique; }; +// ============================================================================ +// HELPER: Deduplicate by entry+terminal endpoints +// ============================================================================ + +/** + * Keep only the longest trace per unique entry→terminal pair. + * Multiple paths between the same two endpoints are redundant for agents. + */ +const deduplicateByEndpoints = (traces: string[][]): string[][] => { + if (traces.length === 0) return []; + + const byEndpoints = new Map(); + // Sort longest first so the first seen per key is the longest + const sorted = [...traces].sort((a, b) => b.length - a.length); + + for (const trace of sorted) { + const key = `${trace[0]}::${trace[trace.length - 1]}`; + if (!byEndpoints.has(key)) { + byEndpoints.set(key, trace); + } + } + + return Array.from(byEndpoints.values()); +}; + // ============================================================================ // HELPER: String utilities // ============================================================================ diff --git a/gitnexus/src/core/search/bm25-index.ts b/gitnexus/src/core/search/bm25-index.ts index bad4128c39..b71ab64214 100644 --- a/gitnexus/src/core/search/bm25-index.ts +++ b/gitnexus/src/core/search/bm25-index.ts @@ -13,6 +13,39 @@ export interface BM25SearchResult { rank: number; } +/** + * Execute a single FTS query via a custom executor (for MCP connection pool). + * Returns the same shape as core queryFTS. + */ +async function queryFTSViaExecutor( + executor: (cypher: string) => Promise, + tableName: string, + indexName: string, + query: string, + limit: number, +): Promise> { + const escapedQuery = query.replace(/'/g, "''"); + const cypher = ` + CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := false) + RETURN node, score + ORDER BY score DESC + LIMIT ${limit} + `; + try { + const rows = await executor(cypher); + return rows.map((row: any) => { + const node = row.node || row[0] || {}; + const score = row.score ?? row[1] ?? 0; + return { + filePath: node.filePath || '', + score: typeof score === 'number' ? score : parseFloat(score) || 0, + }; + }); + } catch { + return []; + } +} + /** * Search using KuzuDB's built-in FTS (always fresh, reads from disk) * @@ -21,16 +54,33 @@ export interface BM25SearchResult { * * @param query - Search query string * @param limit - Maximum results + * @param repoId - If provided, queries will be routed via the MCP connection pool * @returns Ranked search results from FTS indexes */ -export const searchFTSFromKuzu = async (query: string, limit: number = 20): Promise => { - // Search multiple tables with searchable content - const [fileResults, functionResults, classResults, methodResults] = await Promise.all([ - queryFTS('File', 'file_fts', query, limit, false).catch(() => []), - queryFTS('Function', 'function_fts', query, limit, false).catch(() => []), - queryFTS('Class', 'class_fts', query, limit, false).catch(() => []), - queryFTS('Method', 'method_fts', query, limit, false).catch(() => []), - ]); +export const searchFTSFromKuzu = async (query: string, limit: number = 20, repoId?: string): Promise => { + let fileResults: any[], functionResults: any[], classResults: any[], methodResults: any[], interfaceResults: any[]; + + if (repoId) { + // Use MCP connection pool via dynamic import + const { executeQuery } = await import('../../mcp/core/kuzu-adapter.js'); + const executor = (cypher: string) => executeQuery(repoId, cypher); + [fileResults, functionResults, classResults, methodResults, interfaceResults] = await Promise.all([ + queryFTSViaExecutor(executor, 'File', 'file_fts', query, limit), + queryFTSViaExecutor(executor, 'Function', 'function_fts', query, limit), + queryFTSViaExecutor(executor, 'Class', 'class_fts', query, limit), + queryFTSViaExecutor(executor, 'Method', 'method_fts', query, limit), + queryFTSViaExecutor(executor, 'Interface', 'interface_fts', query, limit), + ]); + } else { + // Use core kuzu adapter (CLI / pipeline context) + [fileResults, functionResults, classResults, methodResults, interfaceResults] = await Promise.all([ + queryFTS('File', 'file_fts', query, limit, false).catch(() => []), + queryFTS('Function', 'function_fts', query, limit, false).catch(() => []), + queryFTS('Class', 'class_fts', query, limit, false).catch(() => []), + queryFTS('Method', 'method_fts', query, limit, false).catch(() => []), + queryFTS('Interface', 'interface_fts', query, limit, false).catch(() => []), + ]); + } // Merge results by filePath, summing scores for same file const merged = new Map(); @@ -50,6 +100,7 @@ export const searchFTSFromKuzu = async (query: string, limit: number = 20): Prom addResults(functionResults); addResults(classResults); addResults(methodResults); + addResults(interfaceResults); // Sort by score descending and add rank const sorted = Array.from(merged.values()) diff --git a/gitnexus/src/mcp/core/embedder.ts b/gitnexus/src/mcp/core/embedder.ts index 2979ddc534..b254f52bf7 100644 --- a/gitnexus/src/mcp/core/embedder.ts +++ b/gitnexus/src/mcp/core/embedder.ts @@ -41,14 +41,22 @@ export const initEmbedder = async (): Promise => { for (const device of devicesToTry) { try { - embedderInstance = await (pipeline as any)( - 'feature-extraction', - MODEL_ID, - { - device: device, - dtype: 'fp32', - } - ); + // Silence stdout during model load — ONNX Runtime and transformers.js + // may write progress/init messages to stdout which corrupts MCP stdio protocol. + const origWrite = process.stdout.write; + process.stdout.write = (() => true) as any; + try { + embedderInstance = await (pipeline as any)( + 'feature-extraction', + MODEL_ID, + { + device: device, + dtype: 'fp32', + } + ); + } finally { + process.stdout.write = origWrite; + } console.error(`GitNexus: Embedding model loaded (${device})`); return embedderInstance!; } catch { diff --git a/gitnexus/src/mcp/core/kuzu-adapter.ts b/gitnexus/src/mcp/core/kuzu-adapter.ts index 3ef8f89c4f..06a7ba14d1 100644 --- a/gitnexus/src/mcp/core/kuzu-adapter.ts +++ b/gitnexus/src/mcp/core/kuzu-adapter.ts @@ -1,63 +1,151 @@ /** - * KuzuDB Adapter (Persistent Connection) + * KuzuDB Adapter (Connection Pool) * - * Holds a single database connection for the lifetime of the MCP session. - * This is safe since the watcher has been removed -- only one process - * accesses the database at a time. + * Manages a pool of KuzuDB connections keyed by repoId. + * Connections are lazily opened on first query and evicted + * after idle timeout or when pool exceeds max size (LRU). */ import fs from 'fs/promises'; import kuzu from 'kuzu'; -let db: kuzu.Database | null = null; -let conn: kuzu.Connection | null = null; +interface PoolEntry { + db: kuzu.Database; + conn: kuzu.Connection; + lastUsed: number; + dbPath: string; +} + +const pool = new Map(); +const MAX_POOL_SIZE = 5; +const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +let idleTimer: ReturnType | null = null; /** - * Initialize with a persistent connection to the database + * Start the idle cleanup timer (runs every 60s) */ -export const initKuzu = async (path: string): Promise => { - if (conn) return; // Already initialized +function ensureIdleTimer(): void { + if (idleTimer) return; + idleTimer = setInterval(() => { + const now = Date.now(); + for (const [repoId, entry] of pool) { + if (now - entry.lastUsed > IDLE_TIMEOUT_MS) { + closeOne(repoId); + } + } + }, 60_000); + // Don't keep the process alive just for this timer + if (idleTimer && typeof idleTimer === 'object' && 'unref' in idleTimer) { + (idleTimer as NodeJS.Timeout).unref(); + } +} + +/** + * Evict the least-recently-used connection if pool is at capacity + */ +function evictLRU(): void { + if (pool.size < MAX_POOL_SIZE) return; + + let oldestId: string | null = null; + let oldestTime = Infinity; + for (const [id, entry] of pool) { + if (entry.lastUsed < oldestTime) { + oldestTime = entry.lastUsed; + oldestId = id; + } + } + if (oldestId) { + closeOne(oldestId); + } +} + +/** + * Close a single pool entry + */ +function closeOne(repoId: string): void { + const entry = pool.get(repoId); + if (!entry) return; + try { entry.conn.close(); } catch {} + try { entry.db.close(); } catch {} + pool.delete(repoId); +} + +/** + * Initialize (or reuse) a connection for a specific repo + */ +export const initKuzu = async (repoId: string, dbPath: string): Promise => { + const existing = pool.get(repoId); + if (existing) { + existing.lastUsed = Date.now(); + return; + } // Check if database exists try { - await fs.stat(path); + await fs.stat(dbPath); } catch { - throw new Error(`KuzuDB not found at ${path}. Run: gitnexus analyze`); + throw new Error(`KuzuDB not found at ${dbPath}. Run: gitnexus analyze`); } - db = new kuzu.Database(path); - conn = new kuzu.Connection(db); + evictLRU(); + + // Silence stdout during KuzuDB init — native module may write to stdout + // which corrupts the MCP stdio protocol. + const origWrite = process.stdout.write; + process.stdout.write = (() => true) as any; + let db: kuzu.Database; + let conn: kuzu.Connection; + try { + db = new kuzu.Database(dbPath); + conn = new kuzu.Connection(db); + } finally { + process.stdout.write = origWrite; + } + pool.set(repoId, { db, conn, lastUsed: Date.now(), dbPath }); + + ensureIdleTimer(); }; /** - * Execute a query using the persistent connection + * Execute a query on a specific repo's connection */ -export const executeQuery = async (cypher: string): Promise => { - if (!conn) { - throw new Error('KuzuDB not initialized. Call initKuzu first.'); +export const executeQuery = async (repoId: string, cypher: string): Promise => { + const entry = pool.get(repoId); + if (!entry) { + throw new Error(`KuzuDB not initialized for repo "${repoId}". Call initKuzu first.`); } - const queryResult = await conn.query(cypher); + entry.lastUsed = Date.now(); + const queryResult = await entry.conn.query(cypher); const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; const rows = await result.getAll(); return rows; }; /** - * Close the persistent connection + * Close one or all connections. + * If repoId is provided, close only that connection. + * If omitted, close all connections in the pool. */ -export const closeKuzu = async (): Promise => { - if (conn) { - try { await conn.close(); } catch {} - conn = null; +export const closeKuzu = async (repoId?: string): Promise => { + if (repoId) { + closeOne(repoId); + return; } - if (db) { - try { await db.close(); } catch {} - db = null; + + // Close all + for (const id of [...pool.keys()]) { + closeOne(id); + } + + if (idleTimer) { + clearInterval(idleTimer); + idleTimer = null; } }; /** - * Check if the database connection is active + * Check if a specific repo's connection is active */ -export const isKuzuReady = (): boolean => conn !== null && db !== null; +export const isKuzuReady = (repoId: string): boolean => pool.has(repoId); diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index bb16d311b4..d646576193 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -1,99 +1,38 @@ /** - * Local Backend + * Local Backend (Multi-Repo) * - * Provides tool implementations using local .gitnexus/ index. - * This enables MCP to work without the browser. + * Provides tool implementations using local .gitnexus/ indexes. + * Supports multiple indexed repositories via a global registry. + * KuzuDB connections are opened lazily per repo on first query. */ import fs from 'fs/promises'; import path from 'path'; import { initKuzu, executeQuery, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js'; import { embedQuery, getEmbeddingDims, disposeEmbedder } from '../core/embedder.js'; -import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js'; +// git utilities available if needed +// import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js'; import { - getStoragePaths as getRepoStoragePaths, - saveMeta as saveRepoMeta, - loadMeta as loadRepoMeta, - addToGitignore, + listRegisteredRepos, + type RegistryEntry, } from '../../storage/repo-manager.js'; -import { generateAIContextFiles } from '../../cli/ai-context.js'; +// AI context generation is CLI-only (gitnexus analyze) +// import { generateAIContextFiles } from '../../cli/ai-context.js'; -export interface RepoMeta { - repoPath: string; - lastCommit: string; - indexedAt: string; - stats?: { - files?: number; - nodes?: number; - edges?: number; - communities?: number; - processes?: number; - }; -} - -export interface IndexedRepo { - repoPath: string; - storagePath: string; - kuzuPath: string; - metaPath: string; - meta: RepoMeta; -} - -const GITNEXUS_DIR = '.gitnexus'; - -function getStoragePaths(repoPath: string) { - const storagePath = path.join(path.resolve(repoPath), GITNEXUS_DIR); - return { - storagePath, - kuzuPath: path.join(storagePath, 'kuzu'), - metaPath: path.join(storagePath, 'meta.json'), - }; -} - -async function loadMeta(storagePath: string): Promise { - try { - // Verify both meta.json and kuzu exist for a valid index - const metaPath = path.join(storagePath, 'meta.json'); - const kuzuPath = path.join(storagePath, 'kuzu'); - - // Check kuzu exists (can be file or directory depending on how it was saved) - try { - await fs.stat(kuzuPath); - } catch { - return null; // kuzu doesn't exist - } - - // Load and parse meta.json - const raw = await fs.readFile(metaPath, 'utf-8'); - return JSON.parse(raw) as RepoMeta; - } catch { - return null; - } -} - -async function loadRepo(repoPath: string): Promise { - const paths = getStoragePaths(repoPath); - const meta = await loadMeta(paths.storagePath); - if (!meta) return null; - - return { - repoPath: path.resolve(repoPath), - ...paths, - meta, - }; -} - -export async function findRepo(startPath: string): Promise { - let current = path.resolve(startPath); - const root = path.parse(current).root; - - while (current !== root) { - const repo = await loadRepo(current); - if (repo) return repo; - current = path.dirname(current); - } - - return null; +/** + * Quick test-file detection for filtering impact results. + * Matches common test file patterns across all supported languages. + */ +function isTestFilePath(filePath: string): boolean { + const p = filePath.toLowerCase().replace(/\\/g, '/'); + return ( + p.includes('.test.') || p.includes('.spec.') || + p.includes('__tests__/') || p.includes('__mocks__/') || + p.includes('/test/') || p.includes('/tests/') || + p.includes('/testing/') || p.includes('/fixtures/') || + p.endsWith('_test.go') || p.endsWith('_test.py') || + p.includes('/test_') || p.includes('/conftest.') + ); } export interface CodebaseContext { @@ -101,101 +40,203 @@ export interface CodebaseContext { stats: { fileCount: number; functionCount: number; - classCount: number; - interfaceCount: number; - methodCount: number; communityCount: number; processCount: number; }; - hotspots: Array<{ - name: string; - type: string; - filePath: string; - connections: number; - }>; - folderTree: string; +} + +interface RepoHandle { + id: string; // unique key = repo name (basename) + name: string; + repoPath: string; + storagePath: string; + kuzuPath: string; + indexedAt: string; + lastCommit: string; + stats?: RegistryEntry['stats']; } export class LocalBackend { - private repo: IndexedRepo | null = null; - private _context: CodebaseContext | null = null; - private initialized = false; + private repos: Map = new Map(); + private contextCache: Map = new Map(); + private initializedRepos: Set = new Set(); - async init(cwd: string): Promise { - this.repo = await findRepo(cwd); - if (!this.repo) return false; - - const stats = this.repo.meta.stats || {}; - this._context = { - projectName: path.basename(this.repo.repoPath), - stats: { - fileCount: stats.files || 0, - functionCount: stats.nodes || 0, - classCount: 0, - interfaceCount: 0, - methodCount: 0, - communityCount: stats.communities || 0, - processCount: stats.processes || 0, - }, - hotspots: [], - folderTree: '', - }; - - return true; + // ─── Initialization ────────────────────────────────────────────── + + /** + * Initialize from the global registry. + * Returns true if at least one repo is available. + */ + async init(): Promise { + const entries = await listRegisteredRepos({ validate: true }); + + for (const entry of entries) { + const id = this.repoId(entry.name, entry.path); + const storagePath = entry.storagePath; + const kuzuPath = path.join(storagePath, 'kuzu'); + + const handle: RepoHandle = { + id, + name: entry.name, + repoPath: entry.path, + storagePath, + kuzuPath, + indexedAt: entry.indexedAt, + lastCommit: entry.lastCommit, + stats: entry.stats, + }; + + this.repos.set(id, handle); + + // Build lightweight context (no KuzuDB needed) + const s = entry.stats || {}; + this.contextCache.set(id, { + projectName: entry.name, + stats: { + fileCount: s.files || 0, + functionCount: s.nodes || 0, + communityCount: s.communities || 0, + processCount: s.processes || 0, + }, + }); + } + + return this.repos.size > 0; } - private async ensureInitialized(): Promise { - if (this.initialized || !this.repo) return; - - await initKuzu(this.repo.kuzuPath); - this.initialized = true; + /** + * Generate a stable repo ID from name + path. + * If names collide, append a hash of the path. + */ + private repoId(name: string, repoPath: string): string { + const base = name.toLowerCase(); + // Check for name collision with a different path + for (const [id, handle] of this.repos) { + if (id === base && handle.repoPath !== path.resolve(repoPath)) { + // Collision — use path hash + const hash = Buffer.from(repoPath).toString('base64url').slice(0, 6); + return `${base}-${hash}`; + } + } + return base; } - get context(): CodebaseContext | null { - return this._context; + // ─── Repo Resolution ───────────────────────────────────────────── + + /** + * Resolve which repo to use. + * - If repoParam is given, match by name or path + * - If only 1 repo, use it + * - If 0 or multiple without param, throw with helpful message + */ + resolveRepo(repoParam?: string): RepoHandle { + if (this.repos.size === 0) { + throw new Error('No indexed repositories. Run: gitnexus analyze'); + } + + if (repoParam) { + const paramLower = repoParam.toLowerCase(); + // Match by id + if (this.repos.has(paramLower)) return this.repos.get(paramLower)!; + // Match by name (case-insensitive) + for (const handle of this.repos.values()) { + if (handle.name.toLowerCase() === paramLower) return handle; + } + // Match by path (substring) + const resolved = path.resolve(repoParam); + for (const handle of this.repos.values()) { + if (handle.repoPath === resolved) return handle; + } + // Match by partial name + for (const handle of this.repos.values()) { + if (handle.name.toLowerCase().includes(paramLower)) return handle; + } + + const names = [...this.repos.values()].map(h => h.name); + throw new Error(`Repository "${repoParam}" not found. Available: ${names.join(', ')}`); + } + + if (this.repos.size === 1) { + return this.repos.values().next().value!; + } + + const names = [...this.repos.values()].map(h => h.name); + throw new Error( + `Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${names.join(', ')}` + ); } - get isReady(): boolean { - return this.repo !== null; + // ─── Lazy KuzuDB Init ──────────────────────────────────────────── + + private async ensureInitialized(repoId: string): Promise { + // Always check the actual pool — the idle timer may have evicted the connection + if (this.initializedRepos.has(repoId) && isKuzuReady(repoId)) return; + + const handle = this.repos.get(repoId); + if (!handle) throw new Error(`Unknown repo: ${repoId}`); + + await initKuzu(repoId, handle.kuzuPath); + this.initializedRepos.add(repoId); } - get repoPath(): string | null { - return this.repo?.repoPath || null; + // ─── Public Getters ────────────────────────────────────────────── + + /** + * Get context for a specific repo (or the single repo if only one). + */ + getContext(repoId?: string): CodebaseContext | null { + if (repoId && this.contextCache.has(repoId)) { + return this.contextCache.get(repoId)!; + } + if (this.repos.size === 1) { + return this.contextCache.values().next().value ?? null; + } + return null; } - get storagePath(): string | null { - return this.repo?.storagePath || null; + /** + * List all registered repos with their metadata. + */ + listRepos(): Array<{ name: string; path: string; indexedAt: string; lastCommit: string; stats?: any }> { + return [...this.repos.values()].map(h => ({ + name: h.name, + path: h.repoPath, + indexedAt: h.indexedAt, + lastCommit: h.lastCommit, + stats: h.stats, + })); } - get meta(): any { - return this.repo?.meta || null; - } + // ─── Tool Dispatch ─────────────────────────────────────────────── async callTool(method: string, params: any): Promise { - if (!this.repo) { - throw new Error('Repository not indexed. Run: gitnexus analyze'); + if (method === 'list_repos') { + return this.listRepos(); } + // Resolve repo from optional param + const repo = this.resolveRepo(params?.repo); + switch (method) { case 'search': - return this.search(params); + return this.search(repo, params); case 'cypher': - return this.cypher(params); + return this.cypher(repo, params); case 'overview': - return this.overview(params); + return this.overview(repo, params); case 'explore': - return this.explore(params); + return this.explore(repo, params); case 'impact': - return this.impact(params); - case 'analyze': - return this.analyze(params); + return this.impact(repo, params); default: throw new Error(`Unknown tool: ${method}`); } } - private async search(params: { query: string; limit?: number; depth?: string; groupByProcess?: boolean }): Promise { - await this.ensureInitialized(); + // ─── Tool Implementations ──────────────────────────────────────── + + private async search(repo: RepoHandle, params: { query: string; limit?: number; depth?: string }): Promise { + await this.ensureInitialized(repo.id); const limit = params.limit || 10; const query = params.query; @@ -203,18 +244,20 @@ export class LocalBackend { // Run BM25 and semantic search in parallel const [bm25Results, semanticResults] = await Promise.all([ - this.bm25Search(query, limit * 2), - this.semanticSearch(query, limit * 2), + this.bm25Search(repo, query, limit * 2), + this.semanticSearch(repo, query, limit * 2), ]); // Merge and deduplicate results using reciprocal rank fusion + // Key by nodeId (symbol-level) so semantic precision is preserved. + // Fall back to filePath for File-level results that lack a nodeId. const scoreMap = new Map(); // BM25 results for (let i = 0; i < bm25Results.length; i++) { const result = bm25Results[i]; - const key = result.filePath; - const rrfScore = 1 / (60 + i); // RRF formula with k=60 + const key = result.nodeId || result.filePath; + const rrfScore = 1 / (60 + i); const existing = scoreMap.get(key); if (existing) { existing.score += rrfScore; @@ -227,7 +270,7 @@ export class LocalBackend { // Semantic results for (let i = 0; i < semanticResults.length; i++) { const result = semanticResults[i]; - const key = result.filePath; + const key = result.nodeId || result.filePath; const rrfScore = 1 / (60 + i); const existing = scoreMap.get(key); if (existing) { @@ -251,15 +294,37 @@ export class LocalBackend { result.searchSource = item.source; result.fusedScore = item.score; + // Add cluster membership context for each result with a nodeId + if (result.nodeId) { + try { + const clusterQuery = ` + MATCH (n {id: '${result.nodeId.replace(/'/g, "''")}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) + RETURN c.label AS label, c.heuristicLabel AS heuristicLabel + LIMIT 1 + `; + const clusters = await executeQuery(repo.id, clusterQuery); + if (clusters.length > 0) { + result.cluster = { + label: clusters[0].label || clusters[0][0], + heuristicLabel: clusters[0].heuristicLabel || clusters[0][1], + }; + } + } catch { + // Cluster lookup failed - continue without it + } + } + // Add relationships if depth is 'full' and we have a node ID + // Only include connections with actual name/path data (skip MEMBER_OF, STEP_IN_PROCESS noise) if (depth === 'full' && result.nodeId) { try { const relQuery = ` MATCH (n {id: '${result.nodeId.replace(/'/g, "''")}'})-[r:CodeRelation]->(m) + WHERE r.type IN ['CALLS', 'IMPORTS', 'DEFINES', 'EXTENDS', 'IMPLEMENTS'] RETURN r.type AS type, m.name AS targetName, m.filePath AS targetPath LIMIT 5 `; - const rels = await executeQuery(relQuery); + const rels = await executeQuery(repo.id, relQuery); result.connections = rels.map((rel: any) => ({ type: rel.type || rel[0], name: rel.targetName || rel[1], @@ -279,23 +344,22 @@ export class LocalBackend { /** * BM25 keyword search helper - uses KuzuDB FTS for always-fresh results */ - private async bm25Search(query: string, limit: number): Promise { - // Import dynamically to avoid circular dependency + private async bm25Search(repo: RepoHandle, query: string, limit: number): Promise { const { searchFTSFromKuzu } = await import('../../core/search/bm25-index.js'); - const bm25Results = await searchFTSFromKuzu(query, limit); + const bm25Results = await searchFTSFromKuzu(query, limit, repo.id); const results: any[] = []; for (const bm25Result of bm25Results) { - const fileName = bm25Result.filePath.split('/').pop() || bm25Result.filePath; + const fullPath = bm25Result.filePath; try { const symbolQuery = ` MATCH (n) - WHERE n.filePath CONTAINS '${fileName.replace(/'/g, "''")}' + WHERE n.filePath = '${fullPath.replace(/'/g, "''")}' RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine LIMIT 3 `; - const symbols = await executeQuery(symbolQuery); + const symbols = await executeQuery(repo.id, symbolQuery); if (symbols.length > 0) { for (const sym of symbols) { @@ -310,6 +374,7 @@ export class LocalBackend { }); } } else { + const fileName = fullPath.split('/').pop() || fullPath; results.push({ name: fileName, type: 'File', @@ -318,6 +383,7 @@ export class LocalBackend { }); } } catch { + const fileName = fullPath.split('/').pop() || fullPath; results.push({ name: fileName, type: 'File', @@ -333,14 +399,12 @@ export class LocalBackend { /** * Semantic vector search helper */ - private async semanticSearch(query: string, limit: number): Promise { + private async semanticSearch(repo: RepoHandle, query: string, limit: number): Promise { try { - // Embed the query const queryVec = await embedQuery(query); const dims = getEmbeddingDims(); const queryVecStr = `[${queryVec.join(',')}]`; - // Query vector index const vectorQuery = ` CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', CAST(${queryVecStr} AS FLOAT[${dims}]), ${limit}) @@ -351,18 +415,16 @@ export class LocalBackend { ORDER BY distance `; - const embResults = await executeQuery(vectorQuery); + const embResults = await executeQuery(repo.id, vectorQuery); if (embResults.length === 0) return []; - // Get metadata for each result const results: any[] = []; for (const embRow of embResults) { const nodeId = embRow.nodeId ?? embRow[0]; const distance = embRow.distance ?? embRow[1]; - // Extract label from node ID const labelEndIdx = nodeId.indexOf(':'); const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown'; @@ -371,7 +433,7 @@ export class LocalBackend { ? `MATCH (n:File {id: '${nodeId.replace(/'/g, "''")}'}) RETURN n.name AS name, n.filePath AS filePath` : `MATCH (n:${label} {id: '${nodeId.replace(/'/g, "''")}'}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`; - const nodeRows = await executeQuery(nodeQuery); + const nodeRows = await executeQuery(repo.id, nodeQuery); if (nodeRows.length > 0) { const nodeRow = nodeRows[0]; results.push({ @@ -389,53 +451,95 @@ export class LocalBackend { return results; } catch (err: any) { - // Semantic search unavailable (no embeddings or model not loaded) console.error('GitNexus: Semantic search unavailable -', err.message); return []; } } - private async cypher(params: { query: string }): Promise { - await this.ensureInitialized(); + private async cypher(repo: RepoHandle, params: { query: string }): Promise { + await this.ensureInitialized(repo.id); - if (!isKuzuReady()) { + if (!isKuzuReady(repo.id)) { return { error: 'KuzuDB not ready. Index may be corrupted.' }; } try { - const result = await executeQuery(params.query); + const result = await executeQuery(repo.id, params.query); return result; } catch (err: any) { return { error: err.message || 'Query failed' }; } } - private async overview(params: { showClusters?: boolean; showProcesses?: boolean; limit?: number }): Promise { - await this.ensureInitialized(); + /** + * Aggregate same-named clusters: group by heuristicLabel, sum symbols, + * weighted-average cohesion, filter out tiny clusters (<5 symbols). + * Raw communities stay intact in KuzuDB for Cypher queries. + */ + private aggregateClusters(clusters: any[]): any[] { + const groups = new Map(); + + for (const c of clusters) { + const label = c.heuristicLabel || c.label || 'Unknown'; + const symbols = c.symbolCount || 0; + const cohesion = c.cohesion || 0; + const existing = groups.get(label); + + if (!existing) { + groups.set(label, { ids: [c.id], totalSymbols: symbols, weightedCohesion: cohesion * symbols, largest: c }); + } else { + existing.ids.push(c.id); + existing.totalSymbols += symbols; + existing.weightedCohesion += cohesion * symbols; + if (symbols > (existing.largest.symbolCount || 0)) { + existing.largest = c; + } + } + } + + return Array.from(groups.entries()) + .map(([label, g]) => ({ + id: g.largest.id, + label, + heuristicLabel: label, + symbolCount: g.totalSymbols, + cohesion: g.totalSymbols > 0 ? g.weightedCohesion / g.totalSymbols : 0, + subCommunities: g.ids.length, + })) + .filter(c => c.symbolCount >= 5) + .sort((a, b) => b.symbolCount - a.symbolCount); + } + + private async overview(repo: RepoHandle, params: { showClusters?: boolean; showProcesses?: boolean; limit?: number }): Promise { + await this.ensureInitialized(repo.id); const limit = params.limit || 20; const result: any = { - repoPath: this.repo!.repoPath, - stats: this.repo!.meta.stats, - indexedAt: this.repo!.meta.indexedAt, - lastCommit: this.repo!.meta.lastCommit, + repo: repo.name, + repoPath: repo.repoPath, + stats: repo.stats, + indexedAt: repo.indexedAt, + lastCommit: repo.lastCommit, }; if (params.showClusters !== false) { try { - const clusters = await executeQuery(` + // Fetch more raw communities than the display limit so aggregation has enough data + const rawLimit = Math.max(limit * 5, 200); + const clusters = await executeQuery(repo.id, ` MATCH (c:Community) RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount ORDER BY c.symbolCount DESC - LIMIT ${limit} + LIMIT ${rawLimit} `); - result.clusters = clusters.map((c: any) => ({ + const rawClusters = clusters.map((c: any) => ({ id: c.id || c[0], label: c.label || c[1], heuristicLabel: c.heuristicLabel || c[2], cohesion: c.cohesion || c[3], symbolCount: c.symbolCount || c[4], })); + result.clusters = this.aggregateClusters(rawClusters).slice(0, limit); } catch { result.clusters = []; } @@ -443,7 +547,7 @@ export class LocalBackend { if (params.showProcesses !== false) { try { - const processes = await executeQuery(` + const processes = await executeQuery(repo.id, ` MATCH (p:Process) RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount ORDER BY p.stepCount DESC @@ -464,50 +568,51 @@ export class LocalBackend { return result; } - private async explore(params: { name: string; type: 'symbol' | 'cluster' | 'process' }): Promise { - await this.ensureInitialized(); + private async explore(repo: RepoHandle, params: { name: string; type: 'symbol' | 'cluster' | 'process' }): Promise { + await this.ensureInitialized(repo.id); const { name, type } = params; if (type === 'symbol') { - // Find symbol and its context - const symbolQuery = ` - MATCH (n) - WHERE n.name = '${name.replace(/'/g, "''")}' - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine - LIMIT 1 - `; - const symbols = await executeQuery(symbolQuery); + // If name contains a path separator or ':', treat it as a qualified lookup + const isQualified = name.includes('/') || name.includes(':'); + const symbolQuery = isQualified + ? `MATCH (n) WHERE n.id = '${name.replace(/'/g, "''")}' OR (n.name = '${name.replace(/'/g, "''")}') + RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine + LIMIT 5` + : `MATCH (n) WHERE n.name = '${name.replace(/'/g, "''")}' + RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine + LIMIT 5`; + + const symbols = await executeQuery(repo.id, symbolQuery); if (symbols.length === 0) return { error: `Symbol '${name}' not found` }; + // Use the first match for detailed exploration const sym = symbols[0]; const symId = sym.id || sym[0]; - // Get callers const callersQuery = ` MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(n {id: '${symId}'}) RETURN caller.name AS name, caller.filePath AS filePath LIMIT 10 `; - const callers = await executeQuery(callersQuery); + const callers = await executeQuery(repo.id, callersQuery); - // Get callees const calleesQuery = ` MATCH (n {id: '${symId}'})-[:CodeRelation {type: 'CALLS'}]->(callee) RETURN callee.name AS name, callee.filePath AS filePath LIMIT 10 `; - const callees = await executeQuery(calleesQuery); + const callees = await executeQuery(repo.id, calleesQuery); - // Get community const communityQuery = ` MATCH (n {id: '${symId}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) RETURN c.label AS label, c.heuristicLabel AS heuristicLabel LIMIT 1 `; - const communities = await executeQuery(communityQuery); + const communities = await executeQuery(repo.id, communityQuery); - return { + const result: any = { symbol: { id: symId, name: sym.name || sym[1], @@ -523,35 +628,66 @@ export class LocalBackend { heuristicLabel: communities[0].heuristicLabel || communities[0][1], } : null, }; + + // If multiple symbols share the same name, show alternatives so the agent can disambiguate + if (symbols.length > 1) { + result.alternatives = symbols.slice(1).map((s: any) => ({ + id: s.id || s[0], + type: s.type || s[2], + filePath: s.filePath || s[3], + })); + result.hint = `Multiple symbols named '${name}' found. Showing details for ${result.symbol.filePath}. Use the full node ID to explore a specific alternative.`; + } + + return result; } if (type === 'cluster') { + const escaped = name.replace(/'/g, "''"); + + // Find ALL communities with this label (not just one) const clusterQuery = ` MATCH (c:Community) - WHERE c.label = '${name.replace(/'/g, "''")}' OR c.heuristicLabel = '${name.replace(/'/g, "''")}' + WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}' RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount - LIMIT 1 `; - const clusters = await executeQuery(clusterQuery); + const clusters = await executeQuery(repo.id, clusterQuery); if (clusters.length === 0) return { error: `Cluster '${name}' not found` }; - const cluster = clusters[0]; - const clusterId = cluster.id || cluster[0]; + const rawClusters = clusters.map((c: any) => ({ + id: c.id || c[0], + label: c.label || c[1], + heuristicLabel: c.heuristicLabel || c[2], + cohesion: c.cohesion || c[3], + symbolCount: c.symbolCount || c[4], + })); + // Aggregate: sum symbols, weighted-average cohesion across sub-communities + let totalSymbols = 0; + let weightedCohesion = 0; + for (const c of rawClusters) { + const s = c.symbolCount || 0; + totalSymbols += s; + weightedCohesion += (c.cohesion || 0) * s; + } + + // Fetch members from ALL matching sub-communities (DISTINCT to avoid dupes) const membersQuery = ` - MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c {id: '${clusterId}'}) - RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath - LIMIT 20 + MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) + WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}' + RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath + LIMIT 30 `; - const members = await executeQuery(membersQuery); + const members = await executeQuery(repo.id, membersQuery); return { cluster: { - id: clusterId, - label: cluster.label || cluster[1], - heuristicLabel: cluster.heuristicLabel || cluster[2], - cohesion: cluster.cohesion || cluster[3], - symbolCount: cluster.symbolCount || cluster[4], + id: rawClusters[0].id, + label: rawClusters[0].heuristicLabel || rawClusters[0].label, + heuristicLabel: rawClusters[0].heuristicLabel || rawClusters[0].label, + cohesion: totalSymbols > 0 ? weightedCohesion / totalSymbols : 0, + symbolCount: totalSymbols, + subCommunities: rawClusters.length, }, members: members.map((m: any) => ({ name: m.name || m[0], @@ -568,7 +704,7 @@ export class LocalBackend { RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, p.entryPointId AS entryPointId, p.terminalId AS terminalId LIMIT 1 `; - const processes = await executeQuery(processQuery); + const processes = await executeQuery(repo.id, processQuery); if (processes.length === 0) return { error: `Process '${name}' not found` }; const proc = processes[0]; @@ -579,7 +715,7 @@ export class LocalBackend { RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step ORDER BY r.step `; - const steps = await executeQuery(stepsQuery); + const steps = await executeQuery(repo.id, stepsQuery); return { process: { @@ -601,26 +737,39 @@ export class LocalBackend { return { error: 'Invalid type. Use: symbol, cluster, or process' }; } - private async impact(params: { target: string; direction: 'upstream' | 'downstream'; maxDepth?: number }): Promise { - await this.ensureInitialized(); + private async impact(repo: RepoHandle, params: { + target: string; + direction: 'upstream' | 'downstream'; + maxDepth?: number; + relationTypes?: string[]; + includeTests?: boolean; + minConfidence?: number; + }): Promise { + await this.ensureInitialized(repo.id); const { target, direction } = params; const maxDepth = params.maxDepth || 3; + const relationTypes = params.relationTypes && params.relationTypes.length > 0 + ? params.relationTypes + : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; + const includeTests = params.includeTests ?? false; + const minConfidence = params.minConfidence ?? 0; + + const relTypeFilter = relationTypes.map(t => `'${t}'`).join(', '); + const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : ''; - // Find target symbol const targetQuery = ` MATCH (n) WHERE n.name = '${target.replace(/'/g, "''")}' RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath LIMIT 1 `; - const targets = await executeQuery(targetQuery); + const targets = await executeQuery(repo.id, targetQuery); if (targets.length === 0) return { error: `Target '${target}' not found` }; const sym = targets[0]; const symId = sym.id || sym[0]; - // BFS to find impacted nodes const impacted: any[] = []; const visited = new Set([symId]); let frontier = [symId]; @@ -630,13 +779,17 @@ export class LocalBackend { for (const nodeId of frontier) { const query = direction === 'upstream' - ? `MATCH (caller)-[r:CodeRelation]->(n {id: '${nodeId}'}) WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'] RETURN caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence` - : `MATCH (n {id: '${nodeId}'})-[r:CodeRelation]->(callee) WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'] RETURN callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`; + ? `MATCH (caller)-[r:CodeRelation]->(n {id: '${nodeId}'}) WHERE r.type IN [${relTypeFilter}]${confidenceFilter} RETURN caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence` + : `MATCH (n {id: '${nodeId}'})-[r:CodeRelation]->(callee) WHERE r.type IN [${relTypeFilter}]${confidenceFilter} RETURN callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`; - const related = await executeQuery(query); + const related = await executeQuery(repo.id, query); for (const rel of related) { const relId = rel.id || rel[0]; + const filePath = rel.filePath || rel[3] || ''; + + if (!includeTests && isTestFilePath(filePath)) continue; + if (!visited.has(relId)) { visited.add(relId); nextFrontier.push(relId); @@ -645,7 +798,7 @@ export class LocalBackend { id: relId, name: rel.name || rel[1], type: rel.type || rel[2], - filePath: rel.filePath || rel[3], + filePath, relationType: rel.relType || rel[4], confidence: rel.confidence || rel[5] || 1.0, }); @@ -656,7 +809,6 @@ export class LocalBackend { frontier = nextFrontier; } - // Group by depth const grouped: Record = {}; for (const item of impacted) { if (!grouped[item.depth]) grouped[item.depth] = []; @@ -676,160 +828,11 @@ export class LocalBackend { }; } - private async analyze(params: { path?: string; force?: boolean; skipEmbeddings?: boolean }): Promise { - // Determine target repo path - let repoPath: string; - if (params.path) { - repoPath = path.resolve(params.path); - } else if (this.repo) { - repoPath = this.repo.repoPath; - } else { - const gitRoot = getGitRoot(process.cwd()); - if (!gitRoot) { - return { error: 'Not inside a git repository' }; - } - repoPath = gitRoot; - } - - if (!isGitRepo(repoPath)) { - return { error: 'Not a git repository' }; - } - - const { storagePath, kuzuPath } = getRepoStoragePaths(repoPath); - const currentCommit = getCurrentCommit(repoPath); - const existingMeta = await loadRepoMeta(storagePath); - - // Skip if already indexed at same commit (unless force) - if (existingMeta && !params.force && existingMeta.lastCommit === currentCommit) { - return { status: 'up_to_date', message: 'Repository already up to date.' }; - } - - // Close MCP's persistent connection before pipeline takes over - await closeKuzu(); - this.initialized = false; - - try { - // Import pipeline modules dynamically to avoid circular deps - const { runPipelineFromRepo } = await import('../../core/ingestion/pipeline.js'); - const coreKuzu = await import('../../core/kuzu/kuzu-adapter.js'); - - // Run ingestion pipeline - console.error('GitNexus: Running indexing pipeline...'); - const pipelineResult = await runPipelineFromRepo(repoPath, (progress) => { - if (progress.percent % 20 === 0) { - console.error(`GitNexus: ${progress.phase} ${progress.percent}%`); - } - }); - - // Load graph into KuzuDB - console.error('GitNexus: Loading graph into KuzuDB...'); - await coreKuzu.initKuzu(kuzuPath); - await coreKuzu.loadGraphToKuzu(pipelineResult.graph, pipelineResult.fileContents, storagePath); - - // Create FTS indexes - console.error('GitNexus: Creating FTS indexes...'); - try { - await coreKuzu.createFTSIndex('File', 'file_fts', ['name', 'content']); - await coreKuzu.createFTSIndex('Function', 'function_fts', ['name', 'content']); - await coreKuzu.createFTSIndex('Class', 'class_fts', ['name', 'content']); - await coreKuzu.createFTSIndex('Method', 'method_fts', ['name', 'content']); - } catch (e: any) { - console.error('GitNexus: Some FTS indexes may not have been created:', e.message); - } - - // Generate embeddings (unless skipped) - if (!params.skipEmbeddings) { - try { - console.error('GitNexus: Generating embeddings...'); - const { runEmbeddingPipeline } = await import('../../core/embeddings/embedding-pipeline.js'); - await runEmbeddingPipeline( - coreKuzu.executeQuery, - coreKuzu.executeWithReusedStatement, - (progress) => { - if (progress.percent % 25 === 0) { - console.error(`GitNexus: Embeddings ${progress.percent}%`); - } - } - ); - } catch (e: any) { - console.error('GitNexus: Embedding generation failed (non-fatal):', e.message); - } - } - - // Save metadata - const stats = await coreKuzu.getKuzuStats(); - await saveRepoMeta(storagePath, { - repoPath, - lastCommit: currentCommit, - indexedAt: new Date().toISOString(), - stats: { - files: pipelineResult.fileContents.size, - nodes: stats.nodes, - edges: stats.edges, - communities: pipelineResult.communityResult?.stats.totalCommunities, - processes: pipelineResult.processResult?.stats.totalProcesses, - }, - }); - - // Add .gitnexus to .gitignore - await addToGitignore(repoPath); - - // Generate AI context files - const projectName = path.basename(repoPath); - await generateAIContextFiles(repoPath, storagePath, projectName, { - files: pipelineResult.fileContents.size, - nodes: stats.nodes, - edges: stats.edges, - communities: pipelineResult.communityResult?.stats.totalCommunities, - processes: pipelineResult.processResult?.stats.totalProcesses, - }); - - // Close core kuzu connection (pipeline is done) - await coreKuzu.closeKuzu(); - - // Re-init MCP state so next tool call reconnects - this.repo = await loadRepo(repoPath); - if (this.repo) { - const repoStats = this.repo.meta.stats || {}; - this._context = { - projectName: path.basename(this.repo.repoPath), - stats: { - fileCount: repoStats.files || 0, - functionCount: repoStats.nodes || 0, - classCount: 0, - interfaceCount: 0, - methodCount: 0, - communityCount: repoStats.communities || 0, - processCount: repoStats.processes || 0, - }, - hotspots: [], - folderTree: '', - }; - } - - console.error('GitNexus: Indexing complete!'); - return { - status: 'success', - message: `Repository indexed successfully.`, - stats: { - files: pipelineResult.fileContents.size, - nodes: stats.nodes, - edges: stats.edges, - communities: pipelineResult.communityResult?.stats.totalCommunities, - processes: pipelineResult.processResult?.stats.totalProcesses, - }, - }; - } catch (e: any) { - console.error('GitNexus: Indexing failed:', e.message); - return { error: `Indexing failed: ${e.message}` }; - } - } - async disconnect(): Promise { - closeKuzu(); + await closeKuzu(); // close all connections await disposeEmbedder(); - this.repo = null; - this._context = null; - this.initialized = false; + this.repos.clear(); + this.contextCache.clear(); + this.initializedRepos.clear(); } } diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index 1707cb893b..af6317b1a8 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -1,8 +1,8 @@ /** - * MCP Resources + * MCP Resources (Multi-Repo) * * Provides structured on-demand data to AI agents. - * Resources complement tools by offering lightweight, cacheable data. + * All resources use repo-scoped URIs: gitnexus://repo/{name}/context */ import type { LocalBackend } from './local/local-backend.js'; @@ -23,35 +23,48 @@ export interface ResourceTemplate { } /** - * Static resources available when codebase is indexed + * Static resources — includes per-repo resources and the global repos list */ -export function getResourceDefinitions(projectName: string): ResourceDefinition[] { - return [ +export function getResourceDefinitions(backend: LocalBackend): ResourceDefinition[] { + const resources: ResourceDefinition[] = [ { - uri: 'gitnexus://context', - name: `${projectName} Overview`, - description: 'Codebase stats, hotspots, and available tools', - mimeType: 'text/yaml', - }, - { - uri: 'gitnexus://clusters', - name: 'All Clusters', - description: 'List of all functional clusters with stats', - mimeType: 'text/yaml', - }, - { - uri: 'gitnexus://processes', - name: 'All Processes', - description: 'List of all execution flows with types', - mimeType: 'text/yaml', - }, - { - uri: 'gitnexus://schema', - name: 'Graph Schema', - description: 'Node types and relationships for Cypher queries', + uri: 'gitnexus://repos', + name: 'All Indexed Repositories', + description: 'List of all indexed repos with stats. Read this first to discover available repos.', mimeType: 'text/yaml', }, ]; + + // Add per-repo context resources + const repos = backend.listRepos(); + for (const repo of repos) { + resources.push({ + uri: `gitnexus://repo/${repo.name}/context`, + name: `${repo.name} Overview`, + description: `Codebase stats and available tools for ${repo.name}`, + mimeType: 'text/yaml', + }); + resources.push({ + uri: `gitnexus://repo/${repo.name}/clusters`, + name: `${repo.name} Clusters`, + description: `All functional clusters for ${repo.name}`, + mimeType: 'text/yaml', + }); + resources.push({ + uri: `gitnexus://repo/${repo.name}/processes`, + name: `${repo.name} Processes`, + description: `All execution flows for ${repo.name}`, + mimeType: 'text/yaml', + }); + resources.push({ + uri: `gitnexus://repo/${repo.name}/schema`, + name: `${repo.name} Schema`, + description: `Graph schema for Cypher queries on ${repo.name}`, + mimeType: 'text/yaml', + }); + } + + return resources; } /** @@ -60,13 +73,13 @@ export function getResourceDefinitions(projectName: string): ResourceDefinition[ export function getResourceTemplates(): ResourceTemplate[] { return [ { - uriTemplate: 'gitnexus://cluster/{name}', + uriTemplate: 'gitnexus://repo/{name}/cluster/{clusterName}', name: 'Cluster Detail', description: 'Deep dive into a specific cluster', mimeType: 'text/yaml', }, { - uriTemplate: 'gitnexus://process/{name}', + uriTemplate: 'gitnexus://repo/{name}/process/{processName}', name: 'Process Trace', description: 'Step-by-step execution trace', mimeType: 'text/yaml', @@ -75,55 +88,126 @@ export function getResourceTemplates(): ResourceTemplate[] { } /** - * Read a resource and return its content + * Parse a resource URI to extract the repo name and resource type. */ -export async function readResource(uri: string, backend: LocalBackend): Promise { - // Static resources - if (uri === 'gitnexus://context') { - return getContextResource(backend); +function parseUri(uri: string): { repoName?: string; resourceType: string; param?: string } { + if (uri === 'gitnexus://repos') return { resourceType: 'repos' }; + + // Repo-scoped: gitnexus://repo/{name}/context + const repoMatch = uri.match(/^gitnexus:\/\/repo\/([^/]+)\/(.+)$/); + if (repoMatch) { + const repoName = decodeURIComponent(repoMatch[1]); + const rest = repoMatch[2]; + + if (rest.startsWith('cluster/')) { + return { repoName, resourceType: 'cluster', param: decodeURIComponent(rest.replace('cluster/', '')) }; + } + if (rest.startsWith('process/')) { + return { repoName, resourceType: 'process', param: decodeURIComponent(rest.replace('process/', '')) }; + } + + return { repoName, resourceType: rest }; } - if (uri === 'gitnexus://clusters') { - return getClustersResource(backend); - } - if (uri === 'gitnexus://processes') { - return getProcessesResource(backend); - } - if (uri === 'gitnexus://schema') { - return getSchemaResource(); - } - - // Dynamic resources - if (uri.startsWith('gitnexus://cluster/')) { - const name = uri.replace('gitnexus://cluster/', ''); - return getClusterDetailResource(name, backend); - } - if (uri.startsWith('gitnexus://process/')) { - const name = uri.replace('gitnexus://process/', ''); - return getProcessDetailResource(name, backend); - } - - throw new Error(`Unknown resource: ${uri}`); + + throw new Error(`Unknown resource URI: ${uri}`); } /** - * Context resource - codebase overview + * Read a resource and return its content */ -async function getContextResource(backend: LocalBackend): Promise { - const context = backend.context; +export async function readResource(uri: string, backend: LocalBackend): Promise { + const parsed = parseUri(uri); + + // Global repos list — no repo context needed + if (parsed.resourceType === 'repos') { + return getReposResource(backend); + } + + const repoName = parsed.repoName; + + switch (parsed.resourceType) { + case 'context': + return getContextResource(backend, repoName); + case 'clusters': + return getClustersResource(backend, repoName); + case 'processes': + return getProcessesResource(backend, repoName); + case 'schema': + return getSchemaResource(); + case 'cluster': + return getClusterDetailResource(parsed.param!, backend, repoName); + case 'process': + return getProcessDetailResource(parsed.param!, backend, repoName); + default: + throw new Error(`Unknown resource: ${uri}`); + } +} + +// ─── Resource Implementations ───────────────────────────────────────── + +/** + * Repos resource — list all indexed repositories + */ +function getReposResource(backend: LocalBackend): string { + const repos = backend.listRepos(); + + if (repos.length === 0) { + return 'repos: []\n# No repositories indexed. Run: gitnexus analyze'; + } + + const lines: string[] = ['repos:']; + for (const repo of repos) { + lines.push(` - name: "${repo.name}"`); + lines.push(` path: "${repo.path}"`); + lines.push(` indexed: "${repo.indexedAt}"`); + lines.push(` commit: "${repo.lastCommit?.slice(0, 7) || 'unknown'}"`); + if (repo.stats) { + lines.push(` files: ${repo.stats.files || 0}`); + lines.push(` symbols: ${repo.stats.nodes || 0}`); + lines.push(` processes: ${repo.stats.processes || 0}`); + } + } + + if (repos.length > 1) { + lines.push(''); + lines.push('# Multiple repos indexed. Use repo parameter in tool calls:'); + lines.push(`# gitnexus_search({query: "auth", repo: "${repos[0].name}"})`); + } + + return lines.join('\n'); +} + +/** + * Context resource — codebase overview for a specific repo + */ +async function getContextResource(backend: LocalBackend, repoName?: string): Promise { + // Resolve repo + const repo = backend.resolveRepo(repoName); + const repoId = repo.name.toLowerCase(); + const context = backend.getContext(repoId) || backend.getContext(); + if (!context) { return 'error: No codebase loaded. Run: gitnexus analyze'; } // Check staleness - const repoPath = backend.repoPath; - const lastCommit = backend.meta?.lastCommit || 'HEAD'; + const repoPath = repo.repoPath; + const lastCommit = repo.lastCommit || 'HEAD'; const staleness = repoPath ? checkStaleness(repoPath, lastCommit) : { isStale: false, commitsBehind: 0 }; + + // Get aggregated cluster count (matches what overview/clusters resource shows) + let clusterCount = context.stats.communityCount; + try { + const overview = await backend.callTool('overview', { showClusters: true, showProcesses: false, limit: 100, repo: repoName }); + if (overview.clusters) { + clusterCount = overview.clusters.length; + } + } catch { /* fall back to raw count */ } const lines: string[] = [ `project: ${context.projectName}`, ]; - // Add staleness warning if index is behind if (staleness.isStale && staleness.hint) { lines.push(''); lines.push(`staleness: "${staleness.hint}"`); @@ -133,46 +217,59 @@ async function getContextResource(backend: LocalBackend): Promise { lines.push('stats:'); lines.push(` files: ${context.stats.fileCount}`); lines.push(` symbols: ${context.stats.functionCount}`); - lines.push(` clusters: ${context.stats.communityCount}`); + lines.push(` clusters: ${clusterCount}`); lines.push(` processes: ${context.stats.processCount}`); lines.push(''); lines.push('tools_available:'); + lines.push(' - list_repos: Discover all indexed repositories'); lines.push(' - search: Hybrid semantic + keyword search'); lines.push(' - explore: Deep dive on symbol/cluster/process'); lines.push(' - impact: Blast radius analysis'); lines.push(' - overview: List all clusters and processes'); lines.push(' - cypher: Raw graph queries'); - lines.push(' - analyze: Re-index to update stale data'); + lines.push(''); + lines.push('re_index: Run `npx gitnexus analyze` in terminal if data is stale'); lines.push(''); lines.push('resources_available:'); - lines.push(' - gitnexus://clusters: All clusters'); - lines.push(' - gitnexus://processes: All processes'); - lines.push(' - gitnexus://cluster/{name}: Cluster details'); - lines.push(' - gitnexus://process/{name}: Process trace'); + lines.push(' - gitnexus://repos: All indexed repositories'); + lines.push(` - gitnexus://repo/${context.projectName}/clusters: All clusters`); + lines.push(` - gitnexus://repo/${context.projectName}/processes: All processes`); + lines.push(` - gitnexus://repo/${context.projectName}/cluster/{name}: Cluster details`); + lines.push(` - gitnexus://repo/${context.projectName}/process/{name}: Process trace`); return lines.join('\n'); } /** - * Clusters resource - list all clusters + * Clusters resource */ -async function getClustersResource(backend: LocalBackend): Promise { +async function getClustersResource(backend: LocalBackend, repoName?: string): Promise { try { - const result = await backend.callTool('overview', { showClusters: true, showProcesses: false, limit: 50 }); + // Request more than we display so aggregation has enough raw data + const result = await backend.callTool('overview', { showClusters: true, showProcesses: false, limit: 100, repo: repoName }); if (!result.clusters || result.clusters.length === 0) { return 'clusters: []\n# No clusters detected. Run: gitnexus analyze'; } + const displayLimit = 20; const lines: string[] = ['clusters:']; + const toShow = result.clusters.slice(0, displayLimit); - for (const cluster of result.clusters) { + for (const cluster of toShow) { const label = cluster.heuristicLabel || cluster.label || cluster.id; lines.push(` - name: "${label}"`); lines.push(` symbols: ${cluster.symbolCount || 0}`); if (cluster.cohesion) { lines.push(` cohesion: ${(cluster.cohesion * 100).toFixed(0)}%`); } + if (cluster.subCommunities && cluster.subCommunities > 1) { + lines.push(` sub_clusters: ${cluster.subCommunities}`); + } + } + + if (result.clusters.length > displayLimit) { + lines.push(`\n# Showing top ${displayLimit} of ${result.clusters.length} clusters. Use gitnexus_search or gitnexus_explore for more.`); } return lines.join('\n'); @@ -182,25 +279,31 @@ async function getClustersResource(backend: LocalBackend): Promise { } /** - * Processes resource - list all processes + * Processes resource */ -async function getProcessesResource(backend: LocalBackend): Promise { +async function getProcessesResource(backend: LocalBackend, repoName?: string): Promise { try { - const result = await backend.callTool('overview', { showClusters: false, showProcesses: true, limit: 50 }); + const result = await backend.callTool('overview', { showClusters: false, showProcesses: true, limit: 50, repo: repoName }); if (!result.processes || result.processes.length === 0) { return 'processes: []\n# No processes detected. Run: gitnexus analyze'; } + const displayLimit = 20; const lines: string[] = ['processes:']; + const toShow = result.processes.slice(0, displayLimit); - for (const proc of result.processes) { + for (const proc of toShow) { const label = proc.heuristicLabel || proc.label || proc.id; lines.push(` - name: "${label}"`); lines.push(` type: ${proc.processType || 'unknown'}`); lines.push(` steps: ${proc.stepCount || 0}`); } + if (result.processes.length > displayLimit) { + lines.push(`\n# Showing top ${displayLimit} of ${result.processes.length} processes. Use gitnexus_explore for more.`); + } + return lines.join('\n'); } catch (err: any) { return `error: ${err.message}`; @@ -208,7 +311,7 @@ async function getProcessesResource(backend: LocalBackend): Promise { } /** - * Schema resource - graph structure for Cypher queries + * Schema resource — graph structure for Cypher queries */ function getSchemaResource(): string { return `# GitNexus Graph Schema @@ -252,9 +355,9 @@ example_queries: /** * Cluster detail resource */ -async function getClusterDetailResource(name: string, backend: LocalBackend): Promise { +async function getClusterDetailResource(name: string, backend: LocalBackend, repoName?: string): Promise { try { - const result = await backend.callTool('explore', { name, type: 'cluster' }); + const result = await backend.callTool('explore', { name, type: 'cluster', repo: repoName }); if (result.error) { return `error: ${result.error}`; @@ -271,6 +374,9 @@ async function getClusterDetailResource(name: string, backend: LocalBackend): Pr if (cluster.cohesion) { lines.push(`cohesion: ${(cluster.cohesion * 100).toFixed(0)}%`); } + if (cluster.subCommunities && cluster.subCommunities > 1) { + lines.push(`sub_clusters: ${cluster.subCommunities}`); + } if (members.length > 0) { lines.push(''); @@ -294,9 +400,9 @@ async function getClusterDetailResource(name: string, backend: LocalBackend): Pr /** * Process detail resource */ -async function getProcessDetailResource(name: string, backend: LocalBackend): Promise { +async function getProcessDetailResource(name: string, backend: LocalBackend, repoName?: string): Promise { try { - const result = await backend.callTool('explore', { name, type: 'process' }); + const result = await backend.callTool('explore', { name, type: 'process', repo: repoName }); if (result.error) { return `error: ${result.error}`; diff --git a/gitnexus/src/mcp/server.ts b/gitnexus/src/mcp/server.ts index 586dfcead0..26450fb05b 100644 --- a/gitnexus/src/mcp/server.ts +++ b/gitnexus/src/mcp/server.ts @@ -1,12 +1,14 @@ /** - * MCP Server + * MCP Server (Multi-Repo) * * Model Context Protocol server that runs on stdio. * External AI tools (Cursor, Claude) spawn this process and * communicate via stdin/stdout using the MCP protocol. * - * Tools: context, search, cypher, overview, explore, impact, analyze - * Resources: context, clusters, processes, schema, cluster/{name}, process/{name} + * Supports multiple indexed repositories via the global registry. + * + * Tools: list_repos, search, cypher, overview, explore, impact, analyze + * Resources: repos, repo/{name}/context, repo/{name}/clusters, ... */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; @@ -26,7 +28,7 @@ export async function startMCPServer(backend: LocalBackend): Promise { const server = new Server( { name: 'gitnexus', - version: '0.2.0', + version: '1.1.0', }, { capabilities: { @@ -38,13 +40,7 @@ export async function startMCPServer(backend: LocalBackend): Promise { // Handle list resources request server.setRequestHandler(ListResourcesRequestSchema, async () => { - const context = backend.context; - - if (!context) { - return { resources: [] }; - } - - const resources = getResourceDefinitions(context.projectName); + const resources = getResourceDefinitions(backend); return { resources: resources.map(r => ({ uri: r.uri, diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index a6cb5c877d..07f8cb2c7b 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -2,7 +2,7 @@ * MCP Tool Definitions * * Defines the tools that GitNexus exposes to external AI agents. - * Only includes tools that provide unique value over native IDE capabilities. + * All tools support an optional `repo` parameter for multi-repo setups. */ export interface ToolDefinition { @@ -23,51 +23,38 @@ export interface ToolDefinition { export const GITNEXUS_TOOLS: ToolDefinition[] = [ { - name: 'analyze', - description: `Index or re-index the current repository. Runs the full pipeline in-process. + name: 'list_repos', + description: `List all indexed repositories available to GitNexus. -Creates .gitnexus/ in repo root with: -- Knowledge graph (functions, classes, calls, imports) -- Full-text search indexes -- Community detection (Leiden) -- Process tracing -- Embeddings for semantic search +Returns each repo's name, path, indexed date, last commit, and stats. +Use this to discover which repos are available before querying. -Run this when: -- First time using GitNexus on a repo -- After major code changes -- When staleness warning appears in gitnexus://context -- When 'not indexed' error appears - -Note: This may take 30-120 seconds for large repos.`, +When multiple repos are indexed, you MUST specify the "repo" parameter +on other tools (search, explore, impact, etc.) to target the correct one.`, inputSchema: { type: 'object', - properties: { - path: { type: 'string', description: 'Repo path (default: current directory)' }, - force: { type: 'boolean', description: 'Re-index even if exists', default: false }, - skipEmbeddings: { type: 'boolean', description: 'Skip embedding generation (faster)', default: false }, - }, + properties: {}, required: [], }, }, { name: 'search', description: `Hybrid search (keyword + semantic) across the codebase. -Returns code nodes with their graph connections, grouped by process. +Returns code nodes with cluster context and optional graph connections. BETTER THAN IDE search because: -- Process-aware grouping (shows execution flows) -- Cluster context (which functional area) -- Relationship data (callers/callees) +- Cluster context (which functional area each result belongs to) +- Relationship data (callers/callees with depth=full) +- Hybrid ranking (BM25 + semantic via Reciprocal Rank Fusion) -RETURNS: Array of {name, type, filePath, code, connections[], cluster, processes[]}`, +RETURNS: Array of {name, type, filePath, cluster?, connections[]?, fusedScore, searchSource}`, inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Natural language or keyword search query' }, limit: { type: 'number', description: 'Max results to return', default: 10 }, depth: { type: 'string', description: 'Result detail: "definitions" (symbols only) or "full" (with relationships)', enum: ['definitions', 'full'], default: 'definitions' }, - groupByProcess: { type: 'boolean', description: 'Group results by process', default: true }, + repo: { type: 'string', description: 'Repository name or path. Omit if only one repo is indexed.' }, }, required: ['query'], }, @@ -98,6 +85,7 @@ TIPS: type: 'object', properties: { query: { type: 'string', description: 'Cypher query to execute' }, + repo: { type: 'string', description: 'Repository name or path. Omit if only one repo is indexed.' }, }, required: ['query'], }, @@ -118,6 +106,7 @@ Use after search to understand context of a specific node.`, properties: { name: { type: 'string', description: 'Name of symbol, cluster, or process to explore' }, type: { type: 'string', description: 'Type: symbol, cluster, or process' }, + repo: { type: 'string', description: 'Repository name or path. Omit if only one repo is indexed.' }, }, required: ['name', 'type'], }, @@ -138,6 +127,7 @@ Use to understand overall codebase structure before diving deep.`, showProcesses: { type: 'boolean', description: 'Include process list', default: true }, showClusters: { type: 'boolean', description: 'Include cluster list', default: true }, limit: { type: 'number', description: 'Max items per category', default: 20 }, + repo: { type: 'string', description: 'Repository name or path. Omit if only one repo is indexed.' }, }, required: [], }, @@ -171,6 +161,7 @@ Depth groups: relationTypes: { type: 'array', items: { type: 'string' }, description: 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS (default: usage-based)' }, includeTests: { type: 'boolean', description: 'Include test files (default: false)' }, minConfidence: { type: 'number', description: 'Minimum confidence 0-1 (default: 0.7)' }, + repo: { type: 'string', description: 'Repository name or path. Omit if only one repo is indexed.' }, }, required: ['target', 'direction'], }, diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 151dc073b9..2630a577b1 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -2,10 +2,13 @@ * Repository Manager * * Manages GitNexus index storage in .gitnexus/ at repo root. + * Also maintains a global registry at ~/.gitnexus/registry.json + * so the MCP server can discover indexed repos from any cwd. */ import fs from 'fs/promises'; import path from 'path'; +import os from 'os'; export interface RepoMeta { repoPath: string; @@ -29,8 +32,22 @@ export interface IndexedRepo { meta: RepoMeta; } +/** + * Shape of an entry in the global registry (~/.gitnexus/registry.json) + */ +export interface RegistryEntry { + name: string; + path: string; + storagePath: string; + indexedAt: string; + lastCommit: string; + stats?: RepoMeta['stats']; +} + const GITNEXUS_DIR = '.gitnexus'; +// ─── Local Storage Helpers ───────────────────────────────────────────── + /** * Get the .gitnexus storage path for a repository */ @@ -135,3 +152,113 @@ export const addToGitignore = async (repoPath: string): Promise => { await fs.writeFile(gitignorePath, `${GITNEXUS_DIR}\n`, 'utf-8'); } }; + +// ─── Global Registry (~/.gitnexus/registry.json) ─────────────────────── + +/** + * Get the path to the global GitNexus directory + */ +export const getGlobalDir = (): string => { + return path.join(os.homedir(), '.gitnexus'); +}; + +/** + * Get the path to the global registry file + */ +export const getGlobalRegistryPath = (): string => { + return path.join(getGlobalDir(), 'registry.json'); +}; + +/** + * Read the global registry. Returns empty array if not found. + */ +export const readRegistry = async (): Promise => { + try { + const raw = await fs.readFile(getGlobalRegistryPath(), 'utf-8'); + const data = JSON.parse(raw); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +}; + +/** + * Write the global registry to disk + */ +const writeRegistry = async (entries: RegistryEntry[]): Promise => { + const dir = getGlobalDir(); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(getGlobalRegistryPath(), JSON.stringify(entries, null, 2), 'utf-8'); +}; + +/** + * Register (add or update) a repo in the global registry. + * Called after `gitnexus analyze` completes. + */ +export const registerRepo = async (repoPath: string, meta: RepoMeta): Promise => { + const resolved = path.resolve(repoPath); + const name = path.basename(resolved); + const { storagePath } = getStoragePaths(resolved); + + const entries = await readRegistry(); + const existing = entries.findIndex( + (e) => path.resolve(e.path) === resolved + ); + + const entry: RegistryEntry = { + name, + path: resolved, + storagePath, + indexedAt: meta.indexedAt, + lastCommit: meta.lastCommit, + stats: meta.stats, + }; + + if (existing >= 0) { + entries[existing] = entry; + } else { + entries.push(entry); + } + + await writeRegistry(entries); +}; + +/** + * Remove a repo from the global registry. + * Called after `gitnexus clean`. + */ +export const unregisterRepo = async (repoPath: string): Promise => { + const resolved = path.resolve(repoPath); + const entries = await readRegistry(); + const filtered = entries.filter( + (e) => path.resolve(e.path) !== resolved + ); + await writeRegistry(filtered); +}; + +/** + * List all registered repos from the global registry. + * Optionally validates that each entry's .gitnexus/ still exists. + */ +export const listRegisteredRepos = async (opts?: { validate?: boolean }): Promise => { + const entries = await readRegistry(); + if (!opts?.validate) return entries; + + // Validate each entry still has a .gitnexus/ directory + const valid: RegistryEntry[] = []; + for (const entry of entries) { + try { + await fs.access(path.join(entry.storagePath, 'meta.json')); + valid.push(entry); + } catch { + // Index no longer exists — skip + } + } + + // If we pruned any entries, save the cleaned registry + if (valid.length !== entries.length) { + await writeRegistry(valid); + } + + return valid; +};