Compare commits
142
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12ee6b100b | ||
|
|
3d4a787ad4 | ||
|
|
23d3765a8d | ||
|
|
824b22ba1a | ||
|
|
3dbe08fab6 | ||
|
|
1afe9166aa | ||
|
|
1d9a9c476d | ||
|
|
03bfa3c4d9 | ||
|
|
74c0e462c3 | ||
|
|
7376e92063 | ||
|
|
1be910f54a | ||
|
|
fa9ba8925c | ||
|
|
8efc272609 | ||
|
|
c990d7e6c6 | ||
|
|
b4fbf33bd6 | ||
|
|
892e1d6088 | ||
|
|
c2bd8667a3 | ||
|
|
1952c2c346 | ||
|
|
c4eaf45ab1 | ||
|
|
0796e1e68c | ||
|
|
9d5ec5d19a | ||
|
|
4de40e4011 | ||
|
|
20e8c52028 | ||
|
|
2868da5ddb | ||
|
|
3db47f7ee5 | ||
|
|
821871cec1 | ||
|
|
f9a54cd588 | ||
|
|
8c6b064d18 | ||
|
|
84ef6524bc | ||
|
|
5674b2201d | ||
|
|
6915a9350b | ||
|
|
76e0e5a35a | ||
|
|
8e7d976c2a | ||
|
|
46b4b7e157 | ||
|
|
cbeb0e231a | ||
|
|
3431edcea0 | ||
|
|
40cb863cb4 | ||
|
|
ee95808478 | ||
|
|
3e3ea86ce4 | ||
|
|
1a52d05131 | ||
|
|
3d64e26f8f | ||
|
|
3576802574 | ||
|
|
20ebd6b781 | ||
|
|
8a100a76d3 | ||
|
|
c129e71ee7 | ||
|
|
80eff73459 | ||
|
|
48c8e6fe57 | ||
|
|
2eca3e0da3 | ||
|
|
e046bf734d | ||
|
|
b30248f969 | ||
|
|
eb48c7352e | ||
|
|
29db66c304 | ||
|
|
b7c582de76 | ||
|
|
508402fd4a | ||
|
|
da63281a5a | ||
|
|
c758f4eaf0 | ||
|
|
fd507a19ae | ||
|
|
799de20172 | ||
|
|
2be88ae1f8 | ||
|
|
019ed3ff85 | ||
|
|
6b4f10cae1 | ||
|
|
43f525d056 | ||
|
|
e2a8bfa5ab | ||
|
|
ee6753bf05 | ||
|
|
1b8c3c77af | ||
|
|
a7fc9d2f88 | ||
|
|
0074fd71ff | ||
|
|
de935a4f4c | ||
|
|
989673a624 | ||
|
|
8c41970631 | ||
|
|
5c3a32d0c6 | ||
|
|
a8b3c6b23f | ||
|
|
15caf1e014 | ||
|
|
1ed34a0007 | ||
|
|
7a4bc9a260 | ||
|
|
3872a73875 | ||
|
|
f557716998 | ||
|
|
ae8a76511d | ||
|
|
e803e7e9d6 | ||
|
|
50fc8df2a1 | ||
|
|
c37b63ae8b | ||
|
|
7fe8830402 | ||
|
|
39b01f101e | ||
|
|
7cb88707a4 | ||
|
|
2a444acf1d | ||
|
|
d97d43f1b8 | ||
|
|
04be81f655 | ||
|
|
f047a84d82 | ||
|
|
0421fcbc76 | ||
|
|
1403cdbf6d | ||
|
|
0e8eed4a8a | ||
|
|
d6738c51c1 | ||
|
|
a5096e8029 | ||
|
|
36e64e892f | ||
|
|
bb6c22a22c | ||
|
|
420122065a | ||
|
|
bef319491a | ||
|
|
238abbd947 | ||
|
|
3f4c4cb4aa | ||
|
|
4f4fe9e587 | ||
|
|
dbf3495713 | ||
|
|
5b8ce44537 | ||
|
|
ffc4b69004 | ||
|
|
470a3377b3 | ||
|
|
91289404c2 | ||
|
|
397dad8ec4 | ||
|
|
7ee2dd1087 | ||
|
|
6aab580f93 | ||
|
|
1c02a06d1b | ||
|
|
890fedaa09 | ||
|
|
8a79465cbf | ||
|
|
945235ce56 | ||
|
|
e67d6c63d3 | ||
|
|
58063ca9ed | ||
|
|
804d975cd0 | ||
|
|
2164dc22f1 | ||
|
|
30aba01188 | ||
|
|
92a5d026c8 | ||
|
|
7883bf2cf0 | ||
|
|
73590b2862 | ||
|
|
bdda9afdca | ||
|
|
6be54ce9d3 | ||
|
|
2e390583fc | ||
|
|
575a4978f2 | ||
|
|
c379c39ae1 | ||
|
|
acd918f44e | ||
|
|
a71924f774 | ||
|
|
8dd1c19bec | ||
|
|
56f92ca1ad | ||
|
|
36c7b3ed12 | ||
|
|
c80bcccba4 | ||
|
|
76d1538c5e | ||
|
|
d1cac0515d | ||
|
|
cb70fc8d6c | ||
|
|
3603178266 | ||
|
|
c7519c8493 | ||
|
|
6726340059 | ||
|
|
e1d3959273 | ||
|
|
6de13ac800 | ||
|
|
d7380de683 | ||
|
|
102850455d | ||
|
|
8e8bb90fe4 |
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "gitnexus-marketplace",
|
||||
"owner": {
|
||||
"name": "GitNexus",
|
||||
"email": "nico@gitnexus.dev"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Code intelligence powered by a knowledge graph — execution flows, blast radius, and semantic search",
|
||||
"homepage": "https://github.com/nicosxt/gitnexus"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.3.3",
|
||||
"source": "./gitnexus-claude-plugin",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebSearch",
|
||||
"WebFetch(domain:cursor.com)",
|
||||
"WebFetch(domain:composio.dev)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(claude rename:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npm link:*)",
|
||||
"Bash(gitnexus --version:*)",
|
||||
"Bash(gitnexus --help:*)",
|
||||
"Bash(npm ls:*)",
|
||||
"Bash(gitnexus augment:*)",
|
||||
"Bash(node -e \"\nconst { augment } = await import\\(''./gitnexus/dist/core/augmentation/engine.js''\\);\ntry {\n const r = await augment\\(''setup'', process.cwd\\(\\)\\);\n console.log\\(''Result:'', r ? r.substring\\(0, 200\\) : ''null''\\);\n} catch\\(e\\) { console.error\\(''Error:'', e.message\\); }\nprocess.exit\\(0\\);\n\")",
|
||||
"Bash(cmd.exe /c \"cd /d D:\\\\Projects\\\\GitnexusV2 && gitnexus augment setup\")",
|
||||
"Bash(cmd.exe /c \"cd /d D:\\\\Projects\\\\GitnexusV2 && gitnexus status\")",
|
||||
"Bash(gh repo clone:*)",
|
||||
"Bash(claude mcp:*)",
|
||||
"Bash(gh issue view:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(node:*)",
|
||||
"Bash(npm view:*)",
|
||||
"Bash(npm version:*)",
|
||||
"Bash(npm pack:*)",
|
||||
"Bash(npm publish:*)",
|
||||
"Bash(npx gitnexus:*)",
|
||||
"mcp__gitnexus__list_repos",
|
||||
"mcp__gitnexus__query",
|
||||
"mcp__gitnexus__context",
|
||||
"mcp__gitnexus__impact",
|
||||
"Bash(git add:*)",
|
||||
"Bash(Glob)",
|
||||
"Bash(Bash\"\\) per new Claude Code schema\n- Rename gitnexus-hook.js → gitnexus-hook.cjs for CommonJS compatibility\n- Fix setup.ts: correct hook filename and timeout \\(8000ms instead of 10ms\\)\n- Bump to v1.1.9 and publish to npm\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git push:*)",
|
||||
"WebFetch(domain:docs.kuzudb.com)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"WebFetch(domain:read.engineerscodex.com)",
|
||||
"WebFetch(domain:towardsdatascience.com)",
|
||||
"WebFetch(domain:kilo.ai)",
|
||||
"WebFetch(domain:deepwiki.com)",
|
||||
"WebFetch(domain:turbopuffer.com)",
|
||||
"WebFetch(domain:windsurf.com)",
|
||||
"WebFetch(domain:modal.com)",
|
||||
"WebFetch(domain:www.augmentcode.com)",
|
||||
"WebFetch(domain:www.qodo.ai)",
|
||||
"WebFetch(domain:arxiv.org)",
|
||||
"WebFetch(domain:cognition.ai)",
|
||||
"WebFetch(domain:microsoft.github.io)",
|
||||
"WebFetch(domain:github.github.com)",
|
||||
"WebFetch(domain:gist.github.com)",
|
||||
"WebFetch(domain:fsoft-ai4code.github.io)",
|
||||
"mcp__gitnexus__cypher",
|
||||
"WebFetch(domain:repomix.com)",
|
||||
"WebFetch(domain:www.humanlayer.dev)",
|
||||
"WebFetch(domain:agents.md)",
|
||||
"WebFetch(domain:eclipsesource.com)",
|
||||
"WebFetch(domain:www.usefulfunctions.co.uk)",
|
||||
"WebFetch(domain:developers.googleblog.com)",
|
||||
"WebFetch(domain:www.anthropic.com)",
|
||||
"WebFetch(domain:www.driver.ai)",
|
||||
"WebFetch(domain:blog.sshh.io)",
|
||||
"WebFetch(domain:docs.qodo.ai)",
|
||||
"WebFetch(domain:smartlogic.io)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(powershell -Command:*)",
|
||||
"Bash(cmd /c \"dir /s C:\\\\Users\\\\ADMIN\\\\.cache\\\\huggingface 2>nul | findstr /i \"\"File\\(s\\)\"\"\")",
|
||||
"Bash(du:*)",
|
||||
"mcp__desktop-commander__list_directory",
|
||||
"Bash(python3 -c \":*)",
|
||||
"mcp__gitnexus__detect_changes"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"gitnexus"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: gitnexus-cli
|
||||
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
|
||||
---
|
||||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
All commands work via `npx` — no global install required.
|
||||
|
||||
## Commands
|
||||
|
||||
### analyze — Build or refresh the index
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
|
||||
|
||||
| Flag | Effect |
|
||||
| -------------- | ---------------------------------------------------------------- |
|
||||
| `--force` | Force full re-index even if up to date |
|
||||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
```bash
|
||||
npx gitnexus status
|
||||
```
|
||||
|
||||
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
|
||||
|
||||
### clean — Delete the index
|
||||
|
||||
```bash
|
||||
npx gitnexus clean
|
||||
```
|
||||
|
||||
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
|
||||
|
||||
| Flag | Effect |
|
||||
| --------- | ------------------------------------------------- |
|
||||
| `--force` | Skip confirmation prompt |
|
||||
| `--all` | Clean all indexed repos, not just the current one |
|
||||
|
||||
### wiki — Generate documentation from the graph
|
||||
|
||||
```bash
|
||||
npx gitnexus wiki
|
||||
```
|
||||
|
||||
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
||||
|
||||
| Flag | Effect |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| `--force` | Force full regeneration |
|
||||
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
|
||||
| `--base-url <url>` | LLM API base URL |
|
||||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||
|
||||
### list — Show all indexed repos
|
||||
|
||||
```bash
|
||||
npx gitnexus list
|
||||
```
|
||||
|
||||
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
|
||||
|
||||
## After Indexing
|
||||
|
||||
1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
|
||||
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Not inside a git repository"**: Run from a directory inside a git repo
|
||||
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
|
||||
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
|
||||
+12
-8
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: gitnexus-debugging
|
||||
description: Trace bugs through call chains using knowledge graph
|
||||
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
|
||||
---
|
||||
|
||||
# Debugging with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Why is this function failing?"
|
||||
- "Trace where this error comes from"
|
||||
- "Who calls this method?"
|
||||
@@ -37,17 +38,18 @@ description: Trace bugs through call chains using knowledge graph
|
||||
|
||||
## Debugging Patterns
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
|---------|-------------------|
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
| Symptom | GitNexus Approach |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find code related to error:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
@@ -55,6 +57,7 @@ gitnexus_query({query: "payment validation error"})
|
||||
```
|
||||
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
@@ -63,6 +66,7 @@ gitnexus_context({name: "validatePayment"})
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
RETURN [n IN nodes(path) | n.name] AS chain
|
||||
+10
-7
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: gitnexus-exploring
|
||||
description: Navigate unfamiliar code using GitNexus knowledge graph
|
||||
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
|
||||
---
|
||||
|
||||
# Exploring Codebases with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "How does authentication work?"
|
||||
- "What's the project structure?"
|
||||
- "Show me the main components"
|
||||
@@ -37,16 +38,17 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | What you get |
|
||||
|----------|-------------|
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
| Resource | What you get |
|
||||
| --------------------------------------- | ------------------------------------------------------- |
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
@@ -54,6 +56,7 @@ gitnexus_query({query: "payment processing"})
|
||||
```
|
||||
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: gitnexus-guide
|
||||
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
|
||||
---
|
||||
|
||||
# GitNexus Guide
|
||||
|
||||
Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
|
||||
|
||||
## Always Start Here
|
||||
|
||||
For any task involving code understanding, debugging, impact analysis, or refactoring:
|
||||
|
||||
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
|
||||
2. **Match your task to a skill below** and **read that skill file**
|
||||
3. **Follow the skill's workflow and checklist**
|
||||
|
||||
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||
|
||||
## Skills
|
||||
|
||||
| Task | Skill to read |
|
||||
| -------------------------------------------- | ------------------- |
|
||||
| Understand architecture / "How does X work?" | `gitnexus-exploring` |
|
||||
| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
|
||||
| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
|
||||
| Rename / extract / split / refactor | `gitnexus-refactoring` |
|
||||
| Tools, resources, schema reference | `gitnexus-guide` (this file) |
|
||||
| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
|
||||
|
||||
## Tools Reference
|
||||
|
||||
| Tool | What it gives you |
|
||||
| ---------------- | ------------------------------------------------------------------------ |
|
||||
| `query` | Process-grouped code intelligence — execution flows related to a concept |
|
||||
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
|
||||
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||
| `detect_changes` | Git-diff impact — what do your current changes affect |
|
||||
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
|
||||
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
|
||||
| `list_repos` | Discover indexed repos |
|
||||
|
||||
## Resources Reference
|
||||
|
||||
Lightweight reads (~100-500 tokens) for navigation:
|
||||
|
||||
| Resource | Content |
|
||||
| ---------------------------------------------- | ----------------------------------------- |
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness check |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
|
||||
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
|
||||
| `gitnexus://repo/{name}/processes` | All execution flows |
|
||||
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
|
||||
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
|
||||
|
||||
## Graph Schema
|
||||
|
||||
**Nodes:** File, Function, Class, Interface, Method, Community, Process
|
||||
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
|
||||
RETURN caller.name, caller.filePath
|
||||
```
|
||||
+14
-11
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: gitnexus-impact-analysis
|
||||
description: Analyze blast radius before making code changes
|
||||
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
|
||||
---
|
||||
|
||||
# Impact Analysis with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Is it safe to change this function?"
|
||||
- "What will break if I modify X?"
|
||||
- "Show me the blast radius"
|
||||
@@ -37,24 +38,25 @@ description: Analyze blast radius before making code changes
|
||||
|
||||
## Understanding Output
|
||||
|
||||
| Depth | Risk Level | Meaning |
|
||||
|-------|-----------|---------|
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
| Depth | Risk Level | Meaning |
|
||||
| ----- | ---------------- | ------------------------ |
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Affected | Risk |
|
||||
|----------|------|
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Affected | Risk |
|
||||
| ------------------------------ | -------- |
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
|
||||
```
|
||||
gitnexus_impact({
|
||||
target: "validateUser",
|
||||
@@ -72,6 +74,7 @@ gitnexus_impact({
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: gitnexus-pr-review
|
||||
description: "Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
|
||||
---
|
||||
|
||||
# PR Review with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Review this PR"
|
||||
- "What does PR #42 change?"
|
||||
- "Is this safe to merge?"
|
||||
- "What's the blast radius of this PR?"
|
||||
- "Are there missing tests for this PR?"
|
||||
- Reviewing someone else's code changes before merge
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gh pr diff <number> → Get the raw diff
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
3. For each changed symbol:
|
||||
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
|
||||
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
6. Summarize findings with risk assessment
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
|
||||
- [ ] gitnexus_detect_changes to map changes to affected execution flows
|
||||
- [ ] gitnexus_impact on each non-trivial changed symbol
|
||||
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
|
||||
- [ ] gitnexus_context on key changed symbols to understand full picture
|
||||
- [ ] Check if affected processes have test coverage
|
||||
- [ ] Assess overall risk level
|
||||
- [ ] Write review summary with findings
|
||||
```
|
||||
|
||||
## Review Dimensions
|
||||
|
||||
| Dimension | How GitNexus Helps |
|
||||
| --- | --- |
|
||||
| **Correctness** | `context` shows callers — are they all compatible with the change? |
|
||||
| **Blast radius** | `impact` shows d=1/d=2/d=3 dependents — anything missed? |
|
||||
| **Completeness** | `detect_changes` shows all affected flows — are they all handled? |
|
||||
| **Test coverage** | `impact({includeTests: true})` shows which tests touch changed code |
|
||||
| **Breaking changes** | d=1 upstream items that aren't updated in the PR = potential breakage |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Signal | Risk |
|
||||
| --- | --- |
|
||||
| Changes touch <3 symbols, 0-1 processes | LOW |
|
||||
| Changes touch 3-10 symbols, 2-5 processes | MEDIUM |
|
||||
| Changes touch >10 symbols or many processes | HIGH |
|
||||
| Changes touch auth, payments, or data integrity code | CRITICAL |
|
||||
| d=1 callers exist outside the PR diff | Potential breakage — flag it |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_detect_changes** — map PR diff to affected execution flows:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
|
||||
→ Changed: 8 symbols in 4 files
|
||||
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**gitnexus_impact** — blast radius per changed symbol:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
|
||||
- webhookHandler (src/webhooks.ts:15) [CALLS, 100%]
|
||||
|
||||
→ d=2 (LIKELY AFFECTED):
|
||||
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**gitnexus_impact with tests** — check test coverage:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
|
||||
→ Tests that cover this symbol:
|
||||
- validatePayment.test.ts [direct]
|
||||
- checkout.integration.test.ts [via processCheckout]
|
||||
```
|
||||
|
||||
**gitnexus_context** — understand a changed symbol's role:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates
|
||||
→ Processes: CheckoutFlow (step 3/7), RefundFlow (step 1/5)
|
||||
```
|
||||
|
||||
## Example: "Review PR #42"
|
||||
|
||||
```
|
||||
1. gh pr diff 42 > /tmp/pr42.diff
|
||||
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
|
||||
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
→ Changed symbols: validatePayment, PaymentInput, formatAmount
|
||||
→ Affected processes: CheckoutFlow, RefundFlow
|
||||
→ Risk: MEDIUM
|
||||
|
||||
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
→ d=1: processCheckout, webhookHandler (WILL BREAK)
|
||||
→ webhookHandler is NOT in the PR diff — potential breakage!
|
||||
|
||||
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
|
||||
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
|
||||
→ createPayment uses the old PaymentInput shape — breaking change!
|
||||
|
||||
5. gitnexus_context({name: "formatAmount"})
|
||||
→ Called by 12 functions — but change is backwards-compatible (added optional param)
|
||||
|
||||
6. Review summary:
|
||||
- MEDIUM risk — 3 changed symbols affect 2 execution flows
|
||||
- BUG: webhookHandler calls validatePayment but isn't updated for new signature
|
||||
- BUG: createPayment depends on PaymentInput type which changed
|
||||
- OK: formatAmount change is backwards-compatible
|
||||
- Tests: checkout.test.ts covers processCheckout path, but no webhook test
|
||||
```
|
||||
|
||||
## Review Output Format
|
||||
|
||||
Structure your review as:
|
||||
|
||||
```markdown
|
||||
## PR Review: <title>
|
||||
|
||||
**Risk: LOW / MEDIUM / HIGH / CRITICAL**
|
||||
|
||||
### Changes Summary
|
||||
- <N> symbols changed across <M> files
|
||||
- <P> execution flows affected
|
||||
|
||||
### Findings
|
||||
1. **[severity]** Description of finding
|
||||
- Evidence from GitNexus tools
|
||||
- Affected callers/flows
|
||||
|
||||
### Missing Coverage
|
||||
- Callers not updated in PR: ...
|
||||
- Untested flows: ...
|
||||
|
||||
### Recommendation
|
||||
APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
|
||||
```
|
||||
+15
-7
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: gitnexus-refactoring
|
||||
description: Plan safe refactors using blast radius and dependency mapping
|
||||
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
|
||||
---
|
||||
|
||||
# Refactoring with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Rename this function safely"
|
||||
- "Extract this into a module"
|
||||
- "Split this service"
|
||||
@@ -26,6 +27,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
## Checklists
|
||||
|
||||
### Rename Symbol
|
||||
|
||||
```
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
@@ -35,6 +37,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
```
|
||||
|
||||
### Extract Module
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
@@ -45,6 +48,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
```
|
||||
|
||||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
@@ -58,6 +62,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
## Tools
|
||||
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
|
||||
```
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
@@ -66,6 +71,7 @@ gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_
|
||||
```
|
||||
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
@@ -73,6 +79,7 @@ gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
@@ -81,6 +88,7 @@ gitnexus_detect_changes({scope: "all"})
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
@@ -88,12 +96,12 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
|
||||
## Risk Rules
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
|-------------|------------|
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
| Risk Factor | Mitigation |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
Submodule .claude/worktrees/determined-hofstadter deleted from e90622aa24
Submodule .claude/worktrees/quirky-stonebraker deleted from e90622aa24
Submodule .claude/worktrees/sweet-faraday deleted from 44572ad0bd
@@ -0,0 +1,28 @@
|
||||
name: Setup GitNexus
|
||||
description: Setup Node.js 20, install dependencies, and optionally build
|
||||
|
||||
inputs:
|
||||
build:
|
||||
description: Whether to run npm run build after install
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Build
|
||||
if: ${{ inputs.build == 'true' }}
|
||||
run: npm run build
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
@@ -0,0 +1,45 @@
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- chore
|
||||
authors:
|
||||
- dependabot
|
||||
- dependabot[bot]
|
||||
categories:
|
||||
- title: "\U0001F6A8 Security"
|
||||
labels:
|
||||
- security
|
||||
- title: "\U0001F4A5 Breaking Changes"
|
||||
labels:
|
||||
- breaking
|
||||
- title: "\U0001F680 Features"
|
||||
labels:
|
||||
- enhancement
|
||||
- title: "\U0001F41B Bug Fixes"
|
||||
labels:
|
||||
- bug
|
||||
- title: "\U0001F3CE\uFE0F Performance"
|
||||
labels:
|
||||
- performance
|
||||
- title: "\U0001F9EA Tests"
|
||||
labels:
|
||||
- test
|
||||
- title: "\U0001F504 Refactoring"
|
||||
labels:
|
||||
- refactor
|
||||
- title: "\U0001F477 CI/CD"
|
||||
labels:
|
||||
- ci
|
||||
- title: "\U0001F4E6 Dependencies"
|
||||
labels:
|
||||
- dependencies
|
||||
- title: "\U0001F4DD Other Changes"
|
||||
labels:
|
||||
- "*"
|
||||
exclude:
|
||||
labels:
|
||||
- dependencies
|
||||
- ci
|
||||
- test
|
||||
- refactor
|
||||
- chore
|
||||
@@ -0,0 +1,192 @@
|
||||
name: Integration Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
collect-coverage:
|
||||
description: 'Whether to run the coverage collection job (only needed for PR reports)'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
# ── Integration test matrix ─────────────────────────────────────────
|
||||
# Each test-group runs on a SEPARATE runner per OS, giving full process
|
||||
# isolation for the KuzuDB native C++ addon.
|
||||
# 3 OS x 4 groups = 12 parallel jobs.
|
||||
#
|
||||
# Groups:
|
||||
# kuzu-db — 7 files using withTestKuzuDB / kuzu-adapter (native addon)
|
||||
# Each file runs as its own `vitest run` invocation for full
|
||||
# process isolation. KuzuDB's native N-API addon registers
|
||||
# persistent handles that prevent fork workers from exiting
|
||||
# on Linux, and its C++ destructors segfault during
|
||||
# process.exit(). Running each file in its own process lets
|
||||
# the OS reclaim all resources cleanly.
|
||||
# pipeline — 12 files: ingestion pipeline + csv + 9 resolver tests
|
||||
# e2e — 2 files: child-process only (spawnSync), no in-process kuzu
|
||||
# standalone — 4 files: pure logic, no kuzu, no child processes
|
||||
test-matrix:
|
||||
name: integration (${{ matrix.os }} / ${{ matrix.test-group }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
test-group: [kuzu-db, pipeline, e2e, standalone]
|
||||
include:
|
||||
- test-group: kuzu-db
|
||||
# Marker — actual files are listed in the run step below
|
||||
test-glob: ''
|
||||
- test-group: pipeline
|
||||
test-glob: >-
|
||||
test/integration/pipeline.test.ts
|
||||
test/integration/csv-pipeline.test.ts
|
||||
test/integration/parsing.test.ts
|
||||
test/integration/resolvers/typescript.test.ts
|
||||
test/integration/resolvers/csharp.test.ts
|
||||
test/integration/resolvers/cpp.test.ts
|
||||
test/integration/resolvers/java.test.ts
|
||||
test/integration/resolvers/python.test.ts
|
||||
test/integration/resolvers/rust.test.ts
|
||||
test/integration/resolvers/go.test.ts
|
||||
test/integration/resolvers/kotlin.test.ts
|
||||
test/integration/resolvers/php.test.ts
|
||||
- test-group: e2e
|
||||
test-glob: >-
|
||||
test/integration/cli-e2e.test.ts
|
||||
test/integration/hooks-e2e.test.ts
|
||||
test/integration/skills-e2e.test.ts
|
||||
- test-group: standalone
|
||||
test-glob: >-
|
||||
test/integration/filesystem-walker.test.ts
|
||||
test/integration/enrichment.test.ts
|
||||
test/integration/tree-sitter-languages.test.ts
|
||||
test/integration/worker-pool.test.ts
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
# kuzu-db: run each file in its own vitest process for full isolation.
|
||||
# KuzuDB's native addon hangs fork workers on Linux — process isolation
|
||||
# is the only reliable fix boundary.
|
||||
- name: Run integration tests — kuzu-db (process-isolated)
|
||||
if: matrix.test-group == 'kuzu-db'
|
||||
working-directory: gitnexus
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
files=(
|
||||
test/integration/kuzu-core-adapter.test.ts
|
||||
test/integration/kuzu-pool.test.ts
|
||||
test/integration/local-backend.test.ts
|
||||
test/integration/local-backend-calltool.test.ts
|
||||
test/integration/search-core.test.ts
|
||||
test/integration/search-pool.test.ts
|
||||
test/integration/augmentation.test.ts
|
||||
)
|
||||
exit_code=0
|
||||
for f in "${files[@]}"; do
|
||||
echo "::group::$f"
|
||||
if ! npx vitest run --reporter=verbose --pool=forks "$f"; then
|
||||
exit_code=1
|
||||
echo "::error::Test file failed: $f"
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
exit $exit_code
|
||||
|
||||
# Non-kuzu groups: run all files in a single vitest invocation
|
||||
- name: Run integration tests — ${{ matrix.test-group }}
|
||||
if: matrix.test-group != 'kuzu-db'
|
||||
shell: bash
|
||||
env:
|
||||
TEST_GLOB: ${{ matrix.test-glob }}
|
||||
run: npx vitest run --reporter=verbose $TEST_GLOB
|
||||
working-directory: gitnexus
|
||||
|
||||
# ── Coverage collection (ubuntu only) ─────────────────────────────────
|
||||
# Runs non-kuzu integration tests with coverage enabled so the PR report
|
||||
# can merge integration + unit coverage for a combined view.
|
||||
# kuzu-db tests are excluded because each file must run in its own vitest
|
||||
# process (native addon isolation) which prevents single-run coverage merge.
|
||||
coverage:
|
||||
name: integration (ubuntu / coverage)
|
||||
if: inputs.collect-coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
- name: Run integration tests with coverage
|
||||
working-directory: gitnexus
|
||||
run: >-
|
||||
npx vitest run
|
||||
--reporter=default
|
||||
--reporter=json
|
||||
--outputFile=integration-results.json
|
||||
--coverage
|
||||
--coverage.reporter=json-summary
|
||||
--coverage.reporter=json
|
||||
--coverage.reporter=text
|
||||
--coverage.thresholdAutoUpdate=false
|
||||
--coverage.reportOnFailure=true
|
||||
--coverage.thresholds.statements=0
|
||||
--coverage.thresholds.branches=0
|
||||
--coverage.thresholds.functions=0
|
||||
--coverage.thresholds.lines=0
|
||||
test/integration/pipeline.test.ts
|
||||
test/integration/csv-pipeline.test.ts
|
||||
test/integration/parsing.test.ts
|
||||
test/integration/cli-e2e.test.ts
|
||||
test/integration/hooks-e2e.test.ts
|
||||
test/integration/filesystem-walker.test.ts
|
||||
test/integration/enrichment.test.ts
|
||||
test/integration/tree-sitter-languages.test.ts
|
||||
test/integration/worker-pool.test.ts
|
||||
test/integration/resolvers/typescript.test.ts
|
||||
test/integration/resolvers/csharp.test.ts
|
||||
test/integration/resolvers/cpp.test.ts
|
||||
test/integration/resolvers/java.test.ts
|
||||
test/integration/resolvers/python.test.ts
|
||||
test/integration/resolvers/rust.test.ts
|
||||
test/integration/resolvers/go.test.ts
|
||||
test/integration/resolvers/kotlin.test.ts
|
||||
test/integration/resolvers/php.test.ts
|
||||
|
||||
- name: Upload integration coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: integration-reports
|
||||
path: |
|
||||
gitnexus/coverage/coverage-summary.json
|
||||
gitnexus/coverage/coverage-final.json
|
||||
gitnexus/integration-results.json
|
||||
retention-days: 5
|
||||
|
||||
# ── Unified status gate ──────────────────────────────────────────────
|
||||
# Branch protection should require THIS job, not the matrix jobs directly.
|
||||
# ci.yml's needs.integration.result aggregates through this gate.
|
||||
status:
|
||||
name: integration (all groups)
|
||||
needs: test-matrix
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check all matrix jobs passed
|
||||
shell: bash
|
||||
env:
|
||||
RESULT: ${{ needs.test-matrix.result }}
|
||||
run: |
|
||||
if [[ "$RESULT" != "success" ]]; then
|
||||
echo "::error::Integration matrix failed or cancelled: $RESULT"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,14 @@
|
||||
name: Quality Checks
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- run: npx tsc --noEmit
|
||||
working-directory: gitnexus
|
||||
@@ -0,0 +1,432 @@
|
||||
name: CI Report
|
||||
|
||||
# Triggered after the CI workflow completes. Because workflow_run
|
||||
# always runs code from the *default branch*, it receives a read/write
|
||||
# GITHUB_TOKEN — even when the triggering PR comes from a fork.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read # needed to list/download workflow run artifacts
|
||||
contents: read # needed for sparse checkout of vitest.config.ts
|
||||
pull-requests: write # needed to post sticky PR comment
|
||||
|
||||
jobs:
|
||||
pr-report:
|
||||
name: PR Report
|
||||
# Only run for pull-request CI runs
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion != 'cancelled'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# ── Download artifacts from the CI run ────────────────────────
|
||||
- name: Download artifacts
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const runId = context.payload.workflow_run.id;
|
||||
|
||||
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: runId,
|
||||
});
|
||||
|
||||
async function downloadArtifact(name, dest) {
|
||||
const match = allArtifacts.data.artifacts.find(a => a.name === name);
|
||||
if (!match) {
|
||||
core.warning(`Artifact "${name}" not found`);
|
||||
return false;
|
||||
}
|
||||
const zip = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: match.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, `${name}.zip`), Buffer.from(zip.data));
|
||||
return true;
|
||||
}
|
||||
|
||||
const temp = process.env.RUNNER_TEMP;
|
||||
await downloadArtifact('pr-meta', path.join(temp, 'dl'));
|
||||
await downloadArtifact('test-reports', path.join(temp, 'dl'));
|
||||
await downloadArtifact('integration-reports', path.join(temp, 'dl'));
|
||||
|
||||
- name: Extract artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
cd "$RUNNER_TEMP/dl"
|
||||
# Extract each artifact into its own directory to avoid filename collisions
|
||||
for z in *.zip; do
|
||||
[ -f "$z" ] || continue
|
||||
name="${z%.zip}"
|
||||
mkdir -p "$RUNNER_TEMP/artifacts/$name"
|
||||
unzip -o "$z" -d "$RUNNER_TEMP/artifacts/$name"
|
||||
done
|
||||
|
||||
- name: Read PR metadata
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
DIR="$RUNNER_TEMP/artifacts/pr-meta"
|
||||
if [ ! -f "$DIR/pr_number" ]; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::pr_number artifact missing — skipping report"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate PR number is a positive integer (artifact comes from
|
||||
# untrusted fork code, so treat contents defensively).
|
||||
PR_NUM=$(cat "$DIR/pr_number" | tr -d '[:space:]')
|
||||
if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::error::Invalid PR number in artifact: '$PR_NUM'"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_number=$PR_NUM" >> "$GITHUB_OUTPUT"
|
||||
# Validate job-result strings against known GitHub Actions values.
|
||||
# Artifact contents come from the PR workflow (potentially untrusted
|
||||
# fork code), so we whitelist to prevent newline injection into
|
||||
# GITHUB_OUTPUT.
|
||||
validate_result() {
|
||||
local val
|
||||
val=$(cat "$1" | tr -d '[:space:]')
|
||||
case "$val" in
|
||||
success|failure|cancelled|skipped) echo "$val" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
echo "quality=$(validate_result "$DIR/quality_result")" >> "$GITHUB_OUTPUT"
|
||||
echo "unit=$(validate_result "$DIR/unit_result")" >> "$GITHUB_OUTPUT"
|
||||
echo "integration=$(validate_result "$DIR/integration_result")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout (for vitest config)
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
sparse-checkout: gitnexus/vitest.config.ts
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
# ── Merge coverage from unit + integration ─────────────────────
|
||||
- name: Setup Node.js
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install coverage merge tools
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
run: npm install --no-save istanbul-lib-coverage istanbul-lib-report istanbul-reports
|
||||
|
||||
- name: Merge coverage reports
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
id: coverage
|
||||
shell: bash
|
||||
run: |
|
||||
DIR="$RUNNER_TEMP/artifacts"
|
||||
UNIT_COV=$(find "$DIR/test-reports" -name "coverage-final.json" -type f 2>/dev/null | head -1)
|
||||
INTEG_COV=$(find "$DIR/integration-reports" -name "coverage-final.json" -type f 2>/dev/null | head -1)
|
||||
MERGED_DIR="$RUNNER_TEMP/merged-coverage"
|
||||
mkdir -p "$MERGED_DIR"
|
||||
|
||||
if [ -n "$UNIT_COV" ] && [ -n "$INTEG_COV" ]; then
|
||||
echo "has_merged=true" >> "$GITHUB_OUTPUT"
|
||||
# Merge using Node.js + istanbul-lib-coverage.
|
||||
# Paths are passed via env vars to avoid shell interpolation
|
||||
# inside the script string.
|
||||
UNIT_COV_PATH="$UNIT_COV" \
|
||||
INTEG_COV_PATH="$INTEG_COV" \
|
||||
MERGED_OUT_DIR="$MERGED_DIR" \
|
||||
node -e "
|
||||
const libCoverage = require('istanbul-lib-coverage');
|
||||
const libReport = require('istanbul-lib-report');
|
||||
const reports = require('istanbul-reports');
|
||||
const fs = require('fs');
|
||||
|
||||
const map = libCoverage.createCoverageMap({});
|
||||
map.merge(JSON.parse(fs.readFileSync(process.env.UNIT_COV_PATH, 'utf8')));
|
||||
map.merge(JSON.parse(fs.readFileSync(process.env.INTEG_COV_PATH, 'utf8')));
|
||||
|
||||
const context = libReport.createContext({
|
||||
coverageMap: map,
|
||||
dir: process.env.MERGED_OUT_DIR,
|
||||
});
|
||||
reports.create('json-summary').execute(context);
|
||||
console.log('Merged coverage written to ' + process.env.MERGED_OUT_DIR + '/coverage-summary.json');
|
||||
"
|
||||
elif [ -n "$UNIT_COV" ]; then
|
||||
echo "has_merged=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::Integration coverage not found — using unit coverage only"
|
||||
else
|
||||
echo "has_merged=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::No coverage data found"
|
||||
fi
|
||||
|
||||
- name: Build report
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
id: report
|
||||
shell: bash
|
||||
env:
|
||||
QUALITY: ${{ steps.meta.outputs.quality }}
|
||||
UNIT: ${{ steps.meta.outputs.unit }}
|
||||
INTEG: ${{ steps.meta.outputs.integration }}
|
||||
HAS_MERGED: ${{ steps.coverage.outputs.has_merged }}
|
||||
RUN_URL: ${{ github.event.workflow_run.html_url }}
|
||||
run: |
|
||||
DIR="$RUNNER_TEMP/artifacts"
|
||||
MERGED_DIR="$RUNNER_TEMP/merged-coverage"
|
||||
|
||||
# ── Helper: read coverage summary into prefixed vars ──
|
||||
# Uses printf -v for safe variable assignment (no eval).
|
||||
read_cov() {
|
||||
local prefix=$1 file=$2
|
||||
if [ -n "$file" ] && [ -f "$file" ]; then
|
||||
local val
|
||||
val=$(jq -r '.total.statements.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
|
||||
printf -v "${prefix}_STMTS" '%s' "$val"
|
||||
val=$(jq -r '.total.branches.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
|
||||
printf -v "${prefix}_BRANCH" '%s' "$val"
|
||||
val=$(jq -r '.total.functions.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
|
||||
printf -v "${prefix}_FUNCS" '%s' "$val"
|
||||
val=$(jq -r '.total.lines.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
|
||||
printf -v "${prefix}_LINES" '%s' "$val"
|
||||
val=$(jq -r '"\(.total.statements.covered)/\(.total.statements.total)"' "$file" 2>/dev/null) || val=""
|
||||
printf -v "${prefix}_STMTS_COV" '%s' "$val"
|
||||
val=$(jq -r '"\(.total.branches.covered)/\(.total.branches.total)"' "$file" 2>/dev/null) || val=""
|
||||
printf -v "${prefix}_BRANCH_COV" '%s' "$val"
|
||||
val=$(jq -r '"\(.total.functions.covered)/\(.total.functions.total)"' "$file" 2>/dev/null) || val=""
|
||||
printf -v "${prefix}_FUNCS_COV" '%s' "$val"
|
||||
val=$(jq -r '"\(.total.lines.covered)/\(.total.lines.total)"' "$file" 2>/dev/null) || val=""
|
||||
printf -v "${prefix}_LINES_COV" '%s' "$val"
|
||||
return 0
|
||||
else
|
||||
printf -v "${prefix}_STMTS" '%s' "N/A"
|
||||
printf -v "${prefix}_BRANCH" '%s' "N/A"
|
||||
printf -v "${prefix}_FUNCS" '%s' "N/A"
|
||||
printf -v "${prefix}_LINES" '%s' "N/A"
|
||||
printf -v "${prefix}_STMTS_COV" '%s' ""
|
||||
printf -v "${prefix}_BRANCH_COV" '%s' ""
|
||||
printf -v "${prefix}_FUNCS_COV" '%s' ""
|
||||
printf -v "${prefix}_LINES_COV" '%s' ""
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Read all three coverage reports ──
|
||||
UNIT_SUMMARY=$(find "$DIR/test-reports" -name "coverage-summary.json" -type f 2>/dev/null | head -1)
|
||||
INTEG_SUMMARY=$(find "$DIR/integration-reports" -name "coverage-summary.json" -type f 2>/dev/null | head -1)
|
||||
MERGED_SUMMARY="$MERGED_DIR/coverage-summary.json"
|
||||
|
||||
read_cov "U" "$UNIT_SUMMARY"
|
||||
HAS_UNIT=$?
|
||||
read_cov "I" "$INTEG_SUMMARY"
|
||||
HAS_INTEG=$?
|
||||
read_cov "M" "$MERGED_SUMMARY"
|
||||
|
||||
# ── Locate test results (unit) ──
|
||||
RESULTS_FILE=$(find "$DIR/test-reports" -name "test-results.json" -type f 2>/dev/null | head -1)
|
||||
INTEG_RESULTS=$(find "$DIR/integration-reports" -name "integration-results.json" -type f 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$RESULTS_FILE" ]; then
|
||||
U_TOTAL=$(jq -r '.numTotalTests' "$RESULTS_FILE" 2>/dev/null || echo 0)
|
||||
U_PASSED=$(jq -r '.numPassedTests' "$RESULTS_FILE" 2>/dev/null || echo 0)
|
||||
U_FAILED=$(jq -r '.numFailedTests' "$RESULTS_FILE" 2>/dev/null || echo 0)
|
||||
U_SKIPPED=$(jq -r '.numPendingTests' "$RESULTS_FILE" 2>/dev/null || echo 0)
|
||||
U_SUITES=$(jq -r '.numTotalTestSuites' "$RESULTS_FILE" 2>/dev/null || echo 0)
|
||||
U_DURATION=$(jq -r '((.testResults | map(.endTime) | max) - (.startTime)) / 1000 | floor' "$RESULTS_FILE" 2>/dev/null || echo 0)
|
||||
else
|
||||
U_TOTAL=0; U_PASSED=0; U_FAILED=0; U_SKIPPED=0; U_SUITES=0; U_DURATION=0
|
||||
fi
|
||||
|
||||
if [ -n "$INTEG_RESULTS" ]; then
|
||||
I_TOTAL=$(jq -r '.numTotalTests' "$INTEG_RESULTS" 2>/dev/null || echo 0)
|
||||
I_PASSED=$(jq -r '.numPassedTests' "$INTEG_RESULTS" 2>/dev/null || echo 0)
|
||||
I_FAILED=$(jq -r '.numFailedTests' "$INTEG_RESULTS" 2>/dev/null || echo 0)
|
||||
I_SKIPPED=$(jq -r '.numPendingTests' "$INTEG_RESULTS" 2>/dev/null || echo 0)
|
||||
I_SUITES=$(jq -r '.numTotalTestSuites' "$INTEG_RESULTS" 2>/dev/null || echo 0)
|
||||
I_DURATION=$(jq -r '((.testResults | map(.endTime) | max) - (.startTime)) / 1000 | floor' "$INTEG_RESULTS" 2>/dev/null || echo 0)
|
||||
else
|
||||
I_TOTAL=0; I_PASSED=0; I_FAILED=0; I_SKIPPED=0; I_SUITES=0; I_DURATION=0
|
||||
fi
|
||||
|
||||
# ── Sum test results ──
|
||||
TOTAL=$((U_TOTAL + I_TOTAL))
|
||||
PASSED=$((U_PASSED + I_PASSED))
|
||||
FAILED=$((U_FAILED + I_FAILED))
|
||||
SKIPPED=$((U_SKIPPED + I_SKIPPED))
|
||||
SUITES=$((U_SUITES + I_SUITES))
|
||||
DURATION=$((U_DURATION + I_DURATION))
|
||||
|
||||
# ── Coverage thresholds (read from vitest.config.ts) ──
|
||||
if [ -f gitnexus/vitest.config.ts ]; then
|
||||
THRESH_STMTS=$(grep -oP 'statements:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0)
|
||||
THRESH_BRANCH=$(grep -oP 'branches:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0)
|
||||
THRESH_FUNCS=$(grep -oP 'functions:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0)
|
||||
THRESH_LINES=$(grep -oP 'lines:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0)
|
||||
else
|
||||
THRESH_STMTS=0; THRESH_BRANCH=0; THRESH_FUNCS=0; THRESH_LINES=0
|
||||
fi
|
||||
|
||||
# ── Status helpers ──
|
||||
status_icon() {
|
||||
case "$1" in
|
||||
success) echo "✅" ;;
|
||||
failure) echo "❌" ;;
|
||||
cancelled) echo "⏭️" ;;
|
||||
*) echo "❓" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cov_bar() {
|
||||
local pct=$1 thresh=$2
|
||||
if [ "$pct" = "N/A" ]; then echo "—"; return; fi
|
||||
local filled
|
||||
filled=$(awk "BEGIN { printf \"%d\", $pct / 5 }")
|
||||
(( filled < 0 )) && filled=0
|
||||
(( filled > 20 )) && filled=20
|
||||
local empty=$((20 - filled))
|
||||
local bar=""
|
||||
for ((i=0; i<filled; i++)); do bar+="█"; done
|
||||
for ((i=0; i<empty; i++)); do bar+="░"; done
|
||||
if [ "$(awk "BEGIN { print ($pct >= $thresh) ? 1 : 0 }")" = "1" ]; then
|
||||
echo "🟢 ${bar}"
|
||||
else
|
||||
echo "🔴 ${bar}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Overall status ──
|
||||
if [[ "$QUALITY" == "success" && "$UNIT" == "success" && "$INTEG" == "success" ]]; then
|
||||
OVERALL="✅ **All checks passed**"
|
||||
else
|
||||
OVERALL="❌ **Some checks failed**"
|
||||
fi
|
||||
|
||||
# ── Build markdown ──
|
||||
{
|
||||
echo "body<<GITNEXUS_CI_REPORT_EOF_7f3a"
|
||||
echo "## CI Report"
|
||||
echo ""
|
||||
echo "${OVERALL}"
|
||||
echo ""
|
||||
echo "### Pipeline Status"
|
||||
echo ""
|
||||
echo "| Stage | Status | Details |"
|
||||
echo "|-------|--------|---------|"
|
||||
echo "| $(status_icon "$QUALITY") Typecheck | \`${QUALITY}\` | tsc --noEmit |"
|
||||
echo "| $(status_icon "$UNIT") Unit Tests | \`${UNIT}\` | 3 platforms |"
|
||||
echo "| $(status_icon "$INTEG") Integration | \`${INTEG}\` | 3 OS x 4 groups = 12 jobs |"
|
||||
echo ""
|
||||
|
||||
if [ "$TOTAL" -gt 0 ] 2>/dev/null; then
|
||||
echo "### Test Results"
|
||||
echo ""
|
||||
if [ "$FAILED" = "0" ]; then
|
||||
echo "✅ **${PASSED}** passed"
|
||||
else
|
||||
echo "❌ **${FAILED}** failed / **${PASSED}** passed"
|
||||
fi
|
||||
if [ "$SKIPPED" != "0" ]; then
|
||||
echo " · ${SKIPPED} skipped"
|
||||
fi
|
||||
echo " · ${SUITES} suites · ${TOTAL} total"
|
||||
echo " · ⏱️ ${DURATION}s"
|
||||
if [ "$I_TOTAL" -gt 0 ] 2>/dev/null; then
|
||||
echo " · 📊 ${U_TOTAL} unit + ${I_TOTAL} integration"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── Coverage table helper ──
|
||||
cov_table() {
|
||||
local label=$1 s=$2 b=$3 f=$4 l=$5 sc=$6 bc=$7 fc=$8 lc=$9
|
||||
shift 9
|
||||
local ts=$1 tb=$2 tf=$3 tl=$4
|
||||
echo "#### ${label}"
|
||||
echo ""
|
||||
echo "| Metric | Coverage | Covered | Threshold | Status |"
|
||||
echo "|--------|----------|---------|-----------|--------|"
|
||||
echo "| Statements | **${s}%** | ${sc} | ${ts}% | $(cov_bar "$s" "$ts") |"
|
||||
echo "| Branches | **${b}%** | ${bc} | ${tb}% | $(cov_bar "$b" "$tb") |"
|
||||
echo "| Functions | **${f}%** | ${fc} | ${tf}% | $(cov_bar "$f" "$tf") |"
|
||||
echo "| Lines | **${l}%** | ${lc} | ${tl}% | $(cov_bar "$l" "$tl") |"
|
||||
echo ""
|
||||
}
|
||||
|
||||
if [ "$M_STMTS" != "N/A" ]; then
|
||||
echo "### Code Coverage"
|
||||
echo ""
|
||||
cov_table "Combined (Unit + Integration)" \
|
||||
"$M_STMTS" "$M_BRANCH" "$M_FUNCS" "$M_LINES" \
|
||||
"$M_STMTS_COV" "$M_BRANCH_COV" "$M_FUNCS_COV" "$M_LINES_COV" \
|
||||
"$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES"
|
||||
|
||||
echo "<details>"
|
||||
echo "<summary>Coverage breakdown by test suite</summary>"
|
||||
echo ""
|
||||
if [ "$U_STMTS" != "N/A" ]; then
|
||||
cov_table "Unit Tests" \
|
||||
"$U_STMTS" "$U_BRANCH" "$U_FUNCS" "$U_LINES" \
|
||||
"$U_STMTS_COV" "$U_BRANCH_COV" "$U_FUNCS_COV" "$U_LINES_COV" \
|
||||
"$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES"
|
||||
fi
|
||||
if [ "$I_STMTS" != "N/A" ]; then
|
||||
cov_table "Integration Tests" \
|
||||
"$I_STMTS" "$I_BRANCH" "$I_FUNCS" "$I_LINES" \
|
||||
"$I_STMTS_COV" "$I_BRANCH_COV" "$I_FUNCS_COV" "$I_LINES_COV" \
|
||||
"$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES"
|
||||
fi
|
||||
echo "</details>"
|
||||
echo ""
|
||||
echo "<details>"
|
||||
echo "<summary>Coverage thresholds are auto-ratcheted — they only go up</summary>"
|
||||
echo ""
|
||||
echo "Vitest \`thresholds.autoUpdate\` bumps the floor whenever local coverage exceeds it."
|
||||
echo "CI enforces the current thresholds; developers commit the ratcheted values."
|
||||
echo "</details>"
|
||||
echo ""
|
||||
elif [ "$U_STMTS" != "N/A" ]; then
|
||||
echo "### Code Coverage (Unit only)"
|
||||
echo ""
|
||||
cov_table "Unit Tests" \
|
||||
"$U_STMTS" "$U_BRANCH" "$U_FUNCS" "$U_LINES" \
|
||||
"$U_STMTS_COV" "$U_BRANCH_COV" "$U_FUNCS_COV" "$U_LINES_COV" \
|
||||
"$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES"
|
||||
echo "<details>"
|
||||
echo "<summary>Coverage thresholds are auto-ratcheted — they only go up</summary>"
|
||||
echo ""
|
||||
echo "Vitest \`thresholds.autoUpdate\` bumps the floor whenever local coverage exceeds it."
|
||||
echo "CI enforces the current thresholds; developers commit the ratcheted values."
|
||||
echo "</details>"
|
||||
echo ""
|
||||
else
|
||||
echo "### Code Coverage"
|
||||
echo ""
|
||||
echo "⚠️ Coverage data unavailable - check the [unit test job](${RUN_URL}) for details."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "<sub>📋 [View full run](${RUN_URL}) · Generated by CI</sub>"
|
||||
echo "GITNEXUS_CI_REPORT_EOF_7f3a"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment on PR
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2
|
||||
with:
|
||||
header: ci-report
|
||||
number: ${{ steps.meta.outputs.pr_number }}
|
||||
message: ${{ steps.report.outputs.body }}
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Unit Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
name: unit (ubuntu / coverage)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
run: >-
|
||||
npx vitest run test/unit
|
||||
--reporter=default
|
||||
--reporter=json
|
||||
--outputFile=test-results.json
|
||||
--coverage
|
||||
--coverage.reporter=json-summary
|
||||
--coverage.reporter=json
|
||||
--coverage.reporter=text
|
||||
--coverage.thresholdAutoUpdate=false
|
||||
--coverage.reportOnFailure=true
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: test-reports
|
||||
path: |
|
||||
gitnexus/coverage/coverage-summary.json
|
||||
gitnexus/coverage/coverage-final.json
|
||||
gitnexus/test-results.json
|
||||
retention-days: 5
|
||||
|
||||
cross-platform:
|
||||
name: unit (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Ubuntu already covered by the coverage job above
|
||||
os: [windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- run: npx vitest run test/unit
|
||||
working-directory: gitnexus
|
||||
+89
-10
@@ -1,20 +1,99 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
workflow_call:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# ── Reusable workflow orchestration ─────────────────────────────────
|
||||
# Each concern lives in its own workflow file for maintainability:
|
||||
# ci-quality.yml — typecheck (tsc --noEmit)
|
||||
# ci-unit-tests.yml — unit tests with coverage + cross-platform
|
||||
# ci-integration.yml — integration test matrix (3 OS x 4 groups)
|
||||
#
|
||||
# Shared setup is DRY via .github/actions/setup-gitnexus composite action.
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
quality:
|
||||
uses: ./.github/workflows/ci-quality.yml
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
unit-tests:
|
||||
uses: ./.github/workflows/ci-unit-tests.yml
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
integration:
|
||||
uses: ./.github/workflows/ci-integration.yml
|
||||
with:
|
||||
collect-coverage: ${{ github.event_name == 'pull_request' }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# ── Save PR metadata for the reporting workflow ─────────────────
|
||||
# The ci-report.yml workflow (triggered by workflow_run) needs the
|
||||
# PR number and job results to post a comment. We save them as an
|
||||
# artifact because workflow_run context doesn't reliably carry PR
|
||||
# info for fork PRs.
|
||||
save-pr-meta:
|
||||
name: Save PR Metadata
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
needs: [quality, unit-tests, integration]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Write metadata
|
||||
shell: bash
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
QUALITY: ${{ needs.quality.result }}
|
||||
UNIT: ${{ needs.unit-tests.result }}
|
||||
INTEG: ${{ needs.integration.result }}
|
||||
run: |
|
||||
mkdir -p pr-meta
|
||||
echo "$PR_NUMBER" > pr-meta/pr_number
|
||||
echo "$QUALITY" > pr-meta/quality_result
|
||||
echo "$UNIT" > pr-meta/unit_result
|
||||
echo "$INTEG" > pr-meta/integration_result
|
||||
|
||||
- name: Upload PR metadata
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: gitnexus
|
||||
- run: npx tsc --noEmit
|
||||
working-directory: gitnexus
|
||||
name: pr-meta
|
||||
path: pr-meta/
|
||||
retention-days: 1
|
||||
|
||||
# ── Unified CI gate ──────────────────────────────────────────────
|
||||
# Single required check for branch protection.
|
||||
ci-status:
|
||||
name: CI Gate
|
||||
needs: [quality, unit-tests, integration]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check all jobs passed
|
||||
shell: bash
|
||||
env:
|
||||
QUALITY: ${{ needs.quality.result }}
|
||||
UNIT: ${{ needs.unit-tests.result }}
|
||||
INTEG: ${{ needs.integration.result }}
|
||||
run: |
|
||||
echo "Quality: $QUALITY"
|
||||
echo "Unit Tests: $UNIT"
|
||||
echo "Integration: $INTEG"
|
||||
if [[ "$QUALITY" != "success" ]] ||
|
||||
[[ "$UNIT" != "success" ]] ||
|
||||
[[ "$INTEG" != "success" ]]; then
|
||||
echo "::error::One or more CI jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,44 +1,97 @@
|
||||
name: Claude Code Review
|
||||
|
||||
# Uses pull_request_target so the workflow runs as defined on the default branch,
|
||||
# which allows access to secrets for posting review comments on fork PRs.
|
||||
# SECURITY: The checkout below uses the PR head SHA to review the correct code.
|
||||
# The claude-code-action sandboxes execution — it does NOT run arbitrary code
|
||||
# from the checked-out source.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
# Trigger only when explicitly requested:
|
||||
# - Add the "claude-review" label to a PR, OR
|
||||
# - Comment "@claude" or "/review" on a PR
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
# Run only when:
|
||||
# 1. The "claude-review" label is added to a non-draft PR by a trusted contributor, OR
|
||||
# 2. A trusted contributor comments "@claude" or "/review" on a PR
|
||||
if: |
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.label.name == 'claude-review' &&
|
||||
github.event.pull_request.draft == false &&
|
||||
(github.event.pull_request.author_association == 'OWNER' ||
|
||||
github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
(contains(github.event.comment.body, '@claude') ||
|
||||
contains(github.event.comment.body, '/review')) &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
contents: write # needed to push fork branch to origin
|
||||
pull-requests: write
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
# For issue_comment triggers, resolve the PR number, head SHA, and branch name
|
||||
- name: Resolve PR context
|
||||
id: pr
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
let pr;
|
||||
if (context.eventName === 'issue_comment') {
|
||||
const resp = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.issue.number,
|
||||
});
|
||||
pr = resp.data;
|
||||
} else {
|
||||
pr = context.payload.pull_request;
|
||||
}
|
||||
core.setOutput('number', pr.number);
|
||||
core.setOutput('sha', pr.head.sha);
|
||||
core.setOutput('branch', pr.head.ref);
|
||||
core.setOutput('is_fork', String(pr.head.repo.full_name !== pr.base.repo.full_name));
|
||||
|
||||
- name: Checkout PR head
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
# claude-code-action fetches branches by name from origin, which fails
|
||||
# for fork PRs. Work around by pushing the fork branch to origin so
|
||||
# the action can find it. Cleaned up in the post step below.
|
||||
- name: Push fork branch to origin
|
||||
if: steps.pr.outputs.is_fork == 'true'
|
||||
run: git push origin HEAD:refs/heads/${{ steps.pr.outputs.branch }}
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
|
||||
|
||||
# Clean up the temporary branch we pushed for fork PRs
|
||||
- name: Delete fork branch from origin
|
||||
if: always() && steps.pr.outputs.is_fork == 'true'
|
||||
run: git push origin --delete refs/heads/${{ steps.pr.outputs.branch }} || true
|
||||
|
||||
@@ -18,33 +18,25 @@ jobs:
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
|
||||
@@ -5,14 +5,25 @@ on:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
# No workflow-level permissions — scoped per job below.
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
publish:
|
||||
needs: ci
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org
|
||||
@@ -20,9 +31,38 @@ jobs:
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: gitnexus
|
||||
- run: npx tsc --noEmit
|
||||
|
||||
- name: Verify version consistency
|
||||
shell: bash
|
||||
run: |
|
||||
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
||||
echo "::error::Tag does not follow semver: v$TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||||
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
|
||||
echo "::error::Tag version (v$TAG_VERSION) does not match package.json version ($PKG_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version verified: $PKG_VERSION"
|
||||
working-directory: gitnexus
|
||||
- run: npm publish
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Dry-run publish
|
||||
run: npm publish --dry-run
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance --access public
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
+16
@@ -17,6 +17,8 @@ dist/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.claude/settings.local.json
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
@@ -41,9 +43,23 @@ coverage/
|
||||
|
||||
.env*.local
|
||||
.gitnexus
|
||||
.claude/settings.local.json
|
||||
|
||||
# Claude Code worktrees
|
||||
.claude/worktrees/
|
||||
|
||||
# Claude code skills
|
||||
.claude/skills/generated/
|
||||
|
||||
# Assets (screenshots, images)
|
||||
assets/
|
||||
|
||||
# Generated files (should not be indexed)
|
||||
repomix-output*
|
||||
|
||||
# Design docs (local only)
|
||||
docs/plans/
|
||||
|
||||
gitnexus/test/fixtures/mini-repo/*.md
|
||||
gitnexus/test/fixtures/mini-repo/.claude
|
||||
gitnexus/test/fixtures/mini-repo/.gitignore
|
||||
@@ -1,64 +1,103 @@
|
||||
# AI Agent Rules
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus MCP
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitnexusV2** (1309 symbols, 3350 relationships, 101 execution flows).
|
||||
This project is indexed by GitNexus as **GitNexus** (1747 symbols, 4569 relationships, 130 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search.
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Start Here
|
||||
## Always Do
|
||||
|
||||
For any task involving code understanding, debugging, impact analysis, or refactoring, you must:
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
|
||||
2. **Match your task to a skill below** and **read that skill file**
|
||||
3. **Follow the skill's workflow and checklist**
|
||||
## When Debugging
|
||||
|
||||
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## Skills
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/refactoring/SKILL.md` |
|
||||
|
||||
## Tools Reference
|
||||
|
||||
| Tool | What it gives you |
|
||||
|------|-------------------|
|
||||
| `query` | Process-grouped code intelligence — execution flows related to a concept |
|
||||
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
|
||||
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||
| `detect_changes` | Git-diff impact — what do your current changes affect |
|
||||
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
|
||||
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
|
||||
| `list_repos` | Discover indexed repos |
|
||||
|
||||
## Resources Reference
|
||||
|
||||
Lightweight reads (~100-500 tokens) for navigation:
|
||||
|
||||
| Resource | Content |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness check |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
|
||||
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
|
||||
| `gitnexus://repo/{name}/processes` | All execution flows |
|
||||
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
|
||||
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
|
||||
|
||||
## Graph Schema
|
||||
|
||||
**Nodes:** File, Function, Class, Interface, Method, Community, Process
|
||||
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
|
||||
RETURN caller.name, caller.filePath
|
||||
```
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
| Work in the Ingestion area (135 symbols) | `.claude/skills/generated/ingestion/SKILL.md` |
|
||||
| Work in the Workers area (70 symbols) | `.claude/skills/generated/workers/SKILL.md` |
|
||||
| Work in the Cli area (63 symbols) | `.claude/skills/generated/cli/SKILL.md` |
|
||||
| Work in the Kuzu area (52 symbols) | `.claude/skills/generated/kuzu/SKILL.md` |
|
||||
| Work in the Wiki area (52 symbols) | `.claude/skills/generated/wiki/SKILL.md` |
|
||||
| Work in the Embeddings area (48 symbols) | `.claude/skills/generated/embeddings/SKILL.md` |
|
||||
| Work in the Components area (42 symbols) | `.claude/skills/generated/components/SKILL.md` |
|
||||
| Work in the Local area (36 symbols) | `.claude/skills/generated/local/SKILL.md` |
|
||||
| Work in the Storage area (36 symbols) | `.claude/skills/generated/storage/SKILL.md` |
|
||||
| Work in the Services area (35 symbols) | `.claude/skills/generated/services/SKILL.md` |
|
||||
| Work in the Mcp area (32 symbols) | `.claude/skills/generated/mcp/SKILL.md` |
|
||||
| Work in the Llm area (30 symbols) | `.claude/skills/generated/llm/SKILL.md` |
|
||||
| Work in the Eval area (18 symbols) | `.claude/skills/generated/eval/SKILL.md` |
|
||||
| Work in the Bridge area (15 symbols) | `.claude/skills/generated/bridge/SKILL.md` |
|
||||
| Work in the Hooks area (14 symbols) | `.claude/skills/generated/hooks/SKILL.md` |
|
||||
| Work in the Search area (11 symbols) | `.claude/skills/generated/search/SKILL.md` |
|
||||
| Work in the Environments area (11 symbols) | `.claude/skills/generated/environments/SKILL.md` |
|
||||
| Work in the Analysis area (10 symbols) | `.claude/skills/generated/analysis/SKILL.md` |
|
||||
| Work in the Agents area (9 symbols) | `.claude/skills/generated/agents/SKILL.md` |
|
||||
| Work in the Graph area (6 symbols) | `.claude/skills/generated/graph/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to GitNexus will be documented in this file.
|
||||
|
||||
## [1.4.0] - 2026-03-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Language-aware symbol resolution engine** with 3-tier resolver: exact FQN → scope-walk → guarded fuzzy fallback that refuses ambiguous matches (#238) — @magyargergo
|
||||
- **Method Resolution Order (MRO)** with 5 language-specific strategies: C++ leftmost-base, C#/Java class-over-interface, Python C3 linearization, Rust qualified syntax, default BFS (#238) — @magyargergo
|
||||
- **Constructor & struct literal resolution** across all languages — `new Foo()`, `User{...}`, C# primary constructors, target-typed new (#238) — @magyargergo
|
||||
- **Receiver-constrained resolution** using per-file TypeEnv — disambiguates `user.save()` vs `repo.save()` via `ownerId` matching (#238) — @magyargergo
|
||||
- **Heritage & ownership edges** — HAS_METHOD, OVERRIDES, Go struct embedding, Swift extension heritage, method signatures (`parameterCount`, `returnType`) (#238) — @magyargergo
|
||||
- **Language-specific resolver directory** (`resolvers/`) — extracted JVM, Go, C#, PHP, Rust resolvers from monolithic import-processor (#238) — @magyargergo
|
||||
- **Type extractor directory** (`type-extractors/`) — per-language type binding extraction with `Record<SupportedLanguages, Handler>` + `satisfies` dispatch (#238) — @magyargergo
|
||||
- **Export detection dispatch table** — compile-time exhaustive `Record` + `satisfies` pattern replacing switch/if chains (#238) — @magyargergo
|
||||
- **Language config module** (`language-config.ts`) — centralized tsconfig, go.mod, composer.json, .csproj, Swift package config loaders (#238) — @magyargergo
|
||||
- **Optional skill generation** via `npx gitnexus analyze --skills` — generates AI agent skills from KuzuDB knowledge graph (#171) — @zander-raycraft
|
||||
- **First-class C# support** — sibling-based modifier scanning, record/delegate/property/field/event declaration types (#163, #170, #178 via #237) — @Alice523, @benny-yamagata, @jnMetaCode
|
||||
- **C/C++ support fixes** — `.h` → C++ mapping, static-linkage export detection, qualified/parenthesized declarators, 48 entry point patterns (#163, #227 via #237) — @Alice523, @bitgineer
|
||||
- **Rust support fixes** — sibling-based `visibility_modifier` scanning for `pub` detection (#227 via #237) — @bitgineer
|
||||
- **Adaptive tree-sitter buffer sizing** — `Math.min(Math.max(contentLength * 2, 512KB), 32MB)` (#216 via #237) — @JasonOA888
|
||||
- **Call expression matching** in tree-sitter queries (#234 via #237) — @ex-nihilo-jg
|
||||
- **DeepSeek model configurations** (#217) — @JasonOA888
|
||||
- 282+ new unit tests, 178 integration resolver tests across 9 languages, 53 test files, 1146 total tests passing
|
||||
|
||||
### Fixed
|
||||
|
||||
- Skip unavailable native Swift parsers in sequential ingestion (#188) — @Gujiassh
|
||||
- Heritage heuristic language-gated — no longer applies class/interface rules to wrong languages (#238) — @magyargergo
|
||||
- C# `base_list` distinguishes EXTENDS vs IMPLEMENTS via symbol table + `I[A-Z]` heuristic (#238) — @magyargergo
|
||||
- Go `qualified_type` (`models.User`) correctly unwrapped in TypeEnv (#238) — @magyargergo
|
||||
- Global tier no longer blocks resolution when kind/arity filtering can narrow to 1 candidate (#238) — @magyargergo
|
||||
|
||||
### Changed
|
||||
|
||||
- `import-processor.ts` reduced from 1412 → 711 lines (50% reduction) via resolver and config extraction (#238) — @magyargergo
|
||||
- `type-env.ts` reduced from 635 → ~125 lines via type-extractor extraction (#238) — @magyargergo
|
||||
- CI/CD workflows hardened with security fixes and fork PR support (#222, #225) — @magyargergo
|
||||
|
||||
## [1.3.11] - 2026-03-08
|
||||
|
||||
### Security
|
||||
|
||||
- Fix FTS Cypher injection by escaping backslashes in search queries (#209) — @magyargergo
|
||||
|
||||
### Added
|
||||
|
||||
- Auto-reindex hook that runs `gitnexus analyze` after commits and merges, with automatic embeddings preservation (#205) — @L1nusB
|
||||
- 968 integration tests (up from ~840) covering unhappy paths across search, enrichment, CLI, pipeline, worker pool, and KuzuDB (#209) — @magyargergo
|
||||
- Coverage auto-ratcheting so thresholds bump automatically on CI (#209) — @magyargergo
|
||||
- Rich CI PR report with coverage bars, test counts, and threshold tracking (#209) — @magyargergo
|
||||
- Modular CI workflow architecture with separate unit-test, integration-test, and orchestrator jobs (#209) — @magyargergo
|
||||
|
||||
### Fixed
|
||||
|
||||
- KuzuDB native addon crashes on Linux/macOS by running integration tests in isolated vitest processes with `--pool=forks` (#209) — @magyargergo
|
||||
- Worker pool `MODULE_NOT_FOUND` crash when script path is invalid (#209) — @magyargergo
|
||||
|
||||
### Changed
|
||||
|
||||
- Added macOS to the cross-platform CI test matrix (#208) — @magyargergo
|
||||
|
||||
## [1.3.10] - 2026-03-07
|
||||
|
||||
### Security
|
||||
|
||||
- **MCP transport buffer cap**: Added 10 MB `MAX_BUFFER_SIZE` limit to prevent out-of-memory attacks via oversized `Content-Length` headers or unbounded newline-delimited input
|
||||
- **Content-Length validation**: Reject `Content-Length` values exceeding the buffer cap before allocating memory
|
||||
- **Stack overflow prevention**: Replaced recursive `readNewlineMessage` with iterative loop to prevent stack overflow from consecutive empty lines
|
||||
- **Ambiguous prefix hardening**: Tightened `looksLikeContentLength` to require 14+ bytes before matching, preventing false framing detection on short input
|
||||
- **Closed transport guard**: `send()` now rejects with a clear error when called after `close()`, with proper write-error propagation
|
||||
|
||||
### Added
|
||||
|
||||
- **Dual-framing MCP transport** (`CompatibleStdioServerTransport`): Auto-detects Content-Length (Codex/OpenCode) and newline-delimited JSON (Cursor/Claude Code) framing on the first message, responds in the same format (#207)
|
||||
- **Lazy CLI module loading**: All CLI subcommands now use `createLazyAction()` to defer heavy imports (tree-sitter, ONNX, KuzuDB) until invocation, significantly improving `gitnexus mcp` startup time (#207)
|
||||
- **Type-safe lazy actions**: `createLazyAction` uses constrained generics to validate export names against module types at compile time
|
||||
- **Regression test suite**: 13 unit tests covering transport framing, security hardening, buffer limits, and lazy action loading
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CALLS edge sourceId alignment**: `findEnclosingFunctionId` now generates IDs with `:startLine` suffix matching node creation format, fixing process detector finding 0 entry points (#194)
|
||||
- **LRU cache zero maxSize crash**: Guard `createASTCache` against `maxSize=0` when repos have no parseable files (#144)
|
||||
|
||||
### Changed
|
||||
|
||||
- Transport constructor accepts `NodeJS.ReadableStream` / `NodeJS.WritableStream` (widened from concrete `ReadStream`/`WriteStream`)
|
||||
- `processReadBuffer` simplified to break on first error instead of stale-buffer retry loop
|
||||
|
||||
## [1.3.9] - 2026-03-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- Aligned CALLS edge sourceId with node ID format in parse worker (#194)
|
||||
|
||||
## [1.3.8] - 2026-03-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Force-exit after analyze to prevent KuzuDB native cleanup hang (#192)
|
||||
@@ -1,62 +1,103 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus MCP
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitnexusV2** (1309 symbols, 3350 relationships, 101 execution flows).
|
||||
This project is indexed by GitNexus as **GitNexus** (1747 symbols, 4569 relationships, 130 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search.
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Start Here
|
||||
## Always Do
|
||||
|
||||
For any task involving code understanding, debugging, impact analysis, or refactoring, you must:
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
|
||||
2. **Match your task to a skill below** and **read that skill file**
|
||||
3. **Follow the skill's workflow and checklist**
|
||||
## When Debugging
|
||||
|
||||
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## Skills
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/refactoring/SKILL.md` |
|
||||
|
||||
## Tools Reference
|
||||
|
||||
| Tool | What it gives you |
|
||||
|------|-------------------|
|
||||
| `query` | Process-grouped code intelligence — execution flows related to a concept |
|
||||
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
|
||||
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||
| `detect_changes` | Git-diff impact — what do your current changes affect |
|
||||
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
|
||||
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
|
||||
| `list_repos` | Discover indexed repos |
|
||||
|
||||
## Resources Reference
|
||||
|
||||
Lightweight reads (~100-500 tokens) for navigation:
|
||||
|
||||
| Resource | Content |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness check |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
|
||||
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
|
||||
| `gitnexus://repo/{name}/processes` | All execution flows |
|
||||
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
|
||||
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
|
||||
|
||||
## Graph Schema
|
||||
|
||||
**Nodes:** File, Function, Class, Interface, Method, Community, Process
|
||||
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
|
||||
RETURN caller.name, caller.filePath
|
||||
```
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
| Work in the Ingestion area (135 symbols) | `.claude/skills/generated/ingestion/SKILL.md` |
|
||||
| Work in the Workers area (70 symbols) | `.claude/skills/generated/workers/SKILL.md` |
|
||||
| Work in the Cli area (63 symbols) | `.claude/skills/generated/cli/SKILL.md` |
|
||||
| Work in the Kuzu area (52 symbols) | `.claude/skills/generated/kuzu/SKILL.md` |
|
||||
| Work in the Wiki area (52 symbols) | `.claude/skills/generated/wiki/SKILL.md` |
|
||||
| Work in the Embeddings area (48 symbols) | `.claude/skills/generated/embeddings/SKILL.md` |
|
||||
| Work in the Components area (42 symbols) | `.claude/skills/generated/components/SKILL.md` |
|
||||
| Work in the Local area (36 symbols) | `.claude/skills/generated/local/SKILL.md` |
|
||||
| Work in the Storage area (36 symbols) | `.claude/skills/generated/storage/SKILL.md` |
|
||||
| Work in the Services area (35 symbols) | `.claude/skills/generated/services/SKILL.md` |
|
||||
| Work in the Mcp area (32 symbols) | `.claude/skills/generated/mcp/SKILL.md` |
|
||||
| Work in the Llm area (30 symbols) | `.claude/skills/generated/llm/SKILL.md` |
|
||||
| Work in the Eval area (18 symbols) | `.claude/skills/generated/eval/SKILL.md` |
|
||||
| Work in the Bridge area (15 symbols) | `.claude/skills/generated/bridge/SKILL.md` |
|
||||
| Work in the Hooks area (14 symbols) | `.claude/skills/generated/hooks/SKILL.md` |
|
||||
| Work in the Search area (11 symbols) | `.claude/skills/generated/search/SKILL.md` |
|
||||
| Work in the Environments area (11 symbols) | `.claude/skills/generated/environments/SKILL.md` |
|
||||
| Work in the Analysis area (10 symbols) | `.claude/skills/generated/analysis/SKILL.md` |
|
||||
| Work in the Agents area (9 symbols) | `.claude/skills/generated/agents/SKILL.md` |
|
||||
| Work in the Graph area (6 symbols) | `.claude/skills/generated/graph/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
# GitNexus
|
||||
⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
|
||||
|
||||
<a href="https://trendshift.io/repositories/19809" target="_blank"><img src="https://trendshift.io/api/badge/repositories/19809" alt="abhigyanpatwari%2FGitNexus | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<div align="center">
|
||||
|
||||
**Building git for agent context.**
|
||||
<a href="https://trendshift.io/repositories/19809" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/19809" alt="abhigyanpatwari%2FGitNexus | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
|
||||
<h2>Join the official Discord to discuss ideas, issues etc!</h2>
|
||||
|
||||
<a href="https://discord.gg/AAsRVT6fGb">
|
||||
<img src="https://img.shields.io/discord/1477255801545429032?color=5865F2&logo=discord&logoColor=white" alt="Discord"/>
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/gitnexus">
|
||||
<img src="https://img.shields.io/npm/v/gitnexus.svg" alt="npm version"/>
|
||||
</a>
|
||||
<a href="https://polyformproject.org/licenses/noncommercial/1.0.0/">
|
||||
<img src="https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg" alt="License: PolyForm Noncommercial"/>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
**Building nervous system 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.
|
||||
|
||||
[](https://www.npmjs.com/package/gitnexus)
|
||||
[](https://polyformproject.org/licenses/noncommercial/1.0.0/)
|
||||
|
||||
|
||||
|
||||
@@ -32,12 +49,14 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
||||
| ----------------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| **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) |
|
||||
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
|
||||
| **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 |
|
||||
|
||||
> **Bridge mode:** `gitnexus serve` connects the two — the web UI auto-detects the local server and can browse all your CLI-indexed repos without re-uploading or re-indexing.
|
||||
|
||||
---
|
||||
|
||||
## CLI + MCP (recommended)
|
||||
@@ -63,12 +82,18 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
||||
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
| --------------------- | --- | ------ | -------------------- | -------------- |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
|
||||
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that automatically enrich grep/glob/bash calls with knowledge graph context.
|
||||
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that auto-reindex after commits.
|
||||
|
||||
### Community Integrations
|
||||
|
||||
| Agent | Install | Source |
|
||||
|-------|---------|--------|
|
||||
| [pi](https://pi.dev) | `pi install npm:pi-gitnexus` | [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) |
|
||||
|
||||
If you prefer manual configuration:
|
||||
|
||||
@@ -110,9 +135,12 @@ claude mcp add gitnexus -- npx -y gitnexus@latest mcp
|
||||
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 analyze --skills # Generate repo-specific skill files from detected communities
|
||||
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
|
||||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start HTTP server for web UI connection
|
||||
gitnexus serve # Start local HTTP server (multi-repo) 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
|
||||
@@ -164,6 +192,10 @@ gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
|
||||
- **Impact Analysis** — Analyze blast radius before changes
|
||||
- **Refactoring** — Plan safe refactors using dependency mapping
|
||||
|
||||
**Repo-specific skills** generated with `--skills`:
|
||||
|
||||
When you run `gitnexus analyze --skills`, GitNexus detects the functional areas of your codebase (via Leiden community detection) and generates a `SKILL.md` file for each one under `.claude/skills/generated/`. Each skill describes a module's key files, entry points, execution flows, and cross-area connections — so your AI agent gets targeted context for the exact area of code you're working in. Skills are regenerated on each `--skills` run to stay current with the codebase.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Repo MCP Architecture
|
||||
@@ -233,6 +265,8 @@ 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.
|
||||
|
||||
**Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically.
|
||||
|
||||
---
|
||||
|
||||
## The Problem GitNexus Solves
|
||||
@@ -293,7 +327,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
|
||||
|
||||
### Supported Languages
|
||||
|
||||
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust
|
||||
TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Rust, PHP, Swift
|
||||
|
||||
---
|
||||
|
||||
@@ -455,7 +489,7 @@ The wiki generator reads the indexed graph structure, groups files into modules
|
||||
|
||||
- [X] Wiki Generation, Multi-File Rename, Git-Diff Impact Analysis
|
||||
- [X] Process-Grouped Search, 360-Degree Context, Claude Code Hooks
|
||||
- [X] Multi-Repo MCP, Zero-Config Setup, 9 Language Support
|
||||
- [X] Multi-Repo MCP, Zero-Config Setup, 11 Language Support
|
||||
- [X] Community Detection, Process Detection, Confidence Scoring
|
||||
- [X] Hybrid Search, Vector Index
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -164,7 +165,7 @@ def run_swebench_evaluation(results_dir: Path, run_id: str, subset: str = "lite"
|
||||
try:
|
||||
eval_output = results_dir / run_id / "swebench_eval"
|
||||
cmd = [
|
||||
"python", "-m", "swebench.harness.run_evaluation",
|
||||
sys.executable, "-m", "swebench.harness.run_evaluation",
|
||||
"--dataset_name", dataset_mapping.get(subset, subset),
|
||||
"--predictions_path", str(preds_path),
|
||||
"--max_workers", "4",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Claude 3.5 Haiku — fast, cheap, good baseline
|
||||
# Claude Haiku 4.5 — fast, cheap, good baseline
|
||||
# Via OpenRouter (set OPENROUTER_API_KEY in .env)
|
||||
# To use Anthropic directly, change to: anthropic/claude-3-5-haiku-20241022
|
||||
model:
|
||||
model_name: "openrouter/anthropic/claude-3.5-haiku"
|
||||
model_name: "openrouter/anthropic/claude-haiku-4.5"
|
||||
cost_tracking: "ignore_errors"
|
||||
model_kwargs:
|
||||
max_tokens: 8192
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
model: deepseek-ai/deepseek-chat
|
||||
provider: openrouter
|
||||
cost:
|
||||
input: 0.14 # per 1M tokens
|
||||
output: 0.28 # per 1M tokens
|
||||
|
||||
# Native DeepSeek API (direct)
|
||||
api_key: null
|
||||
base_url: null
|
||||
|
||||
# For OpenRouter, uncomment below and comment out direct config above
|
||||
# api_key: \${OPENROUTER_API_KEY}
|
||||
# base_url: https://openrouter.ai/api/v1
|
||||
@@ -0,0 +1,15 @@
|
||||
model: deepseek-ai/DeepSeek-V3
|
||||
provider: openrouter
|
||||
cost:
|
||||
input: 0.27 # per 1M tokens
|
||||
output: 1.10 # per 1M tokens
|
||||
|
||||
# Native DeepSeek API (direct)
|
||||
# Get your API key at: https://platform.deepseek.com/
|
||||
# Or use OpenRouter with: OPENROUTER_API_KEY
|
||||
api_key: null
|
||||
base_url: null
|
||||
|
||||
# For OpenRouter, uncomment below and comment out direct config above
|
||||
# api_key: \${OPENROUTER_API_KEY}
|
||||
# base_url: https://openrouter.ai/api/v1
|
||||
@@ -0,0 +1,11 @@
|
||||
# MiniMax M2.5 — via OpenRouter (set OPENROUTER_API_KEY in .env)
|
||||
# Uses text-based model class because MiniMax doesn't support tool_calls natively.
|
||||
# The action_regex tells mini-swe-agent to parse ```bash blocks from responses.
|
||||
model:
|
||||
model_class: litellm_textbased
|
||||
model_name: "openrouter/minimax/minimax-m2.5"
|
||||
action_regex: "```(?:bash|mswea_bash_command)\\s*\\n(.*?)\\n```"
|
||||
cost_tracking: "ignore_errors"
|
||||
model_kwargs:
|
||||
max_tokens: 8192
|
||||
temperature: 0
|
||||
@@ -30,6 +30,10 @@ gitnexus-eval-analyze = "analysis.analyze_results:app"
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["agents", "environments", "analysis", "bridge"]
|
||||
extra-files = ["run_eval.py"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
|
||||
+10
-3
@@ -178,7 +178,7 @@ def process_instance(
|
||||
env_class_name = env_config.pop("environment_class", "docker")
|
||||
|
||||
if env_class_name == "eval.environments.gitnexus_docker.GitNexusDockerEnvironment":
|
||||
from eval.environments.gitnexus_docker import GitNexusDockerEnvironment
|
||||
from environments.gitnexus_docker import GitNexusDockerEnvironment
|
||||
env_config["image"] = get_swebench_docker_image(instance)
|
||||
env = GitNexusDockerEnvironment(**env_config)
|
||||
else:
|
||||
@@ -189,7 +189,7 @@ def process_instance(
|
||||
agent_config = dict(config.get("agent", {}))
|
||||
agent_class_name = agent_config.pop("agent_class", "eval.agents.gitnexus_agent.GitNexusAgent")
|
||||
|
||||
from eval.agents.gitnexus_agent import GitNexusAgent
|
||||
from agents.gitnexus_agent import GitNexusAgent
|
||||
traj_path = instance_dir / f"{instance_id}.traj.json"
|
||||
agent_config["output_path"] = traj_path
|
||||
agent = GitNexusAgent(model, env, **agent_config)
|
||||
@@ -199,11 +199,18 @@ def process_instance(
|
||||
info = agent.run(instance["problem_statement"])
|
||||
|
||||
result["exit_status"] = info.get("exit_status")
|
||||
result["submission"] = info.get("submission", "")
|
||||
result["cost"] = agent.cost
|
||||
result["n_calls"] = agent.n_calls
|
||||
result["gitnexus_metrics"] = agent.gitnexus_metrics.to_dict()
|
||||
|
||||
# Extract git diff patch from the container (SWE-bench needs the model_patch)
|
||||
try:
|
||||
patch_output = env.execute({"command": "cd /testbed && git diff"})
|
||||
result["submission"] = patch_output.get("output", "").strip()
|
||||
except Exception as patch_err:
|
||||
logger.warning(f"[{run_id}] Failed to extract patch: {patch_err}")
|
||||
result["submission"] = info.get("submission", "")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{run_id}] Error on {instance_id}: {e}")
|
||||
result["exit_status"] = type(e).__name__
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.6",
|
||||
"author": {
|
||||
"name": "GitNexus"
|
||||
},
|
||||
"homepage": "https://github.com/nicosxt/gitnexus",
|
||||
"repository": "https://github.com/nicosxt/gitnexus"
|
||||
"homepage": "https://github.com/abhigyanpatwari/GitNexus",
|
||||
"repository": "https://github.com/abhigyanpatwari/GitNexus",
|
||||
"keywords": ["code-intelligence", "knowledge-graph", "mcp", "static-analysis"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* GitNexus Claude Code Hook
|
||||
* GitNexus Claude Code Plugin Hook
|
||||
*
|
||||
* PreToolUse handler — intercepts Grep/Glob/Bash searches
|
||||
* and augments with graph context from the GitNexus index.
|
||||
* PreToolUse — intercepts Grep/Glob/Bash searches and augments
|
||||
* with graph context from the GitNexus index.
|
||||
* PostToolUse — detects stale index after git mutations and notifies
|
||||
* the agent to reindex.
|
||||
*
|
||||
* NOTE: SessionStart hooks are broken on Windows (Claude Code bug).
|
||||
* NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
|
||||
* Session context is injected via CLAUDE.md / skills instead.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
/**
|
||||
* Read JSON input from stdin synchronously.
|
||||
@@ -26,19 +28,19 @@ function readInput() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory (or ancestor) has a .gitnexus index.
|
||||
* Find the .gitnexus directory by walking up from startDir.
|
||||
* Returns the path to .gitnexus/ or null if not found.
|
||||
*/
|
||||
function findGitNexusIndex(startDir) {
|
||||
function findGitNexusDir(startDir) {
|
||||
let dir = startDir || process.cwd();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (fs.existsSync(path.join(dir, '.gitnexus'))) {
|
||||
return true;
|
||||
}
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,40 +85,146 @@ function extractPattern(toolName, toolInput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a gitnexus CLI command synchronously.
|
||||
* Detects binary on PATH once, then runs exactly once.
|
||||
*
|
||||
* SECURITY: Never use shell: true with user-controlled arguments.
|
||||
* On Windows, invoke gitnexus.cmd directly (no shell needed).
|
||||
*/
|
||||
function runGitNexusCli(args, cwd, timeout) {
|
||||
const isWin = process.platform === 'win32';
|
||||
|
||||
// Detect whether 'gitnexus' is on PATH (cheap check, no execution)
|
||||
let useDirectBinary = false;
|
||||
try {
|
||||
const which = spawnSync(
|
||||
isWin ? 'where' : 'which', ['gitnexus'],
|
||||
{ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
useDirectBinary = which.status === 0;
|
||||
} catch { /* not on PATH */ }
|
||||
|
||||
if (useDirectBinary) {
|
||||
return spawnSync(
|
||||
isWin ? 'gitnexus.cmd' : 'gitnexus', args,
|
||||
{ encoding: 'utf-8', timeout, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
}
|
||||
// npx fallback needs shell on Windows since npx is a .cmd script
|
||||
return spawnSync(
|
||||
isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args],
|
||||
{ encoding: 'utf-8', timeout: timeout + 5000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a hook response with additional context for the agent.
|
||||
*/
|
||||
function sendHookResponse(hookEventName, message) {
|
||||
console.log(JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName, additionalContext: message }
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* PreToolUse handler — augment searches with graph context.
|
||||
*/
|
||||
function handlePreToolUse(input) {
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
if (!findGitNexusDir(cwd)) return;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
|
||||
if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return;
|
||||
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
let result = '';
|
||||
try {
|
||||
const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000);
|
||||
if (!child.error && child.status === 0) {
|
||||
result = child.stderr || '';
|
||||
}
|
||||
} catch { /* graceful failure */ }
|
||||
|
||||
if (result && result.trim()) {
|
||||
sendHookResponse('PreToolUse', result.trim());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PostToolUse handler — detect index staleness after git mutations.
|
||||
*
|
||||
* Instead of spawning a full `gitnexus analyze` synchronously (which blocks
|
||||
* the agent for up to 120s and risks KuzuDB corruption on timeout), we do a
|
||||
* lightweight staleness check: compare `git rev-parse HEAD` against the
|
||||
* lastCommit stored in `.gitnexus/meta.json`. If they differ, notify the
|
||||
* agent so it can decide when to reindex.
|
||||
*/
|
||||
function handlePostToolUse(input) {
|
||||
const toolName = input.tool_name || '';
|
||||
if (toolName !== 'Bash') return;
|
||||
|
||||
const command = (input.tool_input || {}).command || '';
|
||||
if (!/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) return;
|
||||
|
||||
// Only proceed if the command succeeded
|
||||
const toolOutput = input.tool_output || {};
|
||||
if (toolOutput.exit_code !== undefined && toolOutput.exit_code !== 0) return;
|
||||
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
const gitNexusDir = findGitNexusDir(cwd);
|
||||
if (!gitNexusDir) return;
|
||||
|
||||
// Compare HEAD against last indexed commit — skip if unchanged
|
||||
let currentHead = '';
|
||||
try {
|
||||
const headResult = spawnSync('git', ['rev-parse', 'HEAD'], {
|
||||
encoding: 'utf-8', timeout: 3000, cwd, stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
currentHead = (headResult.stdout || '').trim();
|
||||
} catch { return; }
|
||||
|
||||
if (!currentHead) return;
|
||||
|
||||
let lastCommit = '';
|
||||
let hadEmbeddings = false;
|
||||
try {
|
||||
const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
|
||||
lastCommit = meta.lastCommit || '';
|
||||
hadEmbeddings = (meta.stats && meta.stats.embeddings > 0);
|
||||
} catch { /* no meta — treat as stale */ }
|
||||
|
||||
// If HEAD matches last indexed commit, no reindex needed
|
||||
if (currentHead && currentHead === lastCommit) return;
|
||||
|
||||
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
|
||||
sendHookResponse('PostToolUse',
|
||||
`GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
|
||||
`Run \`${analyzeCmd}\` to update the knowledge graph.`
|
||||
);
|
||||
}
|
||||
|
||||
// Dispatch map for hook events
|
||||
const handlers = {
|
||||
PreToolUse: handlePreToolUse,
|
||||
PostToolUse: handlePostToolUse,
|
||||
};
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const input = readInput();
|
||||
const hookEvent = input.hook_event_name || '';
|
||||
|
||||
if (hookEvent !== 'PreToolUse') return;
|
||||
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!findGitNexusIndex(cwd)) return;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
|
||||
if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return;
|
||||
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
const result = execFileSync(
|
||||
'gitnexus',
|
||||
['augment', pattern],
|
||||
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
|
||||
if (result && result.trim()) {
|
||||
console.log(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
additionalContext: result.trim()
|
||||
}
|
||||
}));
|
||||
const handler = handlers[input.hook_event_name || ''];
|
||||
if (handler) handler(input);
|
||||
} catch (err) {
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
} catch {
|
||||
// Graceful failure
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,19 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/gitnexus-hook.js",
|
||||
"timeout": 10,
|
||||
"statusMessage": "Checking GitNexus index freshness..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/bin/bash
|
||||
# GitNexus PreToolUse hook for Claude Code
|
||||
# Intercepts Grep/Glob/Bash searches and augments with graph context.
|
||||
# Receives JSON on stdin with { tool_name, tool_input, cwd, ... }
|
||||
# Returns JSON with additionalContext for graph-enriched results.
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
||||
CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null)
|
||||
|
||||
# Extract search pattern based on tool type
|
||||
PATTERN=""
|
||||
|
||||
case "$TOOL_NAME" in
|
||||
Grep)
|
||||
PATTERN=$(echo "$INPUT" | jq -r '.tool_input.pattern // empty' 2>/dev/null)
|
||||
;;
|
||||
Glob)
|
||||
# Glob patterns are file paths, not search terms — extract meaningful part
|
||||
RAW=$(echo "$INPUT" | jq -r '.tool_input.pattern // empty' 2>/dev/null)
|
||||
# Strip glob syntax to get the meaningful name (e.g., "**/*.ts" → skip, "auth*.ts" → "auth")
|
||||
PATTERN=$(echo "$RAW" | sed -n 's/.*[*\/]\([a-zA-Z][a-zA-Z0-9_-]*\).*/\1/p')
|
||||
;;
|
||||
Bash)
|
||||
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
||||
# Only augment grep/rg commands
|
||||
if echo "$CMD" | grep -qE '\brg\b|\bgrep\b'; then
|
||||
# Extract pattern from rg/grep
|
||||
if echo "$CMD" | grep -qE '\brg\b'; then
|
||||
PATTERN=$(echo "$CMD" | sed -n "s/.*\brg\s\+\(--[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
|
||||
elif echo "$CMD" | grep -qE '\bgrep\b'; then
|
||||
PATTERN=$(echo "$CMD" | sed -n "s/.*\bgrep\s\+\(-[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# Not a search tool — skip
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Skip if pattern too short or empty
|
||||
if [ -z "$PATTERN" ] || [ ${#PATTERN} -lt 3 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if we're in a GitNexus-indexed repo
|
||||
dir="${CWD:-$PWD}"
|
||||
found=false
|
||||
for i in 1 2 3 4 5; do
|
||||
if [ -d "$dir/.gitnexus" ]; then
|
||||
found=true
|
||||
break
|
||||
fi
|
||||
parent="$(dirname "$dir")"
|
||||
[ "$parent" = "$dir" ] && break
|
||||
dir="$parent"
|
||||
done
|
||||
|
||||
if [ "$found" = false ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run gitnexus augment — must be fast (<500ms target)
|
||||
RESULT=$(cd "$CWD" && npx -y gitnexus augment "$PATTERN" 2>/dev/null)
|
||||
|
||||
if [ -n "$RESULT" ]; then
|
||||
ESCAPED=$(echo "$RESULT" | jq -Rs .)
|
||||
jq -n --argjson ctx "$ESCAPED" '{
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
additionalContext: $ctx
|
||||
}
|
||||
}'
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
@@ -1,41 +0,0 @@
|
||||
// GitNexus SessionStart hook for Claude Code
|
||||
// Fires on session startup. Stdout is injected into Claude's context.
|
||||
// Checks if the current directory has a GitNexus index.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let dir = process.cwd();
|
||||
let found = false;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (fs.existsSync(path.join(dir, '.gitnexus'))) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stdout.write(`## GitNexus Code Intelligence
|
||||
|
||||
This codebase is indexed by GitNexus, providing a knowledge graph with execution flows, relationships, and semantic search.
|
||||
|
||||
**Available MCP Tools:**
|
||||
- \`query\` — Process-grouped code intelligence (execution flows related to a concept)
|
||||
- \`context\` — 360-degree symbol view (categorized refs, process participation)
|
||||
- \`impact\` — Blast radius analysis (what breaks if you change a symbol)
|
||||
- \`detect_changes\` — Git-diff impact analysis (what do your changes affect)
|
||||
- \`rename\` — Multi-file coordinated rename with confidence tags
|
||||
- \`cypher\` — Raw graph queries
|
||||
- \`list_repos\` — Discover indexed repos
|
||||
|
||||
**Quick Start:** READ \`gitnexus://repo/{name}/context\` for codebase overview, then use \`query\` to find execution flows.
|
||||
|
||||
**Resources:** \`gitnexus://repo/{name}/context\` (overview), \`/processes\` (execution flows), \`/schema\` (for Cypher)
|
||||
`);
|
||||
process.exit(0);
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/bin/bash
|
||||
# GitNexus SessionStart hook for Claude Code
|
||||
# Fires on session startup. Stdout is injected into Claude's context.
|
||||
# Checks if the current directory has a GitNexus index.
|
||||
|
||||
dir="$PWD"
|
||||
found=false
|
||||
for i in 1 2 3 4 5; do
|
||||
if [ -d "$dir/.gitnexus" ]; then
|
||||
found=true
|
||||
break
|
||||
fi
|
||||
parent="$(dirname "$dir")"
|
||||
[ "$parent" = "$dir" ] && break
|
||||
dir="$parent"
|
||||
done
|
||||
|
||||
if [ "$found" = false ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Inject GitNexus context — this stdout goes directly into Claude's context
|
||||
cat << 'EOF'
|
||||
## GitNexus Code Intelligence
|
||||
|
||||
This codebase is indexed by GitNexus, providing a knowledge graph with execution flows, relationships, and semantic search.
|
||||
|
||||
**Available MCP Tools:**
|
||||
- `query` — Process-grouped code intelligence (execution flows related to a concept)
|
||||
- `context` — 360-degree symbol view (categorized refs, process participation)
|
||||
- `impact` — Blast radius analysis (what breaks if you change a symbol)
|
||||
- `detect_changes` — Git-diff impact analysis (what do your changes affect)
|
||||
- `rename` — Multi-file coordinated rename with confidence tags
|
||||
- `cypher` — Raw graph queries
|
||||
- `list_repos` — Discover indexed repos
|
||||
|
||||
**Quick Start:** READ `gitnexus://repo/{name}/context` for codebase overview, then use `query` to find execution flows.
|
||||
|
||||
**Resources:** `gitnexus://repo/{name}/context` (overview), `/processes` (execution flows), `/schema` (for Cypher)
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: gitnexus-cli
|
||||
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
|
||||
---
|
||||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
All commands work via `npx` — no global install required.
|
||||
|
||||
## Commands
|
||||
|
||||
### analyze — Build or refresh the index
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--force` | Force full re-index even if up to date |
|
||||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
```bash
|
||||
npx gitnexus status
|
||||
```
|
||||
|
||||
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
|
||||
|
||||
### clean — Delete the index
|
||||
|
||||
```bash
|
||||
npx gitnexus clean
|
||||
```
|
||||
|
||||
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--force` | Skip confirmation prompt |
|
||||
| `--all` | Clean all indexed repos, not just the current one |
|
||||
|
||||
### wiki — Generate documentation from the graph
|
||||
|
||||
```bash
|
||||
npx gitnexus wiki
|
||||
```
|
||||
|
||||
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--force` | Force full regeneration |
|
||||
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
|
||||
| `--base-url <url>` | LLM API base URL |
|
||||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||
|
||||
### list — Show all indexed repos
|
||||
|
||||
```bash
|
||||
npx gitnexus list
|
||||
```
|
||||
|
||||
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
|
||||
|
||||
## After Indexing
|
||||
|
||||
1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
|
||||
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Not inside a git repository"**: Run from a directory inside a git repo
|
||||
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
|
||||
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-8
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: gitnexus-debugging
|
||||
description: Trace bugs through call chains using knowledge graph
|
||||
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
|
||||
---
|
||||
|
||||
# Debugging with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Why is this function failing?"
|
||||
- "Trace where this error comes from"
|
||||
- "Who calls this method?"
|
||||
@@ -37,17 +38,18 @@ description: Trace bugs through call chains using knowledge graph
|
||||
|
||||
## Debugging Patterns
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
|---------|-------------------|
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
| Symptom | GitNexus Approach |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find code related to error:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
@@ -55,6 +57,7 @@ gitnexus_query({query: "payment validation error"})
|
||||
```
|
||||
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
@@ -63,6 +66,7 @@ gitnexus_context({name: "validatePayment"})
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
RETURN [n IN nodes(path) | n.name] AS chain
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-7
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: gitnexus-exploring
|
||||
description: Navigate unfamiliar code using GitNexus knowledge graph
|
||||
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
|
||||
---
|
||||
|
||||
# Exploring Codebases with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "How does authentication work?"
|
||||
- "What's the project structure?"
|
||||
- "Show me the main components"
|
||||
@@ -37,16 +38,17 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | What you get |
|
||||
|----------|-------------|
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
| Resource | What you get |
|
||||
| --------------------------------------- | ------------------------------------------------------- |
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
@@ -54,6 +56,7 @@ gitnexus_query({query: "payment processing"})
|
||||
```
|
||||
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: gitnexus-guide
|
||||
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
|
||||
---
|
||||
|
||||
# GitNexus Guide
|
||||
|
||||
Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
|
||||
|
||||
## Always Start Here
|
||||
|
||||
For any task involving code understanding, debugging, impact analysis, or refactoring:
|
||||
|
||||
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
|
||||
2. **Match your task to a skill below** and **read that skill file**
|
||||
3. **Follow the skill's workflow and checklist**
|
||||
|
||||
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||
|
||||
## Skills
|
||||
|
||||
| Task | Skill to read |
|
||||
| -------------------------------------------- | ------------------- |
|
||||
| Understand architecture / "How does X work?" | `gitnexus-exploring` |
|
||||
| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
|
||||
| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
|
||||
| Rename / extract / split / refactor | `gitnexus-refactoring` |
|
||||
| Tools, resources, schema reference | `gitnexus-guide` (this file) |
|
||||
| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
|
||||
|
||||
## Tools Reference
|
||||
|
||||
| Tool | What it gives you |
|
||||
| ---------------- | ------------------------------------------------------------------------ |
|
||||
| `query` | Process-grouped code intelligence — execution flows related to a concept |
|
||||
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
|
||||
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||
| `detect_changes` | Git-diff impact — what do your current changes affect |
|
||||
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
|
||||
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
|
||||
| `list_repos` | Discover indexed repos |
|
||||
|
||||
## Resources Reference
|
||||
|
||||
Lightweight reads (~100-500 tokens) for navigation:
|
||||
|
||||
| Resource | Content |
|
||||
| ---------------------------------------------- | ----------------------------------------- |
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness check |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
|
||||
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
|
||||
| `gitnexus://repo/{name}/processes` | All execution flows |
|
||||
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
|
||||
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
|
||||
|
||||
## Graph Schema
|
||||
|
||||
**Nodes:** File, Function, Class, Interface, Method, Community, Process
|
||||
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
|
||||
RETURN caller.name, caller.filePath
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-11
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: gitnexus-impact-analysis
|
||||
description: Analyze blast radius before making code changes
|
||||
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
|
||||
---
|
||||
|
||||
# Impact Analysis with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Is it safe to change this function?"
|
||||
- "What will break if I modify X?"
|
||||
- "Show me the blast radius"
|
||||
@@ -37,24 +38,25 @@ description: Analyze blast radius before making code changes
|
||||
|
||||
## Understanding Output
|
||||
|
||||
| Depth | Risk Level | Meaning |
|
||||
|-------|-----------|---------|
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
| Depth | Risk Level | Meaning |
|
||||
| ----- | ---------------- | ------------------------ |
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Affected | Risk |
|
||||
|----------|------|
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Affected | Risk |
|
||||
| ------------------------------ | -------- |
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
|
||||
```
|
||||
gitnexus_impact({
|
||||
target: "validateUser",
|
||||
@@ -72,6 +74,7 @@ gitnexus_impact({
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: gitnexus-pr-review
|
||||
description: "Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
|
||||
---
|
||||
|
||||
# PR Review with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Review this PR"
|
||||
- "What does PR #42 change?"
|
||||
- "Is this safe to merge?"
|
||||
- "What's the blast radius of this PR?"
|
||||
- "Are there missing tests for this PR?"
|
||||
- Reviewing someone else's code changes before merge
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gh pr diff <number> → Get the raw diff
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
3. For each changed symbol:
|
||||
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
|
||||
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
6. Summarize findings with risk assessment
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
|
||||
- [ ] gitnexus_detect_changes to map changes to affected execution flows
|
||||
- [ ] gitnexus_impact on each non-trivial changed symbol
|
||||
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
|
||||
- [ ] gitnexus_context on key changed symbols to understand full picture
|
||||
- [ ] Check if affected processes have test coverage
|
||||
- [ ] Assess overall risk level
|
||||
- [ ] Write review summary with findings
|
||||
```
|
||||
|
||||
## Review Dimensions
|
||||
|
||||
| Dimension | How GitNexus Helps |
|
||||
| --- | --- |
|
||||
| **Correctness** | `context` shows callers — are they all compatible with the change? |
|
||||
| **Blast radius** | `impact` shows d=1/d=2/d=3 dependents — anything missed? |
|
||||
| **Completeness** | `detect_changes` shows all affected flows — are they all handled? |
|
||||
| **Test coverage** | `impact({includeTests: true})` shows which tests touch changed code |
|
||||
| **Breaking changes** | d=1 upstream items that aren't updated in the PR = potential breakage |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Signal | Risk |
|
||||
| --- | --- |
|
||||
| Changes touch <3 symbols, 0-1 processes | LOW |
|
||||
| Changes touch 3-10 symbols, 2-5 processes | MEDIUM |
|
||||
| Changes touch >10 symbols or many processes | HIGH |
|
||||
| Changes touch auth, payments, or data integrity code | CRITICAL |
|
||||
| d=1 callers exist outside the PR diff | Potential breakage — flag it |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_detect_changes** — map PR diff to affected execution flows:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
|
||||
→ Changed: 8 symbols in 4 files
|
||||
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**gitnexus_impact** — blast radius per changed symbol:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
|
||||
- webhookHandler (src/webhooks.ts:15) [CALLS, 100%]
|
||||
|
||||
→ d=2 (LIKELY AFFECTED):
|
||||
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**gitnexus_impact with tests** — check test coverage:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
|
||||
→ Tests that cover this symbol:
|
||||
- validatePayment.test.ts [direct]
|
||||
- checkout.integration.test.ts [via processCheckout]
|
||||
```
|
||||
|
||||
**gitnexus_context** — understand a changed symbol's role:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates
|
||||
→ Processes: CheckoutFlow (step 3/7), RefundFlow (step 1/5)
|
||||
```
|
||||
|
||||
## Example: "Review PR #42"
|
||||
|
||||
```
|
||||
1. gh pr diff 42 > /tmp/pr42.diff
|
||||
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
|
||||
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
→ Changed symbols: validatePayment, PaymentInput, formatAmount
|
||||
→ Affected processes: CheckoutFlow, RefundFlow
|
||||
→ Risk: MEDIUM
|
||||
|
||||
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
→ d=1: processCheckout, webhookHandler (WILL BREAK)
|
||||
→ webhookHandler is NOT in the PR diff — potential breakage!
|
||||
|
||||
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
|
||||
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
|
||||
→ createPayment uses the old PaymentInput shape — breaking change!
|
||||
|
||||
5. gitnexus_context({name: "formatAmount"})
|
||||
→ Called by 12 functions — but change is backwards-compatible (added optional param)
|
||||
|
||||
6. Review summary:
|
||||
- MEDIUM risk — 3 changed symbols affect 2 execution flows
|
||||
- BUG: webhookHandler calls validatePayment but isn't updated for new signature
|
||||
- BUG: createPayment depends on PaymentInput type which changed
|
||||
- OK: formatAmount change is backwards-compatible
|
||||
- Tests: checkout.test.ts covers processCheckout path, but no webhook test
|
||||
```
|
||||
|
||||
## Review Output Format
|
||||
|
||||
Structure your review as:
|
||||
|
||||
```markdown
|
||||
## PR Review: <title>
|
||||
|
||||
**Risk: LOW / MEDIUM / HIGH / CRITICAL**
|
||||
|
||||
### Changes Summary
|
||||
- <N> symbols changed across <M> files
|
||||
- <P> execution flows affected
|
||||
|
||||
### Findings
|
||||
1. **[severity]** Description of finding
|
||||
- Evidence from GitNexus tools
|
||||
- Affected callers/flows
|
||||
|
||||
### Missing Coverage
|
||||
- Callers not updated in PR: ...
|
||||
- Untested flows: ...
|
||||
|
||||
### Recommendation
|
||||
APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
|
||||
```
|
||||
+15
-7
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: gitnexus-refactoring
|
||||
description: Plan safe refactors using blast radius and dependency mapping
|
||||
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
|
||||
---
|
||||
|
||||
# Refactoring with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Rename this function safely"
|
||||
- "Extract this into a module"
|
||||
- "Split this service"
|
||||
@@ -26,6 +27,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
## Checklists
|
||||
|
||||
### Rename Symbol
|
||||
|
||||
```
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
@@ -35,6 +37,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
```
|
||||
|
||||
### Extract Module
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
@@ -45,6 +48,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
```
|
||||
|
||||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
@@ -58,6 +62,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
## Tools
|
||||
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
|
||||
```
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
@@ -66,6 +71,7 @@ gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_
|
||||
```
|
||||
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
@@ -73,6 +79,7 @@ gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
@@ -81,6 +88,7 @@ gitnexus_detect_changes({scope: "all"})
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
@@ -88,12 +96,12 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
|
||||
## Risk Rules
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
|-------------|------------|
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
| Risk Factor | Mitigation |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: gitnexus-pr-review
|
||||
description: "Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
|
||||
---
|
||||
|
||||
# PR Review with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Review this PR"
|
||||
- "What does PR #42 change?"
|
||||
- "Is this safe to merge?"
|
||||
- "What's the blast radius of this PR?"
|
||||
- "Are there missing tests for this PR?"
|
||||
- Reviewing someone else's code changes before merge
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gh pr diff <number> → Get the raw diff
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
3. For each changed symbol:
|
||||
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
|
||||
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
6. Summarize findings with risk assessment
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
|
||||
- [ ] gitnexus_detect_changes to map changes to affected execution flows
|
||||
- [ ] gitnexus_impact on each non-trivial changed symbol
|
||||
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
|
||||
- [ ] gitnexus_context on key changed symbols to understand full picture
|
||||
- [ ] Check if affected processes have test coverage
|
||||
- [ ] Assess overall risk level
|
||||
- [ ] Write review summary with findings
|
||||
```
|
||||
|
||||
## Review Dimensions
|
||||
|
||||
| Dimension | How GitNexus Helps |
|
||||
| --- | --- |
|
||||
| **Correctness** | `context` shows callers — are they all compatible with the change? |
|
||||
| **Blast radius** | `impact` shows d=1/d=2/d=3 dependents — anything missed? |
|
||||
| **Completeness** | `detect_changes` shows all affected flows — are they all handled? |
|
||||
| **Test coverage** | `impact({includeTests: true})` shows which tests touch changed code |
|
||||
| **Breaking changes** | d=1 upstream items that aren't updated in the PR = potential breakage |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Signal | Risk |
|
||||
| --- | --- |
|
||||
| Changes touch <3 symbols, 0-1 processes | LOW |
|
||||
| Changes touch 3-10 symbols, 2-5 processes | MEDIUM |
|
||||
| Changes touch >10 symbols or many processes | HIGH |
|
||||
| Changes touch auth, payments, or data integrity code | CRITICAL |
|
||||
| d=1 callers exist outside the PR diff | Potential breakage — flag it |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_detect_changes** — map PR diff to affected execution flows:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
|
||||
→ Changed: 8 symbols in 4 files
|
||||
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**gitnexus_impact** — blast radius per changed symbol:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
|
||||
- webhookHandler (src/webhooks.ts:15) [CALLS, 100%]
|
||||
|
||||
→ d=2 (LIKELY AFFECTED):
|
||||
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**gitnexus_impact with tests** — check test coverage:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
|
||||
→ Tests that cover this symbol:
|
||||
- validatePayment.test.ts [direct]
|
||||
- checkout.integration.test.ts [via processCheckout]
|
||||
```
|
||||
|
||||
**gitnexus_context** — understand a changed symbol's role:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates
|
||||
→ Processes: CheckoutFlow (step 3/7), RefundFlow (step 1/5)
|
||||
```
|
||||
|
||||
## Example: "Review PR #42"
|
||||
|
||||
```
|
||||
1. gh pr diff 42 > /tmp/pr42.diff
|
||||
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
|
||||
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
→ Changed symbols: validatePayment, PaymentInput, formatAmount
|
||||
→ Affected processes: CheckoutFlow, RefundFlow
|
||||
→ Risk: MEDIUM
|
||||
|
||||
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
→ d=1: processCheckout, webhookHandler (WILL BREAK)
|
||||
→ webhookHandler is NOT in the PR diff — potential breakage!
|
||||
|
||||
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
|
||||
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
|
||||
→ createPayment uses the old PaymentInput shape — breaking change!
|
||||
|
||||
5. gitnexus_context({name: "formatAmount"})
|
||||
→ Called by 12 functions — but change is backwards-compatible (added optional param)
|
||||
|
||||
6. Review summary:
|
||||
- MEDIUM risk — 3 changed symbols affect 2 execution flows
|
||||
- BUG: webhookHandler calls validatePayment but isn't updated for new signature
|
||||
- BUG: createPayment depends on PaymentInput type which changed
|
||||
- OK: formatAmount change is backwards-compatible
|
||||
- Tests: checkout.test.ts covers processCheckout path, but no webhook test
|
||||
```
|
||||
|
||||
## Review Output Format
|
||||
|
||||
Structure your review as:
|
||||
|
||||
```markdown
|
||||
## PR Review: <title>
|
||||
|
||||
**Risk: LOW / MEDIUM / HIGH / CRITICAL**
|
||||
|
||||
### Changes Summary
|
||||
- <N> symbols changed across <M> files
|
||||
- <P> execution flows affected
|
||||
|
||||
### Findings
|
||||
1. **[severity]** Description of finding
|
||||
- Evidence from GitNexus tools
|
||||
- Affected callers/flows
|
||||
|
||||
### Missing Coverage
|
||||
- Callers not updated in PR: ...
|
||||
- Untested flows: ...
|
||||
|
||||
### Recommendation
|
||||
APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
|
||||
```
|
||||
Binary file not shown.
BIN
Binary file not shown.
+124
-3
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { AppStateProvider, useAppState } from './hooks/useAppState';
|
||||
import { DropZone } from './components/DropZone';
|
||||
import { LoadingOverlay } from './components/LoadingOverlay';
|
||||
@@ -11,6 +11,8 @@ import { FileTreePanel } from './components/FileTreePanel';
|
||||
import { CodeReferencesPanel } from './components/CodeReferencesPanel';
|
||||
import { FileEntry } from './services/zip';
|
||||
import { getActiveProviderConfig } from './core/llm/settings-service';
|
||||
import { createKnowledgeGraph } from './core/graph/graph';
|
||||
import { connectToServer, fetchRepos, normalizeServerUrl, type ConnectToServerResult } from './services/server-connection';
|
||||
|
||||
const AppContent = () => {
|
||||
const {
|
||||
@@ -33,6 +35,11 @@ const AppContent = () => {
|
||||
codeReferences,
|
||||
selectedNode,
|
||||
isCodePanelOpen,
|
||||
serverBaseUrl,
|
||||
setServerBaseUrl,
|
||||
availableRepos,
|
||||
setAvailableRepos,
|
||||
switchRepo,
|
||||
} = useAppState();
|
||||
|
||||
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
|
||||
@@ -125,6 +132,102 @@ const AppContent = () => {
|
||||
}
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings, initializeAgent]);
|
||||
|
||||
const handleServerConnect = useCallback((result: ConnectToServerResult) => {
|
||||
// Extract project name from repoPath
|
||||
const repoPath = result.repoInfo.repoPath;
|
||||
const projectName = repoPath.split('/').pop() || 'server-project';
|
||||
setProjectName(projectName);
|
||||
|
||||
// Build KnowledgeGraph from server data (bypasses WASM pipeline entirely)
|
||||
const graph = createKnowledgeGraph();
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode(node);
|
||||
}
|
||||
for (const rel of result.relationships) {
|
||||
graph.addRelationship(rel);
|
||||
}
|
||||
setGraph(graph);
|
||||
|
||||
// Set file contents from extracted File node content
|
||||
const fileMap = new Map<string, string>();
|
||||
for (const [path, content] of Object.entries(result.fileContents)) {
|
||||
fileMap.set(path, content);
|
||||
}
|
||||
setFileContents(fileMap);
|
||||
|
||||
// Transition directly to exploring view
|
||||
setViewMode('exploring');
|
||||
|
||||
// Initialize agent if LLM is configured
|
||||
if (getActiveProviderConfig()) {
|
||||
initializeAgent(projectName);
|
||||
}
|
||||
|
||||
// Auto-start embeddings
|
||||
startEmbeddings().catch((err) => {
|
||||
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
|
||||
startEmbeddings('wasm').catch(console.warn);
|
||||
} else {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
}
|
||||
});
|
||||
}, [setViewMode, setGraph, setFileContents, setProjectName, initializeAgent, startEmbeddings]);
|
||||
|
||||
// Auto-connect when ?server query param is present (bookmarkable shortcut)
|
||||
const autoConnectRan = useRef(false);
|
||||
useEffect(() => {
|
||||
if (autoConnectRan.current) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!params.has('server')) return;
|
||||
autoConnectRan.current = true;
|
||||
|
||||
// Clean the URL so a refresh won't re-trigger
|
||||
const cleanUrl = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState(null, '', cleanUrl);
|
||||
|
||||
setProgress({ phase: 'extracting', percent: 0, message: 'Connecting to server...', detail: 'Validating server' });
|
||||
setViewMode('loading');
|
||||
|
||||
const serverUrl = params.get('server') || window.location.origin;
|
||||
|
||||
const baseUrl = normalizeServerUrl(serverUrl);
|
||||
|
||||
connectToServer(serverUrl, (phase, downloaded, total) => {
|
||||
if (phase === 'validating') {
|
||||
setProgress({ phase: 'extracting', percent: 5, message: 'Connecting to server...', detail: 'Validating server' });
|
||||
} else if (phase === 'downloading') {
|
||||
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
|
||||
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
||||
setProgress({ phase: 'extracting', percent: pct, message: 'Downloading graph...', detail: `${mb} MB downloaded` });
|
||||
} else if (phase === 'extracting') {
|
||||
setProgress({ phase: 'extracting', percent: 97, message: 'Processing...', detail: 'Extracting file contents' });
|
||||
}
|
||||
}).then(async (result) => {
|
||||
handleServerConnect(result);
|
||||
|
||||
// Store server URL and fetch available repos for the repo switcher
|
||||
setServerBaseUrl(baseUrl);
|
||||
try {
|
||||
const repos = await fetchRepos(baseUrl);
|
||||
setAvailableRepos(repos);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch repo list:', e);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error('Auto-connect failed:', err);
|
||||
setProgress({
|
||||
phase: 'error',
|
||||
percent: 0,
|
||||
message: 'Failed to connect to server',
|
||||
detail: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
setTimeout(() => {
|
||||
setViewMode('onboarding');
|
||||
setProgress(null);
|
||||
}, 3000);
|
||||
});
|
||||
}, [handleServerConnect, setProgress, setViewMode, setServerBaseUrl, setAvailableRepos]);
|
||||
|
||||
const handleFocusNode = useCallback((nodeId: string) => {
|
||||
graphCanvasRef.current?.focusNode(nodeId);
|
||||
}, []);
|
||||
@@ -138,7 +241,25 @@ const AppContent = () => {
|
||||
|
||||
// Render based on view mode
|
||||
if (viewMode === 'onboarding') {
|
||||
return <DropZone onFileSelect={handleFileSelect} onGitClone={handleGitClone} />;
|
||||
return (
|
||||
<DropZone
|
||||
onFileSelect={handleFileSelect}
|
||||
onGitClone={handleGitClone}
|
||||
onServerConnect={async (result, serverUrl) => {
|
||||
handleServerConnect(result);
|
||||
if (serverUrl) {
|
||||
const baseUrl = normalizeServerUrl(serverUrl);
|
||||
setServerBaseUrl(baseUrl);
|
||||
try {
|
||||
const repos = await fetchRepos(baseUrl);
|
||||
setAvailableRepos(repos);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch repo list:', e);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'loading' && progress) {
|
||||
@@ -148,7 +269,7 @@ const AppContent = () => {
|
||||
// Exploring view
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-void overflow-hidden">
|
||||
<Header onFocusNode={handleFocusNode} />
|
||||
<Header onFocusNode={handleFocusNode} availableRepos={availableRepos} onSwitchRepo={switchRepo} />
|
||||
|
||||
<main className="flex-1 flex min-h-0">
|
||||
{/* Left Panel - File Tree */}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Server, ArrowRight } from 'lucide-react';
|
||||
import { BackendRepo } from '../services/backend';
|
||||
|
||||
interface BackendRepoSelectorProps {
|
||||
repos: BackendRepo[];
|
||||
onSelectRepo: (repoName: string) => void;
|
||||
backendUrl: string;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
export const BackendRepoSelector = ({
|
||||
repos,
|
||||
onSelectRepo,
|
||||
backendUrl,
|
||||
isConnected,
|
||||
}: BackendRepoSelectorProps) => {
|
||||
return (
|
||||
<div className="p-8 bg-surface border border-border-default rounded-3xl">
|
||||
{/* Icon */}
|
||||
<div className="mx-auto w-20 h-20 mb-6 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-2xl shadow-glow">
|
||||
<Server className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-xl font-semibold text-text-primary text-center mb-2">
|
||||
Local Repositories
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary text-center mb-4">
|
||||
Select an indexed repository from your local GitNexus server
|
||||
</p>
|
||||
|
||||
{/* Connected status badge */}
|
||||
{isConnected && (
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
|
||||
<span className="text-xs text-green-400">Connected to {backendUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repo list or empty state */}
|
||||
{repos.length > 0 ? (
|
||||
<div className="max-h-80 overflow-y-auto space-y-2">
|
||||
{repos.map((repo) => (
|
||||
<button
|
||||
key={repo.name}
|
||||
onClick={() => onSelectRepo(repo.name)}
|
||||
className="w-full p-4 bg-elevated border border-border-subtle rounded-xl hover:border-accent/50 hover:bg-hover transition-all text-left group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-text-primary group-hover:text-accent transition-colors">
|
||||
{repo.name}
|
||||
</span>
|
||||
<ArrowRight className="w-4 h-4 text-text-muted group-hover:text-accent transition-colors" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-text-muted">
|
||||
{repo.stats?.files != null && <span>{repo.stats.files} files</span>}
|
||||
{repo.stats?.nodes != null && <span>{repo.stats.nodes} nodes</span>}
|
||||
{repo.stats?.edges != null && <span>{repo.stats.edges} edges</span>}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1">
|
||||
Indexed {new Date(repo.indexedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-text-muted py-8">
|
||||
<p className="text-sm mb-2">No indexed repositories found</p>
|
||||
<p className="text-xs">
|
||||
Run{' '}
|
||||
<code className="px-1 py-0.5 bg-elevated rounded">gitnexus analyze</code>{' '}
|
||||
in a repository
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom hints */}
|
||||
<div className="mt-4 flex items-center justify-center gap-3 text-xs text-text-muted">
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
{repos.length} {repos.length === 1 ? 'repo' : 'repos'}
|
||||
</span>
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
Pre-indexed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,24 @@
|
||||
import { useState, useCallback, DragEvent } from 'react';
|
||||
import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff } from 'lucide-react';
|
||||
import { useState, useCallback, useRef, DragEvent } from 'react';
|
||||
import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff, Globe, X } from 'lucide-react';
|
||||
import { cloneRepository, parseGitHubUrl } from '../services/git-clone';
|
||||
import { connectToServer, type ConnectToServerResult } from '../services/server-connection';
|
||||
import { FileEntry } from '../services/zip';
|
||||
|
||||
interface DropZoneProps {
|
||||
onFileSelect: (file: File) => void;
|
||||
onGitClone?: (files: FileEntry[]) => void;
|
||||
onServerConnect?: (result: ConnectToServerResult, serverUrl?: string) => void;
|
||||
}
|
||||
|
||||
export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => {
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export const DropZone = ({ onFileSelect, onGitClone, onServerConnect }: DropZoneProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'zip' | 'github'>('zip');
|
||||
const [activeTab, setActiveTab] = useState<'zip' | 'github' | 'server'>('zip');
|
||||
const [githubUrl, setGithubUrl] = useState('');
|
||||
const [githubToken, setGithubToken] = useState('');
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
@@ -18,6 +26,18 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => {
|
||||
const [cloneProgress, setCloneProgress] = useState({ phase: '', percent: 0 });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Server tab state
|
||||
const [serverUrl, setServerUrl] = useState(() =>
|
||||
localStorage.getItem('gitnexus-server-url') || ''
|
||||
);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [serverProgress, setServerProgress] = useState<{
|
||||
phase: string;
|
||||
downloaded: number;
|
||||
total: number | null;
|
||||
}>({ phase: '', downloaded: 0, total: null });
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -78,10 +98,9 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => {
|
||||
const files = await cloneRepository(
|
||||
githubUrl,
|
||||
(phase, percent) => setCloneProgress({ phase, percent }),
|
||||
githubToken || undefined // Pass token if provided
|
||||
githubToken || undefined
|
||||
);
|
||||
|
||||
// Clear token from memory after successful clone
|
||||
setGithubToken('');
|
||||
|
||||
if (onGitClone) {
|
||||
@@ -90,12 +109,11 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => {
|
||||
} catch (err) {
|
||||
console.error('Clone failed:', err);
|
||||
const message = err instanceof Error ? err.message : 'Failed to clone repository';
|
||||
// Provide helpful error for auth failures
|
||||
if (message.includes('401') || message.includes('403') || message.includes('Authentication')) {
|
||||
if (!githubToken) {
|
||||
setError('🔒 This looks like a private repo. Add a GitHub PAT (Personal Access Token) to access it.');
|
||||
setError('This looks like a private repo. Add a GitHub PAT (Personal Access Token) to access it.');
|
||||
} else {
|
||||
setError('🔑 Authentication failed. Check your token permissions (needs repo access).');
|
||||
setError('Authentication failed. Check your token permissions (needs repo access).');
|
||||
}
|
||||
} else if (message.includes('404') || message.includes('not found')) {
|
||||
setError('Repository not found. Check the URL or it might be private (needs PAT).');
|
||||
@@ -107,6 +125,62 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerConnect = async () => {
|
||||
const urlToUse = serverUrl.trim() || window.location.origin;
|
||||
if (!urlToUse) {
|
||||
setError('Please enter a server URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist URL to localStorage
|
||||
localStorage.setItem('gitnexus-server-url', serverUrl);
|
||||
|
||||
setError(null);
|
||||
setIsConnecting(true);
|
||||
setServerProgress({ phase: 'validating', downloaded: 0, total: null });
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
const result = await connectToServer(
|
||||
urlToUse,
|
||||
(phase, downloaded, total) => {
|
||||
setServerProgress({ phase, downloaded, total });
|
||||
},
|
||||
abortController.signal
|
||||
);
|
||||
|
||||
if (onServerConnect) {
|
||||
onServerConnect(result, urlToUse);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
// User cancelled
|
||||
return;
|
||||
}
|
||||
console.error('Server connect failed:', err);
|
||||
const message = err instanceof Error ? err.message : 'Failed to connect to server';
|
||||
if (message.includes('Failed to fetch') || message.includes('NetworkError')) {
|
||||
setError('Cannot reach server. Check the URL and ensure the server is running.');
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
abortControllerRef.current?.abort();
|
||||
setIsConnecting(false);
|
||||
};
|
||||
|
||||
const serverProgressPercent = serverProgress.total
|
||||
? Math.round((serverProgress.downloaded / serverProgress.total) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-8 bg-void">
|
||||
{/* Background gradient effects */}
|
||||
@@ -146,6 +220,20 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => {
|
||||
<Github className="w-4 h-4" />
|
||||
GitHub URL
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab('server'); setError(null); }}
|
||||
className={`
|
||||
flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg
|
||||
text-sm font-medium transition-all duration-200
|
||||
${activeTab === 'server'
|
||||
? 'bg-accent text-white shadow-md'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-elevated'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
Server
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
@@ -160,7 +248,7 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => {
|
||||
<>
|
||||
<div
|
||||
className={`
|
||||
relative p-16
|
||||
relative p-16
|
||||
bg-surface border-2 border-dashed rounded-3xl
|
||||
transition-all duration-300 cursor-pointer
|
||||
${isDragging
|
||||
@@ -247,7 +335,7 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => {
|
||||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
className="
|
||||
w-full px-4 py-3
|
||||
w-full px-4 py-3
|
||||
bg-elevated border border-border-default rounded-xl
|
||||
text-text-primary placeholder-text-muted
|
||||
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
|
||||
@@ -273,7 +361,7 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => {
|
||||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
className="
|
||||
w-full pl-10 pr-10 py-3
|
||||
w-full pl-10 pr-10 py-3
|
||||
bg-elevated border border-border-default rounded-xl
|
||||
text-text-primary placeholder-text-muted
|
||||
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
|
||||
@@ -294,9 +382,9 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => {
|
||||
onClick={handleGitClone}
|
||||
disabled={isCloning || !githubUrl.trim()}
|
||||
className="
|
||||
w-full flex items-center justify-center gap-2
|
||||
px-4 py-3
|
||||
bg-accent hover:bg-accent/90
|
||||
w-full flex items-center justify-center gap-2
|
||||
px-4 py-3
|
||||
bg-accent hover:bg-accent/90
|
||||
text-white font-medium rounded-xl
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200
|
||||
@@ -336,7 +424,7 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => {
|
||||
{/* Security note */}
|
||||
{githubToken && (
|
||||
<p className="mt-3 text-xs text-text-muted text-center">
|
||||
🔒 Token stays in your browser only, never sent to any server
|
||||
Token stays in your browser only, never sent to any server
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -351,6 +439,133 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server Tab */}
|
||||
{activeTab === 'server' && (
|
||||
<div className="p-8 bg-surface border border-border-default rounded-3xl">
|
||||
{/* Icon */}
|
||||
<div className="mx-auto w-20 h-20 mb-6 flex items-center justify-center bg-gradient-to-br from-accent to-emerald-600 rounded-2xl shadow-lg">
|
||||
<Globe className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<h2 className="text-xl font-semibold text-text-primary text-center mb-2">
|
||||
Connect to Server
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary text-center mb-6">
|
||||
Load a pre-built knowledge graph from a running GitNexus server
|
||||
</p>
|
||||
|
||||
{/* Inputs */}
|
||||
<div className="space-y-3" data-form-type="other">
|
||||
<input
|
||||
type="url"
|
||||
name="server-url-input"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isConnecting && handleServerConnect()}
|
||||
placeholder={window.location.origin}
|
||||
disabled={isConnecting}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
className="
|
||||
w-full px-4 py-3
|
||||
bg-elevated border border-border-default rounded-xl
|
||||
text-text-primary placeholder-text-muted
|
||||
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200
|
||||
"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleServerConnect}
|
||||
disabled={isConnecting}
|
||||
className="
|
||||
flex-1 flex items-center justify-center gap-2
|
||||
px-4 py-3
|
||||
bg-accent hover:bg-accent/90
|
||||
text-white font-medium rounded-xl
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200
|
||||
"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
{serverProgress.phase === 'validating'
|
||||
? 'Validating...'
|
||||
: serverProgress.phase === 'downloading'
|
||||
? serverProgressPercent !== null
|
||||
? `Downloading... ${serverProgressPercent}%`
|
||||
: `Downloading... ${formatBytes(serverProgress.downloaded)}`
|
||||
: serverProgress.phase === 'extracting'
|
||||
? 'Processing...'
|
||||
: 'Connecting...'
|
||||
}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Connect
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isConnecting && (
|
||||
<button
|
||||
onClick={handleCancelConnect}
|
||||
className="
|
||||
flex items-center justify-center
|
||||
px-4 py-3
|
||||
bg-red-500/20 hover:bg-red-500/30
|
||||
text-red-400 font-medium rounded-xl
|
||||
transition-all duration-200
|
||||
"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{isConnecting && serverProgress.phase === 'downloading' && (
|
||||
<div className="mt-4">
|
||||
<div className="h-2 bg-elevated rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full bg-accent transition-all duration-300 ease-out ${
|
||||
serverProgressPercent === null ? 'animate-pulse' : ''
|
||||
}`}
|
||||
style={{
|
||||
width: serverProgressPercent !== null
|
||||
? `${serverProgressPercent}%`
|
||||
: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{serverProgress.total && (
|
||||
<p className="mt-1 text-xs text-text-muted text-center">
|
||||
{formatBytes(serverProgress.downloaded)} / {formatBytes(serverProgress.total)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hints */}
|
||||
<div className="mt-4 flex items-center justify-center gap-3 text-xs text-text-muted">
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
Pre-indexed
|
||||
</span>
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
No WASM needed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,20 +8,21 @@ import { WebGPUFallbackDialog } from './WebGPUFallbackDialog';
|
||||
* Shows in header when graph is loaded
|
||||
*/
|
||||
export const EmbeddingStatus = () => {
|
||||
const {
|
||||
embeddingStatus,
|
||||
embeddingProgress,
|
||||
startEmbeddings,
|
||||
const {
|
||||
embeddingStatus,
|
||||
embeddingProgress,
|
||||
startEmbeddings,
|
||||
graph,
|
||||
viewMode,
|
||||
serverBaseUrl,
|
||||
testArrayParams,
|
||||
} = useAppState();
|
||||
|
||||
|
||||
const [testResult, setTestResult] = useState<string | null>(null);
|
||||
const [showFallbackDialog, setShowFallbackDialog] = useState(false);
|
||||
|
||||
// Only show when exploring a loaded graph
|
||||
if (viewMode !== 'exploring' || !graph) return null;
|
||||
// Only show when exploring a loaded graph; hide in backend mode (no WASM DB)
|
||||
if (viewMode !== 'exploring' || !graph || serverBaseUrl) return null;
|
||||
|
||||
const nodeCount = graph.nodes.length;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Search, Settings, HelpCircle, Sparkles, Github, Star } from 'lucide-react';
|
||||
import { Search, Settings, HelpCircle, Sparkles, Github, Star, ChevronDown } from 'lucide-react';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import type { RepoSummary } from '../services/server-connection';
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import { GraphNode } from '../core/graph/types';
|
||||
import { EmbeddingStatus } from './EmbeddingStatus';
|
||||
@@ -19,9 +20,11 @@ const NODE_TYPE_COLORS: Record<string, string> = {
|
||||
|
||||
interface HeaderProps {
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
availableRepos?: RepoSummary[];
|
||||
onSwitchRepo?: (repoName: string) => void;
|
||||
}
|
||||
|
||||
export const Header = ({ onFocusNode }: HeaderProps) => {
|
||||
export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: HeaderProps) => {
|
||||
const {
|
||||
projectName,
|
||||
graph,
|
||||
@@ -30,6 +33,8 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
|
||||
rightPanelTab,
|
||||
setSettingsPanelOpen,
|
||||
} = useAppState();
|
||||
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
|
||||
const repoDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
@@ -49,12 +54,15 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
|
||||
.slice(0, 10); // Limit to 10 results
|
||||
}, [graph, searchQuery]);
|
||||
|
||||
// Handle clicking outside to close dropdown
|
||||
// Handle clicking outside to close dropdowns
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
if (repoDropdownRef.current && !repoDropdownRef.current.contains(e.target as Node)) {
|
||||
setIsRepoDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
@@ -116,11 +124,50 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
|
||||
<span className="font-semibold text-[15px] tracking-tight">GitNexus</span>
|
||||
</div>
|
||||
|
||||
{/* Project badge */}
|
||||
{/* Project badge / Repo selector dropdown */}
|
||||
{projectName && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface border border-border-subtle rounded-lg text-sm text-text-secondary">
|
||||
<span className="w-1.5 h-1.5 bg-node-function rounded-full animate-pulse" />
|
||||
<span className="truncate max-w-[200px]">{projectName}</span>
|
||||
<div className="relative" ref={repoDropdownRef}>
|
||||
<button
|
||||
onClick={() => availableRepos.length >= 2 && setIsRepoDropdownOpen(prev => !prev)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 bg-surface border border-border-subtle rounded-lg text-sm text-text-secondary transition-colors ${availableRepos.length >= 2 ? 'hover:bg-hover cursor-pointer' : ''}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 bg-node-function rounded-full animate-pulse" />
|
||||
<span className="truncate max-w-[200px]">{projectName}</span>
|
||||
{availableRepos.length >= 2 && (
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-text-muted transition-transform ${isRepoDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Repo dropdown */}
|
||||
{isRepoDropdownOpen && availableRepos.length >= 2 && (
|
||||
<div className="absolute top-full left-0 mt-1 w-72 bg-surface border border-border-subtle rounded-lg shadow-xl overflow-hidden z-50">
|
||||
{availableRepos.map((repo) => {
|
||||
const isCurrent = repo.name === projectName;
|
||||
return (
|
||||
<button
|
||||
key={repo.name}
|
||||
onClick={() => {
|
||||
if (!isCurrent && onSwitchRepo) {
|
||||
onSwitchRepo(repo.name);
|
||||
}
|
||||
setIsRepoDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-4 py-3 flex items-center gap-3 text-left transition-colors ${isCurrent ? 'bg-accent/10 border-l-2 border-accent' : 'hover:bg-hover border-l-2 border-transparent'}`}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${isCurrent ? 'bg-node-function animate-pulse' : 'bg-text-muted'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-medium truncate ${isCurrent ? 'text-accent' : 'text-text-primary'}`}>
|
||||
{repo.name}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">
|
||||
{repo.stats?.nodes ?? '?'} nodes · {repo.stats?.files ?? '?'} files
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { MermaidDiagram } from './MermaidDiagram';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
// Custom syntax theme
|
||||
const customTheme = {
|
||||
@@ -28,13 +29,26 @@ interface MarkdownRendererProps {
|
||||
content: string;
|
||||
onLinkClick?: (href: string) => void;
|
||||
toolCalls?: any[]; // Keep flexible for now
|
||||
showCopyButton?: boolean;
|
||||
}
|
||||
|
||||
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
content,
|
||||
onLinkClick,
|
||||
toolCalls
|
||||
toolCalls,
|
||||
showCopyButton = false
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to format text for display (convert [[links]] to markdown links)
|
||||
const formatMarkdownForDisplay = (md: string) => {
|
||||
@@ -166,6 +180,20 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
{formattedContent}
|
||||
</ReactMarkdown>
|
||||
|
||||
{/* Copy Button */}
|
||||
{showCopyButton && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 px-2 py-1 text-xs text-text-muted hover:text-text-primary hover:bg-surface border border-transparent hover:border-border-subtle rounded transition-all"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool Call Cards appended at the bottom if provided */}
|
||||
{toolCalls && toolCalls.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
|
||||
@@ -345,7 +345,7 @@ export const RightPanel = () => {
|
||||
{/* Render steps in order (reasoning, tool calls, content interleaved) */}
|
||||
{message.steps && message.steps.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{message.steps.map((step) => (
|
||||
{message.steps.map((step, index) => (
|
||||
<div key={step.id}>
|
||||
{step.type === 'reasoning' && step.content && (
|
||||
<div className="text-text-secondary text-sm italic border-l-2 border-text-muted/30 pl-3 mb-3">
|
||||
@@ -364,6 +364,7 @@ export const RightPanel = () => {
|
||||
<MarkdownRenderer
|
||||
content={step.content}
|
||||
onLinkClick={handleLinkClick}
|
||||
showCopyButton={index === message.steps!.length - 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -375,6 +376,7 @@ export const RightPanel = () => {
|
||||
content={message.content}
|
||||
onLinkClick={handleLinkClick}
|
||||
toolCalls={message.toolCalls}
|
||||
showCopyButton={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,9 @@ interface SettingsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSettingsSaved?: () => void;
|
||||
backendUrl?: string;
|
||||
isBackendConnected?: boolean;
|
||||
onBackendUrlChange?: (url: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,7 +212,7 @@ const checkOllamaStatus = async (baseUrl: string): Promise<{ ok: boolean; error:
|
||||
}
|
||||
};
|
||||
|
||||
export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPanelProps) => {
|
||||
export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, isBackendConnected, onBackendUrlChange }: SettingsPanelProps) => {
|
||||
const [settings, setSettings] = useState<LLMSettings>(loadSettings);
|
||||
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
|
||||
@@ -312,6 +315,35 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* Local Server */}
|
||||
{backendUrl !== undefined && onBackendUrlChange && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-text-secondary">
|
||||
Local Server
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Server className="w-4 h-4 text-text-muted" />
|
||||
<span className="text-sm text-text-secondary">Backend URL</span>
|
||||
<span className={`w-2 h-2 rounded-full ${isBackendConnected ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||
<span className="text-xs text-text-muted">
|
||||
{isBackendConnected ? 'Connected' : 'Not connected'}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
value={backendUrl}
|
||||
onChange={(e) => onBackendUrlChange(e.target.value)}
|
||||
placeholder="http://localhost:4747"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Run <code className="px-1 py-0.5 bg-elevated rounded">gitnexus serve</code> to start the local server
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-text-secondary">
|
||||
|
||||
@@ -8,7 +8,7 @@ export enum SupportedLanguages {
|
||||
CSharp = 'csharp',
|
||||
Go = 'go',
|
||||
Rust = 'rust',
|
||||
// PHP = 'php',
|
||||
PHP = 'php',
|
||||
// Ruby = 'ruby',
|
||||
// Swift = 'swift',
|
||||
Swift = 'swift',
|
||||
}
|
||||
@@ -216,6 +216,58 @@ export const processCalls = async (
|
||||
});
|
||||
});
|
||||
|
||||
// Extract Laravel routes from route files via procedural AST walk
|
||||
if (language === 'php' && (file.path.includes('/routes/') || file.path.startsWith('routes/')) && file.path.endsWith('.php')) {
|
||||
const extractedRoutes = extractLaravelRoutes(tree, file.path);
|
||||
for (const route of extractedRoutes) {
|
||||
if (!route.controllerName || !route.methodName) continue;
|
||||
|
||||
const controllerDefs = symbolTable.lookupFuzzy(route.controllerName);
|
||||
if (controllerDefs.length === 0) continue;
|
||||
|
||||
const routeImportedFiles = importMap.get(route.filePath);
|
||||
let controllerDef = controllerDefs[0];
|
||||
let conf = controllerDefs.length === 1 ? 0.7 : 0.5;
|
||||
|
||||
if (routeImportedFiles) {
|
||||
for (const def of controllerDefs) {
|
||||
if (routeImportedFiles.has(def.filePath)) {
|
||||
controllerDef = def;
|
||||
conf = 0.9;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const methodId = symbolTable.lookupExact(controllerDef.filePath, route.methodName);
|
||||
const routeSourceId = generateId('File', route.filePath);
|
||||
|
||||
if (!methodId) {
|
||||
const guessedId = generateId('Method', `${controllerDef.filePath}:${route.methodName}`);
|
||||
const routeRelId = generateId('CALLS', `${routeSourceId}:route->${guessedId}`);
|
||||
graph.addRelationship({
|
||||
id: routeRelId,
|
||||
sourceId: routeSourceId,
|
||||
targetId: guessedId,
|
||||
type: 'CALLS',
|
||||
confidence: conf * 0.8,
|
||||
reason: 'laravel-route',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const routeRelId = generateId('CALLS', `${routeSourceId}:route->${methodId}`);
|
||||
graph.addRelationship({
|
||||
id: routeRelId,
|
||||
sourceId: routeSourceId,
|
||||
targetId: methodId,
|
||||
type: 'CALLS',
|
||||
confidence: conf,
|
||||
reason: 'laravel-route',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup if re-parsed
|
||||
if (wasReparsed) {
|
||||
tree.delete();
|
||||
@@ -223,6 +275,387 @@ export const processCalls = async (
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Laravel Route Extraction (procedural AST walk)
|
||||
// ============================================================================
|
||||
|
||||
interface ExtractedRoute {
|
||||
filePath: string;
|
||||
httpMethod: string;
|
||||
routePath: string | null;
|
||||
controllerName: string | null;
|
||||
methodName: string | null;
|
||||
middleware: string[];
|
||||
prefix: string | null;
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
interface RouteGroupContext {
|
||||
middleware: string[];
|
||||
prefix: string | null;
|
||||
controller: string | null;
|
||||
}
|
||||
|
||||
const ROUTE_HTTP_METHODS = new Set([
|
||||
'get', 'post', 'put', 'patch', 'delete', 'options', 'any', 'match',
|
||||
]);
|
||||
|
||||
const ROUTE_RESOURCE_METHODS = new Set(['resource', 'apiResource']);
|
||||
|
||||
const RESOURCE_ACTIONS = ['index', 'create', 'store', 'show', 'edit', 'update', 'destroy'];
|
||||
const API_RESOURCE_ACTIONS = ['index', 'store', 'show', 'update', 'destroy'];
|
||||
|
||||
function isRouteStaticCall(node: any): boolean {
|
||||
if (node.type !== 'scoped_call_expression') return false;
|
||||
const obj = node.childForFieldName?.('object') ?? node.children?.[0];
|
||||
return obj?.text === 'Route';
|
||||
}
|
||||
|
||||
function getCallMethodName(node: any): string | null {
|
||||
const nameNode = node.childForFieldName?.('name') ??
|
||||
node.children?.find((c: any) => c.type === 'name');
|
||||
return nameNode?.text ?? null;
|
||||
}
|
||||
|
||||
function getArguments(node: any): any {
|
||||
return node.children?.find((c: any) => c.type === 'arguments') ?? null;
|
||||
}
|
||||
|
||||
function findClosureBody(argsNode: any): any | null {
|
||||
if (!argsNode) return null;
|
||||
for (const child of argsNode.children ?? []) {
|
||||
if (child.type === 'argument') {
|
||||
for (const inner of child.children ?? []) {
|
||||
if (inner.type === 'anonymous_function' ||
|
||||
inner.type === 'arrow_function') {
|
||||
return inner.childForFieldName?.('body') ??
|
||||
inner.children?.find((c: any) => c.type === 'compound_statement');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (child.type === 'anonymous_function' ||
|
||||
child.type === 'arrow_function') {
|
||||
return child.childForFieldName?.('body') ??
|
||||
child.children?.find((c: any) => c.type === 'compound_statement');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findDescendant(node: any, type: string): any {
|
||||
if (node.type === type) return node;
|
||||
for (const child of (node.children ?? [])) {
|
||||
const found = findDescendant(child, type);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractStringContent(node: any): string | null {
|
||||
if (!node) return null;
|
||||
const content = node.children?.find((c: any) => c.type === 'string_content');
|
||||
if (content) return content.text;
|
||||
if (node.type === 'string_content') return node.text;
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractFirstStringArg(argsNode: any): string | null {
|
||||
if (!argsNode) return null;
|
||||
for (const child of argsNode.children ?? []) {
|
||||
const target = child.type === 'argument' ? child.children?.[0] : child;
|
||||
if (!target) continue;
|
||||
if (target.type === 'string' || target.type === 'encapsed_string') {
|
||||
return extractStringContent(target);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractMiddlewareArg(argsNode: any): string[] {
|
||||
if (!argsNode) return [];
|
||||
for (const child of argsNode.children ?? []) {
|
||||
const target = child.type === 'argument' ? child.children?.[0] : child;
|
||||
if (!target) continue;
|
||||
if (target.type === 'string' || target.type === 'encapsed_string') {
|
||||
const val = extractStringContent(target);
|
||||
return val ? [val] : [];
|
||||
}
|
||||
if (target.type === 'array_creation_expression') {
|
||||
const items: string[] = [];
|
||||
for (const el of target.children ?? []) {
|
||||
if (el.type === 'array_element_initializer') {
|
||||
const str = el.children?.find((c: any) => c.type === 'string' || c.type === 'encapsed_string');
|
||||
const val = str ? extractStringContent(str) : null;
|
||||
if (val) items.push(val);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractClassArg(argsNode: any): string | null {
|
||||
if (!argsNode) return null;
|
||||
for (const child of argsNode.children ?? []) {
|
||||
const target = child.type === 'argument' ? child.children?.[0] : child;
|
||||
if (target?.type === 'class_constant_access_expression') {
|
||||
return target.children?.find((c: any) => c.type === 'name')?.text ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractControllerTarget(argsNode: any): { controller: string | null; method: string | null } {
|
||||
if (!argsNode) return { controller: null, method: null };
|
||||
|
||||
const args: any[] = [];
|
||||
for (const child of argsNode.children ?? []) {
|
||||
if (child.type === 'argument') args.push(child.children?.[0]);
|
||||
else if (child.type !== '(' && child.type !== ')' && child.type !== ',') args.push(child);
|
||||
}
|
||||
|
||||
const handlerNode = args[1];
|
||||
if (!handlerNode) return { controller: null, method: null };
|
||||
|
||||
if (handlerNode.type === 'array_creation_expression') {
|
||||
let controller: string | null = null;
|
||||
let method: string | null = null;
|
||||
const elements: any[] = [];
|
||||
for (const el of handlerNode.children ?? []) {
|
||||
if (el.type === 'array_element_initializer') elements.push(el);
|
||||
}
|
||||
if (elements[0]) {
|
||||
const classAccess = findDescendant(elements[0], 'class_constant_access_expression');
|
||||
if (classAccess) {
|
||||
controller = classAccess.children?.find((c: any) => c.type === 'name')?.text ?? null;
|
||||
}
|
||||
}
|
||||
if (elements[1]) {
|
||||
const str = findDescendant(elements[1], 'string');
|
||||
method = str ? extractStringContent(str) : null;
|
||||
}
|
||||
return { controller, method };
|
||||
}
|
||||
|
||||
if (handlerNode.type === 'string' || handlerNode.type === 'encapsed_string') {
|
||||
const text = extractStringContent(handlerNode);
|
||||
if (text?.includes('@')) {
|
||||
const [controller, method] = text.split('@');
|
||||
return { controller, method };
|
||||
}
|
||||
}
|
||||
|
||||
if (handlerNode.type === 'class_constant_access_expression') {
|
||||
const controller = handlerNode.children?.find((c: any) => c.type === 'name')?.text ?? null;
|
||||
return { controller, method: '__invoke' };
|
||||
}
|
||||
|
||||
return { controller: null, method: null };
|
||||
}
|
||||
|
||||
interface ChainedRouteCall {
|
||||
isRouteFacade: boolean;
|
||||
terminalMethod: string;
|
||||
attributes: { method: string; argsNode: any }[];
|
||||
terminalArgs: any;
|
||||
node: any;
|
||||
}
|
||||
|
||||
function unwrapRouteChain(node: any): ChainedRouteCall | null {
|
||||
if (node.type !== 'member_call_expression') return null;
|
||||
|
||||
const terminalMethod = getCallMethodName(node);
|
||||
if (!terminalMethod) return null;
|
||||
|
||||
const terminalArgs = getArguments(node);
|
||||
const attributes: { method: string; argsNode: any }[] = [];
|
||||
|
||||
let current = node.children?.[0];
|
||||
|
||||
while (current) {
|
||||
if (current.type === 'member_call_expression') {
|
||||
const method = getCallMethodName(current);
|
||||
const args = getArguments(current);
|
||||
if (method) attributes.unshift({ method, argsNode: args });
|
||||
current = current.children?.[0];
|
||||
} else if (current.type === 'scoped_call_expression') {
|
||||
const obj = current.childForFieldName?.('object') ?? current.children?.[0];
|
||||
if (obj?.text !== 'Route') return null;
|
||||
|
||||
const method = getCallMethodName(current);
|
||||
const args = getArguments(current);
|
||||
if (method) attributes.unshift({ method, argsNode: args });
|
||||
|
||||
return { isRouteFacade: true, terminalMethod, attributes, terminalArgs, node };
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseArrayGroupArgs(argsNode: any): RouteGroupContext {
|
||||
const ctx: RouteGroupContext = { middleware: [], prefix: null, controller: null };
|
||||
if (!argsNode) return ctx;
|
||||
|
||||
for (const child of argsNode.children ?? []) {
|
||||
const target = child.type === 'argument' ? child.children?.[0] : child;
|
||||
if (target?.type === 'array_creation_expression') {
|
||||
for (const el of target.children ?? []) {
|
||||
if (el.type !== 'array_element_initializer') continue;
|
||||
const children = el.children ?? [];
|
||||
const arrowIdx = children.findIndex((c: any) => c.type === '=>');
|
||||
if (arrowIdx === -1) continue;
|
||||
const key = extractStringContent(children[arrowIdx - 1]);
|
||||
const val = children[arrowIdx + 1];
|
||||
if (key === 'middleware') {
|
||||
if (val?.type === 'string') {
|
||||
const s = extractStringContent(val);
|
||||
if (s) ctx.middleware.push(s);
|
||||
} else if (val?.type === 'array_creation_expression') {
|
||||
for (const item of val.children ?? []) {
|
||||
if (item.type === 'array_element_initializer') {
|
||||
const str = item.children?.find((c: any) => c.type === 'string');
|
||||
const s = str ? extractStringContent(str) : null;
|
||||
if (s) ctx.middleware.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (key === 'prefix') {
|
||||
ctx.prefix = extractStringContent(val) ?? null;
|
||||
} else if (key === 'controller') {
|
||||
if (val?.type === 'class_constant_access_expression') {
|
||||
ctx.controller = val.children?.find((c: any) => c.type === 'name')?.text ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function extractLaravelRoutes(tree: any, filePath: string): ExtractedRoute[] {
|
||||
const routes: ExtractedRoute[] = [];
|
||||
|
||||
function resolveStack(stack: RouteGroupContext[]): { middleware: string[]; prefix: string | null; controller: string | null } {
|
||||
const middleware: string[] = [];
|
||||
let prefix: string | null = null;
|
||||
let controller: string | null = null;
|
||||
for (const ctx of stack) {
|
||||
middleware.push(...ctx.middleware);
|
||||
if (ctx.prefix) prefix = prefix ? `${prefix}/${ctx.prefix}`.replace(/\/+/g, '/') : ctx.prefix;
|
||||
if (ctx.controller) controller = ctx.controller;
|
||||
}
|
||||
return { middleware, prefix, controller };
|
||||
}
|
||||
|
||||
function emitRoute(
|
||||
httpMethod: string,
|
||||
argsNode: any,
|
||||
lineNumber: number,
|
||||
groupStack: RouteGroupContext[],
|
||||
chainAttrs: { method: string; argsNode: any }[],
|
||||
) {
|
||||
const effective = resolveStack(groupStack);
|
||||
|
||||
for (const attr of chainAttrs) {
|
||||
if (attr.method === 'middleware') effective.middleware.push(...extractMiddlewareArg(attr.argsNode));
|
||||
if (attr.method === 'prefix') {
|
||||
const p = extractFirstStringArg(attr.argsNode);
|
||||
if (p) effective.prefix = effective.prefix ? `${effective.prefix}/${p}` : p;
|
||||
}
|
||||
if (attr.method === 'controller') {
|
||||
const cls = extractClassArg(attr.argsNode);
|
||||
if (cls) effective.controller = cls;
|
||||
}
|
||||
}
|
||||
|
||||
const routePath = extractFirstStringArg(argsNode);
|
||||
|
||||
if (ROUTE_RESOURCE_METHODS.has(httpMethod)) {
|
||||
const target = extractControllerTarget(argsNode);
|
||||
const actions = httpMethod === 'apiResource' ? API_RESOURCE_ACTIONS : RESOURCE_ACTIONS;
|
||||
for (const action of actions) {
|
||||
routes.push({
|
||||
filePath, httpMethod, routePath,
|
||||
controllerName: target.controller ?? effective.controller,
|
||||
methodName: action,
|
||||
middleware: [...effective.middleware],
|
||||
prefix: effective.prefix,
|
||||
lineNumber,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const target = extractControllerTarget(argsNode);
|
||||
routes.push({
|
||||
filePath, httpMethod, routePath,
|
||||
controllerName: target.controller ?? effective.controller,
|
||||
methodName: target.method,
|
||||
middleware: [...effective.middleware],
|
||||
prefix: effective.prefix,
|
||||
lineNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function walk(node: any, groupStack: RouteGroupContext[]) {
|
||||
if (isRouteStaticCall(node)) {
|
||||
const method = getCallMethodName(node);
|
||||
if (method && (ROUTE_HTTP_METHODS.has(method) || ROUTE_RESOURCE_METHODS.has(method))) {
|
||||
emitRoute(method, getArguments(node), node.startPosition.row, groupStack, []);
|
||||
return;
|
||||
}
|
||||
if (method === 'group') {
|
||||
const argsNode = getArguments(node);
|
||||
const groupCtx = parseArrayGroupArgs(argsNode);
|
||||
const body = findClosureBody(argsNode);
|
||||
if (body) {
|
||||
groupStack.push(groupCtx);
|
||||
walkChildren(body, groupStack);
|
||||
groupStack.pop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const chain = unwrapRouteChain(node);
|
||||
if (chain) {
|
||||
if (chain.terminalMethod === 'group') {
|
||||
const groupCtx: RouteGroupContext = { middleware: [], prefix: null, controller: null };
|
||||
for (const attr of chain.attributes) {
|
||||
if (attr.method === 'middleware') groupCtx.middleware.push(...extractMiddlewareArg(attr.argsNode));
|
||||
if (attr.method === 'prefix') groupCtx.prefix = extractFirstStringArg(attr.argsNode);
|
||||
if (attr.method === 'controller') groupCtx.controller = extractClassArg(attr.argsNode);
|
||||
}
|
||||
const body = findClosureBody(chain.terminalArgs);
|
||||
if (body) {
|
||||
groupStack.push(groupCtx);
|
||||
walkChildren(body, groupStack);
|
||||
groupStack.pop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ROUTE_HTTP_METHODS.has(chain.terminalMethod) || ROUTE_RESOURCE_METHODS.has(chain.terminalMethod)) {
|
||||
emitRoute(chain.terminalMethod, chain.terminalArgs, node.startPosition.row, groupStack, chain.attributes);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
walkChildren(node, groupStack);
|
||||
}
|
||||
|
||||
function walkChildren(node: any, groupStack: RouteGroupContext[]) {
|
||||
for (const child of node.children ?? []) {
|
||||
walk(child, groupStack);
|
||||
}
|
||||
}
|
||||
|
||||
walk(tree.rootNode, []);
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolution result with confidence scoring
|
||||
*/
|
||||
|
||||
@@ -330,25 +330,20 @@ const calculateCohesion = (memberIds: string[], graph: Graph): number => {
|
||||
|
||||
const memberSet = new Set(memberIds);
|
||||
let internalEdges = 0;
|
||||
|
||||
// Count edges within the community
|
||||
let totalEdges = 0;
|
||||
|
||||
// Count internal vs total edges for community members
|
||||
memberIds.forEach(nodeId => {
|
||||
if (graph.hasNode(nodeId)) {
|
||||
graph.forEachNeighbor(nodeId, neighbor => {
|
||||
totalEdges++;
|
||||
if (memberSet.has(neighbor)) {
|
||||
internalEdges++;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Each edge is counted twice (once from each end), so divide by 2
|
||||
internalEdges = internalEdges / 2;
|
||||
|
||||
// Maximum possible internal edges for n nodes: n*(n-1)/2
|
||||
const maxPossibleEdges = (memberIds.length * (memberIds.length - 1)) / 2;
|
||||
|
||||
if (maxPossibleEdges === 0) return 1.0;
|
||||
|
||||
return Math.min(1.0, internalEdges / maxPossibleEdges);
|
||||
|
||||
if (totalEdges === 0) return 1.0;
|
||||
return Math.min(1.0, internalEdges / totalEdges);
|
||||
};
|
||||
|
||||
@@ -102,6 +102,47 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
|
||||
/^Run$/, // Run methods
|
||||
/^Start$/, // Start methods
|
||||
],
|
||||
|
||||
// Swift / iOS
|
||||
'swift': [
|
||||
/^viewDidLoad$/, // UIKit lifecycle
|
||||
/^viewWillAppear$/, // UIKit lifecycle
|
||||
/^viewDidAppear$/, // UIKit lifecycle
|
||||
/^viewWillDisappear$/, // UIKit lifecycle
|
||||
/^viewDidDisappear$/, // UIKit lifecycle
|
||||
/^application\(/, // AppDelegate methods
|
||||
/^scene\(/, // SceneDelegate methods
|
||||
/^body$/, // SwiftUI View.body
|
||||
/Coordinator$/, // Coordinator pattern
|
||||
/^sceneDidBecomeActive$/, // SceneDelegate lifecycle
|
||||
/^sceneWillResignActive$/, // SceneDelegate lifecycle
|
||||
/^didFinishLaunchingWithOptions$/, // AppDelegate
|
||||
/ViewController$/, // ViewController classes
|
||||
/^configure[A-Z]/, // Configuration methods
|
||||
/^setup[A-Z]/, // Setup methods
|
||||
/^makeBody$/, // SwiftUI ViewModifier
|
||||
],
|
||||
|
||||
// PHP / Laravel
|
||||
'php': [
|
||||
/Controller$/, // UserController (class name convention)
|
||||
/^handle$/, // Job::handle(), Listener::handle()
|
||||
/^execute$/, // Command::execute()
|
||||
/^boot$/, // ServiceProvider::boot()
|
||||
/^register$/, // ServiceProvider::register()
|
||||
/^__invoke$/, // Invokable controllers/actions
|
||||
/^(index|show|store|update|destroy|create|edit)$/, // RESTful resource methods
|
||||
/^(get|post|put|delete|patch)[A-Z]/, // Explicit HTTP method actions
|
||||
/^run$/, // Command/Job run()
|
||||
/^fire$/, // Event fire()
|
||||
/^dispatch$/, // Dispatchable jobs
|
||||
/Service$/, // UserService (Service layer)
|
||||
/Repository$/, // UserRepository (Repository pattern)
|
||||
/^find$/, // Repository::find()
|
||||
/^findAll$/, // Repository::findAll()
|
||||
/^save$/, // Repository::save()
|
||||
/^delete$/, // Repository::delete()
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -250,9 +291,18 @@ export function isTestFile(filePath: string): boolean {
|
||||
p.includes('/src/test/') ||
|
||||
// Rust test patterns (inline tests are different, but test files)
|
||||
p.includes('/tests/') ||
|
||||
// Swift/iOS test patterns
|
||||
p.endsWith('tests.swift') ||
|
||||
p.endsWith('test.swift') ||
|
||||
p.includes('uitests/') ||
|
||||
// C# test patterns
|
||||
p.includes('.tests/') ||
|
||||
p.includes('tests.cs')
|
||||
p.includes('tests.cs') ||
|
||||
// PHP/Laravel test patterns
|
||||
p.endsWith('test.php') ||
|
||||
p.endsWith('spec.php') ||
|
||||
p.includes('/tests/feature/') ||
|
||||
p.includes('/tests/unit/')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -195,22 +195,132 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
|
||||
return { framework: 'c-cpp', entryPointMultiplier: 2.5, reason: 'c-app' };
|
||||
}
|
||||
|
||||
// ========== PHP / LARAVEL FRAMEWORKS ==========
|
||||
|
||||
// Laravel routes (highest - these ARE the entry point definitions)
|
||||
if (p.includes('/routes/') && p.endsWith('.php')) {
|
||||
return { framework: 'laravel', entryPointMultiplier: 3.0, reason: 'laravel-routes' };
|
||||
}
|
||||
|
||||
// Laravel controllers (very high - receive HTTP requests)
|
||||
if ((p.includes('/http/controllers/') || p.includes('/controllers/')) && p.endsWith('.php')) {
|
||||
return { framework: 'laravel', entryPointMultiplier: 3.0, reason: 'laravel-controller' };
|
||||
}
|
||||
|
||||
// Laravel controller by file name convention
|
||||
if (p.endsWith('controller.php')) {
|
||||
return { framework: 'laravel', entryPointMultiplier: 3.0, reason: 'laravel-controller-file' };
|
||||
}
|
||||
|
||||
// Laravel console commands
|
||||
if ((p.includes('/console/commands/') || p.includes('/commands/')) && p.endsWith('.php')) {
|
||||
return { framework: 'laravel', entryPointMultiplier: 2.5, reason: 'laravel-command' };
|
||||
}
|
||||
|
||||
// Laravel jobs (queue entry points)
|
||||
if (p.includes('/jobs/') && p.endsWith('.php')) {
|
||||
return { framework: 'laravel', entryPointMultiplier: 2.5, reason: 'laravel-job' };
|
||||
}
|
||||
|
||||
// Laravel listeners (event-driven entry points)
|
||||
if (p.includes('/listeners/') && p.endsWith('.php')) {
|
||||
return { framework: 'laravel', entryPointMultiplier: 2.5, reason: 'laravel-listener' };
|
||||
}
|
||||
|
||||
// Laravel middleware
|
||||
if (p.includes('/http/middleware/') && p.endsWith('.php')) {
|
||||
return { framework: 'laravel', entryPointMultiplier: 2.5, reason: 'laravel-middleware' };
|
||||
}
|
||||
|
||||
// Laravel service providers
|
||||
if (p.includes('/providers/') && p.endsWith('.php')) {
|
||||
return { framework: 'laravel', entryPointMultiplier: 1.8, reason: 'laravel-provider' };
|
||||
}
|
||||
|
||||
// Laravel policies
|
||||
if (p.includes('/policies/') && p.endsWith('.php')) {
|
||||
return { framework: 'laravel', entryPointMultiplier: 2.0, reason: 'laravel-policy' };
|
||||
}
|
||||
|
||||
// Laravel models (important but not entry points per se)
|
||||
if (p.includes('/models/') && p.endsWith('.php')) {
|
||||
return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-model' };
|
||||
}
|
||||
|
||||
// Laravel services (Service Repository pattern)
|
||||
if (p.includes('/services/') && p.endsWith('.php')) {
|
||||
return { framework: 'laravel', entryPointMultiplier: 1.8, reason: 'laravel-service' };
|
||||
}
|
||||
|
||||
// Laravel repositories (Service Repository pattern)
|
||||
if (p.includes('/repositories/') && p.endsWith('.php')) {
|
||||
return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-repository' };
|
||||
}
|
||||
|
||||
// ========== SWIFT / iOS ==========
|
||||
|
||||
// iOS App entry points (highest priority)
|
||||
if (p.endsWith('/appdelegate.swift') || p.endsWith('/scenedelegate.swift') || p.endsWith('/app.swift')) {
|
||||
return { framework: 'ios', entryPointMultiplier: 3.0, reason: 'ios-app-entry' };
|
||||
}
|
||||
|
||||
// SwiftUI App entry (@main)
|
||||
if (p.endsWith('app.swift') && p.includes('/sources/')) {
|
||||
return { framework: 'swiftui', entryPointMultiplier: 3.0, reason: 'swiftui-app' };
|
||||
}
|
||||
|
||||
// UIKit ViewControllers (high priority - screen entry points)
|
||||
if ((p.includes('/viewcontrollers/') || p.includes('/controllers/') || p.includes('/screens/')) && p.endsWith('.swift')) {
|
||||
return { framework: 'uikit', entryPointMultiplier: 2.5, reason: 'uikit-viewcontroller' };
|
||||
}
|
||||
|
||||
// ViewController by filename convention
|
||||
if (p.endsWith('viewcontroller.swift') || p.endsWith('vc.swift')) {
|
||||
return { framework: 'uikit', entryPointMultiplier: 2.5, reason: 'uikit-viewcontroller-file' };
|
||||
}
|
||||
|
||||
// Coordinator pattern (navigation entry points)
|
||||
if (p.includes('/coordinators/') && p.endsWith('.swift')) {
|
||||
return { framework: 'ios-coordinator', entryPointMultiplier: 2.5, reason: 'ios-coordinator' };
|
||||
}
|
||||
|
||||
// Coordinator by filename
|
||||
if (p.endsWith('coordinator.swift')) {
|
||||
return { framework: 'ios-coordinator', entryPointMultiplier: 2.5, reason: 'ios-coordinator-file' };
|
||||
}
|
||||
|
||||
// SwiftUI Views (moderate - reusable components)
|
||||
if ((p.includes('/views/') || p.includes('/scenes/')) && p.endsWith('.swift')) {
|
||||
return { framework: 'swiftui', entryPointMultiplier: 1.8, reason: 'swiftui-view' };
|
||||
}
|
||||
|
||||
// Service layer
|
||||
if (p.includes('/services/') && p.endsWith('.swift')) {
|
||||
return { framework: 'ios-service', entryPointMultiplier: 1.8, reason: 'ios-service' };
|
||||
}
|
||||
|
||||
// Router / navigation
|
||||
if (p.includes('/router/') && p.endsWith('.swift')) {
|
||||
return { framework: 'ios-router', entryPointMultiplier: 2.0, reason: 'ios-router' };
|
||||
}
|
||||
|
||||
// ========== GENERIC PATTERNS ==========
|
||||
|
||||
|
||||
// Any language: index files in API folders
|
||||
if (p.includes('/api/') && (
|
||||
p.endsWith('/index.ts') || p.endsWith('/index.js') ||
|
||||
p.endsWith('/index.ts') || p.endsWith('/index.js') ||
|
||||
p.endsWith('/__init__.py')
|
||||
)) {
|
||||
return { framework: 'api', entryPointMultiplier: 1.8, reason: 'api-index' };
|
||||
}
|
||||
|
||||
|
||||
// No framework detected - return null for graceful fallback (1.0 multiplier)
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FUTURE: AST-BASED PATTERNS (for Phase 3)
|
||||
// PARTIALLY IMPLEMENTED: Route::* detection via procedural AST walk in parse-worker/call-processor
|
||||
// Remaining: NestJS, Express, FastAPI, Flask, Spring, etc.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
@@ -235,9 +345,18 @@ export const FRAMEWORK_AST_PATTERNS = {
|
||||
|
||||
// Go patterns (function signatures)
|
||||
'go-http': ['http.Handler', 'http.HandlerFunc', 'ServeHTTP'],
|
||||
|
||||
|
||||
// PHP/Laravel
|
||||
'laravel': ['Route::get', 'Route::post', 'Route::put', 'Route::delete',
|
||||
'Route::resource', 'Route::apiResource', '#[Route('],
|
||||
|
||||
// Rust macros
|
||||
'actix': ['#[get', '#[post', '#[put', '#[delete'],
|
||||
'axum': ['Router::new'],
|
||||
'rocket': ['#[get', '#[post'],
|
||||
|
||||
// Swift/iOS
|
||||
'uikit': ['viewDidLoad', 'viewWillAppear', 'viewDidAppear', 'UIViewController'],
|
||||
'swiftui': ['@main', 'WindowGroup', 'ContentView', '@StateObject', '@ObservedObject'],
|
||||
'combine': ['sink', 'assign', 'Publisher', 'Subscriber'],
|
||||
};
|
||||
|
||||
@@ -317,6 +317,138 @@ export const RUST_QUERIES = `
|
||||
(impl_item trait: (generic_type type: (type_identifier) @heritage.trait) type: (type_identifier) @heritage.class) @heritage
|
||||
`;
|
||||
|
||||
// PHP queries - works with tree-sitter-php (php_only grammar)
|
||||
export const PHP_QUERIES = `
|
||||
; ── Namespace ────────────────────────────────────────────────────────────────
|
||||
(namespace_definition
|
||||
name: (namespace_name) @name) @definition.namespace
|
||||
|
||||
; ── Classes ──────────────────────────────────────────────────────────────────
|
||||
(class_declaration
|
||||
name: (name) @name) @definition.class
|
||||
|
||||
; ── Interfaces ───────────────────────────────────────────────────────────────
|
||||
(interface_declaration
|
||||
name: (name) @name) @definition.interface
|
||||
|
||||
; ── Traits ───────────────────────────────────────────────────────────────────
|
||||
(trait_declaration
|
||||
name: (name) @name) @definition.trait
|
||||
|
||||
; ── Enums (PHP 8.1) ──────────────────────────────────────────────────────────
|
||||
(enum_declaration
|
||||
name: (name) @name) @definition.enum
|
||||
|
||||
; ── Top-level functions ───────────────────────────────────────────────────────
|
||||
(function_definition
|
||||
name: (name) @name) @definition.function
|
||||
|
||||
; ── Methods (including constructors) ─────────────────────────────────────────
|
||||
(method_declaration
|
||||
name: (name) @name) @definition.method
|
||||
|
||||
; ── Class properties (including Eloquent $fillable, $casts, etc.) ────────────
|
||||
(property_declaration
|
||||
(property_element
|
||||
(variable_name
|
||||
(name) @name))) @definition.property
|
||||
|
||||
; ── Imports: use statements ──────────────────────────────────────────────────
|
||||
; Simple: use App\\Models\\User;
|
||||
(namespace_use_declaration
|
||||
(namespace_use_clause
|
||||
(qualified_name) @import.source)) @import
|
||||
|
||||
; ── Function/method calls ────────────────────────────────────────────────────
|
||||
; Regular function call: foo()
|
||||
(function_call_expression
|
||||
function: (name) @call.name) @call
|
||||
|
||||
; Method call: $obj->method()
|
||||
(member_call_expression
|
||||
name: (name) @call.name) @call
|
||||
|
||||
; Nullsafe method call: $obj?->method()
|
||||
(nullsafe_member_call_expression
|
||||
name: (name) @call.name) @call
|
||||
|
||||
; Static call: Foo::bar() (php_only uses scoped_call_expression)
|
||||
(scoped_call_expression
|
||||
name: (name) @call.name) @call
|
||||
|
||||
; ── Heritage: extends ────────────────────────────────────────────────────────
|
||||
(class_declaration
|
||||
name: (name) @heritage.class
|
||||
(base_clause
|
||||
[(name) (qualified_name)] @heritage.extends)) @heritage
|
||||
|
||||
; ── Heritage: implements ─────────────────────────────────────────────────────
|
||||
(class_declaration
|
||||
name: (name) @heritage.class
|
||||
(class_interface_clause
|
||||
[(name) (qualified_name)] @heritage.implements)) @heritage.impl
|
||||
|
||||
; ── Heritage: use trait (must capture enclosing class name) ──────────────────
|
||||
(class_declaration
|
||||
name: (name) @heritage.class
|
||||
body: (declaration_list
|
||||
(use_declaration
|
||||
[(name) (qualified_name)] @heritage.trait))) @heritage
|
||||
`;
|
||||
|
||||
// Swift queries - works with tree-sitter-swift
|
||||
export const SWIFT_QUERIES = `
|
||||
; Classes
|
||||
(class_declaration "class" name: (type_identifier) @name) @definition.class
|
||||
|
||||
; Structs
|
||||
(class_declaration "struct" name: (type_identifier) @name) @definition.struct
|
||||
|
||||
; Enums
|
||||
(class_declaration "enum" name: (type_identifier) @name) @definition.enum
|
||||
|
||||
; Extensions (mapped to class — no dedicated label in schema)
|
||||
(class_declaration "extension" name: (user_type (type_identifier) @name)) @definition.class
|
||||
|
||||
; Actors
|
||||
(class_declaration "actor" name: (type_identifier) @name) @definition.class
|
||||
|
||||
; Protocols (mapped to interface)
|
||||
(protocol_declaration name: (type_identifier) @name) @definition.interface
|
||||
|
||||
; Type aliases
|
||||
(typealias_declaration name: (type_identifier) @name) @definition.type
|
||||
|
||||
; Functions (top-level and methods)
|
||||
(function_declaration name: (simple_identifier) @name) @definition.function
|
||||
|
||||
; Protocol method declarations
|
||||
(protocol_function_declaration name: (simple_identifier) @name) @definition.method
|
||||
|
||||
; Initializers
|
||||
(init_declaration) @definition.constructor
|
||||
|
||||
; Properties (stored and computed)
|
||||
(property_declaration (pattern (simple_identifier) @name)) @definition.property
|
||||
|
||||
; Imports
|
||||
(import_declaration (identifier (simple_identifier) @import.source)) @import
|
||||
|
||||
; Calls - direct function calls
|
||||
(call_expression (simple_identifier) @call.name) @call
|
||||
|
||||
; Calls - member/navigation calls (obj.method())
|
||||
(call_expression (navigation_expression (navigation_suffix (simple_identifier) @call.name))) @call
|
||||
|
||||
; Heritage - class/struct/enum inheritance and protocol conformance
|
||||
(class_declaration name: (type_identifier) @heritage.class
|
||||
(inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage
|
||||
|
||||
; Heritage - protocol inheritance
|
||||
(protocol_declaration name: (type_identifier) @heritage.class
|
||||
(inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage
|
||||
`;
|
||||
|
||||
export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
|
||||
[SupportedLanguages.TypeScript]: TYPESCRIPT_QUERIES,
|
||||
[SupportedLanguages.JavaScript]: JAVASCRIPT_QUERIES,
|
||||
@@ -327,5 +459,7 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
|
||||
[SupportedLanguages.CPlusPlus]: CPP_QUERIES,
|
||||
[SupportedLanguages.CSharp]: CSHARP_QUERIES,
|
||||
[SupportedLanguages.Rust]: RUST_QUERIES,
|
||||
[SupportedLanguages.PHP]: PHP_QUERIES,
|
||||
[SupportedLanguages.Swift]: SWIFT_QUERIES,
|
||||
};
|
||||
|
||||
@@ -25,6 +25,14 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages |
|
||||
if (filename.endsWith('.go')) return SupportedLanguages.Go;
|
||||
// Rust
|
||||
if (filename.endsWith('.rs')) return SupportedLanguages.Rust;
|
||||
// PHP (all common extensions)
|
||||
if (filename.endsWith('.php') || filename.endsWith('.phtml') ||
|
||||
filename.endsWith('.php3') || filename.endsWith('.php4') ||
|
||||
filename.endsWith('.php5') || filename.endsWith('.php8')) {
|
||||
return SupportedLanguages.PHP;
|
||||
}
|
||||
// Swift
|
||||
if (filename.endsWith('.swift')) return SupportedLanguages.Swift;
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ const getWasmPath = (language: SupportedLanguages, filePath?: string): string =>
|
||||
[SupportedLanguages.CSharp]: '/wasm/csharp/tree-sitter-csharp.wasm',
|
||||
[SupportedLanguages.Go]: '/wasm/go/tree-sitter-go.wasm',
|
||||
[SupportedLanguages.Rust]: '/wasm/rust/tree-sitter-rust.wasm',
|
||||
[SupportedLanguages.PHP]: '/wasm/php/tree-sitter-php.wasm',
|
||||
[SupportedLanguages.Swift]: '/wasm/swift/tree-sitter-swift.wasm',
|
||||
};
|
||||
|
||||
return languageFileMap[language];
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { LLMSettings, ProviderConfig, AgentStreamChunk, ChatMessage, ToolCa
|
||||
import { loadSettings, getActiveProviderConfig, saveSettings } from '../core/llm/settings-service';
|
||||
import type { AgentMessage } from '../core/llm/agent';
|
||||
import { DEFAULT_VISIBLE_EDGES, type EdgeType } from '../lib/constants';
|
||||
import type { RepoSummary, ConnectToServerResult } from '../services/server-connection';
|
||||
import { fetchRepos, connectToServer } from '../services/server-connection';
|
||||
|
||||
export type ViewMode = 'onboarding' | 'loading' | 'exploring';
|
||||
export type RightPanelTab = 'code' | 'chat';
|
||||
@@ -111,6 +113,13 @@ interface AppState {
|
||||
projectName: string;
|
||||
setProjectName: (name: string) => void;
|
||||
|
||||
// Multi-repo switching
|
||||
serverBaseUrl: string | null;
|
||||
setServerBaseUrl: (url: string | null) => void;
|
||||
availableRepos: RepoSummary[];
|
||||
setAvailableRepos: (repos: RepoSummary[]) => void;
|
||||
switchRepo: (repoName: string) => Promise<void>;
|
||||
|
||||
// Worker API (shared across app)
|
||||
runPipeline: (file: File, onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise<PipelineResult>;
|
||||
runPipelineFromFiles: (files: FileEntry[], onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise<PipelineResult>;
|
||||
@@ -270,6 +279,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
// Project info
|
||||
const [projectName, setProjectName] = useState<string>('');
|
||||
|
||||
// Multi-repo switching
|
||||
const [serverBaseUrl, setServerBaseUrl] = useState<string | null>(null);
|
||||
const [availableRepos, setAvailableRepos] = useState<RepoSummary[]>([]);
|
||||
|
||||
// Embedding state
|
||||
const [embeddingStatus, setEmbeddingStatus] = useState<EmbeddingStatus>('idle');
|
||||
const [embeddingProgress, setEmbeddingProgress] = useState<EmbeddingProgress | null>(null);
|
||||
@@ -291,7 +304,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [isCodePanelOpen, setCodePanelOpen] = useState(false);
|
||||
const [codeReferenceFocus, setCodeReferenceFocus] = useState<CodeReferenceFocus | null>(null);
|
||||
|
||||
const normalizePath = useCallback((p: string) => {
|
||||
const normalizePath = useCallback((p: string) => {
|
||||
return p.replace(/\\/g, '/').replace(/^\.?\//, '');
|
||||
}, []);
|
||||
|
||||
@@ -959,6 +972,73 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
setAgentError(null);
|
||||
}, []);
|
||||
|
||||
// Switch to a different repo on the connected server
|
||||
const switchRepo = useCallback(async (repoName: string) => {
|
||||
if (!serverBaseUrl) return;
|
||||
|
||||
setProgress({ phase: 'extracting', percent: 0, message: 'Switching repository...', detail: `Loading ${repoName}` });
|
||||
setViewMode('loading');
|
||||
|
||||
// Clear stale graph state from previous repo (highlights, selections, blast radius)
|
||||
// Without this, sigma reducers dim ALL nodes/edges because old node IDs don't match
|
||||
setHighlightedNodeIds(new Set());
|
||||
clearAIToolHighlights();
|
||||
clearBlastRadius();
|
||||
setSelectedNode(null);
|
||||
setQueryResult(null);
|
||||
setCodeReferences([]);
|
||||
setCodePanelOpen(false);
|
||||
setCodeReferenceFocus(null);
|
||||
|
||||
try {
|
||||
const result: ConnectToServerResult = await connectToServer(serverBaseUrl, (phase, downloaded, total) => {
|
||||
if (phase === 'validating') {
|
||||
setProgress({ phase: 'extracting', percent: 5, message: 'Switching repository...', detail: 'Validating' });
|
||||
} else if (phase === 'downloading') {
|
||||
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
|
||||
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
||||
setProgress({ phase: 'extracting', percent: pct, message: 'Downloading graph...', detail: `${mb} MB downloaded` });
|
||||
} else if (phase === 'extracting') {
|
||||
setProgress({ phase: 'extracting', percent: 97, message: 'Processing...', detail: 'Extracting file contents' });
|
||||
}
|
||||
}, undefined, repoName);
|
||||
|
||||
// Reuse the same handleServerConnect logic inline
|
||||
const repoPath = result.repoInfo.repoPath;
|
||||
const pName = result.repoInfo.name || repoPath.split('/').pop() || 'server-project';
|
||||
setProjectName(pName);
|
||||
|
||||
const graph = createKnowledgeGraph();
|
||||
for (const node of result.nodes) graph.addNode(node);
|
||||
for (const rel of result.relationships) graph.addRelationship(rel);
|
||||
setGraph(graph);
|
||||
|
||||
const fileMap = new Map<string, string>();
|
||||
for (const [p, c] of Object.entries(result.fileContents)) fileMap.set(p, c);
|
||||
setFileContents(fileMap);
|
||||
|
||||
setViewMode('exploring');
|
||||
|
||||
if (getActiveProviderConfig()) initializeAgent(pName);
|
||||
|
||||
startEmbeddings().catch((err) => {
|
||||
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
|
||||
startEmbeddings('wasm').catch(console.warn);
|
||||
} else {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Repo switch failed:', err);
|
||||
setProgress({
|
||||
phase: 'error', percent: 0,
|
||||
message: 'Failed to switch repository',
|
||||
detail: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
setTimeout(() => { setViewMode('exploring'); setProgress(null); }, 3000);
|
||||
}
|
||||
}, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, initializeAgent, startEmbeddings, setHighlightedNodeIds, clearAIToolHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]);
|
||||
|
||||
const removeCodeReference = useCallback((id: string) => {
|
||||
setCodeReferences(prev => {
|
||||
const ref = prev.find(r => r.id === id);
|
||||
@@ -1052,6 +1132,12 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
setProgress,
|
||||
projectName,
|
||||
setProjectName,
|
||||
// Multi-repo switching
|
||||
serverBaseUrl,
|
||||
setServerBaseUrl,
|
||||
availableRepos,
|
||||
setAvailableRepos,
|
||||
switchRepo,
|
||||
runPipeline,
|
||||
runPipelineFromFiles,
|
||||
runQuery,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
probeBackend,
|
||||
fetchRepos,
|
||||
setBackendUrl as setServiceUrl,
|
||||
getBackendUrl,
|
||||
type BackendRepo,
|
||||
} from '../services/backend';
|
||||
|
||||
// ── localStorage keys ────────────────────────────────────────────────────────
|
||||
|
||||
const LS_URL_KEY = 'gitnexus-backend-url';
|
||||
const LS_REPO_KEY = 'gitnexus-backend-repo';
|
||||
const DEFAULT_URL = 'http://localhost:4747';
|
||||
|
||||
// ── Debounce delay ───────────────────────────────────────────────────────────
|
||||
|
||||
const DEBOUNCE_MS = 500;
|
||||
|
||||
// ── Public interface ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseBackendResult {
|
||||
/** Backend probe succeeded */
|
||||
isConnected: boolean;
|
||||
/** Currently checking connection */
|
||||
isProbing: boolean;
|
||||
/** Current backend URL */
|
||||
backendUrl: string;
|
||||
|
||||
/** Available repos from the server */
|
||||
repos: BackendRepo[];
|
||||
/** Currently selected repo name */
|
||||
selectedRepo: string | null;
|
||||
|
||||
/** Change the backend URL, persist to localStorage, and re-probe */
|
||||
setBackendUrl: (url: string) => void;
|
||||
/** Select a repo (persisted to localStorage) */
|
||||
selectRepo: (name: string) => void;
|
||||
/** Manually re-check the backend connection */
|
||||
probe: () => Promise<boolean>;
|
||||
/** Clear connection state and go back to browser-only mode */
|
||||
disconnect: () => void;
|
||||
}
|
||||
|
||||
// ── Hook implementation ──────────────────────────────────────────────────────
|
||||
|
||||
export function useBackend(): UseBackendResult {
|
||||
// Read persisted values on first render only
|
||||
const [backendUrl, setUrlState] = useState<string>(() => {
|
||||
try {
|
||||
return localStorage.getItem(LS_URL_KEY) ?? DEFAULT_URL;
|
||||
} catch {
|
||||
return DEFAULT_URL;
|
||||
}
|
||||
});
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isProbing, setIsProbing] = useState(false);
|
||||
const [repos, setRepos] = useState<BackendRepo[]>([]);
|
||||
const [selectedRepo, setSelectedRepo] = useState<string | null>(() => {
|
||||
try {
|
||||
return localStorage.getItem(LS_REPO_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Race-condition guard: monotonically increasing probe ID
|
||||
const probeIdRef = useRef(0);
|
||||
// Debounce timer handle
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// ── Core probe logic (not debounced) ─────────────────────────────────────
|
||||
|
||||
const probe = useCallback(async (): Promise<boolean> => {
|
||||
const id = ++probeIdRef.current;
|
||||
setIsProbing(true);
|
||||
|
||||
try {
|
||||
const ok = await probeBackend();
|
||||
|
||||
// If a newer probe was started while we were in-flight, discard this result
|
||||
if (id !== probeIdRef.current) return false;
|
||||
|
||||
setIsConnected(ok);
|
||||
|
||||
if (ok) {
|
||||
try {
|
||||
const repoList = await fetchRepos();
|
||||
// Re-check: still the latest probe?
|
||||
if (id !== probeIdRef.current) return false;
|
||||
setRepos(repoList);
|
||||
} catch {
|
||||
if (id === probeIdRef.current) {
|
||||
setRepos([]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setRepos([]);
|
||||
}
|
||||
|
||||
return ok;
|
||||
} catch {
|
||||
if (id === probeIdRef.current) {
|
||||
setIsConnected(false);
|
||||
setRepos([]);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (id === probeIdRef.current) {
|
||||
setIsProbing(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── setBackendUrl: persist, update service, trigger debounced re-probe ───
|
||||
|
||||
const setBackendUrl = useCallback(
|
||||
(url: string) => {
|
||||
setUrlState(url);
|
||||
setServiceUrl(url);
|
||||
|
||||
try {
|
||||
localStorage.setItem(LS_URL_KEY, url);
|
||||
} catch {
|
||||
// localStorage may be unavailable (e.g. incognito quota exceeded)
|
||||
}
|
||||
|
||||
// Debounce: clear any pending probe, schedule a new one
|
||||
if (debounceRef.current !== null) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
void probe();
|
||||
}, DEBOUNCE_MS);
|
||||
},
|
||||
[probe],
|
||||
);
|
||||
|
||||
// ── selectRepo: persist and update state ─────────────────────────────────
|
||||
|
||||
const selectRepo = useCallback((name: string) => {
|
||||
setSelectedRepo(name);
|
||||
try {
|
||||
localStorage.setItem(LS_REPO_KEY, name);
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── disconnect: clear connection state (URL stays in localStorage) ───────
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
// Bump probe ID so any in-flight probe is ignored
|
||||
probeIdRef.current++;
|
||||
setIsConnected(false);
|
||||
setIsProbing(false);
|
||||
setRepos([]);
|
||||
setSelectedRepo(null);
|
||||
try {
|
||||
localStorage.removeItem(LS_REPO_KEY);
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Mount: sync service URL + auto-probe ─────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure the service module is in sync with the persisted URL
|
||||
setServiceUrl(backendUrl);
|
||||
void probe();
|
||||
|
||||
// Cleanup debounce timer on unmount
|
||||
return () => {
|
||||
if (debounceRef.current !== null) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
// Only run on mount — backendUrl and probe are stable refs from useState/useCallback
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
isProbing,
|
||||
backendUrl,
|
||||
repos,
|
||||
selectedRepo,
|
||||
setBackendUrl,
|
||||
selectRepo,
|
||||
probe,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Stateless HTTP client for the local GitNexus backend server.
|
||||
* All functions use fetch() with AbortController timeouts.
|
||||
*/
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BackendRepo {
|
||||
name: string;
|
||||
path: string;
|
||||
indexedAt: string;
|
||||
lastCommit: string;
|
||||
stats?: {
|
||||
files?: number;
|
||||
nodes?: number;
|
||||
edges?: number;
|
||||
communities?: number;
|
||||
processes?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Configuration ──────────────────────────────────────────────────────────
|
||||
|
||||
let backendUrl = 'http://localhost:4747';
|
||||
|
||||
export const setBackendUrl = (url: string): void => {
|
||||
backendUrl = url.replace(/\/$/, '');
|
||||
};
|
||||
|
||||
export const getBackendUrl = (): string => backendUrl;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const PROBE_TIMEOUT_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Perform a fetch with an AbortController timeout.
|
||||
* Throws a cleaner error message on network failures.
|
||||
*/
|
||||
const fetchWithTimeout = async (
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<Response> => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { ...init, signal: controller.signal });
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
if (error instanceof TypeError) {
|
||||
throw new Error(`Network error reaching GitNexus backend at ${backendUrl}: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert the response is OK, otherwise throw with the server's error message if available.
|
||||
*/
|
||||
const assertOk = async (response: Response): Promise<void> => {
|
||||
if (response.ok) return;
|
||||
|
||||
let message = `Backend returned ${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (body && typeof body.error === 'string') {
|
||||
message = body.error;
|
||||
}
|
||||
} catch {
|
||||
// Response body was not JSON — use the status text
|
||||
}
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
// ── API functions ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Probe the backend to check if it is reachable.
|
||||
* Uses a short 2-second timeout. Returns true if reachable, false otherwise.
|
||||
*/
|
||||
export const probeBackend = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
`${backendUrl}/api/repos`,
|
||||
{},
|
||||
PROBE_TIMEOUT_MS,
|
||||
);
|
||||
return response.status === 200;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the list of indexed repositories.
|
||||
*/
|
||||
export const fetchRepos = async (): Promise<BackendRepo[]> => {
|
||||
const response = await fetchWithTimeout(`${backendUrl}/api/repos`);
|
||||
await assertOk(response);
|
||||
return response.json() as Promise<BackendRepo[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the full graph (nodes + relationships) for a repository.
|
||||
*/
|
||||
export const fetchGraph = async (
|
||||
repo: string,
|
||||
): Promise<{ nodes: unknown[]; relationships: unknown[] }> => {
|
||||
// Graph loading can take a while for large repos — use 60s timeout
|
||||
const response = await fetchWithTimeout(
|
||||
`${backendUrl}/api/graph?repo=${encodeURIComponent(repo)}`,
|
||||
{},
|
||||
60_000,
|
||||
);
|
||||
await assertOk(response);
|
||||
return response.json() as Promise<{ nodes: unknown[]; relationships: unknown[] }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a raw Cypher query against the repository's graph.
|
||||
* Unwraps the `{ result }` wrapper returned by the server.
|
||||
*/
|
||||
export const runCypherQuery = async (
|
||||
repo: string,
|
||||
cypher: string,
|
||||
): Promise<unknown[]> => {
|
||||
const response = await fetchWithTimeout(`${backendUrl}/api/query`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cypher, repo }),
|
||||
});
|
||||
await assertOk(response);
|
||||
|
||||
const body = await response.json();
|
||||
if (body && typeof body.error === 'string') {
|
||||
throw new Error(body.error);
|
||||
}
|
||||
return (body.result ?? body) as unknown[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a semantic search across the repository's graph.
|
||||
*/
|
||||
export const runSearch = async (
|
||||
repo: string,
|
||||
query: string,
|
||||
limit?: number,
|
||||
): Promise<unknown> => {
|
||||
const response = await fetchWithTimeout(`${backendUrl}/api/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, limit, repo }),
|
||||
});
|
||||
await assertOk(response);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the source content of a file in a repository.
|
||||
*/
|
||||
export const fetchFileContent = async (
|
||||
repo: string,
|
||||
filePath: string,
|
||||
): Promise<string> => {
|
||||
const response = await fetchWithTimeout(
|
||||
`${backendUrl}/api/file?repo=${encodeURIComponent(repo)}&path=${encodeURIComponent(filePath)}`,
|
||||
);
|
||||
await assertOk(response);
|
||||
|
||||
const body = (await response.json()) as { content: string };
|
||||
return body.content;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch all execution-flow processes for a repository.
|
||||
*/
|
||||
export const fetchProcesses = async (repo: string): Promise<unknown> => {
|
||||
const response = await fetchWithTimeout(
|
||||
`${backendUrl}/api/processes?repo=${encodeURIComponent(repo)}`,
|
||||
);
|
||||
await assertOk(response);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the detailed step-by-step trace for a single process.
|
||||
*/
|
||||
export const fetchProcessDetail = async (
|
||||
repo: string,
|
||||
name: string,
|
||||
): Promise<unknown> => {
|
||||
const response = await fetchWithTimeout(
|
||||
`${backendUrl}/api/process?repo=${encodeURIComponent(repo)}&name=${encodeURIComponent(name)}`,
|
||||
);
|
||||
await assertOk(response);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch all functional-area clusters for a repository.
|
||||
*/
|
||||
export const fetchClusters = async (repo: string): Promise<unknown> => {
|
||||
const response = await fetchWithTimeout(
|
||||
`${backendUrl}/api/clusters?repo=${encodeURIComponent(repo)}`,
|
||||
);
|
||||
await assertOk(response);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the members of a single cluster.
|
||||
*/
|
||||
export const fetchClusterDetail = async (
|
||||
repo: string,
|
||||
name: string,
|
||||
): Promise<unknown> => {
|
||||
const response = await fetchWithTimeout(
|
||||
`${backendUrl}/api/cluster?repo=${encodeURIComponent(repo)}&name=${encodeURIComponent(name)}`,
|
||||
);
|
||||
await assertOk(response);
|
||||
return response.json();
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import { GraphNode, GraphRelationship } from '../core/graph/types';
|
||||
|
||||
export interface RepoSummary {
|
||||
name: string;
|
||||
path: string;
|
||||
indexedAt: string;
|
||||
lastCommit: string;
|
||||
stats: {
|
||||
files: number;
|
||||
nodes: number;
|
||||
edges: number;
|
||||
communities: number;
|
||||
processes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServerRepoInfo {
|
||||
name: string;
|
||||
repoPath: string;
|
||||
indexedAt: string;
|
||||
stats: {
|
||||
files: number;
|
||||
nodes: number;
|
||||
edges: number;
|
||||
communities: number;
|
||||
processes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConnectToServerResult {
|
||||
nodes: GraphNode[];
|
||||
relationships: GraphRelationship[];
|
||||
fileContents: Record<string, string>;
|
||||
repoInfo: ServerRepoInfo;
|
||||
}
|
||||
|
||||
export function normalizeServerUrl(input: string): string {
|
||||
let url = input.trim();
|
||||
|
||||
// Strip trailing slashes
|
||||
url = url.replace(/\/+$/, '');
|
||||
|
||||
// Add protocol if missing
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
if (url.startsWith('localhost') || url.startsWith('127.0.0.1')) {
|
||||
url = `http://${url}`;
|
||||
} else {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add /api if not already present
|
||||
if (!url.endsWith('/api')) {
|
||||
url = `${url}/api`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function fetchRepos(baseUrl: string): Promise<RepoSummary[]> {
|
||||
const response = await fetch(`${baseUrl}/repos`);
|
||||
if (!response.ok) throw new Error(`Server returned ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchRepoInfo(baseUrl: string, repoName?: string): Promise<ServerRepoInfo> {
|
||||
const url = repoName ? `${baseUrl}/repo?repo=${encodeURIComponent(repoName)}` : `${baseUrl}/repo`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
// npm gitnexus@1.3.3 returns "path"; git HEAD returns "repoPath"
|
||||
return { ...data, repoPath: data.repoPath ?? data.path };
|
||||
}
|
||||
|
||||
export async function fetchGraph(
|
||||
baseUrl: string,
|
||||
onProgress?: (downloaded: number, total: number | null) => void,
|
||||
signal?: AbortSignal,
|
||||
repoName?: string
|
||||
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> {
|
||||
const url = repoName ? `${baseUrl}/graph?repo=${encodeURIComponent(repoName)}` : `${baseUrl}/graph`;
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
const total = contentLength ? parseInt(contentLength, 10) : null;
|
||||
|
||||
if (!response.body) {
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let downloaded = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
chunks.push(value);
|
||||
downloaded += value.length;
|
||||
onProgress?.(downloaded, total);
|
||||
}
|
||||
|
||||
const combined = new Uint8Array(downloaded);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
combined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
const text = new TextDecoder().decode(combined);
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
export function extractFileContents(nodes: GraphNode[]): Record<string, string> {
|
||||
const contents: Record<string, string> = {};
|
||||
for (const node of nodes) {
|
||||
if (node.label === 'File' && (node.properties as any).content) {
|
||||
contents[node.properties.filePath] = (node.properties as any).content;
|
||||
}
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
export async function connectToServer(
|
||||
url: string,
|
||||
onProgress?: (phase: string, downloaded: number, total: number | null) => void,
|
||||
signal?: AbortSignal,
|
||||
repoName?: string
|
||||
): Promise<ConnectToServerResult> {
|
||||
const baseUrl = normalizeServerUrl(url);
|
||||
|
||||
// Phase 1: Validate server
|
||||
onProgress?.('validating', 0, null);
|
||||
const repoInfo = await fetchRepoInfo(baseUrl, repoName);
|
||||
|
||||
// Phase 2: Download graph
|
||||
onProgress?.('downloading', 0, null);
|
||||
const { nodes, relationships } = await fetchGraph(
|
||||
baseUrl,
|
||||
(downloaded, total) => onProgress?.('downloading', downloaded, total),
|
||||
signal,
|
||||
repoName
|
||||
);
|
||||
|
||||
// Phase 3: Extract file contents
|
||||
onProgress?.('extracting', 0, null);
|
||||
const fileContents = extractFileContents(nodes);
|
||||
|
||||
return { nodes, relationships, fileContents, repoInfo };
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { SystemMessage } from '@langchain/core/messages';
|
||||
import { enrichClustersBatch, ClusterMemberInfo, ClusterEnrichment } from '../core/ingestion/cluster-enricher';
|
||||
import { CommunityNode } from '../core/ingestion/community-processor';
|
||||
import { PipelineResult } from '../types/pipeline';
|
||||
import { buildCodebaseContext } from '../core/llm/context-builder';
|
||||
import { buildCodebaseContext, type CodebaseContext } from '../core/llm/context-builder';
|
||||
import {
|
||||
buildBM25Index,
|
||||
searchBM25,
|
||||
@@ -54,6 +54,91 @@ let enrichmentCancelled = false;
|
||||
// Chat cancellation flag
|
||||
let chatCancelled = false;
|
||||
|
||||
// ============================================================
|
||||
// HTTP helpers for backend mode
|
||||
// ============================================================
|
||||
|
||||
const httpFetchWithTimeout = async (
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs: number = 30_000,
|
||||
): Promise<Response> => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
const createHttpExecuteQuery = (backendUrl: string, repo: string) => {
|
||||
return async (cypher: string): Promise<any[]> => {
|
||||
const response = await httpFetchWithTimeout(`${backendUrl}/api/query`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cypher, repo }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Backend query failed: ${response.status}`);
|
||||
}
|
||||
const body = await response.json();
|
||||
return (body.result ?? body) as any[];
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a search function that calls the backend's /api/search endpoint,
|
||||
* which runs full hybrid search (BM25 + semantic + RRF) on the server.
|
||||
* Results are flattened from the process-grouped response into the flat
|
||||
* array format expected by createGraphRAGTools.
|
||||
*/
|
||||
const createHttpHybridSearch = (backendUrl: string, repo: string) => {
|
||||
return async (query: string, k: number = 15): Promise<any[]> => {
|
||||
try {
|
||||
const response = await httpFetchWithTimeout(`${backendUrl}/api/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, limit: k, repo }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
const body = await response.json();
|
||||
const data = body.results ?? body;
|
||||
|
||||
// Flatten process_symbols + definitions into a single ranked list
|
||||
const symbols: any[] = (data.process_symbols ?? []).map((s: any, i: number) => ({
|
||||
nodeId: s.id,
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
label: s.type,
|
||||
filePath: s.filePath,
|
||||
startLine: s.startLine,
|
||||
endLine: s.endLine,
|
||||
content: s.content ?? '',
|
||||
sources: ['bm25', 'semantic'],
|
||||
score: 1 - (i * 0.02),
|
||||
}));
|
||||
|
||||
const defs: any[] = (data.definitions ?? []).map((d: any, i: number) => ({
|
||||
id: d.name,
|
||||
name: d.name,
|
||||
label: d.type || 'File',
|
||||
filePath: d.filePath,
|
||||
content: '',
|
||||
sources: ['bm25'],
|
||||
score: 0.5 - (i * 0.02),
|
||||
}));
|
||||
|
||||
return [...symbols, ...defs].slice(0, k);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Worker API exposed via Comlink
|
||||
*
|
||||
@@ -540,6 +625,70 @@ const workerApi = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize the Graph RAG agent in backend mode (HTTP-backed tools).
|
||||
* Uses HTTP wrappers instead of local KuzuDB for all tool queries.
|
||||
* @param config - Provider configuration for the LLM
|
||||
* @param backendUrl - Base URL of the gitnexus serve backend
|
||||
* @param repoName - Repository name on the backend
|
||||
* @param fileContentsEntries - File contents as [path, content][] (Comlink can't transfer Maps)
|
||||
* @param projectName - Display name for the project
|
||||
*/
|
||||
async initializeBackendAgent(
|
||||
config: ProviderConfig,
|
||||
backendUrl: string,
|
||||
repoName: string,
|
||||
fileContentsEntries: [string, string][],
|
||||
projectName?: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Rebuild Map from serializable entries (Comlink can't transfer Maps)
|
||||
const contents = new Map<string, string>(fileContentsEntries);
|
||||
storedFileContents = contents;
|
||||
|
||||
// Create HTTP-based tool wrappers
|
||||
const executeQuery = createHttpExecuteQuery(backendUrl, repoName);
|
||||
const hybridSearch = createHttpHybridSearch(backendUrl, repoName);
|
||||
|
||||
// Build codebase context (uses Cypher queries — works via HTTP)
|
||||
let codebaseContext: CodebaseContext | undefined;
|
||||
try {
|
||||
codebaseContext = await buildCodebaseContext(executeQuery, projectName || repoName);
|
||||
} catch {
|
||||
// Non-fatal — agent works without context
|
||||
}
|
||||
|
||||
// Create agent with HTTP-backed tools.
|
||||
// hybridSearch calls /api/search which runs full BM25 + semantic + RRF on the server.
|
||||
// isEmbeddingReady is false — no local embedding model is loaded in backend mode.
|
||||
// isBM25Ready is true — BM25 is available via the server's hybrid search.
|
||||
currentAgent = createGraphRAGAgent(
|
||||
config,
|
||||
executeQuery, // Cypher via HTTP
|
||||
hybridSearch, // semanticSearch → server hybrid search
|
||||
hybridSearch, // semanticSearchWithContext → same
|
||||
hybridSearch, // hybridSearch → server hybrid search
|
||||
() => false, // isEmbeddingReady → no local embedder
|
||||
() => true, // isBM25Ready → available via server
|
||||
contents, // fileContents Map
|
||||
codebaseContext,
|
||||
);
|
||||
|
||||
currentProviderConfig = config;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🤖 Backend agent initialized with provider:', config.provider);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('❌ Backend agent initialization failed:', err);
|
||||
}
|
||||
return { success: false, error: err.message || 'Failed to initialize backend agent' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the agent is initialized
|
||||
*/
|
||||
|
||||
+31
-3
@@ -39,6 +39,12 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
||||
|
||||
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that automatically enrich grep/glob/bash calls with knowledge graph context.
|
||||
|
||||
### Community Integrations
|
||||
|
||||
| Agent | Install | Source |
|
||||
|-------|---------|--------|
|
||||
| [pi](https://pi.dev) | `pi install npm:pi-gitnexus` | [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) |
|
||||
|
||||
## MCP Setup (manual)
|
||||
|
||||
If you prefer to configure manually instead of using `gitnexus setup`:
|
||||
@@ -133,9 +139,10 @@ Your AI agent gets these tools automatically:
|
||||
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 analyze --skip-embeddings # Skip embedding generation (faster)
|
||||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start HTTP server for web UI
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI
|
||||
gitnexus list # List all indexed repositories
|
||||
gitnexus status # Show index status for current repo
|
||||
gitnexus clean # Delete index for current repo
|
||||
@@ -150,7 +157,26 @@ GitNexus supports indexing multiple repositories. Each `gitnexus analyze` regist
|
||||
|
||||
## Supported Languages
|
||||
|
||||
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust
|
||||
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift
|
||||
|
||||
### Language Feature Matrix
|
||||
|
||||
| Language | Imports | Types | Exports | Named Bindings | Config | Frameworks | Entry Points | Heritage |
|
||||
|----------|---------|-------|---------|----------------|--------|------------|-------------|----------|
|
||||
| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| JavaScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Python | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Java | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ |
|
||||
| Kotlin | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ |
|
||||
| Go | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ |
|
||||
| Rust | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ |
|
||||
| PHP | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — |
|
||||
| Swift | — | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ |
|
||||
| C | — | ✓ | ✓ | — | — | ✓ | ✓ | ✓ |
|
||||
| C++ | — | ✓ | ✓ | — | — | ✓ | ✓ | ✓ |
|
||||
|
||||
**Imports** — cross-file import resolution · **Types** — type annotation extraction · **Exports** — public/exported symbol detection · **Named Bindings** — `import { X }` tracking · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics · **Heritage** — class inheritance / interface implementation
|
||||
|
||||
## Agent Skills
|
||||
|
||||
@@ -179,6 +205,8 @@ Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setu
|
||||
|
||||
GitNexus also has a browser-based UI at [gitnexus.vercel.app](https://gitnexus.vercel.app) — 100% client-side, your code never leaves the browser.
|
||||
|
||||
**Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically.
|
||||
|
||||
## License
|
||||
|
||||
[PolyForm Noncommercial 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0/)
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
/**
|
||||
* GitNexus Claude Code Hook
|
||||
*
|
||||
* PreToolUse handler — intercepts Grep/Glob/Bash searches
|
||||
* and augments with graph context from the GitNexus index.
|
||||
* PreToolUse — intercepts Grep/Glob/Bash searches and augments
|
||||
* with graph context from the GitNexus index.
|
||||
* PostToolUse — detects stale index after git mutations and notifies
|
||||
* the agent to reindex.
|
||||
*
|
||||
* NOTE: SessionStart hooks are broken on Windows (Claude Code bug).
|
||||
* Session context is injected via CLAUDE.md / skills instead.
|
||||
@@ -11,7 +13,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
/**
|
||||
* Read JSON input from stdin synchronously.
|
||||
@@ -26,19 +28,19 @@ function readInput() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory (or ancestor) has a .gitnexus index.
|
||||
* Find the .gitnexus directory by walking up from startDir.
|
||||
* Returns the path to .gitnexus/ or null if not found.
|
||||
*/
|
||||
function findGitNexusIndex(startDir) {
|
||||
function findGitNexusDir(startDir) {
|
||||
let dir = startDir || process.cwd();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (fs.existsSync(path.join(dir, '.gitnexus'))) {
|
||||
return true;
|
||||
}
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,52 +85,153 @@ function extractPattern(toolName, toolInput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the gitnexus CLI path.
|
||||
* 1. Relative path (works when script is inside npm package)
|
||||
* 2. require.resolve (works when gitnexus is globally installed)
|
||||
* 3. Fall back to npx (returns empty string)
|
||||
*/
|
||||
function resolveCliPath() {
|
||||
let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
|
||||
if (!fs.existsSync(cliPath)) {
|
||||
try {
|
||||
cliPath = require.resolve('gitnexus/dist/cli/index.js');
|
||||
} catch {
|
||||
cliPath = '';
|
||||
}
|
||||
}
|
||||
return cliPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a gitnexus CLI command synchronously.
|
||||
* Returns the stderr output (KuzuDB captures stdout at OS level).
|
||||
*/
|
||||
function runGitNexusCli(cliPath, args, cwd, timeout) {
|
||||
const isWin = process.platform === 'win32';
|
||||
if (cliPath) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, ...args],
|
||||
{ encoding: 'utf-8', timeout, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
}
|
||||
// On Windows, invoke npx.cmd directly (no shell needed)
|
||||
return spawnSync(
|
||||
isWin ? 'npx.cmd' : 'npx',
|
||||
['-y', 'gitnexus', ...args],
|
||||
{ encoding: 'utf-8', timeout: timeout + 5000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PreToolUse handler — augment searches with graph context.
|
||||
*/
|
||||
function handlePreToolUse(input) {
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
if (!findGitNexusDir(cwd)) return;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
|
||||
if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return;
|
||||
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
const cliPath = resolveCliPath();
|
||||
let result = '';
|
||||
try {
|
||||
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
|
||||
if (!child.error && child.status === 0) {
|
||||
result = child.stderr || '';
|
||||
}
|
||||
} catch { /* graceful failure */ }
|
||||
|
||||
if (result && result.trim()) {
|
||||
sendHookResponse('PreToolUse', result.trim());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a PostToolUse hook response with additional context for the agent.
|
||||
*/
|
||||
function sendHookResponse(hookEventName, message) {
|
||||
console.log(JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName, additionalContext: message }
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* PostToolUse handler — detect index staleness after git mutations.
|
||||
*
|
||||
* Instead of spawning a full `gitnexus analyze` synchronously (which blocks
|
||||
* the agent for up to 120s and risks KuzuDB corruption on timeout), we do a
|
||||
* lightweight staleness check: compare `git rev-parse HEAD` against the
|
||||
* lastCommit stored in `.gitnexus/meta.json`. If they differ, notify the
|
||||
* agent so it can decide when to reindex.
|
||||
*/
|
||||
function handlePostToolUse(input) {
|
||||
const toolName = input.tool_name || '';
|
||||
if (toolName !== 'Bash') return;
|
||||
|
||||
const command = (input.tool_input || {}).command || '';
|
||||
if (!/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) return;
|
||||
|
||||
// Only proceed if the command succeeded
|
||||
const toolOutput = input.tool_output || {};
|
||||
if (toolOutput.exit_code !== undefined && toolOutput.exit_code !== 0) return;
|
||||
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
const gitNexusDir = findGitNexusDir(cwd);
|
||||
if (!gitNexusDir) return;
|
||||
|
||||
// Compare HEAD against last indexed commit — skip if unchanged
|
||||
let currentHead = '';
|
||||
try {
|
||||
const headResult = spawnSync('git', ['rev-parse', 'HEAD'], {
|
||||
encoding: 'utf-8', timeout: 3000, cwd, stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
currentHead = (headResult.stdout || '').trim();
|
||||
} catch { return; }
|
||||
|
||||
if (!currentHead) return;
|
||||
|
||||
let lastCommit = '';
|
||||
let hadEmbeddings = false;
|
||||
try {
|
||||
const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
|
||||
lastCommit = meta.lastCommit || '';
|
||||
hadEmbeddings = (meta.stats && meta.stats.embeddings > 0);
|
||||
} catch { /* no meta — treat as stale */ }
|
||||
|
||||
// If HEAD matches last indexed commit, no reindex needed
|
||||
if (currentHead && currentHead === lastCommit) return;
|
||||
|
||||
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
|
||||
sendHookResponse('PostToolUse',
|
||||
`GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
|
||||
`Run \`${analyzeCmd}\` to update the knowledge graph.`
|
||||
);
|
||||
}
|
||||
|
||||
// Dispatch map for hook events
|
||||
const handlers = {
|
||||
PreToolUse: handlePreToolUse,
|
||||
PostToolUse: handlePostToolUse,
|
||||
};
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const input = readInput();
|
||||
const hookEvent = input.hook_event_name || '';
|
||||
|
||||
if (hookEvent !== 'PreToolUse') return;
|
||||
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!findGitNexusIndex(cwd)) return;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
|
||||
if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return;
|
||||
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
// Resolve CLI path relative to this hook script (same package)
|
||||
// hooks/claude/gitnexus-hook.cjs → dist/cli/index.js
|
||||
const cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
|
||||
|
||||
// augment CLI writes result to stderr (KuzuDB's native module captures
|
||||
// stdout fd at OS level, making it unusable in subprocess contexts).
|
||||
const { spawnSync } = require('child_process');
|
||||
let result = '';
|
||||
try {
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, 'augment', pattern],
|
||||
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
result = child.stderr || '';
|
||||
} catch { /* graceful failure */ }
|
||||
|
||||
if (result && result.trim()) {
|
||||
console.log(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
additionalContext: result.trim()
|
||||
}
|
||||
}));
|
||||
}
|
||||
const handler = handlers[input.hook_event_name || ''];
|
||||
if (handler) handler(input);
|
||||
} catch (err) {
|
||||
// Graceful failure — log to stderr for debugging
|
||||
console.error('GitNexus hook error:', err.message);
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,8 @@ if [ "$found" = false ]; then
|
||||
fi
|
||||
|
||||
# Run gitnexus augment — must be fast (<500ms target)
|
||||
RESULT=$(cd "$CWD" && npx -y gitnexus augment "$PATTERN" 2>/dev/null)
|
||||
# augment writes to stderr (KuzuDB captures stdout at OS level), so capture stderr and discard stdout
|
||||
RESULT=$(cd "$CWD" && npx -y gitnexus augment "$PATTERN" 2>&1 1>/dev/null)
|
||||
|
||||
if [ -n "$RESULT" ]; then
|
||||
ESCAPED=$(echo "$RESULT" | jq -Rs .)
|
||||
|
||||
Generated
+1291
-4
File diff suppressed because it is too large
Load Diff
+18
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.2.8",
|
||||
"version": "1.4.0",
|
||||
"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",
|
||||
@@ -32,13 +32,20 @@
|
||||
"files": [
|
||||
"dist",
|
||||
"hooks",
|
||||
"scripts",
|
||||
"skills",
|
||||
"vendor"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/cli/index.ts",
|
||||
"prepare": "npm run build"
|
||||
"test": "vitest run test/unit",
|
||||
"test:integration": "vitest run test/integration",
|
||||
"test:all": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prepare": "npm run build",
|
||||
"postinstall": "node scripts/patch-tree-sitter-swift.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.0.0",
|
||||
@@ -62,19 +69,26 @@
|
||||
"tree-sitter-go": "^0.21.0",
|
||||
"tree-sitter-java": "^0.21.0",
|
||||
"tree-sitter-javascript": "^0.21.0",
|
||||
"tree-sitter-kotlin": "^0.3.8",
|
||||
"tree-sitter-php": "^0.23.12",
|
||||
"tree-sitter-python": "^0.21.0",
|
||||
"tree-sitter-rust": "^0.21.0",
|
||||
"tree-sitter-typescript": "^0.21.0",
|
||||
"typescript": "^5.4.5",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"tree-sitter-swift": "^0.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"tsx": "^4.0.0"
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* WORKAROUND: tree-sitter-swift@0.6.0 binding.gyp build failure
|
||||
*
|
||||
* Background:
|
||||
* tree-sitter-swift@0.6.0's binding.gyp contains an "actions" array that
|
||||
* invokes `tree-sitter generate` to regenerate parser.c from grammar.js.
|
||||
* This is intended for grammar developers, but the published npm package
|
||||
* already ships pre-generated parser files (parser.c, scanner.c), so the
|
||||
* actions are unnecessary for consumers. Since consumers don't have
|
||||
* tree-sitter-cli installed, the actions always fail during `npm install`.
|
||||
*
|
||||
* Why we can't just upgrade:
|
||||
* tree-sitter-swift@0.7.1 fixes this (removes postinstall, ships prebuilds),
|
||||
* but it requires tree-sitter@^0.22.1. The upstream project pins tree-sitter
|
||||
* to ^0.21.0 and all other grammar packages depend on that version.
|
||||
* Upgrading tree-sitter would be a separate breaking change.
|
||||
*
|
||||
* How this workaround works:
|
||||
* 1. tree-sitter-swift's own postinstall fails (npm warns but continues)
|
||||
* 2. This script runs as gitnexus's postinstall
|
||||
* 3. It removes the "actions" array from binding.gyp
|
||||
* 4. It rebuilds the native binding with the cleaned binding.gyp
|
||||
*
|
||||
* TODO: Remove this script when tree-sitter is upgraded to ^0.22.x,
|
||||
* which allows using tree-sitter-swift@0.7.1+ directly.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift');
|
||||
const bindingPath = path.join(swiftDir, 'binding.gyp');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(bindingPath)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(bindingPath, 'utf8');
|
||||
let needsRebuild = false;
|
||||
|
||||
if (content.includes('"actions"')) {
|
||||
// Strip Python-style comments (#) before JSON parsing
|
||||
const cleaned = content.replace(/#[^\n]*/g, '');
|
||||
const gyp = JSON.parse(cleaned);
|
||||
|
||||
if (gyp.targets && gyp.targets[0] && gyp.targets[0].actions) {
|
||||
delete gyp.targets[0].actions;
|
||||
fs.writeFileSync(bindingPath, JSON.stringify(gyp, null, 2) + '\n');
|
||||
console.log('[tree-sitter-swift] Patched binding.gyp (removed actions array)');
|
||||
needsRebuild = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if native binding exists
|
||||
const bindingNode = path.join(swiftDir, 'build', 'Release', 'tree_sitter_swift_binding.node');
|
||||
if (!fs.existsSync(bindingNode)) {
|
||||
needsRebuild = true;
|
||||
}
|
||||
|
||||
if (needsRebuild) {
|
||||
console.log('[tree-sitter-swift] Rebuilding native binding...');
|
||||
execSync('npx node-gyp rebuild', {
|
||||
cwd: swiftDir,
|
||||
stdio: 'pipe',
|
||||
timeout: 120000,
|
||||
});
|
||||
console.log('[tree-sitter-swift] Native binding built successfully');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[tree-sitter-swift] Could not build native binding:', err.message);
|
||||
console.warn('[tree-sitter-swift] You may need to manually run: cd node_modules/tree-sitter-swift && npx node-gyp rebuild');
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: gitnexus-cli
|
||||
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
|
||||
---
|
||||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
All commands work via `npx` — no global install required.
|
||||
|
||||
## Commands
|
||||
|
||||
### analyze — Build or refresh the index
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
|
||||
|
||||
| Flag | Effect |
|
||||
| -------------- | ---------------------------------------------------------------- |
|
||||
| `--force` | Force full re-index even if up to date |
|
||||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
```bash
|
||||
npx gitnexus status
|
||||
```
|
||||
|
||||
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
|
||||
|
||||
### clean — Delete the index
|
||||
|
||||
```bash
|
||||
npx gitnexus clean
|
||||
```
|
||||
|
||||
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
|
||||
|
||||
| Flag | Effect |
|
||||
| --------- | ------------------------------------------------- |
|
||||
| `--force` | Skip confirmation prompt |
|
||||
| `--all` | Clean all indexed repos, not just the current one |
|
||||
|
||||
### wiki — Generate documentation from the graph
|
||||
|
||||
```bash
|
||||
npx gitnexus wiki
|
||||
```
|
||||
|
||||
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
||||
|
||||
| Flag | Effect |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| `--force` | Force full regeneration |
|
||||
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
|
||||
| `--base-url <url>` | LLM API base URL |
|
||||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||
|
||||
### list — Show all indexed repos
|
||||
|
||||
```bash
|
||||
npx gitnexus list
|
||||
```
|
||||
|
||||
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
|
||||
|
||||
## After Indexing
|
||||
|
||||
1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
|
||||
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Not inside a git repository"**: Run from a directory inside a git repo
|
||||
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
|
||||
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: gitnexus-debugging
|
||||
description: Trace bugs through call chains using knowledge graph
|
||||
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
|
||||
---
|
||||
|
||||
# Debugging with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Why is this function failing?"
|
||||
- "Trace where this error comes from"
|
||||
- "Who calls this method?"
|
||||
@@ -37,17 +38,18 @@ description: Trace bugs through call chains using knowledge graph
|
||||
|
||||
## Debugging Patterns
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
|---------|-------------------|
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
| Symptom | GitNexus Approach |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find code related to error:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
@@ -55,6 +57,7 @@ gitnexus_query({query: "payment validation error"})
|
||||
```
|
||||
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
@@ -63,6 +66,7 @@ gitnexus_context({name: "validatePayment"})
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
RETURN [n IN nodes(path) | n.name] AS chain
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: gitnexus-exploring
|
||||
description: Navigate unfamiliar code using GitNexus knowledge graph
|
||||
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
|
||||
---
|
||||
|
||||
# Exploring Codebases with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "How does authentication work?"
|
||||
- "What's the project structure?"
|
||||
- "Show me the main components"
|
||||
@@ -37,16 +38,17 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | What you get |
|
||||
|----------|-------------|
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
| Resource | What you get |
|
||||
| --------------------------------------- | ------------------------------------------------------- |
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
@@ -54,6 +56,7 @@ gitnexus_query({query: "payment processing"})
|
||||
```
|
||||
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: gitnexus-guide
|
||||
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
|
||||
---
|
||||
|
||||
# GitNexus Guide
|
||||
|
||||
Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
|
||||
|
||||
## Always Start Here
|
||||
|
||||
For any task involving code understanding, debugging, impact analysis, or refactoring:
|
||||
|
||||
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
|
||||
2. **Match your task to a skill below** and **read that skill file**
|
||||
3. **Follow the skill's workflow and checklist**
|
||||
|
||||
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||
|
||||
## Skills
|
||||
|
||||
| Task | Skill to read |
|
||||
| -------------------------------------------- | ------------------- |
|
||||
| Understand architecture / "How does X work?" | `gitnexus-exploring` |
|
||||
| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
|
||||
| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
|
||||
| Rename / extract / split / refactor | `gitnexus-refactoring` |
|
||||
| Tools, resources, schema reference | `gitnexus-guide` (this file) |
|
||||
| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
|
||||
|
||||
## Tools Reference
|
||||
|
||||
| Tool | What it gives you |
|
||||
| ---------------- | ------------------------------------------------------------------------ |
|
||||
| `query` | Process-grouped code intelligence — execution flows related to a concept |
|
||||
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
|
||||
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||
| `detect_changes` | Git-diff impact — what do your current changes affect |
|
||||
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
|
||||
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
|
||||
| `list_repos` | Discover indexed repos |
|
||||
|
||||
## Resources Reference
|
||||
|
||||
Lightweight reads (~100-500 tokens) for navigation:
|
||||
|
||||
| Resource | Content |
|
||||
| ---------------------------------------------- | ----------------------------------------- |
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness check |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
|
||||
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
|
||||
| `gitnexus://repo/{name}/processes` | All execution flows |
|
||||
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
|
||||
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
|
||||
|
||||
## Graph Schema
|
||||
|
||||
**Nodes:** File, Function, Class, Interface, Method, Community, Process
|
||||
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
|
||||
RETURN caller.name, caller.filePath
|
||||
```
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: gitnexus-impact-analysis
|
||||
description: Analyze blast radius before making code changes
|
||||
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
|
||||
---
|
||||
|
||||
# Impact Analysis with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Is it safe to change this function?"
|
||||
- "What will break if I modify X?"
|
||||
- "Show me the blast radius"
|
||||
@@ -37,24 +38,25 @@ description: Analyze blast radius before making code changes
|
||||
|
||||
## Understanding Output
|
||||
|
||||
| Depth | Risk Level | Meaning |
|
||||
|-------|-----------|---------|
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
| Depth | Risk Level | Meaning |
|
||||
| ----- | ---------------- | ------------------------ |
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Affected | Risk |
|
||||
|----------|------|
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Affected | Risk |
|
||||
| ------------------------------ | -------- |
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
|
||||
```
|
||||
gitnexus_impact({
|
||||
target: "validateUser",
|
||||
@@ -72,6 +74,7 @@ gitnexus_impact({
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: gitnexus-pr-review
|
||||
description: "Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
|
||||
---
|
||||
|
||||
# PR Review with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Review this PR"
|
||||
- "What does PR #42 change?"
|
||||
- "Is this safe to merge?"
|
||||
- "What's the blast radius of this PR?"
|
||||
- "Are there missing tests for this PR?"
|
||||
- Reviewing someone else's code changes before merge
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gh pr diff <number> → Get the raw diff
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
3. For each changed symbol:
|
||||
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
|
||||
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
6. Summarize findings with risk assessment
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
|
||||
- [ ] gitnexus_detect_changes to map changes to affected execution flows
|
||||
- [ ] gitnexus_impact on each non-trivial changed symbol
|
||||
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
|
||||
- [ ] gitnexus_context on key changed symbols to understand full picture
|
||||
- [ ] Check if affected processes have test coverage
|
||||
- [ ] Assess overall risk level
|
||||
- [ ] Write review summary with findings
|
||||
```
|
||||
|
||||
## Review Dimensions
|
||||
|
||||
| Dimension | How GitNexus Helps |
|
||||
| --- | --- |
|
||||
| **Correctness** | `context` shows callers — are they all compatible with the change? |
|
||||
| **Blast radius** | `impact` shows d=1/d=2/d=3 dependents — anything missed? |
|
||||
| **Completeness** | `detect_changes` shows all affected flows — are they all handled? |
|
||||
| **Test coverage** | `impact({includeTests: true})` shows which tests touch changed code |
|
||||
| **Breaking changes** | d=1 upstream items that aren't updated in the PR = potential breakage |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Signal | Risk |
|
||||
| --- | --- |
|
||||
| Changes touch <3 symbols, 0-1 processes | LOW |
|
||||
| Changes touch 3-10 symbols, 2-5 processes | MEDIUM |
|
||||
| Changes touch >10 symbols or many processes | HIGH |
|
||||
| Changes touch auth, payments, or data integrity code | CRITICAL |
|
||||
| d=1 callers exist outside the PR diff | Potential breakage — flag it |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_detect_changes** — map PR diff to affected execution flows:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
|
||||
→ Changed: 8 symbols in 4 files
|
||||
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**gitnexus_impact** — blast radius per changed symbol:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
|
||||
- webhookHandler (src/webhooks.ts:15) [CALLS, 100%]
|
||||
|
||||
→ d=2 (LIKELY AFFECTED):
|
||||
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**gitnexus_impact with tests** — check test coverage:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
|
||||
→ Tests that cover this symbol:
|
||||
- validatePayment.test.ts [direct]
|
||||
- checkout.integration.test.ts [via processCheckout]
|
||||
```
|
||||
|
||||
**gitnexus_context** — understand a changed symbol's role:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates
|
||||
→ Processes: CheckoutFlow (step 3/7), RefundFlow (step 1/5)
|
||||
```
|
||||
|
||||
## Example: "Review PR #42"
|
||||
|
||||
```
|
||||
1. gh pr diff 42 > /tmp/pr42.diff
|
||||
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
|
||||
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
→ Changed symbols: validatePayment, PaymentInput, formatAmount
|
||||
→ Affected processes: CheckoutFlow, RefundFlow
|
||||
→ Risk: MEDIUM
|
||||
|
||||
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
→ d=1: processCheckout, webhookHandler (WILL BREAK)
|
||||
→ webhookHandler is NOT in the PR diff — potential breakage!
|
||||
|
||||
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
|
||||
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
|
||||
→ createPayment uses the old PaymentInput shape — breaking change!
|
||||
|
||||
5. gitnexus_context({name: "formatAmount"})
|
||||
→ Called by 12 functions — but change is backwards-compatible (added optional param)
|
||||
|
||||
6. Review summary:
|
||||
- MEDIUM risk — 3 changed symbols affect 2 execution flows
|
||||
- BUG: webhookHandler calls validatePayment but isn't updated for new signature
|
||||
- BUG: createPayment depends on PaymentInput type which changed
|
||||
- OK: formatAmount change is backwards-compatible
|
||||
- Tests: checkout.test.ts covers processCheckout path, but no webhook test
|
||||
```
|
||||
|
||||
## Review Output Format
|
||||
|
||||
Structure your review as:
|
||||
|
||||
```markdown
|
||||
## PR Review: <title>
|
||||
|
||||
**Risk: LOW / MEDIUM / HIGH / CRITICAL**
|
||||
|
||||
### Changes Summary
|
||||
- <N> symbols changed across <M> files
|
||||
- <P> execution flows affected
|
||||
|
||||
### Findings
|
||||
1. **[severity]** Description of finding
|
||||
- Evidence from GitNexus tools
|
||||
- Affected callers/flows
|
||||
|
||||
### Missing Coverage
|
||||
- Callers not updated in PR: ...
|
||||
- Untested flows: ...
|
||||
|
||||
### Recommendation
|
||||
APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
|
||||
```
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: gitnexus-refactoring
|
||||
description: Plan safe refactors using blast radius and dependency mapping
|
||||
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
|
||||
---
|
||||
|
||||
# Refactoring with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Rename this function safely"
|
||||
- "Extract this into a module"
|
||||
- "Split this service"
|
||||
@@ -26,6 +27,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
## Checklists
|
||||
|
||||
### Rename Symbol
|
||||
|
||||
```
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
@@ -35,6 +37,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
```
|
||||
|
||||
### Extract Module
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
@@ -45,6 +48,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
```
|
||||
|
||||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
@@ -58,6 +62,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
## Tools
|
||||
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
|
||||
```
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
@@ -66,6 +71,7 @@ gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_
|
||||
```
|
||||
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
@@ -73,6 +79,7 @@ gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
@@ -81,6 +88,7 @@ gitnexus_detect_changes({scope: "all"})
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
@@ -88,12 +96,12 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
|
||||
## Risk Rules
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
|-------------|------------|
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
| Risk Factor | Mitigation |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
+126
-68
@@ -9,6 +9,7 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { type GeneratedSkillInfo } from './skill-gen.js';
|
||||
|
||||
// ESM equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -28,76 +29,124 @@ const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
|
||||
|
||||
/**
|
||||
* Generate the full GitNexus context content.
|
||||
*
|
||||
* Design principles (learned from real agent behavior):
|
||||
* - AGENTS.md is the ROUTER — it tells the agent WHICH skill to read
|
||||
* - Skills contain the actual workflows — AGENTS.md does NOT duplicate them
|
||||
* - Bold **IMPORTANT** block + "Skills — Read First" heading — agents skip soft suggestions
|
||||
* - One-line quick start (read context resource) gives agents an entry point
|
||||
* - Tools/Resources sections are labeled "Reference" — agents treat them as lookup, not workflow
|
||||
*
|
||||
* Design principles (learned from real agent behavior and industry research):
|
||||
* - Inline critical workflows — skills are skipped 56% of the time (Vercel eval data)
|
||||
* - Use RFC 2119 language (MUST, NEVER, ALWAYS) — models follow imperative rules
|
||||
* - Three-tier boundaries (Always/When/Never) — proven to change model behavior
|
||||
* - Keep under 120 lines — adherence degrades past 150 lines
|
||||
* - Exact tool commands with parameters — vague directives get ignored
|
||||
* - Self-review checklist — forces model to verify its own work
|
||||
*/
|
||||
function generateGitNexusContent(projectName: string, stats: RepoStats): string {
|
||||
return `${GITNEXUS_START_MARKER}
|
||||
# GitNexus MCP
|
||||
function generateGitNexusContent(projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[]): string {
|
||||
const generatedRows = (generatedSkills && generatedSkills.length > 0)
|
||||
? generatedSkills.map(s =>
|
||||
`| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/generated/${s.name}/SKILL.md\` |`
|
||||
).join('\n')
|
||||
: '';
|
||||
|
||||
This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows).
|
||||
|
||||
GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search.
|
||||
|
||||
## Always Start Here
|
||||
|
||||
For any task involving code understanding, debugging, impact analysis, or refactoring, you must:
|
||||
|
||||
1. **Read \`gitnexus://repo/{name}/context\`** — codebase overview + check index freshness
|
||||
2. **Match your task to a skill below** and **read that skill file**
|
||||
3. **Follow the skill's workflow and checklist**
|
||||
|
||||
> If step 1 warns the index is stale, run \`npx gitnexus analyze\` in the terminal first.
|
||||
|
||||
## Skills
|
||||
|
||||
| Task | Read this skill file |
|
||||
const skillsTable = `| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/exploring/SKILL.md\` |
|
||||
| Blast radius / "What breaks if I change X?" | \`.claude/skills/gitnexus/impact-analysis/SKILL.md\` |
|
||||
| Trace bugs / "Why is X failing?" | \`.claude/skills/gitnexus/debugging/SKILL.md\` |
|
||||
| Rename / extract / split / refactor | \`.claude/skills/gitnexus/refactoring/SKILL.md\` |
|
||||
| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` |
|
||||
| Blast radius / "What breaks if I change X?" | \`.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md\` |
|
||||
| Trace bugs / "Why is X failing?" | \`.claude/skills/gitnexus/gitnexus-debugging/SKILL.md\` |
|
||||
| Rename / extract / split / refactor | \`.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md\` |
|
||||
| Tools, resources, schema reference | \`.claude/skills/gitnexus/gitnexus-guide/SKILL.md\` |
|
||||
| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |${generatedRows ? '\n' + generatedRows : ''}`;
|
||||
|
||||
## Tools Reference
|
||||
return `${GITNEXUS_START_MARKER}
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
| Tool | What it gives you |
|
||||
|------|-------------------|
|
||||
| \`query\` | Process-grouped code intelligence — execution flows related to a concept |
|
||||
| \`context\` | 360-degree symbol view — categorized refs, processes it participates in |
|
||||
| \`impact\` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||
| \`detect_changes\` | Git-diff impact — what do your current changes affect |
|
||||
| \`rename\` | Multi-file coordinated rename with confidence-tagged edits |
|
||||
| \`cypher\` | Raw graph queries (read \`gitnexus://repo/{name}/schema\` first) |
|
||||
| \`list_repos\` | Discover indexed repos |
|
||||
This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
## Resources Reference
|
||||
> If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first.
|
||||
|
||||
Lightweight reads (~100-500 tokens) for navigation:
|
||||
## Always Do
|
||||
|
||||
| Resource | Content |
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run \`gitnexus_impact({target: "symbolName", direction: "upstream"})\` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run \`gitnexus_detect_changes()\` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use \`gitnexus_query({query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`gitnexus_context({name: "symbolName"})\`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. \`gitnexus_query({query: "<error or symptom>"})\` — find execution flows related to the issue
|
||||
2. \`gitnexus_context({name: "<suspect function>"})\` — see all callers, callees, and process participation
|
||||
3. \`READ gitnexus://repo/${projectName}/process/{processName}\` — trace the full execution flow step by step
|
||||
4. For regressions: \`gitnexus_detect_changes({scope: "compare", base_ref: "main"})\` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use \`gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})\` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with \`dry_run: false\`.
|
||||
- **Extracting/Splitting**: MUST run \`gitnexus_context({name: "target"})\` to see all incoming/outgoing refs, then \`gitnexus_impact({target: "target", direction: "upstream"})\` to find all external callers before moving code.
|
||||
- After any refactor: run \`gitnexus_detect_changes({scope: "all"})\` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running \`gitnexus_impact\` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use \`gitnexus_rename\` which understands the call graph.
|
||||
- NEVER commit changes without running \`gitnexus_detect_changes()\` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| \`query\` | Find code by concept | \`gitnexus_query({query: "auth validation"})\` |
|
||||
| \`context\` | 360-degree view of one symbol | \`gitnexus_context({name: "validateUser"})\` |
|
||||
| \`impact\` | Blast radius before editing | \`gitnexus_impact({target: "X", direction: "upstream"})\` |
|
||||
| \`detect_changes\` | Pre-commit scope check | \`gitnexus_detect_changes({scope: "staged"})\` |
|
||||
| \`rename\` | Safe multi-file rename | \`gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})\` |
|
||||
| \`cypher\` | Custom graph queries | \`gitnexus_cypher({query: "MATCH ..."})\` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| \`gitnexus://repo/{name}/context\` | Stats, staleness check |
|
||||
| \`gitnexus://repo/{name}/clusters\` | All functional areas with cohesion scores |
|
||||
| \`gitnexus://repo/{name}/cluster/{clusterName}\` | Area members |
|
||||
| \`gitnexus://repo/{name}/processes\` | All execution flows |
|
||||
| \`gitnexus://repo/{name}/process/{processName}\` | Step-by-step trace |
|
||||
| \`gitnexus://repo/{name}/schema\` | Graph schema for Cypher |
|
||||
| \`gitnexus://repo/${projectName}/context\` | Codebase overview, check index freshness |
|
||||
| \`gitnexus://repo/${projectName}/clusters\` | All functional areas |
|
||||
| \`gitnexus://repo/${projectName}/processes\` | All execution flows |
|
||||
| \`gitnexus://repo/${projectName}/process/{name}\` | Step-by-step execution trace |
|
||||
|
||||
## Graph Schema
|
||||
## Self-Check Before Finishing
|
||||
|
||||
**Nodes:** File, Function, Class, Interface, Method, Community, Process
|
||||
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
|
||||
Before completing any code modification task, verify:
|
||||
1. \`gitnexus_impact\` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. \`gitnexus_detect_changes()\` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
\`\`\`cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
|
||||
RETURN caller.name, caller.filePath
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
\`\`\`bash
|
||||
npx gitnexus analyze
|
||||
\`\`\`
|
||||
|
||||
If the index previously included embeddings, preserve them by adding \`--embeddings\`:
|
||||
|
||||
\`\`\`bash
|
||||
npx gitnexus analyze --embeddings
|
||||
\`\`\`
|
||||
|
||||
To check whether embeddings exist, inspect \`.gitnexus/meta.json\` — the \`stats.embeddings\` field shows the count (0 means no embeddings). **Running analyze without \`--embeddings\` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after \`git commit\` and \`git merge\`.
|
||||
|
||||
## CLI
|
||||
|
||||
${skillsTable}
|
||||
|
||||
${GITNEXUS_END_MARKER}`;
|
||||
}
|
||||
|
||||
@@ -137,7 +186,7 @@ async function upsertGitNexusSection(
|
||||
const startIdx = existingContent.indexOf(GITNEXUS_START_MARKER);
|
||||
const endIdx = existingContent.indexOf(GITNEXUS_END_MARKER);
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
||||
// Replace existing section
|
||||
const before = existingContent.substring(0, startIdx);
|
||||
const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length);
|
||||
@@ -163,20 +212,28 @@ async function installSkills(repoPath: string): Promise<string[]> {
|
||||
// Skill definitions bundled with the package
|
||||
const skills = [
|
||||
{
|
||||
name: 'exploring',
|
||||
description: 'Navigate unfamiliar code using GitNexus knowledge graph',
|
||||
name: 'gitnexus-exploring',
|
||||
description: 'Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: "How does X work?", "What calls this function?", "Show me the auth flow"',
|
||||
},
|
||||
{
|
||||
name: 'debugging',
|
||||
description: 'Trace bugs through call chains using knowledge graph',
|
||||
name: 'gitnexus-debugging',
|
||||
description: 'Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: "Why is X failing?", "Where does this error come from?", "Trace this bug"',
|
||||
},
|
||||
{
|
||||
name: 'impact-analysis',
|
||||
description: 'Analyze blast radius before making code changes',
|
||||
name: 'gitnexus-impact-analysis',
|
||||
description: 'Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: "Is it safe to change X?", "What depends on this?", "What will break?"',
|
||||
},
|
||||
{
|
||||
name: 'refactoring',
|
||||
description: 'Plan safe refactors using blast radius and dependency mapping',
|
||||
name: 'gitnexus-refactoring',
|
||||
description: 'Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: "Rename this function", "Extract this into a module", "Refactor this class", "Move this to a separate file"',
|
||||
},
|
||||
{
|
||||
name: 'gitnexus-guide',
|
||||
description: 'Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: "What GitNexus tools are available?", "How do I use GitNexus?"',
|
||||
},
|
||||
{
|
||||
name: 'gitnexus-cli',
|
||||
description: 'Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: "Index this repo", "Reanalyze the codebase", "Generate a wiki"',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -197,7 +254,7 @@ async function installSkills(repoPath: string): Promise<string[]> {
|
||||
} catch {
|
||||
// Fallback: generate minimal skill content
|
||||
skillContent = `---
|
||||
name: gitnexus-${skill.name}
|
||||
name: ${skill.name}
|
||||
description: ${skill.description}
|
||||
---
|
||||
|
||||
@@ -227,9 +284,10 @@ export async function generateAIContextFiles(
|
||||
repoPath: string,
|
||||
_storagePath: string,
|
||||
projectName: string,
|
||||
stats: RepoStats
|
||||
stats: RepoStats,
|
||||
generatedSkills?: GeneratedSkillInfo[]
|
||||
): Promise<{ files: string[] }> {
|
||||
const content = generateGitNexusContent(projectName, stats);
|
||||
const content = generateGitNexusContent(projectName, stats, generatedSkills);
|
||||
const createdFiles: string[] = [];
|
||||
|
||||
// Create AGENTS.md (standard for Cursor, Windsurf, OpenCode, Cline, etc.)
|
||||
|
||||
+109
-40
@@ -5,20 +5,49 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import v8 from 'v8';
|
||||
import cliProgress from 'cli-progress';
|
||||
import { runPipelineFromRepo } from '../core/ingestion/pipeline.js';
|
||||
import { initKuzu, loadGraphToKuzu, getKuzuStats, executeQuery, executeWithReusedStatement, closeKuzu, createFTSIndex, loadCachedEmbeddings } from '../core/kuzu/kuzu-adapter.js';
|
||||
import { runEmbeddingPipeline } from '../core/embeddings/embedding-pipeline.js';
|
||||
// Embedding imports are lazy (dynamic import) so onnxruntime-node is never
|
||||
// loaded when embeddings are not requested. This avoids crashes on Node
|
||||
// versions whose ABI is not yet supported by the native binary (#89).
|
||||
// disposeEmbedder intentionally not called — ONNX Runtime segfaults on cleanup (see #38)
|
||||
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 { generateSkillFiles, type GeneratedSkillInfo } from './skill-gen.js';
|
||||
import fs from 'fs/promises';
|
||||
import { registerClaudeHook } from './claude-hooks.js';
|
||||
|
||||
|
||||
const HEAP_MB = 8192;
|
||||
const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`;
|
||||
|
||||
/** Re-exec the process with an 8GB heap if we're currently below that. */
|
||||
function ensureHeap(): boolean {
|
||||
const nodeOpts = process.env.NODE_OPTIONS || '';
|
||||
if (nodeOpts.includes('--max-old-space-size')) return false;
|
||||
|
||||
const v8Heap = v8.getHeapStatistics().heap_size_limit;
|
||||
if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9) return false;
|
||||
|
||||
try {
|
||||
execFileSync(process.execPath, [HEAP_FLAG, ...process.argv.slice(1)], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG}`.trim() },
|
||||
});
|
||||
} catch (e: any) {
|
||||
process.exitCode = e.status ?? 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface AnalyzeOptions {
|
||||
force?: boolean;
|
||||
embeddings?: boolean;
|
||||
skills?: boolean;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
/** Threshold: auto-skip embeddings for repos with more nodes than this */
|
||||
@@ -44,6 +73,12 @@ export const analyzeCommand = async (
|
||||
inputPath?: string,
|
||||
options?: AnalyzeOptions
|
||||
) => {
|
||||
if (ensureHeap()) return;
|
||||
|
||||
if (options?.verbose) {
|
||||
process.env.GITNEXUS_VERBOSE = '1';
|
||||
}
|
||||
|
||||
console.log('\n GitNexus Analyzer\n');
|
||||
|
||||
let repoPath: string;
|
||||
@@ -69,7 +104,7 @@ export const analyzeCommand = async (
|
||||
const currentCommit = getCurrentCommit(repoPath);
|
||||
const existingMeta = await loadMeta(storagePath);
|
||||
|
||||
if (existingMeta && !options?.force && existingMeta.lastCommit === currentCommit) {
|
||||
if (existingMeta && !options?.force && !options?.skills && existingMeta.lastCommit === currentCommit) {
|
||||
console.log(' Already up to date\n');
|
||||
return;
|
||||
}
|
||||
@@ -88,19 +123,47 @@ export const analyzeCommand = async (
|
||||
|
||||
bar.start(100, 0, { phase: 'Initializing...' });
|
||||
|
||||
// Graceful SIGINT handling — clean up resources and exit
|
||||
let aborted = false;
|
||||
const sigintHandler = () => {
|
||||
if (aborted) process.exit(1); // Second Ctrl-C: force exit
|
||||
aborted = true;
|
||||
bar.stop();
|
||||
console.log('\n Interrupted — cleaning up...');
|
||||
closeKuzu().catch(() => {}).finally(() => process.exit(130));
|
||||
};
|
||||
process.on('SIGINT', sigintHandler);
|
||||
|
||||
// Route all console output through bar.log() so the bar doesn't stamp itself
|
||||
// multiple times when other code writes to stdout/stderr mid-render.
|
||||
const origLog = console.log.bind(console);
|
||||
const origWarn = console.warn.bind(console);
|
||||
const origError = console.error.bind(console);
|
||||
const barLog = (...args: any[]) => (bar as any).log(args.map(a => (typeof a === 'string' ? a : String(a))).join(' '));
|
||||
const barLog = (...args: any[]) => {
|
||||
// Clear the bar line, print the message, then let the next bar.update redraw
|
||||
process.stdout.write('\x1b[2K\r');
|
||||
origLog(args.map(a => (typeof a === 'string' ? a : String(a))).join(' '));
|
||||
};
|
||||
console.log = barLog;
|
||||
console.warn = barLog;
|
||||
console.error = barLog;
|
||||
|
||||
// Show elapsed seconds for phases that run longer than 3s
|
||||
// Track elapsed time per phase — both updateBar and the interval use the
|
||||
// same format so they don't flicker against each other.
|
||||
let lastPhaseLabel = 'Initializing...';
|
||||
let phaseStart = Date.now();
|
||||
|
||||
/** Update bar with phase label + elapsed seconds (shown after 3s). */
|
||||
const updateBar = (value: number, phaseLabel: string) => {
|
||||
if (phaseLabel !== lastPhaseLabel) { lastPhaseLabel = phaseLabel; phaseStart = Date.now(); }
|
||||
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
||||
const display = elapsed >= 3 ? `${phaseLabel} (${elapsed}s)` : phaseLabel;
|
||||
bar.update(value, { phase: display });
|
||||
};
|
||||
|
||||
// Tick elapsed seconds for phases with infrequent progress callbacks
|
||||
// (e.g. CSV streaming, FTS indexing). Uses the same display format as
|
||||
// updateBar so there's no flickering.
|
||||
const elapsedTimer = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
||||
if (elapsed >= 3) {
|
||||
@@ -116,7 +179,7 @@ export const analyzeCommand = async (
|
||||
|
||||
if (options?.embeddings && existingMeta && !options?.force) {
|
||||
try {
|
||||
bar.update(0, { phase: 'Caching embeddings...' });
|
||||
updateBar(0, 'Caching embeddings...');
|
||||
await initKuzu(kuzuPath);
|
||||
const cached = await loadCachedEmbeddings();
|
||||
cachedEmbeddingNodeIds = cached.embeddingNodeIds;
|
||||
@@ -131,13 +194,11 @@ export const analyzeCommand = async (
|
||||
const pipelineResult = await runPipelineFromRepo(repoPath, (progress) => {
|
||||
const phaseLabel = PHASE_LABELS[progress.phase] || progress.phase;
|
||||
const scaled = Math.round(progress.percent * 0.6);
|
||||
if (phaseLabel !== lastPhaseLabel) { lastPhaseLabel = phaseLabel; phaseStart = Date.now(); }
|
||||
bar.update(scaled, { phase: phaseLabel });
|
||||
updateBar(scaled, phaseLabel);
|
||||
});
|
||||
|
||||
// ── Phase 2: KuzuDB (60–85%) ──────────────────────────────────────
|
||||
lastPhaseLabel = 'Loading into KuzuDB...'; phaseStart = Date.now();
|
||||
bar.update(60, { phase: lastPhaseLabel });
|
||||
updateBar(60, 'Loading into KuzuDB...');
|
||||
|
||||
await closeKuzu();
|
||||
const kuzuFiles = [kuzuPath, `${kuzuPath}.wal`, `${kuzuPath}.lock`];
|
||||
@@ -148,17 +209,16 @@ export const analyzeCommand = async (
|
||||
const t0Kuzu = Date.now();
|
||||
await initKuzu(kuzuPath);
|
||||
let kuzuMsgCount = 0;
|
||||
const kuzuResult = await loadGraphToKuzu(pipelineResult.graph, pipelineResult.fileContents, storagePath, (msg) => {
|
||||
const kuzuResult = await loadGraphToKuzu(pipelineResult.graph, pipelineResult.repoPath, storagePath, (msg) => {
|
||||
kuzuMsgCount++;
|
||||
const progress = Math.min(84, 60 + Math.round((kuzuMsgCount / (kuzuMsgCount + 10)) * 24));
|
||||
bar.update(progress, { phase: msg });
|
||||
updateBar(progress, msg);
|
||||
});
|
||||
const kuzuTime = ((Date.now() - t0Kuzu) / 1000).toFixed(1);
|
||||
const kuzuWarnings = kuzuResult.warnings;
|
||||
|
||||
// ── Phase 3: FTS (85–90%) ─────────────────────────────────────────
|
||||
lastPhaseLabel = 'Creating search indexes...'; phaseStart = Date.now();
|
||||
bar.update(85, { phase: lastPhaseLabel });
|
||||
updateBar(85, 'Creating search indexes...');
|
||||
|
||||
const t0Fts = Date.now();
|
||||
try {
|
||||
@@ -174,7 +234,7 @@ export const analyzeCommand = async (
|
||||
|
||||
// ── Phase 3.5: Re-insert cached embeddings ────────────────────────
|
||||
if (cachedEmbeddings.length > 0) {
|
||||
bar.update(88, { phase: `Restoring ${cachedEmbeddings.length} cached embeddings...` });
|
||||
updateBar(88, `Restoring ${cachedEmbeddings.length} cached embeddings...`);
|
||||
const EMBED_BATCH = 200;
|
||||
for (let i = 0; i < cachedEmbeddings.length; i += EMBED_BATCH) {
|
||||
const batch = cachedEmbeddings.slice(i, i + EMBED_BATCH);
|
||||
@@ -203,17 +263,16 @@ export const analyzeCommand = async (
|
||||
}
|
||||
|
||||
if (!embeddingSkipped) {
|
||||
lastPhaseLabel = 'Loading embedding model...'; phaseStart = Date.now();
|
||||
bar.update(90, { phase: lastPhaseLabel });
|
||||
updateBar(90, 'Loading embedding model...');
|
||||
const t0Emb = Date.now();
|
||||
const { runEmbeddingPipeline } = await import('../core/embeddings/embedding-pipeline.js');
|
||||
await runEmbeddingPipeline(
|
||||
executeQuery,
|
||||
executeWithReusedStatement,
|
||||
(progress) => {
|
||||
const scaled = 90 + Math.round((progress.percent / 100) * 8);
|
||||
const label = progress.phase === 'loading-model' ? 'Loading embedding model...' : `Embedding ${progress.nodesProcessed || 0}/${progress.totalNodes || '?'}`;
|
||||
if (label !== lastPhaseLabel) { lastPhaseLabel = label; phaseStart = Date.now(); }
|
||||
bar.update(scaled, { phase: label });
|
||||
updateBar(scaled, label);
|
||||
},
|
||||
{},
|
||||
cachedEmbeddingNodeIds.size > 0 ? cachedEmbeddingNodeIds : undefined,
|
||||
@@ -222,26 +281,32 @@ export const analyzeCommand = async (
|
||||
}
|
||||
|
||||
// ── Phase 5: Finalize (98–100%) ───────────────────────────────────
|
||||
bar.update(98, { phase: 'Saving metadata...' });
|
||||
updateBar(98, 'Saving metadata...');
|
||||
|
||||
// Count embeddings in the index (cached + newly generated)
|
||||
let embeddingCount = 0;
|
||||
try {
|
||||
const embResult = await executeQuery(`MATCH (e:CodeEmbedding) RETURN count(e) AS cnt`);
|
||||
embeddingCount = embResult?.[0]?.cnt ?? 0;
|
||||
} catch { /* table may not exist if embeddings never ran */ }
|
||||
|
||||
const meta = {
|
||||
repoPath,
|
||||
lastCommit: currentCommit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
stats: {
|
||||
files: pipelineResult.fileContents.size,
|
||||
files: pipelineResult.totalFileCount,
|
||||
nodes: stats.nodes,
|
||||
edges: stats.edges,
|
||||
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
||||
processes: pipelineResult.processResult?.stats.totalProcesses,
|
||||
embeddings: embeddingCount,
|
||||
},
|
||||
};
|
||||
await saveMeta(storagePath, meta);
|
||||
await registerRepo(repoPath, meta);
|
||||
await addToGitignore(repoPath);
|
||||
|
||||
const hookResult = await registerClaudeHook();
|
||||
|
||||
const projectName = path.basename(repoPath);
|
||||
let aggregatedClusterCount = 0;
|
||||
if (pipelineResult.communityResult?.communities) {
|
||||
@@ -253,14 +318,21 @@ export const analyzeCommand = async (
|
||||
aggregatedClusterCount = Array.from(groups.values()).filter(count => count >= 5).length;
|
||||
}
|
||||
|
||||
let generatedSkills: GeneratedSkillInfo[] = [];
|
||||
if (options?.skills && pipelineResult.communityResult) {
|
||||
updateBar(99, 'Generating skill files...');
|
||||
const skillResult = await generateSkillFiles(repoPath, projectName, pipelineResult);
|
||||
generatedSkills = skillResult.skills;
|
||||
}
|
||||
|
||||
const aiContext = await generateAIContextFiles(repoPath, storagePath, projectName, {
|
||||
files: pipelineResult.fileContents.size,
|
||||
files: pipelineResult.totalFileCount,
|
||||
nodes: stats.nodes,
|
||||
edges: stats.edges,
|
||||
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
||||
clusters: aggregatedClusterCount,
|
||||
processes: pipelineResult.processResult?.stats.totalProcesses,
|
||||
});
|
||||
}, generatedSkills);
|
||||
|
||||
await closeKuzu();
|
||||
// Note: we intentionally do NOT call disposeEmbedder() here.
|
||||
@@ -270,6 +342,8 @@ export const analyzeCommand = async (
|
||||
const totalTime = ((Date.now() - t0Global) / 1000).toFixed(1);
|
||||
|
||||
clearInterval(elapsedTimer);
|
||||
process.removeListener('SIGINT', sigintHandler);
|
||||
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
console.error = origError;
|
||||
@@ -288,16 +362,13 @@ export const analyzeCommand = async (
|
||||
console.log(` Context: ${aiContext.files.join(', ')}`);
|
||||
}
|
||||
|
||||
if (hookResult.registered) {
|
||||
console.log(` Hooks: ${hookResult.message}`);
|
||||
}
|
||||
|
||||
// Show warnings (missing schema pairs, etc.) after the clean output
|
||||
// Show a quiet summary if some edge types needed fallback insertion
|
||||
if (kuzuWarnings.length > 0) {
|
||||
console.log(`\n Warnings (${kuzuWarnings.length}):`);
|
||||
for (const w of kuzuWarnings) {
|
||||
console.log(` ${w}`);
|
||||
}
|
||||
const totalFallback = kuzuWarnings.reduce((sum, w) => {
|
||||
const m = w.match(/\((\d+) edges\)/);
|
||||
return sum + (m ? parseInt(m[1]) : 0);
|
||||
}, 0);
|
||||
console.log(` Note: ${totalFallback} edges across ${kuzuWarnings.length} types inserted via fallback (schema will be updated in next release)`);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -308,10 +379,8 @@ export const analyzeCommand = async (
|
||||
|
||||
console.log('');
|
||||
|
||||
// ONNX Runtime registers native atexit hooks that segfault during process
|
||||
// shutdown on macOS (#38) and some Linux configs (#40). Force-exit to
|
||||
// bypass them when embeddings were loaded.
|
||||
if (!embeddingSkipped) {
|
||||
process.exit(0);
|
||||
}
|
||||
// KuzuDB's native module holds open handles that prevent Node from exiting.
|
||||
// ONNX Runtime also registers native atexit hooks that segfault on some
|
||||
// platforms (#38, #40). Force-exit to ensure clean termination.
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* Claude Code Hook Registration
|
||||
*
|
||||
* Registers the GitNexus PreToolUse hook in ~/.claude/hooks.json
|
||||
* so that grep/glob/bash calls are automatically augmented with
|
||||
* knowledge graph context.
|
||||
*
|
||||
* Idempotent — safe to call multiple times.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
/**
|
||||
* Get the absolute path to the gitnexus-hook.js file.
|
||||
* Works for both local dev and npm-installed packages.
|
||||
*/
|
||||
function getHookScriptPath(): string {
|
||||
// From dist/cli/claude-hooks.js → hooks/claude/gitnexus-hook.js
|
||||
const packageRoot = path.resolve(__dirname, '..', '..');
|
||||
return path.join(packageRoot, 'hooks', 'claude', 'gitnexus-hook.cjs');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register (or verify) the GitNexus hook in Claude Code's global hooks.json.
|
||||
*
|
||||
* - Creates ~/.claude/ and hooks.json if they don't exist
|
||||
* - Preserves existing hooks from other tools
|
||||
* - Skips if GitNexus hook is already registered
|
||||
*
|
||||
* Returns a status message for the CLI output.
|
||||
*/
|
||||
export async function registerClaudeHook(): Promise<{ registered: boolean; message: string }> {
|
||||
const claudeDir = path.join(os.homedir(), '.claude');
|
||||
const hooksFile = path.join(claudeDir, 'hooks.json');
|
||||
const hookScript = getHookScriptPath();
|
||||
|
||||
// Check if the hook script exists
|
||||
try {
|
||||
await fs.access(hookScript);
|
||||
} catch {
|
||||
return { registered: false, message: 'Hook script not found (package may be incomplete)' };
|
||||
}
|
||||
|
||||
// Build the hook command — use node + absolute path for reliability
|
||||
const hookCommand = `node "${hookScript}"`;
|
||||
|
||||
// Check if ~/.claude/ exists (user has Claude Code installed)
|
||||
try {
|
||||
await fs.access(claudeDir);
|
||||
} catch {
|
||||
// No Claude Code installation — skip silently
|
||||
return { registered: false, message: 'Claude Code not detected (~/.claude/ not found)' };
|
||||
}
|
||||
|
||||
// Read existing hooks.json or start fresh
|
||||
let hooksConfig: any = {};
|
||||
try {
|
||||
const existing = await fs.readFile(hooksFile, 'utf-8');
|
||||
hooksConfig = JSON.parse(existing);
|
||||
} catch {
|
||||
// File doesn't exist or is invalid — we'll create it
|
||||
}
|
||||
|
||||
// Ensure the hooks structure exists
|
||||
if (!hooksConfig.hooks) {
|
||||
hooksConfig.hooks = {};
|
||||
}
|
||||
if (!Array.isArray(hooksConfig.hooks.PreToolUse)) {
|
||||
hooksConfig.hooks.PreToolUse = [];
|
||||
}
|
||||
|
||||
// Check if GitNexus hook is already registered
|
||||
const existingEntry = hooksConfig.hooks.PreToolUse.find((entry: any) => {
|
||||
if (!entry.hooks || !Array.isArray(entry.hooks)) return false;
|
||||
return entry.hooks.some((h: any) =>
|
||||
h.command && (
|
||||
h.command.includes('gitnexus-hook') ||
|
||||
h.command.includes('gitnexus augment')
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
if (existingEntry) {
|
||||
return { registered: true, message: 'Claude Code hook already registered' };
|
||||
}
|
||||
|
||||
// Add the GitNexus hook entry
|
||||
hooksConfig.hooks.PreToolUse.push({
|
||||
matcher: {
|
||||
tool_name: "Grep|Glob|Bash"
|
||||
},
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: hookCommand,
|
||||
timeout: 8000
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Write back
|
||||
await fs.writeFile(hooksFile, JSON.stringify(hooksConfig, null, 2) + '\n', 'utf-8');
|
||||
|
||||
return { registered: true, message: 'Claude Code hook registered' };
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export interface EvalServerOptions {
|
||||
// Convert structured JSON results into compact, LLM-friendly text.
|
||||
// Design: minimize tokens, maximize actionability.
|
||||
|
||||
function formatQueryResult(result: any): string {
|
||||
export function formatQueryResult(result: any): string {
|
||||
if (result.error) return `Error: ${result.error}`;
|
||||
|
||||
const lines: string[] = [];
|
||||
@@ -77,7 +77,7 @@ function formatQueryResult(result: any): string {
|
||||
return lines.join('\n').trim();
|
||||
}
|
||||
|
||||
function formatContextResult(result: any): string {
|
||||
export function formatContextResult(result: any): string {
|
||||
if (result.error) return `Error: ${result.error}`;
|
||||
|
||||
if (result.status === 'ambiguous') {
|
||||
@@ -141,7 +141,7 @@ function formatContextResult(result: any): string {
|
||||
return lines.join('\n').trim();
|
||||
}
|
||||
|
||||
function formatImpactResult(result: any): string {
|
||||
export function formatImpactResult(result: any): string {
|
||||
if (result.error) return `Error: ${result.error}`;
|
||||
|
||||
const target = result.target;
|
||||
@@ -181,7 +181,7 @@ function formatImpactResult(result: any): string {
|
||||
return lines.join('\n').trim();
|
||||
}
|
||||
|
||||
function formatCypherResult(result: any): string {
|
||||
export function formatCypherResult(result: any): string {
|
||||
if (result.error) return `Error: ${result.error}`;
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
@@ -202,7 +202,7 @@ function formatCypherResult(result: any): string {
|
||||
return typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
||||
}
|
||||
|
||||
function formatDetectChangesResult(result: any): string {
|
||||
export function formatDetectChangesResult(result: any): string {
|
||||
if (result.error) return `Error: ${result.error}`;
|
||||
|
||||
const summary = result.summary || {};
|
||||
@@ -238,7 +238,7 @@ function formatDetectChangesResult(result: any): string {
|
||||
return lines.join('\n').trim();
|
||||
}
|
||||
|
||||
function formatListReposResult(result: any): string {
|
||||
export function formatListReposResult(result: any): string {
|
||||
if (!Array.isArray(result) || result.length === 0) {
|
||||
return 'No indexed repositories.';
|
||||
}
|
||||
@@ -420,10 +420,20 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
export const MAX_BODY_SIZE = 1024 * 1024; // 1MB
|
||||
|
||||
function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
let totalSize = 0;
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_BODY_SIZE) {
|
||||
req.destroy(new Error('Request body too large (max 1MB)'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||
req.on('error', reject);
|
||||
});
|
||||
|
||||
+27
-26
@@ -1,62 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Heap re-spawn removed — only analyze.ts needs the 8GB heap (via its own ensureHeap()).
|
||||
// Removing it from here improves MCP server startup time significantly.
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { analyzeCommand } from './analyze.js';
|
||||
import { serveCommand } from './serve.js';
|
||||
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';
|
||||
import { augmentCommand } from './augment.js';
|
||||
import { wikiCommand } from './wiki.js';
|
||||
import { queryCommand, contextCommand, impactCommand, cypherCommand } from './tool.js';
|
||||
import { evalServerCommand } from './eval-server.js';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createLazyAction } from './lazy-action.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const pkg = _require('../../package.json');
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('gitnexus')
|
||||
.description('GitNexus local CLI and MCP server')
|
||||
.version('1.2.0');
|
||||
.version(pkg.version);
|
||||
|
||||
program
|
||||
.command('setup')
|
||||
.description('One-time setup: configure MCP for Cursor, Claude Code, OpenCode')
|
||||
.action(setupCommand);
|
||||
.action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
|
||||
|
||||
program
|
||||
.command('analyze [path]')
|
||||
.description('Index a repository (full analysis)')
|
||||
.option('-f, --force', 'Force full re-index even if up to date')
|
||||
.option('--embeddings', 'Enable embedding generation for semantic search (off by default)')
|
||||
.action(analyzeCommand);
|
||||
.option('--skills', 'Generate repo-specific skill files from detected communities')
|
||||
.option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)')
|
||||
.action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
|
||||
|
||||
program
|
||||
.command('serve')
|
||||
.description('Start local HTTP server for web UI connection')
|
||||
.option('-p, --port <port>', 'Port number', '4747')
|
||||
.action(serveCommand);
|
||||
.option('--host <host>', 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)')
|
||||
.action(createLazyAction(() => import('./serve.js'), 'serveCommand'));
|
||||
|
||||
program
|
||||
.command('mcp')
|
||||
.description('Start MCP server (stdio) — serves all indexed repos')
|
||||
.action(mcpCommand);
|
||||
.action(createLazyAction(() => import('./mcp.js'), 'mcpCommand'));
|
||||
|
||||
program
|
||||
.command('list')
|
||||
.description('List all indexed repositories')
|
||||
.action(listCommand);
|
||||
.action(createLazyAction(() => import('./list.js'), 'listCommand'));
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Show index status for current repo')
|
||||
.action(statusCommand);
|
||||
.action(createLazyAction(() => import('./status.js'), 'statusCommand'));
|
||||
|
||||
program
|
||||
.command('clean')
|
||||
.description('Delete GitNexus index for current repo')
|
||||
.option('-f, --force', 'Skip confirmation prompt')
|
||||
.option('--all', 'Clean all indexed repos')
|
||||
.action(cleanCommand);
|
||||
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
|
||||
|
||||
program
|
||||
.command('wiki [path]')
|
||||
@@ -67,12 +68,12 @@ program
|
||||
.option('--api-key <key>', 'LLM API key (saved to ~/.gitnexus/config.json)')
|
||||
.option('--concurrency <n>', 'Parallel LLM calls (default: 3)', '3')
|
||||
.option('--gist', 'Publish wiki as a public GitHub Gist after generation')
|
||||
.action(wikiCommand);
|
||||
.action(createLazyAction(() => import('./wiki.js'), 'wikiCommand'));
|
||||
|
||||
program
|
||||
.command('augment <pattern>')
|
||||
.description('Augment a search pattern with knowledge graph context (used by hooks)')
|
||||
.action(augmentCommand);
|
||||
.action(createLazyAction(() => import('./augment.js'), 'augmentCommand'));
|
||||
|
||||
// ─── Direct Tool Commands (no MCP overhead) ────────────────────────
|
||||
// These invoke LocalBackend directly for use in eval, scripts, and CI.
|
||||
@@ -85,7 +86,7 @@ program
|
||||
.option('-g, --goal <text>', 'What you want to find')
|
||||
.option('-l, --limit <n>', 'Max processes to return (default: 5)')
|
||||
.option('--content', 'Include full symbol source code')
|
||||
.action(queryCommand);
|
||||
.action(createLazyAction(() => import('./tool.js'), 'queryCommand'));
|
||||
|
||||
program
|
||||
.command('context [name]')
|
||||
@@ -94,7 +95,7 @@ program
|
||||
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
|
||||
.option('-f, --file <path>', 'File path to disambiguate common names')
|
||||
.option('--content', 'Include full symbol source code')
|
||||
.action(contextCommand);
|
||||
.action(createLazyAction(() => import('./tool.js'), 'contextCommand'));
|
||||
|
||||
program
|
||||
.command('impact <target>')
|
||||
@@ -103,13 +104,13 @@ program
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('--depth <n>', 'Max relationship depth (default: 3)')
|
||||
.option('--include-tests', 'Include test files in results')
|
||||
.action(impactCommand);
|
||||
.action(createLazyAction(() => import('./tool.js'), 'impactCommand'));
|
||||
|
||||
program
|
||||
.command('cypher <query>')
|
||||
.description('Execute raw Cypher query against the knowledge graph')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.action(cypherCommand);
|
||||
.action(createLazyAction(() => import('./tool.js'), 'cypherCommand'));
|
||||
|
||||
// ─── Eval Server (persistent daemon for SWE-bench) ─────────────────
|
||||
|
||||
@@ -118,6 +119,6 @@ program
|
||||
.description('Start lightweight HTTP server for fast tool calls during evaluation')
|
||||
.option('-p, --port <port>', 'Port number', '4848')
|
||||
.option('--idle-timeout <seconds>', 'Auto-shutdown after N seconds idle (0 = disabled)', '0')
|
||||
.action(evalServerCommand);
|
||||
.action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Creates a lazy-loaded CLI action that defers module import until invocation.
|
||||
* The generic constraints ensure the export name is a valid key of the module
|
||||
* at compile time — catching typos when used with concrete module imports.
|
||||
*/
|
||||
|
||||
function isCallable(value: unknown): value is (...args: unknown[]) => unknown {
|
||||
return typeof value === 'function';
|
||||
}
|
||||
|
||||
export function createLazyAction<
|
||||
TModule extends Record<string, unknown>,
|
||||
TKey extends string & keyof TModule,
|
||||
>(
|
||||
loader: () => Promise<TModule>,
|
||||
exportName: TKey,
|
||||
): (...args: unknown[]) => Promise<void> {
|
||||
return async (...args: unknown[]): Promise<void> => {
|
||||
const module = await loader();
|
||||
const action = module[exportName];
|
||||
if (!isCallable(action)) {
|
||||
throw new Error(`Lazy action export not found: ${exportName}`);
|
||||
}
|
||||
await action(...args);
|
||||
};
|
||||
}
|
||||
+12
-25
@@ -8,46 +8,33 @@
|
||||
|
||||
import { startMCPServer } from '../mcp/server.js';
|
||||
import { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
import { listRegisteredRepos } from '../storage/repo-manager.js';
|
||||
|
||||
export const mcpCommand = async () => {
|
||||
// Prevent unhandled errors from crashing the MCP server process.
|
||||
// KuzuDB lock conflicts and transient errors should degrade gracefully.
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error(`GitNexus MCP: uncaught exception — ${err.message}`);
|
||||
// Process is in an undefined state after uncaughtException — exit after flushing
|
||||
setTimeout(() => process.exit(1), 100);
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
const msg = reason instanceof Error ? reason.message : String(reason);
|
||||
console.error(`GitNexus MCP: unhandled rejection — ${msg}`);
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Initialize multi-repo backend from registry
|
||||
// Initialize multi-repo backend from registry.
|
||||
// The server starts even with 0 repos — tools call refreshRepos() lazily,
|
||||
// so repos indexed after the server starts are discovered automatically.
|
||||
const backend = new LocalBackend();
|
||||
const ok = await backend.init();
|
||||
await backend.init();
|
||||
|
||||
if (!ok) {
|
||||
console.error('GitNexus: Failed to initialize backend from registry.');
|
||||
process.exit(1);
|
||||
const repos = await backend.listRepos();
|
||||
if (repos.length === 0) {
|
||||
console.error('GitNexus: No indexed repos yet. Run `gitnexus analyze` in a git repo — the server will pick it up automatically.');
|
||||
} else {
|
||||
console.error(`GitNexus: MCP server starting with ${repos.length} repo(s): ${repos.map(r => r.name).join(', ')}`);
|
||||
}
|
||||
|
||||
const repoNames = (await 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)
|
||||
// Start MCP server (serves all repos, discovers new ones lazily)
|
||||
await startMCPServer(backend);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createServer } from '../server/api.js';
|
||||
|
||||
export const serveCommand = async (options?: { port?: string }) => {
|
||||
export const serveCommand = async (options?: { port?: string; host?: string }) => {
|
||||
const port = Number(options?.port ?? 4747);
|
||||
await createServer(port);
|
||||
const host = options?.host ?? '127.0.0.1';
|
||||
await createServer(port, host);
|
||||
};
|
||||
|
||||
|
||||
+70
-35
@@ -10,6 +10,7 @@ import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { glob } from 'glob';
|
||||
import { getGlobalDir } from '../storage/repo-manager.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -22,9 +23,16 @@ interface SetupResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* The MCP server entry for all editors
|
||||
* The MCP server entry for all editors.
|
||||
* On Windows, npx must be invoked via cmd /c since it's a .cmd script.
|
||||
*/
|
||||
function getMcpEntry() {
|
||||
if (process.platform === 'win32') {
|
||||
return {
|
||||
command: 'cmd',
|
||||
args: ['/c', 'npx', '-y', 'gitnexus@latest', 'mcp'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: 'npx',
|
||||
args: ['-y', 'gitnexus@latest', 'mcp'],
|
||||
@@ -156,13 +164,23 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
|
||||
const src = path.join(pluginHooksPath, 'gitnexus-hook.cjs');
|
||||
const dest = path.join(destHooksDir, 'gitnexus-hook.cjs');
|
||||
try {
|
||||
const content = await fs.readFile(src, 'utf-8');
|
||||
let content = await fs.readFile(src, 'utf-8');
|
||||
// Inject resolved CLI path so the copied hook can find the CLI
|
||||
// even when it's no longer inside the npm package tree
|
||||
const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
|
||||
const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
|
||||
const jsonCli = JSON.stringify(normalizedCli);
|
||||
content = content.replace(
|
||||
"let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');",
|
||||
`let cliPath = ${jsonCli};`
|
||||
);
|
||||
await fs.writeFile(dest, content, 'utf-8');
|
||||
} catch {
|
||||
// Script not found in source — skip
|
||||
}
|
||||
|
||||
const hookCmd = `node "${path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/')}"`;
|
||||
const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
|
||||
const hookCmd = `node "${hookPath.replace(/"/g, '\\"')}"`;
|
||||
|
||||
// Merge hook config into ~/.claude/settings.json
|
||||
const existing = await readJsonFile(settingsPath) || {};
|
||||
@@ -171,25 +189,31 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
|
||||
// NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
|
||||
// Session context is delivered via CLAUDE.md / skills instead.
|
||||
|
||||
// Add PreToolUse hook if not already present
|
||||
if (!existing.hooks.PreToolUse) existing.hooks.PreToolUse = [];
|
||||
const hasPreToolHook = existing.hooks.PreToolUse.some(
|
||||
(h: any) => h.hooks?.some((hh: any) => hh.command?.includes('gitnexus'))
|
||||
);
|
||||
if (!hasPreToolHook) {
|
||||
existing.hooks.PreToolUse.push({
|
||||
matcher: 'Grep|Glob|Bash',
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: hookCmd,
|
||||
timeout: 8000,
|
||||
statusMessage: 'Enriching with GitNexus graph context...',
|
||||
}],
|
||||
});
|
||||
// Helper: add a hook entry if one with 'gitnexus-hook' isn't already registered
|
||||
interface HookEntry { hooks?: Array<{ command?: string }> }
|
||||
function ensureHookEntry(
|
||||
eventName: string,
|
||||
matcher: string,
|
||||
timeout: number,
|
||||
statusMessage: string,
|
||||
) {
|
||||
if (!existing.hooks[eventName]) existing.hooks[eventName] = [];
|
||||
const hasHook = existing.hooks[eventName].some(
|
||||
(h: HookEntry) => h.hooks?.some(hh => hh.command?.includes('gitnexus-hook'))
|
||||
);
|
||||
if (!hasHook) {
|
||||
existing.hooks[eventName].push({
|
||||
matcher,
|
||||
hooks: [{ type: 'command', command: hookCmd, timeout, statusMessage }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ensureHookEntry('PreToolUse', 'Grep|Glob|Bash', 10, 'Enriching with GitNexus graph context...');
|
||||
ensureHookEntry('PostToolUse', 'Bash', 10, 'Checking GitNexus index freshness...');
|
||||
|
||||
await writeJsonFile(settingsPath, existing);
|
||||
result.configured.push('Claude Code hooks (PreToolUse)');
|
||||
result.configured.push('Claude Code hooks (PreToolUse, PostToolUse)');
|
||||
} catch (err: any) {
|
||||
result.errors.push(`Claude Code hooks: ${err.message}`);
|
||||
}
|
||||
@@ -217,8 +241,6 @@ async function setupOpenCode(result: SetupResult): Promise<void> {
|
||||
|
||||
// ─── Skill Installation ───────────────────────────────────────────
|
||||
|
||||
const SKILL_NAMES = ['exploring', 'debugging', 'impact-analysis', 'refactoring'];
|
||||
|
||||
/**
|
||||
* Install GitNexus skills to a target directory.
|
||||
* Each skill is installed as {targetDir}/gitnexus-{skillName}/SKILL.md
|
||||
@@ -232,25 +254,38 @@ async function installSkillsTo(targetDir: string): Promise<string[]> {
|
||||
const installed: string[] = [];
|
||||
const skillsRoot = path.join(__dirname, '..', '..', 'skills');
|
||||
|
||||
for (const skillName of SKILL_NAMES) {
|
||||
const skillDir = path.join(targetDir, `gitnexus-${skillName}`);
|
||||
let flatFiles: string[] = [];
|
||||
let dirSkillFiles: string[] = [];
|
||||
try {
|
||||
[flatFiles, dirSkillFiles] = await Promise.all([
|
||||
glob('*.md', { cwd: skillsRoot }),
|
||||
glob('*/SKILL.md', { cwd: skillsRoot }),
|
||||
]);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const skillSources = new Map<string, { isDirectory: boolean }>();
|
||||
|
||||
for (const relPath of dirSkillFiles) {
|
||||
skillSources.set(path.dirname(relPath), { isDirectory: true });
|
||||
}
|
||||
for (const relPath of flatFiles) {
|
||||
const skillName = path.basename(relPath, '.md');
|
||||
if (!skillSources.has(skillName)) {
|
||||
skillSources.set(skillName, { isDirectory: false });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [skillName, source] of skillSources) {
|
||||
const skillDir = path.join(targetDir, skillName);
|
||||
|
||||
try {
|
||||
// Try directory-based skill first (skills/{name}/SKILL.md)
|
||||
const dirSource = path.join(skillsRoot, skillName);
|
||||
const dirSkillFile = path.join(dirSource, 'SKILL.md');
|
||||
|
||||
let isDirectory = false;
|
||||
try {
|
||||
const stat = await fs.stat(dirSource);
|
||||
isDirectory = stat.isDirectory();
|
||||
} catch { /* not a directory */ }
|
||||
|
||||
if (isDirectory) {
|
||||
if (source.isDirectory) {
|
||||
const dirSource = path.join(skillsRoot, skillName);
|
||||
await copyDirRecursive(dirSource, skillDir);
|
||||
installed.push(skillName);
|
||||
} else {
|
||||
// Fall back to flat file (skills/{name}.md)
|
||||
const flatSource = path.join(skillsRoot, `${skillName}.md`);
|
||||
const content = await fs.readFile(flatSource, 'utf-8');
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user