Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c576c8bbe | ||
|
|
5674b2201d | ||
|
|
6915a9350b | ||
|
|
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 | ||
|
|
43f525d056 | ||
|
|
e2a8bfa5ab | ||
|
|
ee6753bf05 | ||
|
|
1b8c3c77af | ||
|
|
a7fc9d2f88 | ||
|
|
0074fd71ff | ||
|
|
de935a4f4c | ||
|
|
8c41970631 | ||
|
|
5c3a32d0c6 | ||
|
|
a8b3c6b23f | ||
|
|
15caf1e014 | ||
|
|
1ed34a0007 | ||
|
|
7a4bc9a260 | ||
|
|
3872a73875 | ||
|
|
f557716998 | ||
|
|
ae8a76511d | ||
|
|
e803e7e9d6 | ||
|
|
c37b63ae8b | ||
|
|
73590b2862 |
@@ -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
|
||||
@@ -6,6 +6,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
# 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: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
|
||||
## 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
|
||||
|
||||
@@ -6,6 +6,7 @@ description: "Use when the user asks how code works, wants to understand archite
|
||||
# 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: "Use when the user asks how code works, wants to understand archite
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -6,6 +6,7 @@ description: "Use when the user wants to know what will break if they change som
|
||||
# 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: "Use when the user wants to know what will break if they change som
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -6,6 +6,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
||||
# Refactoring with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Rename this function safely"
|
||||
- "Extract this into a module"
|
||||
- "Split this service"
|
||||
@@ -26,6 +27,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
||||
## 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: "Use when the user wants to rename, extract, split, move, or restru
|
||||
```
|
||||
|
||||
### 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: "Use when the user wants to rename, extract, split, move, or restru
|
||||
```
|
||||
|
||||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
@@ -58,6 +62,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
||||
## 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`
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
@@ -18,3 +21,49 @@ jobs:
|
||||
working-directory: gitnexus
|
||||
- run: npx tsc --noEmit
|
||||
working-directory: gitnexus
|
||||
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: gitnexus
|
||||
- run: npx vitest run test/unit --coverage --coverage.thresholdAutoUpdate=false
|
||||
working-directory: gitnexus
|
||||
|
||||
integration-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: gitnexus
|
||||
- run: npx vitest run test/integration
|
||||
working-directory: gitnexus
|
||||
|
||||
cross-platform:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: gitnexus
|
||||
- run: npx vitest run test/unit
|
||||
working-directory: gitnexus
|
||||
|
||||
@@ -6,10 +6,15 @@ on:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
publish:
|
||||
needs: ci
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -20,9 +25,33 @@ jobs:
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: gitnexus
|
||||
- run: npx tsc --noEmit
|
||||
|
||||
- name: Verify version consistency
|
||||
run: |
|
||||
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
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@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus MCP
|
||||
|
||||
This project is indexed by GitNexus as **GitnexusV2** (1348 symbols, 3469 relationships, 104 execution flows).
|
||||
|
||||
GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search.
|
||||
This project is indexed by GitNexus as **GitnexusV2** (1444 symbols, 3700 relationships, 111 execution flows).
|
||||
|
||||
## 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**
|
||||
@@ -23,40 +19,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
||||
| 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` |
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus MCP
|
||||
|
||||
This project is indexed by GitNexus as **GitnexusV2** (1348 symbols, 3469 relationships, 104 execution flows).
|
||||
|
||||
GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search.
|
||||
This project is indexed by GitNexus as **GitnexusV2** (1444 symbols, 3700 relationships, 111 execution flows).
|
||||
|
||||
## 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**
|
||||
@@ -23,40 +19,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
||||
| 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` |
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
<!-- 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/)
|
||||
|
||||
|
||||
|
||||
@@ -303,7 +320,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
|
||||
|
||||
---
|
||||
|
||||
@@ -465,7 +482,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
|
||||
|
||||
|
||||
@@ -1,11 +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.3.3",
|
||||
"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"]
|
||||
}
|
||||
|
||||
@@ -105,12 +105,14 @@ function main() {
|
||||
// stdout fd at OS level, making it unusable in subprocess contexts).
|
||||
let result = '';
|
||||
|
||||
const isWin = process.platform === 'win32';
|
||||
|
||||
// Try direct gitnexus binary first (faster if globally installed)
|
||||
try {
|
||||
const child = spawnSync(
|
||||
'gitnexus',
|
||||
['augment', pattern],
|
||||
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: isWin }
|
||||
);
|
||||
if (child.status === 0 && child.stderr && child.stderr.trim()) {
|
||||
result = child.stderr;
|
||||
@@ -123,7 +125,7 @@ function main() {
|
||||
const child = spawnSync(
|
||||
'npx',
|
||||
['-y', 'gitnexus', 'augment', pattern],
|
||||
{ encoding: 'utf-8', timeout: 15000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
{ encoding: 'utf-8', timeout: 15000, cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: isWin }
|
||||
);
|
||||
if (child.status === 0 && child.stderr && child.stderr.trim()) {
|
||||
result = child.stderr;
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
BIN
Binary file not shown.
@@ -10,5 +10,5 @@ export enum SupportedLanguages {
|
||||
Rust = 'rust',
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -103,6 +103,26 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
|
||||
/^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)
|
||||
@@ -271,6 +291,10 @@ 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') ||
|
||||
|
||||
@@ -257,22 +257,70 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
|
||||
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.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
@@ -306,4 +354,9 @@ export const FRAMEWORK_AST_PATTERNS = {
|
||||
'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'],
|
||||
};
|
||||
|
||||
@@ -396,6 +396,59 @@ export const PHP_QUERIES = `
|
||||
[(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,
|
||||
@@ -407,5 +460,6 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
|
||||
[SupportedLanguages.CSharp]: CSHARP_QUERIES,
|
||||
[SupportedLanguages.Rust]: RUST_QUERIES,
|
||||
[SupportedLanguages.PHP]: PHP_QUERIES,
|
||||
[SupportedLanguages.Swift]: SWIFT_QUERIES,
|
||||
};
|
||||
|
||||
@@ -31,6 +31,8 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages |
|
||||
filename.endsWith('.php5') || filename.endsWith('.php8')) {
|
||||
return SupportedLanguages.PHP;
|
||||
}
|
||||
// Swift
|
||||
if (filename.endsWith('.swift')) return SupportedLanguages.Swift;
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ const getWasmPath = (language: SupportedLanguages, filePath?: string): string =>
|
||||
[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];
|
||||
|
||||
@@ -69,7 +69,9 @@ export async function fetchRepoInfo(baseUrl: string, repoName?: string): Promise
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
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(
|
||||
|
||||
+1
-1
@@ -156,7 +156,7 @@ 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, Swift
|
||||
|
||||
## Agent Skills
|
||||
|
||||
|
||||
@@ -101,20 +101,40 @@ function main() {
|
||||
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');
|
||||
// Resolve CLI path — try multiple strategies:
|
||||
// 1. Relative path (works when script is inside npm package)
|
||||
// 2. require.resolve (works when gitnexus is globally installed)
|
||||
// 3. Fall back to npx (works when neither is available)
|
||||
let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
|
||||
if (!fs.existsSync(cliPath)) {
|
||||
try {
|
||||
cliPath = require.resolve('gitnexus/dist/cli/index.js');
|
||||
} catch {
|
||||
cliPath = ''; // will use npx fallback
|
||||
}
|
||||
}
|
||||
|
||||
// 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'] }
|
||||
);
|
||||
let child;
|
||||
if (cliPath) {
|
||||
child = spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, 'augment', pattern],
|
||||
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
} else {
|
||||
// npx fallback
|
||||
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
child = spawnSync(
|
||||
cmd,
|
||||
['-y', 'gitnexus', 'augment', pattern],
|
||||
{ encoding: 'utf-8', timeout: 15000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
}
|
||||
result = child.stderr || '';
|
||||
} catch { /* graceful failure */ }
|
||||
|
||||
|
||||
@@ -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
+1262
-4
File diff suppressed because it is too large
Load Diff
+17
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.6",
|
||||
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
|
||||
"author": "Abhigyan Patwari",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
@@ -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,20 +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,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
|
||||
```
|
||||
@@ -28,38 +28,92 @@ 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
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows).
|
||||
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.
|
||||
|
||||
## Always Start Here
|
||||
> If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first.
|
||||
|
||||
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**
|
||||
## Always Do
|
||||
|
||||
> If step 1 warns the index is stale, run \`npx gitnexus analyze\` in the terminal first.
|
||||
- **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"})\`.
|
||||
|
||||
## Skills
|
||||
## When Debugging
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| 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\` |
|
||||
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/${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 |
|
||||
|
||||
## 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
|
||||
|
||||
- Re-index: \`npx gitnexus analyze\`
|
||||
- Check freshness: \`npx gitnexus status\`
|
||||
- Generate docs: \`npx gitnexus wiki\`
|
||||
|
||||
${GITNEXUS_END_MARKER}`;
|
||||
}
|
||||
@@ -100,7 +154,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);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { getStoragePaths, saveMeta, loadMeta, addToGitignore, registerRepo, getG
|
||||
import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
|
||||
import { generateAIContextFiles } from './ai-context.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}`;
|
||||
@@ -292,8 +292,6 @@ export const analyzeCommand = async (
|
||||
await registerRepo(repoPath, meta);
|
||||
await addToGitignore(repoPath);
|
||||
|
||||
const hookResult = await registerClaudeHook();
|
||||
|
||||
const projectName = path.basename(repoPath);
|
||||
let aggregatedClusterCount = 0;
|
||||
if (pipelineResult.communityResult?.communities) {
|
||||
@@ -342,10 +340,6 @@ export const analyzeCommand = async (
|
||||
console.log(` Context: ${aiContext.files.join(', ')}`);
|
||||
}
|
||||
|
||||
if (hookResult.registered) {
|
||||
console.log(` Hooks: ${hookResult.message}`);
|
||||
}
|
||||
|
||||
// Show a quiet summary if some edge types needed fallback insertion
|
||||
if (kuzuWarnings.length > 0) {
|
||||
const totalFallback = kuzuWarnings.reduce((sum, w) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Raise Node heap limit for large repos (e.g. Linux kernel).
|
||||
// Must run before any heavy allocation. If already set by the user, respect it.
|
||||
if (!process.env.NODE_OPTIONS?.includes('--max-old-space-size')) {
|
||||
const execArgv = process.execArgv.join(' ');
|
||||
if (!execArgv.includes('--max-old-space-size')) {
|
||||
// Re-spawn with a larger heap (8 GB)
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
try {
|
||||
execFileSync(process.execPath, ['--max-old-space-size=8192', ...process.argv.slice(1)], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim() },
|
||||
});
|
||||
process.exit(0);
|
||||
} catch (e: any) {
|
||||
// If the child exited with an error code, propagate it
|
||||
process.exit(e.status ?? 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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';
|
||||
@@ -32,12 +15,15 @@ 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';
|
||||
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')
|
||||
|
||||
+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);
|
||||
};
|
||||
|
||||
@@ -163,7 +163,15 @@ 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, '/');
|
||||
content = content.replace(
|
||||
"let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');",
|
||||
`let cliPath = '${normalizedCli}';`
|
||||
);
|
||||
await fs.writeFile(dest, content, 'utf-8');
|
||||
} catch {
|
||||
// Script not found in source — skip
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import path from 'path';
|
||||
import readline from 'readline';
|
||||
import { execSync } from 'child_process';
|
||||
import { execSync, execFileSync } from 'child_process';
|
||||
import cliProgress from 'cli-progress';
|
||||
import { getGitRoot, isGitRepo } from '../storage/git.js';
|
||||
import { getStoragePaths, loadMeta, loadCLIConfig, saveCLIConfig } from '../storage/repo-manager.js';
|
||||
@@ -343,10 +343,11 @@ function hasGhCLI(): boolean {
|
||||
|
||||
function publishGist(htmlPath: string): { url: string; rawUrl: string } | null {
|
||||
try {
|
||||
const output = execSync(
|
||||
`gh gist create "${htmlPath}" --desc "Repository Wiki — generated by GitNexus" --public`,
|
||||
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
).trim();
|
||||
const output = execFileSync('gh', [
|
||||
'gist', 'create', htmlPath,
|
||||
'--desc', 'Repository Wiki — generated by GitNexus',
|
||||
'--public',
|
||||
], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||
|
||||
// gh gist create prints the gist URL as the last line
|
||||
const lines = output.split('\n');
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum SupportedLanguages {
|
||||
Go = 'go',
|
||||
Rust = 'rust',
|
||||
PHP = 'php',
|
||||
Kotlin = 'kotlin',
|
||||
// Ruby = 'ruby',
|
||||
// Swift = 'swift',
|
||||
Swift = 'swift',
|
||||
}
|
||||
@@ -42,6 +42,9 @@ export type NodeProperties = {
|
||||
endLine?: number,
|
||||
language?: string,
|
||||
isExported?: boolean,
|
||||
// Optional AST-derived framework hint (e.g. @Controller, @GetMapping)
|
||||
astFrameworkMultiplier?: number,
|
||||
astFrameworkReason?: string,
|
||||
// Community-specific properties
|
||||
heuristicLabel?: string,
|
||||
cohesion?: number,
|
||||
@@ -113,4 +116,4 @@ export interface KnowledgeGraph {
|
||||
addRelationship: (relationship: GraphRelationship) => void,
|
||||
removeNode: (nodeId: string) => boolean,
|
||||
removeNodesByFile: (filePath: string) => number,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
|
||||
import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { getLanguageFromFilename, yieldToEventLoop } from './utils.js';
|
||||
import type { ExtractedCall } from './workers/parse-worker.js';
|
||||
import type { ExtractedCall, ExtractedRoute } from './workers/parse-worker.js';
|
||||
|
||||
/**
|
||||
* Node types that represent function/method definitions across languages.
|
||||
@@ -37,6 +37,13 @@ const FUNCTION_NODE_TYPES = new Set([
|
||||
// Rust
|
||||
'function_item',
|
||||
'impl_item', // Methods inside impl blocks
|
||||
// Kotlin (function_declaration already included above via JS/TS)
|
||||
'anonymous_function',
|
||||
'lambda_literal',
|
||||
// PHP — no additional node types needed
|
||||
// Swift
|
||||
'init_declaration',
|
||||
'deinit_declaration',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -57,7 +64,13 @@ const findEnclosingFunction = (
|
||||
let label = 'Function';
|
||||
|
||||
// Different node types have different name locations
|
||||
if (current.type === 'function_declaration' ||
|
||||
// Swift init/deinit — handle before generic cases (more specific)
|
||||
if (current.type === 'init_declaration' || current.type === 'deinit_declaration') {
|
||||
const funcName = current.type === 'init_declaration' ? 'init' : 'deinit';
|
||||
return generateId('Constructor', `${filePath}:${funcName}`);
|
||||
}
|
||||
|
||||
if (current.type === 'function_declaration' ||
|
||||
current.type === 'function_definition' ||
|
||||
current.type === 'async_function_declaration' ||
|
||||
current.type === 'generator_function_declaration' ||
|
||||
@@ -315,6 +328,22 @@ const BUILT_IN_NAMES = new Set([
|
||||
'open', 'read', 'write', 'close', 'append', 'extend', 'update',
|
||||
'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr',
|
||||
'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs',
|
||||
// Kotlin stdlib (IMPORTANT: keep in sync with parse-worker.ts BUILT_IN_NAMES)
|
||||
'println', 'print', 'readLine', 'require', 'requireNotNull', 'check', 'assert', 'lazy', 'error',
|
||||
'listOf', 'mapOf', 'setOf', 'mutableListOf', 'mutableMapOf', 'mutableSetOf',
|
||||
'arrayOf', 'sequenceOf', 'also', 'apply', 'run', 'with', 'takeIf', 'takeUnless',
|
||||
'TODO', 'buildString', 'buildList', 'buildMap', 'buildSet',
|
||||
'repeat', 'synchronized',
|
||||
// Kotlin coroutine builders & scope functions
|
||||
'launch', 'async', 'runBlocking', 'withContext', 'coroutineScope',
|
||||
'supervisorScope', 'delay',
|
||||
// Kotlin Flow operators
|
||||
'flow', 'flowOf', 'collect', 'emit', 'onEach', 'catch',
|
||||
'buffer', 'conflate', 'distinctUntilChanged',
|
||||
'flatMapLatest', 'flatMapMerge', 'combine',
|
||||
'stateIn', 'shareIn', 'launchIn',
|
||||
// Kotlin infix stdlib functions
|
||||
'to', 'until', 'downTo', 'step',
|
||||
// C/C++ standard library and common kernel helpers
|
||||
'printf', 'fprintf', 'sprintf', 'snprintf', 'vprintf', 'vfprintf', 'vsprintf', 'vsnprintf',
|
||||
'scanf', 'fscanf', 'sscanf',
|
||||
@@ -336,6 +365,37 @@ const BUILT_IN_NAMES = new Set([
|
||||
'mutex_lock', 'mutex_unlock', 'mutex_init',
|
||||
'kfree', 'kmalloc', 'kzalloc', 'kcalloc', 'krealloc', 'kvmalloc', 'kvfree',
|
||||
'get', 'put',
|
||||
// Swift/iOS built-ins and standard library
|
||||
'print', 'debugPrint', 'dump', 'fatalError', 'precondition', 'preconditionFailure',
|
||||
'assert', 'assertionFailure', 'NSLog',
|
||||
'abs', 'min', 'max', 'zip', 'stride', 'sequence', 'repeatElement',
|
||||
'swap', 'withUnsafePointer', 'withUnsafeMutablePointer', 'withUnsafeBytes',
|
||||
'autoreleasepool', 'unsafeBitCast', 'unsafeDowncast', 'numericCast',
|
||||
'type', 'MemoryLayout',
|
||||
// Swift collection/string methods (common noise)
|
||||
'map', 'flatMap', 'compactMap', 'filter', 'reduce', 'forEach', 'contains',
|
||||
'first', 'last', 'prefix', 'suffix', 'dropFirst', 'dropLast',
|
||||
'sorted', 'reversed', 'enumerated', 'joined', 'split',
|
||||
'append', 'insert', 'remove', 'removeAll', 'removeFirst', 'removeLast',
|
||||
'isEmpty', 'count', 'index', 'startIndex', 'endIndex',
|
||||
// UIKit/Foundation common methods (noise in call graph)
|
||||
'addSubview', 'removeFromSuperview', 'layoutSubviews', 'setNeedsLayout',
|
||||
'layoutIfNeeded', 'setNeedsDisplay', 'invalidateIntrinsicContentSize',
|
||||
'addTarget', 'removeTarget', 'addGestureRecognizer',
|
||||
'addConstraint', 'addConstraints', 'removeConstraint', 'removeConstraints',
|
||||
'NSLocalizedString', 'Bundle',
|
||||
'reloadData', 'reloadSections', 'reloadRows', 'performBatchUpdates',
|
||||
'register', 'dequeueReusableCell', 'dequeueReusableSupplementaryView',
|
||||
'beginUpdates', 'endUpdates', 'insertRows', 'deleteRows', 'insertSections', 'deleteSections',
|
||||
'present', 'dismiss', 'pushViewController', 'popViewController', 'popToRootViewController',
|
||||
'performSegue', 'prepare',
|
||||
// GCD / async
|
||||
'DispatchQueue', 'async', 'sync', 'asyncAfter',
|
||||
'Task', 'withCheckedContinuation', 'withCheckedThrowingContinuation',
|
||||
// Combine
|
||||
'sink', 'store', 'assign', 'receive', 'subscribe',
|
||||
// Notification / KVO
|
||||
'addObserver', 'removeObserver', 'post', 'NotificationCenter',
|
||||
]);
|
||||
|
||||
const isBuiltInOrNoise = (name: string): boolean => BUILT_IN_NAMES.has(name);
|
||||
@@ -396,3 +456,74 @@ export const processCallsFromExtracted = async (
|
||||
|
||||
onProgress?.(totalFiles, totalFiles);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve pre-extracted Laravel routes to CALLS edges from route files to controller methods.
|
||||
*/
|
||||
export const processRoutesFromExtracted = async (
|
||||
graph: KnowledgeGraph,
|
||||
extractedRoutes: ExtractedRoute[],
|
||||
symbolTable: SymbolTable,
|
||||
importMap: ImportMap,
|
||||
onProgress?: (current: number, total: number) => void
|
||||
) => {
|
||||
for (let i = 0; i < extractedRoutes.length; i++) {
|
||||
const route = extractedRoutes[i];
|
||||
if (i % 50 === 0) {
|
||||
onProgress?.(i, extractedRoutes.length);
|
||||
await yieldToEventLoop();
|
||||
}
|
||||
|
||||
if (!route.controllerName || !route.methodName) continue;
|
||||
|
||||
// Resolve controller class in symbol table
|
||||
const controllerDefs = symbolTable.lookupFuzzy(route.controllerName);
|
||||
if (controllerDefs.length === 0) continue;
|
||||
|
||||
// Prefer import-resolved match
|
||||
const importedFiles = importMap.get(route.filePath);
|
||||
let controllerDef = controllerDefs[0];
|
||||
let confidence = controllerDefs.length === 1 ? 0.7 : 0.5;
|
||||
|
||||
if (importedFiles) {
|
||||
for (const def of controllerDefs) {
|
||||
if (importedFiles.has(def.filePath)) {
|
||||
controllerDef = def;
|
||||
confidence = 0.9;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the method on the controller
|
||||
const methodId = symbolTable.lookupExact(controllerDef.filePath, route.methodName);
|
||||
const sourceId = generateId('File', route.filePath);
|
||||
|
||||
if (!methodId) {
|
||||
// Construct method ID manually
|
||||
const guessedId = generateId('Method', `${controllerDef.filePath}:${route.methodName}`);
|
||||
const relId = generateId('CALLS', `${sourceId}:route->${guessedId}`);
|
||||
graph.addRelationship({
|
||||
id: relId,
|
||||
sourceId,
|
||||
targetId: guessedId,
|
||||
type: 'CALLS',
|
||||
confidence: confidence * 0.8,
|
||||
reason: 'laravel-route',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const relId = generateId('CALLS', `${sourceId}:route->${methodId}`);
|
||||
graph.addRelationship({
|
||||
id: relId,
|
||||
sourceId,
|
||||
targetId: methodId,
|
||||
type: 'CALLS',
|
||||
confidence,
|
||||
reason: 'laravel-route',
|
||||
});
|
||||
}
|
||||
|
||||
onProgress?.(extractedRoutes.length, extractedRoutes.length);
|
||||
};
|
||||
|
||||
@@ -103,6 +103,26 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
|
||||
/^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)
|
||||
@@ -271,6 +291,10 @@ 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') ||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Framework Detection
|
||||
*
|
||||
* Detects frameworks from file path patterns and provides entry point multipliers.
|
||||
* This enables framework-aware entry point scoring.
|
||||
* Detects frameworks from:
|
||||
* 1) file path patterns
|
||||
* 2) AST definition text (decorators/annotations/attributes)
|
||||
* and provides entry point multipliers for process scoring.
|
||||
*
|
||||
* DESIGN: Returns null for unknown frameworks, which causes a 1.0 multiplier
|
||||
* (no bonus, no penalty) - same behavior as before this feature.
|
||||
@@ -127,6 +129,49 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
|
||||
return { framework: 'java-service', entryPointMultiplier: 1.8, reason: 'java-service' };
|
||||
}
|
||||
|
||||
// ========== KOTLIN FRAMEWORKS ==========
|
||||
|
||||
// Spring Boot Kotlin controllers
|
||||
if ((p.includes('/controller/') || p.includes('/controllers/')) && p.endsWith('.kt')) {
|
||||
return { framework: 'spring-kotlin', entryPointMultiplier: 3.0, reason: 'spring-kotlin-controller' };
|
||||
}
|
||||
|
||||
// Spring Boot - files ending in Controller.kt
|
||||
if (p.endsWith('controller.kt')) {
|
||||
return { framework: 'spring-kotlin', entryPointMultiplier: 3.0, reason: 'spring-kotlin-controller-file' };
|
||||
}
|
||||
|
||||
// Ktor routes
|
||||
if (p.includes('/routes/') && p.endsWith('.kt')) {
|
||||
return { framework: 'ktor', entryPointMultiplier: 2.5, reason: 'ktor-routes' };
|
||||
}
|
||||
|
||||
// Ktor plugins folder or Routing.kt files
|
||||
if (p.includes('/plugins/') && p.endsWith('.kt')) {
|
||||
return { framework: 'ktor', entryPointMultiplier: 2.0, reason: 'ktor-plugin' };
|
||||
}
|
||||
if (p.endsWith('routing.kt') || p.endsWith('routes.kt')) {
|
||||
return { framework: 'ktor', entryPointMultiplier: 2.5, reason: 'ktor-routing-file' };
|
||||
}
|
||||
|
||||
// Android Activities, Fragments
|
||||
if ((p.includes('/activity/') || p.includes('/ui/')) && p.endsWith('.kt')) {
|
||||
return { framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-ui' };
|
||||
}
|
||||
if (p.endsWith('activity.kt') || p.endsWith('fragment.kt')) {
|
||||
return { framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-component' };
|
||||
}
|
||||
|
||||
// Kotlin main entry point
|
||||
if (p.endsWith('/main.kt')) {
|
||||
return { framework: 'kotlin', entryPointMultiplier: 3.0, reason: 'kotlin-main' };
|
||||
}
|
||||
|
||||
// Kotlin Application entry point (common naming)
|
||||
if (p.endsWith('/application.kt')) {
|
||||
return { framework: 'kotlin', entryPointMultiplier: 2.5, reason: 'kotlin-application' };
|
||||
}
|
||||
|
||||
// ========== C# / .NET FRAMEWORKS ==========
|
||||
|
||||
// ASP.NET Controllers
|
||||
@@ -257,6 +302,53 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
|
||||
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
|
||||
@@ -272,12 +364,12 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FUTURE: AST-BASED PATTERNS (for Phase 3)
|
||||
// AST-BASED FRAMEWORK DETECTION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Patterns that indicate entry points within code (for future AST-based detection)
|
||||
* These would require parsing decorators/annotations in the code itself.
|
||||
* Patterns that indicate framework entry points within code definitions.
|
||||
* These are matched against AST node text (class/method/function declaration text).
|
||||
*/
|
||||
export const FRAMEWORK_AST_PATTERNS = {
|
||||
// JavaScript/TypeScript decorators
|
||||
@@ -306,4 +398,85 @@ export const FRAMEWORK_AST_PATTERNS = {
|
||||
'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'],
|
||||
};
|
||||
|
||||
interface AstFrameworkPatternConfig {
|
||||
framework: string;
|
||||
entryPointMultiplier: number;
|
||||
reason: string;
|
||||
patterns: string[];
|
||||
}
|
||||
|
||||
const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record<string, AstFrameworkPatternConfig[]> = {
|
||||
javascript: [
|
||||
{ framework: 'nestjs', entryPointMultiplier: 3.2, reason: 'nestjs-decorator', patterns: FRAMEWORK_AST_PATTERNS.nestjs },
|
||||
],
|
||||
typescript: [
|
||||
{ framework: 'nestjs', entryPointMultiplier: 3.2, reason: 'nestjs-decorator', patterns: FRAMEWORK_AST_PATTERNS.nestjs },
|
||||
],
|
||||
python: [
|
||||
{ framework: 'fastapi', entryPointMultiplier: 3.0, reason: 'fastapi-decorator', patterns: FRAMEWORK_AST_PATTERNS.fastapi },
|
||||
{ framework: 'flask', entryPointMultiplier: 2.8, reason: 'flask-decorator', patterns: FRAMEWORK_AST_PATTERNS.flask },
|
||||
],
|
||||
java: [
|
||||
{ framework: 'spring', entryPointMultiplier: 3.2, reason: 'spring-annotation', patterns: FRAMEWORK_AST_PATTERNS.spring },
|
||||
{ framework: 'jaxrs', entryPointMultiplier: 3.0, reason: 'jaxrs-annotation', patterns: FRAMEWORK_AST_PATTERNS.jaxrs },
|
||||
],
|
||||
kotlin: [
|
||||
{ framework: 'spring-kotlin', entryPointMultiplier: 3.2, reason: 'spring-kotlin-annotation', patterns: FRAMEWORK_AST_PATTERNS.spring },
|
||||
{ framework: 'jaxrs', entryPointMultiplier: 3.0, reason: 'jaxrs-annotation', patterns: FRAMEWORK_AST_PATTERNS.jaxrs },
|
||||
{ framework: 'ktor', entryPointMultiplier: 2.8, reason: 'ktor-routing', patterns: ['routing', 'embeddedServer', 'Application.module'] },
|
||||
{ framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-annotation', patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('] },
|
||||
],
|
||||
csharp: [
|
||||
{ framework: 'aspnet', entryPointMultiplier: 3.2, reason: 'aspnet-attribute', patterns: FRAMEWORK_AST_PATTERNS.aspnet },
|
||||
],
|
||||
php: [
|
||||
{ framework: 'laravel', entryPointMultiplier: 3.0, reason: 'php-route-attribute', patterns: FRAMEWORK_AST_PATTERNS.laravel },
|
||||
],
|
||||
};
|
||||
|
||||
/** Pre-lowercased patterns for O(1) pattern matching at runtime */
|
||||
const AST_PATTERNS_LOWERED: Record<string, Array<{ framework: string; entryPointMultiplier: number; reason: string; patterns: string[] }>> =
|
||||
Object.fromEntries(
|
||||
Object.entries(AST_FRAMEWORK_PATTERNS_BY_LANGUAGE).map(([lang, cfgs]) => [
|
||||
lang,
|
||||
cfgs.map(cfg => ({ ...cfg, patterns: cfg.patterns.map(p => p.toLowerCase()) })),
|
||||
])
|
||||
);
|
||||
|
||||
/**
|
||||
* Detect framework entry points from AST definition text (decorators/annotations/attributes).
|
||||
* Returns null if no known pattern is found.
|
||||
* Note: callers should slice definitionText to ~300 chars since annotations appear at the start.
|
||||
*/
|
||||
export function detectFrameworkFromAST(
|
||||
language: string,
|
||||
definitionText: string
|
||||
): FrameworkHint | null {
|
||||
if (!language || !definitionText) return null;
|
||||
|
||||
const configs = AST_PATTERNS_LOWERED[language.toLowerCase()];
|
||||
if (!configs || configs.length === 0) return null;
|
||||
|
||||
const normalized = definitionText.toLowerCase();
|
||||
|
||||
for (const cfg of configs) {
|
||||
for (const pattern of cfg.patterns) {
|
||||
if (normalized.includes(pattern)) {
|
||||
return {
|
||||
framework: cfg.framework,
|
||||
entryPointMultiplier: cfg.entryPointMultiplier,
|
||||
reason: cfg.reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -153,6 +153,42 @@ async function loadComposerConfig(repoRoot: string): Promise<ComposerConfig | nu
|
||||
}
|
||||
}
|
||||
|
||||
/** Swift Package Manager module config */
|
||||
interface SwiftPackageConfig {
|
||||
/** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */
|
||||
targets: Map<string, string>;
|
||||
}
|
||||
|
||||
async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPackageConfig | null> {
|
||||
// Swift imports are module-name based (e.g., `import SiuperModel`)
|
||||
// SPM convention: Sources/<TargetName>/ or Package/Sources/<TargetName>/
|
||||
// We scan for these directories to build a target map
|
||||
const targets = new Map<string, string>();
|
||||
|
||||
const sourceDirs = ['Sources', 'Package/Sources', 'src'];
|
||||
for (const sourceDir of sourceDirs) {
|
||||
try {
|
||||
const fullPath = path.join(repoRoot, sourceDir);
|
||||
const entries = await fs.readdir(fullPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
targets.set(entry.name, sourceDir + '/' + entry.name);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
if (targets.size > 0) {
|
||||
if (isDev) {
|
||||
console.log(`📦 Loaded ${targets.size} Swift package targets`);
|
||||
}
|
||||
return { targets };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IMPORT PATH RESOLUTION
|
||||
// ============================================================================
|
||||
@@ -166,6 +202,8 @@ const EXTENSIONS = [
|
||||
'.py', '/__init__.py',
|
||||
// Java
|
||||
'.java',
|
||||
// Kotlin
|
||||
'.kt', '.kts',
|
||||
// C/C++
|
||||
'.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.hh',
|
||||
// C#
|
||||
@@ -176,6 +214,8 @@ const EXTENSIONS = [
|
||||
'.rs', '/mod.rs',
|
||||
// PHP
|
||||
'.php', '.phtml',
|
||||
// Swift
|
||||
'.swift',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -493,26 +533,42 @@ function tryRustModulePath(modulePath: string, allFiles: Set<string>): string |
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append .* to a Kotlin import path if the AST has a wildcard_import sibling node.
|
||||
* Pure function — returns a new string without mutating the input.
|
||||
*/
|
||||
const appendKotlinWildcard = (importPath: string, importNode: any): string => {
|
||||
for (let i = 0; i < importNode.childCount; i++) {
|
||||
if (importNode.child(i)?.type === 'wildcard_import') {
|
||||
return importPath.endsWith('.*') ? importPath : `${importPath}.*`;
|
||||
}
|
||||
}
|
||||
return importPath;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// JAVA MULTI-FILE RESOLUTION
|
||||
// JVM MULTI-FILE RESOLUTION (Java + Kotlin)
|
||||
// ============================================================================
|
||||
|
||||
/** Kotlin file extensions for JVM resolver reuse */
|
||||
const KOTLIN_EXTENSIONS: readonly string[] = ['.kt', '.kts'];
|
||||
|
||||
/**
|
||||
* Resolve a Java wildcard import (com.example.*) to all matching .java files.
|
||||
* Returns an array of file paths.
|
||||
* Resolve a JVM wildcard import (com.example.*) to all matching files.
|
||||
* Works for both Java (.java) and Kotlin (.kt, .kts).
|
||||
*/
|
||||
function resolveJavaWildcard(
|
||||
function resolveJvmWildcard(
|
||||
importPath: string,
|
||||
normalizedFileList: string[],
|
||||
allFileList: string[],
|
||||
extensions: readonly string[],
|
||||
index?: SuffixIndex,
|
||||
): string[] {
|
||||
// "com.example.util.*" -> "com/example/util"
|
||||
const packagePath = importPath.slice(0, -2).replace(/\./g, '/');
|
||||
|
||||
if (index) {
|
||||
// Use directory index: get all .java files in this package directory
|
||||
const candidates = index.getFilesInDir(packagePath, '.java');
|
||||
const candidates = extensions.flatMap(ext => index.getFilesInDir(packagePath, ext));
|
||||
// Filter to only direct children (no subdirectories)
|
||||
const packageSuffix = '/' + packagePath + '/';
|
||||
return candidates.filter(f => {
|
||||
@@ -529,7 +585,8 @@ function resolveJavaWildcard(
|
||||
const matches: string[] = [];
|
||||
for (let i = 0; i < normalizedFileList.length; i++) {
|
||||
const normalized = normalizedFileList[i];
|
||||
if (normalized.includes(packageSuffix) && normalized.endsWith('.java')) {
|
||||
if (normalized.includes(packageSuffix) &&
|
||||
extensions.some(ext => normalized.endsWith(ext))) {
|
||||
const afterPackage = normalized.substring(normalized.indexOf(packageSuffix) + packageSuffix.length);
|
||||
if (!afterPackage.includes('/')) {
|
||||
matches.push(allFileList[i]);
|
||||
@@ -540,36 +597,39 @@ function resolveJavaWildcard(
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to resolve a Java static import by stripping the member name.
|
||||
* "com.example.Constants.VALUE" -> resolve "com.example.Constants"
|
||||
* Try to resolve a JVM member/static import by stripping the member name.
|
||||
* Java: "com.example.Constants.VALUE" -> resolve "com.example.Constants"
|
||||
* Kotlin: "com.example.Constants.VALUE" -> resolve "com.example.Constants"
|
||||
*/
|
||||
function resolveJavaStaticImport(
|
||||
function resolveJvmMemberImport(
|
||||
importPath: string,
|
||||
normalizedFileList: string[],
|
||||
allFileList: string[],
|
||||
extensions: readonly string[],
|
||||
index?: SuffixIndex,
|
||||
): string | null {
|
||||
// Static imports look like: com.example.Constants.VALUE or com.example.Constants.*
|
||||
// The last segment is a member name (field/method) if it starts with lowercase or is ALL_CAPS
|
||||
// Member imports: com.example.Constants.VALUE or com.example.Constants.*
|
||||
// The last segment is a member name if it starts with lowercase, is ALL_CAPS, or is a wildcard
|
||||
const segments = importPath.split('.');
|
||||
if (segments.length < 3) return null;
|
||||
|
||||
const lastSeg = segments[segments.length - 1];
|
||||
// If last segment is a wildcard or ALL_CAPS constant or starts with lowercase, strip it
|
||||
if (lastSeg === '*' || /^[a-z]/.test(lastSeg) || /^[A-Z_]+$/.test(lastSeg)) {
|
||||
const classPath = segments.slice(0, -1).join('/');
|
||||
const classSuffix = classPath + '.java';
|
||||
|
||||
if (index) {
|
||||
return index.get(classSuffix) || index.getInsensitive(classSuffix) || null;
|
||||
}
|
||||
|
||||
// Fallback: linear scan
|
||||
const fullSuffix = '/' + classSuffix;
|
||||
for (let i = 0; i < normalizedFileList.length; i++) {
|
||||
if (normalizedFileList[i].endsWith(fullSuffix) ||
|
||||
normalizedFileList[i].toLowerCase().endsWith(fullSuffix.toLowerCase())) {
|
||||
return allFileList[i];
|
||||
for (const ext of extensions) {
|
||||
const classSuffix = classPath + ext;
|
||||
if (index) {
|
||||
const result = index.get(classSuffix) || index.getInsensitive(classSuffix);
|
||||
if (result) return result;
|
||||
} else {
|
||||
const fullSuffix = '/' + classSuffix;
|
||||
for (let i = 0; i < normalizedFileList.length; i++) {
|
||||
if (normalizedFileList[i].endsWith(fullSuffix) ||
|
||||
normalizedFileList[i].toLowerCase().endsWith(fullSuffix.toLowerCase())) {
|
||||
return allFileList[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -688,6 +748,7 @@ export const processImports = async (
|
||||
const tsconfigPaths = await loadTsconfigPaths(effectiveRoot);
|
||||
const goModule = await loadGoModulePath(effectiveRoot);
|
||||
const composerConfig = await loadComposerConfig(effectiveRoot);
|
||||
const swiftPackageConfig = await loadSwiftPackageConfig(effectiveRoot);
|
||||
|
||||
// Helper: add an IMPORTS edge + update import map
|
||||
const addImportEdge = (filePath: string, resolvedPath: string) => {
|
||||
@@ -778,26 +839,42 @@ export const processImports = async (
|
||||
}
|
||||
|
||||
// Clean path (remove quotes and angle brackets for C/C++ includes)
|
||||
const rawImportPath = sourceNode.text.replace(/['"<>]/g, '');
|
||||
const rawImportPath = language === SupportedLanguages.Kotlin
|
||||
? appendKotlinWildcard(sourceNode.text.replace(/['"<>]/g, ''), captureMap['import'])
|
||||
: sourceNode.text.replace(/['"<>]/g, '');
|
||||
totalImportsFound++;
|
||||
|
||||
// ---- Java: handle wildcards and static imports specially ----
|
||||
if (language === SupportedLanguages.Java) {
|
||||
// ---- JVM languages (Java + Kotlin): handle wildcards and member imports ----
|
||||
if (language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin) {
|
||||
const exts = language === SupportedLanguages.Java ? ['.java'] : KOTLIN_EXTENSIONS;
|
||||
|
||||
if (rawImportPath.endsWith('.*')) {
|
||||
const matchedFiles = resolveJavaWildcard(rawImportPath, normalizedFileList, allFileList, index);
|
||||
const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index);
|
||||
// Kotlin can import Java files in mixed codebases — try .java as fallback
|
||||
if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) {
|
||||
const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
|
||||
for (const matchedFile of javaMatches) {
|
||||
addImportEdge(file.path, matchedFile);
|
||||
}
|
||||
if (javaMatches.length > 0) return;
|
||||
}
|
||||
for (const matchedFile of matchedFiles) {
|
||||
addImportEdge(file.path, matchedFile);
|
||||
}
|
||||
return; // skip single-file resolution
|
||||
}
|
||||
|
||||
// Try static import resolution (strip member name)
|
||||
const staticResolved = resolveJavaStaticImport(rawImportPath, normalizedFileList, allFileList, index);
|
||||
if (staticResolved) {
|
||||
addImportEdge(file.path, staticResolved);
|
||||
// Try member/static import resolution (strip member name)
|
||||
let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index);
|
||||
// Kotlin can import Java files in mixed codebases — try .java as fallback
|
||||
if (!memberResolved && language === SupportedLanguages.Kotlin) {
|
||||
memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
|
||||
}
|
||||
if (memberResolved) {
|
||||
addImportEdge(file.path, memberResolved);
|
||||
return;
|
||||
}
|
||||
// Fall through to normal resolution for regular Java imports
|
||||
// Fall through to normal resolution for regular imports
|
||||
}
|
||||
|
||||
// ---- Go: handle package-level imports ----
|
||||
@@ -821,6 +898,25 @@ export const processImports = async (
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Swift: handle module imports ----
|
||||
if (language === SupportedLanguages.Swift && swiftPackageConfig) {
|
||||
// Swift imports are module names: `import SiuperModel`
|
||||
// Resolve to the module's source directory → all .swift files in it
|
||||
const targetDir = swiftPackageConfig.targets.get(rawImportPath);
|
||||
if (targetDir) {
|
||||
// Find all .swift files in this target directory
|
||||
const dirPrefix = targetDir + '/';
|
||||
for (const filePath2 of allFileList) {
|
||||
if (filePath2.startsWith(dirPrefix) && filePath2.endsWith('.swift')) {
|
||||
addImportEdge(file.path, filePath2);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// External framework (Foundation, UIKit, etc.) — skip
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Standard single-file resolution ----
|
||||
const resolvedPath = resolveImportPath(
|
||||
file.path,
|
||||
@@ -871,6 +967,7 @@ export const processImportsFromExtracted = async (
|
||||
const tsconfigPaths = await loadTsconfigPaths(effectiveRoot);
|
||||
const goModule = await loadGoModulePath(effectiveRoot);
|
||||
const composerConfig = await loadComposerConfig(effectiveRoot);
|
||||
const swiftPackageConfig = await loadSwiftPackageConfig(effectiveRoot);
|
||||
|
||||
const addImportEdge = (filePath: string, resolvedPath: string) => {
|
||||
const sourceId = generateId('File', filePath);
|
||||
@@ -941,20 +1038,34 @@ export const processImportsFromExtracted = async (
|
||||
continue;
|
||||
}
|
||||
|
||||
// Java: handle wildcards and static imports
|
||||
if (language === SupportedLanguages.Java) {
|
||||
// JVM languages (Java + Kotlin): handle wildcards and member imports
|
||||
if (language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin) {
|
||||
const exts = language === SupportedLanguages.Java ? ['.java'] : KOTLIN_EXTENSIONS;
|
||||
|
||||
if (rawImportPath.endsWith('.*')) {
|
||||
const matchedFiles = resolveJavaWildcard(rawImportPath, normalizedFileList, allFileList, index);
|
||||
const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index);
|
||||
// Kotlin can import Java files in mixed codebases — try .java as fallback
|
||||
if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) {
|
||||
const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
|
||||
for (const matchedFile of javaMatches) {
|
||||
addImportEdge(filePath, matchedFile);
|
||||
}
|
||||
if (javaMatches.length > 0) continue;
|
||||
}
|
||||
for (const matchedFile of matchedFiles) {
|
||||
addImportEdge(filePath, matchedFile);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const staticResolved = resolveJavaStaticImport(rawImportPath, normalizedFileList, allFileList, index);
|
||||
if (staticResolved) {
|
||||
resolveCache.set(cacheKey, staticResolved);
|
||||
addImportEdge(filePath, staticResolved);
|
||||
let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index);
|
||||
// Kotlin can import Java files in mixed codebases — try .java as fallback
|
||||
if (!memberResolved && language === SupportedLanguages.Kotlin) {
|
||||
memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
|
||||
}
|
||||
if (memberResolved) {
|
||||
resolveCache.set(cacheKey, memberResolved);
|
||||
addImportEdge(filePath, memberResolved);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -980,6 +1091,20 @@ export const processImportsFromExtracted = async (
|
||||
continue;
|
||||
}
|
||||
|
||||
// Swift: handle module imports
|
||||
if (language === SupportedLanguages.Swift && swiftPackageConfig) {
|
||||
const targetDir = swiftPackageConfig.targets.get(rawImportPath);
|
||||
if (targetDir) {
|
||||
const dirPrefix = targetDir + '/';
|
||||
for (const fp of allFileList) {
|
||||
if (fp.startsWith(dirPrefix) && fp.endsWith('.swift')) {
|
||||
addImportEdge(filePath, fp);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Standard resolution (has its own internal cache)
|
||||
const resolvedPath = resolveImportPath(
|
||||
filePath,
|
||||
|
||||
@@ -5,9 +5,10 @@ import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { SymbolTable } from './symbol-table.js';
|
||||
import { ASTCache } from './ast-cache.js';
|
||||
import { getLanguageFromFilename, yieldToEventLoop } from './utils.js';
|
||||
import { findSiblingChild, getLanguageFromFilename, yieldToEventLoop } from './utils.js';
|
||||
import { detectFrameworkFromAST } from './framework-detection.js';
|
||||
import { WorkerPool } from './workers/worker-pool.js';
|
||||
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage } from './workers/parse-worker.js';
|
||||
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage, ExtractedRoute } from './workers/parse-worker.js';
|
||||
|
||||
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
|
||||
|
||||
@@ -15,8 +16,41 @@ export interface WorkerExtractedData {
|
||||
imports: ExtractedImport[];
|
||||
calls: ExtractedCall[];
|
||||
heritage: ExtractedHeritage[];
|
||||
routes: ExtractedRoute[];
|
||||
}
|
||||
|
||||
const DEFINITION_CAPTURE_KEYS = [
|
||||
'definition.function',
|
||||
'definition.class',
|
||||
'definition.interface',
|
||||
'definition.method',
|
||||
'definition.struct',
|
||||
'definition.enum',
|
||||
'definition.namespace',
|
||||
'definition.module',
|
||||
'definition.trait',
|
||||
'definition.impl',
|
||||
'definition.type',
|
||||
'definition.const',
|
||||
'definition.static',
|
||||
'definition.typedef',
|
||||
'definition.macro',
|
||||
'definition.union',
|
||||
'definition.property',
|
||||
'definition.record',
|
||||
'definition.delegate',
|
||||
'definition.annotation',
|
||||
'definition.constructor',
|
||||
'definition.template',
|
||||
] as const;
|
||||
|
||||
const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): any | null => {
|
||||
for (const key of DEFINITION_CAPTURE_KEYS) {
|
||||
if (captureMap[key]) return captureMap[key];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// EXPORT DETECTION - Language-specific visibility detection
|
||||
// ============================================================================
|
||||
@@ -30,7 +64,7 @@ export interface WorkerExtractedData {
|
||||
* @param language - The programming language
|
||||
* @returns true if the symbol is exported/public
|
||||
*/
|
||||
const isNodeExported = (node: any, name: string, language: string): boolean => {
|
||||
export const isNodeExported = (node: any, name: string, language: string): boolean => {
|
||||
let current = node;
|
||||
|
||||
switch (language) {
|
||||
@@ -108,12 +142,56 @@ const isNodeExported = (node: any, name: string, language: string): boolean => {
|
||||
}
|
||||
return false;
|
||||
|
||||
// Kotlin: Default visibility is public (unlike Java)
|
||||
// visibility_modifier is inside modifiers, a sibling of the name node within the declaration
|
||||
case 'kotlin':
|
||||
while (current) {
|
||||
if (current.parent) {
|
||||
const visMod = findSiblingChild(current.parent, 'modifiers', 'visibility_modifier');
|
||||
if (visMod) {
|
||||
const text = visMod.text;
|
||||
if (text === 'private' || text === 'internal' || text === 'protected') return false;
|
||||
if (text === 'public') return true;
|
||||
}
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
// No visibility modifier = public (Kotlin default)
|
||||
return true;
|
||||
|
||||
// C/C++: No native export concept at language level
|
||||
// Entry points will be detected via name patterns (main, etc.)
|
||||
case 'c':
|
||||
case 'cpp':
|
||||
return false;
|
||||
|
||||
// Swift: Check for 'public' or 'open' access modifiers
|
||||
case 'swift':
|
||||
while (current) {
|
||||
if (current.type === 'modifiers' || current.type === 'visibility_modifier') {
|
||||
const text = current.text || '';
|
||||
if (text.includes('public') || text.includes('open')) return true;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
|
||||
// PHP: Check for visibility modifier or top-level scope
|
||||
case 'php':
|
||||
while (current) {
|
||||
if (current.type === 'class_declaration' ||
|
||||
current.type === 'interface_declaration' ||
|
||||
current.type === 'trait_declaration' ||
|
||||
current.type === 'enum_declaration') {
|
||||
return true;
|
||||
}
|
||||
if (current.type === 'visibility_modifier') {
|
||||
return current.text === 'public';
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return true; // Top-level functions are globally accessible
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -138,7 +216,7 @@ const processParsingWithWorkers = async (
|
||||
if (lang) parseableFiles.push({ path: file.path, content: file.content });
|
||||
}
|
||||
|
||||
if (parseableFiles.length === 0) return { imports: [], calls: [], heritage: [] };
|
||||
if (parseableFiles.length === 0) return { imports: [], calls: [], heritage: [], routes: [] };
|
||||
|
||||
const total = files.length;
|
||||
|
||||
@@ -154,6 +232,7 @@ const processParsingWithWorkers = async (
|
||||
const allImports: ExtractedImport[] = [];
|
||||
const allCalls: ExtractedCall[] = [];
|
||||
const allHeritage: ExtractedHeritage[] = [];
|
||||
const allRoutes: ExtractedRoute[] = [];
|
||||
for (const result of chunkResults) {
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode({
|
||||
@@ -174,11 +253,12 @@ const processParsingWithWorkers = async (
|
||||
allImports.push(...result.imports);
|
||||
allCalls.push(...result.calls);
|
||||
allHeritage.push(...result.heritage);
|
||||
allRoutes.push(...result.routes);
|
||||
}
|
||||
|
||||
// Final progress
|
||||
onFileProgress?.(total, total, 'done');
|
||||
return { imports: allImports, calls: allCalls, heritage: allHeritage };
|
||||
return { imports: allImports, calls: allCalls, heritage: allHeritage, routes: allRoutes };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -209,7 +289,11 @@ const processParsingSequential = async (
|
||||
// Skip very large files — they can crash tree-sitter or cause OOM
|
||||
if (file.content.length > 512 * 1024) continue;
|
||||
|
||||
await loadLanguage(language, file.path);
|
||||
try {
|
||||
await loadLanguage(language, file.path);
|
||||
} catch {
|
||||
continue; // parser unavailable — already warned in pipeline
|
||||
}
|
||||
|
||||
let tree;
|
||||
try {
|
||||
@@ -253,9 +337,9 @@ const processParsingSequential = async (
|
||||
}
|
||||
|
||||
const nameNode = captureMap['name'];
|
||||
if (!nameNode) return;
|
||||
|
||||
const nodeName = nameNode.text;
|
||||
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
|
||||
if (!nameNode && !captureMap['definition.constructor']) return;
|
||||
const nodeName = nameNode ? nameNode.text : 'init';
|
||||
|
||||
let nodeLabel = 'CodeElement';
|
||||
|
||||
@@ -282,7 +366,14 @@ const processParsingSequential = async (
|
||||
else if (captureMap['definition.constructor']) nodeLabel = 'Constructor';
|
||||
else if (captureMap['definition.template']) nodeLabel = 'Template';
|
||||
|
||||
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
|
||||
const definitionNodeForRange = getDefinitionNodeFromCaptures(captureMap);
|
||||
const startLine = definitionNodeForRange ? definitionNodeForRange.startPosition.row : (nameNode ? nameNode.startPosition.row : 0);
|
||||
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}:${startLine}`);
|
||||
|
||||
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
|
||||
const frameworkHint = definitionNode
|
||||
? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300))
|
||||
: null;
|
||||
|
||||
const node: GraphNode = {
|
||||
id: nodeId,
|
||||
@@ -290,11 +381,15 @@ const processParsingSequential = async (
|
||||
properties: {
|
||||
name: nodeName,
|
||||
filePath: file.path,
|
||||
startLine: nameNode.startPosition.row,
|
||||
endLine: nameNode.endPosition.row,
|
||||
startLine: definitionNodeForRange ? definitionNodeForRange.startPosition.row : startLine,
|
||||
endLine: definitionNodeForRange ? definitionNodeForRange.endPosition.row : startLine,
|
||||
language: language,
|
||||
isExported: isNodeExported(nameNode, nodeName, language),
|
||||
}
|
||||
isExported: isNodeExported(nameNode || definitionNodeForRange, nodeName, language),
|
||||
...(frameworkHint ? {
|
||||
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
|
||||
astFrameworkReason: frameworkHint.reason,
|
||||
} : {}),
|
||||
},
|
||||
};
|
||||
|
||||
graph.addNode(node);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createKnowledgeGraph } from '../graph/graph.js';
|
||||
import { processStructure } from './structure-processor.js';
|
||||
import { processParsing } from './parsing-processor.js';
|
||||
import { processImports, processImportsFromExtracted, createImportMap, buildImportResolutionContext } from './import-processor.js';
|
||||
import { processCalls, processCallsFromExtracted } from './call-processor.js';
|
||||
import { processCalls, processCallsFromExtracted, processRoutesFromExtracted } from './call-processor.js';
|
||||
import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js';
|
||||
import { processCommunities } from './community-processor.js';
|
||||
import { processProcesses } from './process-processor.js';
|
||||
@@ -11,6 +11,7 @@ import { createASTCache } from './ast-cache.js';
|
||||
import { PipelineProgress, PipelineResult } from '../../types/pipeline.js';
|
||||
import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js';
|
||||
import { getLanguageFromFilename } from './utils.js';
|
||||
import { isLanguageAvailable } from '../tree-sitter/parser-loader.js';
|
||||
import { createWorkerPool, WorkerPool } from './workers/worker-pool.js';
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
@@ -88,7 +89,23 @@ export const runPipelineFromRepo = async (
|
||||
// Group parseable files into byte-budget chunks so only ~20MB of source
|
||||
// is in memory at a time. Each chunk is: read → parse → extract → free.
|
||||
|
||||
const parseableScanned = scannedFiles.filter(f => getLanguageFromFilename(f.path));
|
||||
const parseableScanned = scannedFiles.filter(f => {
|
||||
const lang = getLanguageFromFilename(f.path);
|
||||
return lang && isLanguageAvailable(lang);
|
||||
});
|
||||
|
||||
// Warn about files skipped due to unavailable parsers
|
||||
const skippedByLang = new Map<string, number>();
|
||||
for (const f of scannedFiles) {
|
||||
const lang = getLanguageFromFilename(f.path);
|
||||
if (lang && !isLanguageAvailable(lang)) {
|
||||
skippedByLang.set(lang, (skippedByLang.get(lang) || 0) + 1);
|
||||
}
|
||||
}
|
||||
for (const [lang, count] of skippedByLang) {
|
||||
console.warn(`Skipping ${count} ${lang} file(s) — ${lang} parser not available (native binding may not have built). Try: npm rebuild tree-sitter-${lang}`);
|
||||
}
|
||||
|
||||
const totalParseable = parseableScanned.length;
|
||||
|
||||
// Build byte-budget chunks
|
||||
@@ -184,6 +201,10 @@ export const runPipelineFromRepo = async (
|
||||
if (chunkWorkerData.heritage.length > 0) {
|
||||
await processHeritageFromExtracted(graph, chunkWorkerData.heritage, symbolTable);
|
||||
}
|
||||
// Routes — resolve immediately (Laravel route→controller CALLS edges)
|
||||
if (chunkWorkerData.routes && chunkWorkerData.routes.length > 0) {
|
||||
await processRoutesFromExtracted(graph, chunkWorkerData.routes, symbolTable, importMap);
|
||||
}
|
||||
} else {
|
||||
await processImports(graph, chunkFiles, astCache, importMap, undefined, repoPath, allPaths);
|
||||
sequentialChunkPaths.push(chunkPaths);
|
||||
|
||||
@@ -285,7 +285,7 @@ const findEntryPoints = (
|
||||
if (callees.length === 0) continue;
|
||||
|
||||
// Calculate entry point score using new scoring system
|
||||
const { score, reasons } = calculateEntryPointScore(
|
||||
const { score: baseScore, reasons } = calculateEntryPointScore(
|
||||
node.properties.name,
|
||||
node.properties.language || 'javascript',
|
||||
node.properties.isExported ?? false,
|
||||
@@ -294,6 +294,13 @@ const findEntryPoints = (
|
||||
filePath // Pass filePath for framework detection
|
||||
);
|
||||
|
||||
let score = baseScore;
|
||||
const astFrameworkMultiplier = node.properties.astFrameworkMultiplier ?? 1.0;
|
||||
if (astFrameworkMultiplier > 1.0) {
|
||||
score *= astFrameworkMultiplier;
|
||||
reasons.push(`framework-ast:${node.properties.astFrameworkReason || 'decorator'}`);
|
||||
}
|
||||
|
||||
if (score > 0) {
|
||||
entryPointCandidates.push({ id: node.id, score, reasons });
|
||||
}
|
||||
@@ -337,8 +344,7 @@ const traceFromEntryPoint = (
|
||||
// BFS with path tracking
|
||||
// Each queue item: [currentNodeId, pathSoFar]
|
||||
const queue: [string, string[]][] = [[entryId, [entryId]]];
|
||||
const visited = new Set<string>();
|
||||
|
||||
|
||||
while (queue.length > 0 && traces.length < config.maxBranching * 3) {
|
||||
const [currentId, path] = queue.shift()!;
|
||||
|
||||
|
||||
@@ -396,6 +396,139 @@ export const PHP_QUERIES = `
|
||||
[(name) (qualified_name)] @heritage.trait))) @heritage
|
||||
`;
|
||||
|
||||
// Kotlin queries - works with tree-sitter-kotlin (fwcd/tree-sitter-kotlin)
|
||||
// Based on official tags.scm; functions use simple_identifier, classes use type_identifier
|
||||
export const KOTLIN_QUERIES = `
|
||||
; ── Interfaces ─────────────────────────────────────────────────────────────
|
||||
; tree-sitter-kotlin (fwcd) has no interface_declaration node type.
|
||||
; Interfaces are class_declaration nodes with an anonymous "interface" keyword child.
|
||||
(class_declaration
|
||||
"interface"
|
||||
(type_identifier) @name) @definition.interface
|
||||
|
||||
; ── Classes (regular, data, sealed, enum) ────────────────────────────────
|
||||
; All have the anonymous "class" keyword child. enum class has both
|
||||
; "enum" and "class" children — the "class" child still matches.
|
||||
(class_declaration
|
||||
"class"
|
||||
(type_identifier) @name) @definition.class
|
||||
|
||||
; ── Object declarations (Kotlin singletons) ──────────────────────────────
|
||||
(object_declaration
|
||||
(type_identifier) @name) @definition.class
|
||||
|
||||
; ── Companion objects (named only) ───────────────────────────────────────
|
||||
(companion_object
|
||||
(type_identifier) @name) @definition.class
|
||||
|
||||
; ── Functions (top-level, member, extension) ──────────────────────────────
|
||||
(function_declaration
|
||||
(simple_identifier) @name) @definition.function
|
||||
|
||||
; ── Properties ───────────────────────────────────────────────────────────
|
||||
(property_declaration
|
||||
(variable_declaration
|
||||
(simple_identifier) @name)) @definition.property
|
||||
|
||||
; ── Enum entries ─────────────────────────────────────────────────────────
|
||||
(enum_entry
|
||||
(simple_identifier) @name) @definition.enum
|
||||
|
||||
; ── Type aliases ─────────────────────────────────────────────────────────
|
||||
(type_alias
|
||||
(type_identifier) @name) @definition.type
|
||||
|
||||
; ── Imports ──────────────────────────────────────────────────────────────
|
||||
(import_header
|
||||
(identifier) @import.source) @import
|
||||
|
||||
; ── Function calls (direct) ──────────────────────────────────────────────
|
||||
(call_expression
|
||||
(simple_identifier) @call.name) @call
|
||||
|
||||
; ── Method calls (via navigation: obj.method()) ──────────────────────────
|
||||
(call_expression
|
||||
(navigation_expression
|
||||
(navigation_suffix
|
||||
(simple_identifier) @call.name))) @call
|
||||
|
||||
; ── Constructor invocations ──────────────────────────────────────────────
|
||||
(constructor_invocation
|
||||
(user_type
|
||||
(type_identifier) @call.name)) @call
|
||||
|
||||
; ── Infix function calls (e.g., a to b, x until y) ──────────────────────
|
||||
(infix_expression
|
||||
(simple_identifier) @call.name) @call
|
||||
|
||||
; ── Heritage: extends / implements via delegation_specifier ──────────────
|
||||
; Interface implementation (bare user_type): class Foo : Bar
|
||||
(class_declaration
|
||||
(type_identifier) @heritage.class
|
||||
(delegation_specifier
|
||||
(user_type (type_identifier) @heritage.extends))) @heritage
|
||||
|
||||
; Class extension (constructor_invocation): class Foo : Bar()
|
||||
(class_declaration
|
||||
(type_identifier) @heritage.class
|
||||
(delegation_specifier
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @heritage.extends)))) @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,
|
||||
@@ -407,5 +540,7 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
|
||||
[SupportedLanguages.CSharp]: CSHARP_QUERIES,
|
||||
[SupportedLanguages.Rust]: RUST_QUERIES,
|
||||
[SupportedLanguages.PHP]: PHP_QUERIES,
|
||||
[SupportedLanguages.Kotlin]: KOTLIN_QUERIES,
|
||||
[SupportedLanguages.Swift]: SWIFT_QUERIES,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,23 @@ import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
*/
|
||||
export const yieldToEventLoop = (): Promise<void> => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
/**
|
||||
* Find a child of `childType` within a sibling node of `siblingType`.
|
||||
* Used for Kotlin AST traversal where visibility_modifier lives inside a modifiers sibling.
|
||||
*/
|
||||
export const findSiblingChild = (parent: any, siblingType: string, childType: string): any | null => {
|
||||
for (let i = 0; i < parent.childCount; i++) {
|
||||
const sibling = parent.child(i);
|
||||
if (sibling?.type === siblingType) {
|
||||
for (let j = 0; j < sibling.childCount; j++) {
|
||||
const child = sibling.child(j);
|
||||
if (child?.type === childType) return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Map file extension to SupportedLanguage enum
|
||||
*/
|
||||
@@ -31,12 +48,15 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages |
|
||||
if (filename.endsWith('.go')) return SupportedLanguages.Go;
|
||||
// Rust
|
||||
if (filename.endsWith('.rs')) return SupportedLanguages.Rust;
|
||||
// Kotlin
|
||||
if (filename.endsWith('.kt') || filename.endsWith('.kts')) return SupportedLanguages.Kotlin;
|
||||
// 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;
|
||||
}
|
||||
if (filename.endsWith('.swift')) return SupportedLanguages.Swift;
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,10 +9,18 @@ import CPP from 'tree-sitter-cpp';
|
||||
import CSharp from 'tree-sitter-c-sharp';
|
||||
import Go from 'tree-sitter-go';
|
||||
import Rust from 'tree-sitter-rust';
|
||||
import Kotlin from 'tree-sitter-kotlin';
|
||||
import PHP from 'tree-sitter-php';
|
||||
import { createRequire } from 'node:module';
|
||||
import { SupportedLanguages } from '../../../config/supported-languages.js';
|
||||
import { LANGUAGE_QUERIES } from '../tree-sitter-queries.js';
|
||||
import { getLanguageFromFilename } from '../utils.js';
|
||||
|
||||
// tree-sitter-swift is an optionalDependency — may not be installed
|
||||
const _require = createRequire(import.meta.url);
|
||||
let Swift: any = null;
|
||||
try { Swift = _require('tree-sitter-swift'); } catch {}
|
||||
import { findSiblingChild, getLanguageFromFilename } from '../utils.js';
|
||||
import { detectFrameworkFromAST } from '../framework-detection.js';
|
||||
import { generateId } from '../../../lib/utils.js';
|
||||
|
||||
// ============================================================================
|
||||
@@ -29,6 +37,8 @@ interface ParsedNode {
|
||||
endLine: number;
|
||||
language: string;
|
||||
isExported: boolean;
|
||||
astFrameworkMultiplier?: number;
|
||||
astFrameworkReason?: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
@@ -70,6 +80,17 @@ export interface ExtractedHeritage {
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export interface ExtractedRoute {
|
||||
filePath: string;
|
||||
httpMethod: string;
|
||||
routePath: string | null;
|
||||
controllerName: string | null;
|
||||
methodName: string | null;
|
||||
middleware: string[];
|
||||
prefix: string | null;
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
export interface ParseWorkerResult {
|
||||
nodes: ParsedNode[];
|
||||
relationships: ParsedRelationship[];
|
||||
@@ -77,6 +98,7 @@ export interface ParseWorkerResult {
|
||||
imports: ExtractedImport[];
|
||||
calls: ExtractedCall[];
|
||||
heritage: ExtractedHeritage[];
|
||||
routes: ExtractedRoute[];
|
||||
fileCount: number;
|
||||
}
|
||||
|
||||
@@ -102,7 +124,9 @@ const languageMap: Record<string, any> = {
|
||||
[SupportedLanguages.CSharp]: CSharp,
|
||||
[SupportedLanguages.Go]: Go,
|
||||
[SupportedLanguages.Rust]: Rust,
|
||||
[SupportedLanguages.Kotlin]: Kotlin,
|
||||
[SupportedLanguages.PHP]: PHP.php_only,
|
||||
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
|
||||
};
|
||||
|
||||
const setLanguage = (language: SupportedLanguages, filePath: string): void => {
|
||||
@@ -184,6 +208,23 @@ const isNodeExported = (node: any, name: string, language: string): boolean => {
|
||||
}
|
||||
return false;
|
||||
|
||||
// Kotlin: Default visibility is public (unlike Java)
|
||||
// visibility_modifier is inside modifiers, a sibling of the name node within the declaration
|
||||
case 'kotlin':
|
||||
while (current) {
|
||||
if (current.parent) {
|
||||
const visMod = findSiblingChild(current.parent, 'modifiers', 'visibility_modifier');
|
||||
if (visMod) {
|
||||
const text = visMod.text;
|
||||
if (text === 'private' || text === 'internal' || text === 'protected') return false;
|
||||
if (text === 'public') return true;
|
||||
}
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
// No visibility modifier = public (Kotlin default)
|
||||
return true;
|
||||
|
||||
case 'c':
|
||||
case 'cpp':
|
||||
return false;
|
||||
@@ -206,6 +247,16 @@ const isNodeExported = (node: any, name: string, language: string): boolean => {
|
||||
// Top-level functions (no parent class) are globally accessible
|
||||
return true;
|
||||
|
||||
case 'swift':
|
||||
while (current) {
|
||||
if (current.type === 'modifiers' || current.type === 'visibility_modifier') {
|
||||
const text = current.text || '';
|
||||
if (text.includes('public') || text.includes('open')) return true;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -221,7 +272,12 @@ const FUNCTION_NODE_TYPES = new Set([
|
||||
'function_definition', 'async_function_declaration', 'async_arrow_function',
|
||||
'method_declaration', 'constructor_declaration',
|
||||
'local_function_statement', 'function_item', 'impl_item',
|
||||
'anonymous_function_creation_expression', // PHP anonymous functions
|
||||
// Kotlin
|
||||
'lambda_literal',
|
||||
// PHP
|
||||
'anonymous_function',
|
||||
// Swift initializers/deinitializers
|
||||
'init_declaration', 'deinit_declaration',
|
||||
]);
|
||||
|
||||
/** Walk up AST to find enclosing function, return its generateId or null for top-level */
|
||||
@@ -232,6 +288,12 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
|
||||
let funcName: string | null = null;
|
||||
let label = 'Function';
|
||||
|
||||
if (current.type === 'init_declaration' || current.type === 'deinit_declaration') {
|
||||
const funcName = current.type === 'init_declaration' ? 'init' : 'deinit';
|
||||
const label = 'Constructor';
|
||||
return generateId(label, `${filePath}:${funcName}`);
|
||||
}
|
||||
|
||||
if (['function_declaration', 'function_definition', 'async_function_declaration',
|
||||
'generator_function_declaration', 'function_item'].includes(current.type)) {
|
||||
const nameNode = current.childForFieldName?.('name') ||
|
||||
@@ -298,6 +360,22 @@ const BUILT_INS = new Set([
|
||||
'open', 'read', 'write', 'close', 'append', 'extend', 'update',
|
||||
'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr',
|
||||
'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs',
|
||||
// Kotlin stdlib (IMPORTANT: keep in sync with call-processor.ts BUILT_IN_NAMES)
|
||||
'println', 'print', 'readLine', 'require', 'requireNotNull', 'check', 'assert', 'lazy', 'error',
|
||||
'listOf', 'mapOf', 'setOf', 'mutableListOf', 'mutableMapOf', 'mutableSetOf',
|
||||
'arrayOf', 'sequenceOf', 'also', 'apply', 'run', 'with', 'takeIf', 'takeUnless',
|
||||
'TODO', 'buildString', 'buildList', 'buildMap', 'buildSet',
|
||||
'repeat', 'synchronized',
|
||||
// Kotlin coroutine builders & scope functions
|
||||
'launch', 'async', 'runBlocking', 'withContext', 'coroutineScope',
|
||||
'supervisorScope', 'delay',
|
||||
// Kotlin Flow operators
|
||||
'flow', 'flowOf', 'collect', 'emit', 'onEach', 'catch',
|
||||
'buffer', 'conflate', 'distinctUntilChanged',
|
||||
'flatMapLatest', 'flatMapMerge', 'combine',
|
||||
'stateIn', 'shareIn', 'launchIn',
|
||||
// Kotlin infix stdlib functions
|
||||
'to', 'until', 'downTo', 'step',
|
||||
// C/C++ standard library
|
||||
'printf', 'fprintf', 'sprintf', 'snprintf', 'vprintf', 'vfprintf', 'vsprintf', 'vsnprintf',
|
||||
'scanf', 'fscanf', 'sscanf',
|
||||
@@ -336,6 +414,37 @@ const BUILT_INS = new Set([
|
||||
'preg_match', 'preg_match_all', 'preg_replace', 'preg_split',
|
||||
'header', 'session_start', 'session_destroy', 'ob_start', 'ob_end_clean', 'ob_get_clean',
|
||||
'dd', 'dump',
|
||||
// Swift/iOS built-ins and standard library
|
||||
'print', 'debugPrint', 'dump', 'fatalError', 'precondition', 'preconditionFailure',
|
||||
'assert', 'assertionFailure', 'NSLog',
|
||||
'abs', 'min', 'max', 'zip', 'stride', 'sequence', 'repeatElement',
|
||||
'swap', 'withUnsafePointer', 'withUnsafeMutablePointer', 'withUnsafeBytes',
|
||||
'autoreleasepool', 'unsafeBitCast', 'unsafeDowncast', 'numericCast',
|
||||
'type', 'MemoryLayout',
|
||||
// Swift collection/string methods (common noise)
|
||||
'map', 'flatMap', 'compactMap', 'filter', 'reduce', 'forEach', 'contains',
|
||||
'first', 'last', 'prefix', 'suffix', 'dropFirst', 'dropLast',
|
||||
'sorted', 'reversed', 'enumerated', 'joined', 'split',
|
||||
'append', 'insert', 'remove', 'removeAll', 'removeFirst', 'removeLast',
|
||||
'isEmpty', 'count', 'index', 'startIndex', 'endIndex',
|
||||
// UIKit/Foundation common methods (noise in call graph)
|
||||
'addSubview', 'removeFromSuperview', 'layoutSubviews', 'setNeedsLayout',
|
||||
'layoutIfNeeded', 'setNeedsDisplay', 'invalidateIntrinsicContentSize',
|
||||
'addTarget', 'removeTarget', 'addGestureRecognizer',
|
||||
'addConstraint', 'addConstraints', 'removeConstraint', 'removeConstraints',
|
||||
'NSLocalizedString', 'Bundle',
|
||||
'reloadData', 'reloadSections', 'reloadRows', 'performBatchUpdates',
|
||||
'register', 'dequeueReusableCell', 'dequeueReusableSupplementaryView',
|
||||
'beginUpdates', 'endUpdates', 'insertRows', 'deleteRows', 'insertSections', 'deleteSections',
|
||||
'present', 'dismiss', 'pushViewController', 'popViewController', 'popToRootViewController',
|
||||
'performSegue', 'prepare',
|
||||
// GCD / async
|
||||
'DispatchQueue', 'async', 'sync', 'asyncAfter',
|
||||
'Task', 'withCheckedContinuation', 'withCheckedThrowingContinuation',
|
||||
// Combine
|
||||
'sink', 'store', 'assign', 'receive', 'subscribe',
|
||||
// Notification / KVO
|
||||
'addObserver', 'removeObserver', 'post', 'NotificationCenter',
|
||||
]);
|
||||
|
||||
// ============================================================================
|
||||
@@ -372,6 +481,51 @@ const getLabelFromCaptures = (captureMap: Record<string, any>): string | null =>
|
||||
return 'CodeElement';
|
||||
};
|
||||
|
||||
const DEFINITION_CAPTURE_KEYS = [
|
||||
'definition.function',
|
||||
'definition.class',
|
||||
'definition.interface',
|
||||
'definition.method',
|
||||
'definition.struct',
|
||||
'definition.enum',
|
||||
'definition.namespace',
|
||||
'definition.module',
|
||||
'definition.trait',
|
||||
'definition.impl',
|
||||
'definition.type',
|
||||
'definition.const',
|
||||
'definition.static',
|
||||
'definition.typedef',
|
||||
'definition.macro',
|
||||
'definition.union',
|
||||
'definition.property',
|
||||
'definition.record',
|
||||
'definition.delegate',
|
||||
'definition.annotation',
|
||||
'definition.constructor',
|
||||
'definition.template',
|
||||
] as const;
|
||||
|
||||
const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): any | null => {
|
||||
for (const key of DEFINITION_CAPTURE_KEYS) {
|
||||
if (captureMap[key]) return captureMap[key];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Append .* to a Kotlin import path if the AST has a wildcard_import sibling node.
|
||||
* Pure function — returns a new string without mutating the input.
|
||||
*/
|
||||
const appendKotlinWildcard = (importPath: string, importNode: any): string => {
|
||||
for (let i = 0; i < importNode.childCount; i++) {
|
||||
if (importNode.child(i)?.type === 'wildcard_import') {
|
||||
return importPath.endsWith('.*') ? importPath : `${importPath}.*`;
|
||||
}
|
||||
}
|
||||
return importPath;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Process a batch of files
|
||||
// ============================================================================
|
||||
@@ -384,6 +538,7 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n
|
||||
imports: [],
|
||||
calls: [],
|
||||
heritage: [],
|
||||
routes: [],
|
||||
fileCount: 0,
|
||||
};
|
||||
|
||||
@@ -434,14 +589,22 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n
|
||||
|
||||
// Process regular files for this language
|
||||
if (regularFiles.length > 0) {
|
||||
setLanguage(language, regularFiles[0].path);
|
||||
processFileGroup(regularFiles, language, queryString, result, onFileProcessed);
|
||||
try {
|
||||
setLanguage(language, regularFiles[0].path);
|
||||
processFileGroup(regularFiles, language, queryString, result, onFileProcessed);
|
||||
} catch {
|
||||
// parser unavailable — skip this language group
|
||||
}
|
||||
}
|
||||
|
||||
// Process tsx files separately (different grammar)
|
||||
if (tsxFiles.length > 0) {
|
||||
setLanguage(language, tsxFiles[0].path);
|
||||
processFileGroup(tsxFiles, language, queryString, result, onFileProcessed);
|
||||
try {
|
||||
setLanguage(language, tsxFiles[0].path);
|
||||
processFileGroup(tsxFiles, language, queryString, result, onFileProcessed);
|
||||
} catch {
|
||||
// parser unavailable — skip this language group
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,6 +714,378 @@ function extractEloquentRelationDescription(methodNode: any): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Laravel Route Extraction (procedural AST walk)
|
||||
// ============================================================================
|
||||
|
||||
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'];
|
||||
|
||||
/** Check if node is a scoped_call_expression with object 'Route' */
|
||||
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';
|
||||
}
|
||||
|
||||
/** Get the method name from a scoped_call_expression or member_call_expression */
|
||||
function getCallMethodName(node: any): string | null {
|
||||
const nameNode = node.childForFieldName?.('name') ??
|
||||
node.children?.find((c: any) => c.type === 'name');
|
||||
return nameNode?.text ?? null;
|
||||
}
|
||||
|
||||
/** Get the arguments node from a call expression */
|
||||
function getArguments(node: any): any {
|
||||
return node.children?.find((c: any) => c.type === 'arguments') ?? null;
|
||||
}
|
||||
|
||||
/** Find the closure body inside arguments */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Extract first string argument from arguments node */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Extract middleware from arguments — handles string or array */
|
||||
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 [];
|
||||
}
|
||||
|
||||
/** Extract Controller::class from arguments */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Extract controller class name from arguments: [Controller::class, 'method'] or 'Controller@method' */
|
||||
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);
|
||||
}
|
||||
|
||||
// Second arg is the handler
|
||||
const handlerNode = args[1];
|
||||
if (!handlerNode) return { controller: null, method: null };
|
||||
|
||||
// Array syntax: [UserController::class, 'index']
|
||||
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 };
|
||||
}
|
||||
|
||||
// String syntax: 'UserController@index'
|
||||
if (handlerNode.type === 'string' || handlerNode.type === 'encapsed_string') {
|
||||
const text = extractStringContent(handlerNode);
|
||||
if (text?.includes('@')) {
|
||||
const [controller, method] = text.split('@');
|
||||
return { controller, method };
|
||||
}
|
||||
}
|
||||
|
||||
// Class reference: UserController::class (invokable controller)
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a chained call like Route::middleware('auth')->prefix('api')->group(fn)
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** Parse Route::group(['middleware' => ..., 'prefix' => ...], fn) array syntax */
|
||||
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[]) {
|
||||
// Case 1: Simple Route::get(...), Route::post(...), etc.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Fluent chain — Route::middleware(...)->group(...) or Route::middleware(...)->get(...)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: recurse into children
|
||||
walkChildren(node, groupStack);
|
||||
}
|
||||
|
||||
function walkChildren(node: any, groupStack: RouteGroupContext[]) {
|
||||
for (const child of node.children ?? []) {
|
||||
walk(child, groupStack);
|
||||
}
|
||||
}
|
||||
|
||||
walk(tree.rootNode, []);
|
||||
return routes;
|
||||
}
|
||||
|
||||
const processFileGroup = (
|
||||
files: ParseWorkerInput[],
|
||||
language: SupportedLanguages,
|
||||
@@ -595,7 +1130,9 @@ const processFileGroup = (
|
||||
|
||||
// Extract import paths before skipping
|
||||
if (captureMap['import'] && captureMap['import.source']) {
|
||||
const rawImportPath = captureMap['import.source'].text.replace(/['"<>]/g, '');
|
||||
const rawImportPath = language === SupportedLanguages.Kotlin
|
||||
? appendKotlinWildcard(captureMap['import.source'].text.replace(/['"<>]/g, ''), captureMap['import'])
|
||||
: captureMap['import.source'].text.replace(/['"<>]/g, '');
|
||||
result.imports.push({
|
||||
filePath: file.path,
|
||||
rawImportPath,
|
||||
@@ -654,8 +1191,12 @@ const processFileGroup = (
|
||||
if (!nodeLabel) continue;
|
||||
|
||||
const nameNode = captureMap['name'];
|
||||
const nodeName = nameNode.text;
|
||||
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
|
||||
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
|
||||
if (!nameNode && nodeLabel !== 'Constructor') continue;
|
||||
const nodeName = nameNode ? nameNode.text : 'init';
|
||||
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
|
||||
const startLine = definitionNode ? definitionNode.startPosition.row : (nameNode ? nameNode.startPosition.row : 0);
|
||||
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}:${startLine}`);
|
||||
|
||||
let description: string | undefined;
|
||||
if (language === SupportedLanguages.PHP) {
|
||||
@@ -666,16 +1207,24 @@ const processFileGroup = (
|
||||
}
|
||||
}
|
||||
|
||||
const frameworkHint = definitionNode
|
||||
? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300))
|
||||
: null;
|
||||
|
||||
result.nodes.push({
|
||||
id: nodeId,
|
||||
label: nodeLabel,
|
||||
properties: {
|
||||
name: nodeName,
|
||||
filePath: file.path,
|
||||
startLine: nameNode.startPosition.row,
|
||||
endLine: nameNode.endPosition.row,
|
||||
startLine: definitionNode ? definitionNode.startPosition.row : startLine,
|
||||
endLine: definitionNode ? definitionNode.endPosition.row : startLine,
|
||||
language: language,
|
||||
isExported: isNodeExported(nameNode, nodeName, language),
|
||||
isExported: isNodeExported(nameNode || definitionNode, nodeName, language),
|
||||
...(frameworkHint ? {
|
||||
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
|
||||
astFrameworkReason: frameworkHint.reason,
|
||||
} : {}),
|
||||
...(description !== undefined ? { description } : {}),
|
||||
},
|
||||
});
|
||||
@@ -698,6 +1247,12 @@ const processFileGroup = (
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
|
||||
// Extract Laravel routes from route files via procedural AST walk
|
||||
if (language === SupportedLanguages.PHP && (file.path.includes('/routes/') || file.path.startsWith('routes/')) && file.path.endsWith('.php')) {
|
||||
const extractedRoutes = extractLaravelRoutes(tree, file.path);
|
||||
result.routes.push(...extractedRoutes);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -708,7 +1263,7 @@ const processFileGroup = (
|
||||
/** Accumulated result across sub-batches */
|
||||
let accumulated: ParseWorkerResult = {
|
||||
nodes: [], relationships: [], symbols: [],
|
||||
imports: [], calls: [], heritage: [], fileCount: 0,
|
||||
imports: [], calls: [], heritage: [], routes: [], fileCount: 0,
|
||||
};
|
||||
let cumulativeProcessed = 0;
|
||||
|
||||
@@ -719,6 +1274,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
|
||||
target.imports.push(...src.imports);
|
||||
target.calls.push(...src.calls);
|
||||
target.heritage.push(...src.heritage);
|
||||
target.routes.push(...src.routes);
|
||||
target.fileCount += src.fileCount;
|
||||
};
|
||||
|
||||
@@ -740,7 +1296,7 @@ parentPort!.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'flush') {
|
||||
parentPort!.postMessage({ type: 'result', data: accumulated });
|
||||
// Reset for potential reuse
|
||||
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], heritage: [], fileCount: 0 };
|
||||
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], heritage: [], routes: [], fileCount: 0 };
|
||||
cumulativeProcessed = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const FLUSH_EVERY = 500;
|
||||
// CSV ESCAPE UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
const sanitizeUTF8 = (str: string): string => {
|
||||
export const sanitizeUTF8 = (str: string): string => {
|
||||
return str
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
@@ -34,14 +34,14 @@ const sanitizeUTF8 = (str: string): string => {
|
||||
.replace(/[\uFFFE\uFFFF]/g, '');
|
||||
};
|
||||
|
||||
const escapeCSVField = (value: string | number | undefined | null): string => {
|
||||
export const escapeCSVField = (value: string | number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '""';
|
||||
let str = String(value);
|
||||
str = sanitizeUTF8(str);
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
const escapeCSVNumber = (value: number | undefined | null, defaultValue: number = -1): string => {
|
||||
export const escapeCSVNumber = (value: number | undefined | null, defaultValue: number = -1): string => {
|
||||
if (value === undefined || value === null) return String(defaultValue);
|
||||
return String(value);
|
||||
};
|
||||
@@ -50,7 +50,7 @@ const escapeCSVNumber = (value: number | undefined | null, defaultValue: number
|
||||
// CONTENT EXTRACTION (lazy — reads from disk on demand)
|
||||
// ============================================================================
|
||||
|
||||
const isBinaryContent = (content: string): boolean => {
|
||||
export const isBinaryContent = (content: string): boolean => {
|
||||
if (!content || content.length === 0) return false;
|
||||
const sample = content.slice(0, 1000);
|
||||
let nonPrintable = 0;
|
||||
@@ -80,7 +80,15 @@ class FileContentCache {
|
||||
async get(relativePath: string): Promise<string> {
|
||||
if (!relativePath) return '';
|
||||
const cached = this.cache.get(relativePath);
|
||||
if (cached !== undefined) return cached;
|
||||
if (cached !== undefined) {
|
||||
// Move to end of accessOrder (LRU promotion)
|
||||
const idx = this.accessOrder.indexOf(relativePath);
|
||||
if (idx !== -1) {
|
||||
this.accessOrder.splice(idx, 1);
|
||||
this.accessOrder.push(relativePath);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const fullPath = path.join(this.repoPath, relativePath);
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
@@ -163,9 +171,17 @@ class BufferedCSVWriter {
|
||||
const chunk = this.buffer.join('\n') + '\n';
|
||||
this.buffer.length = 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ws.once('error', reject);
|
||||
const ok = this.ws.write(chunk);
|
||||
if (ok) resolve();
|
||||
else this.ws.once('drain', resolve);
|
||||
if (ok) {
|
||||
this.ws.removeListener('error', reject);
|
||||
resolve();
|
||||
} else {
|
||||
this.ws.once('drain', () => {
|
||||
this.ws.removeListener('error', reject);
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -264,7 +280,7 @@ export const streamAllCSVsToDisk = async (
|
||||
break;
|
||||
case 'Community': {
|
||||
const keywords = (node.properties as any).keywords || [];
|
||||
const keywordsStr = `[${keywords.map((k: string) => `'${k.replace(/'/g, "''")}'`).join(',')}]`;
|
||||
const keywordsStr = `[${keywords.map((k: string) => `'${k.replace(/\\/g, '\\\\').replace(/'/g, "''").replace(/,/g, '\\,')}'`).join(',')}]`;
|
||||
await communityWriter.addRow([
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
|
||||
@@ -674,24 +674,25 @@ export const getEmbeddingTableName = (): string => EMBEDDING_TABLE_NAME;
|
||||
|
||||
/**
|
||||
* Load the FTS extension (required before using FTS functions).
|
||||
* Safe to call multiple times — tracks loaded state.
|
||||
* Safe to call multiple times — tracks loaded state via module-level ftsLoaded.
|
||||
*/
|
||||
let ftsLoaded = false;
|
||||
export const loadFTSExtension = async (): Promise<void> => {
|
||||
if (ftsLoaded) return;
|
||||
if (!conn) {
|
||||
throw new Error('KuzuDB not initialized. Call initKuzu first.');
|
||||
}
|
||||
if (ftsLoaded) return;
|
||||
try {
|
||||
await conn.query('INSTALL fts');
|
||||
await conn.query('LOAD EXTENSION fts');
|
||||
ftsLoaded = true;
|
||||
} catch {
|
||||
// Extension may already be loaded
|
||||
ftsLoaded = true;
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || '';
|
||||
if (msg.includes('already loaded') || msg.includes('already installed') || msg.includes('already exists')) {
|
||||
ftsLoaded = true;
|
||||
} else {
|
||||
console.error('GitNexus: FTS extension load failed:', msg);
|
||||
}
|
||||
}
|
||||
ftsLoaded = true;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -242,6 +242,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
||||
FROM Function TO \`Const\`,
|
||||
FROM Function TO \`Typedef\`,
|
||||
FROM Function TO \`Union\`,
|
||||
FROM Function TO \`Property\`,
|
||||
FROM Class TO Method,
|
||||
FROM Class TO Function,
|
||||
FROM Class TO Class,
|
||||
@@ -301,7 +302,11 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
||||
FROM \`Struct\` TO \`Enum\`,
|
||||
FROM \`Struct\` TO Function,
|
||||
FROM \`Struct\` TO Method,
|
||||
FROM \`Struct\` TO Interface,
|
||||
FROM \`Enum\` TO \`Enum\`,
|
||||
FROM \`Enum\` TO Community,
|
||||
FROM \`Enum\` TO Class,
|
||||
FROM \`Enum\` TO Interface,
|
||||
FROM \`Macro\` TO Community,
|
||||
FROM \`Macro\` TO Function,
|
||||
FROM \`Macro\` TO Method,
|
||||
@@ -318,6 +323,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
||||
FROM \`Impl\` TO \`Impl\`,
|
||||
FROM \`TypeAlias\` TO Community,
|
||||
FROM \`TypeAlias\` TO \`Trait\`,
|
||||
FROM \`TypeAlias\` TO Class,
|
||||
FROM \`Const\` TO Community,
|
||||
FROM \`Static\` TO Community,
|
||||
FROM \`Property\` TO Community,
|
||||
@@ -339,6 +345,8 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
|
||||
FROM \`Constructor\` TO \`Impl\`,
|
||||
FROM \`Constructor\` TO \`Namespace\`,
|
||||
FROM \`Constructor\` TO \`Module\`,
|
||||
FROM \`Constructor\` TO \`Property\`,
|
||||
FROM \`Constructor\` TO \`Typedef\`,
|
||||
FROM \`Template\` TO Community,
|
||||
FROM \`Module\` TO Community,
|
||||
FROM Function TO Process,
|
||||
|
||||
@@ -8,9 +8,16 @@ import CPP from 'tree-sitter-cpp';
|
||||
import CSharp from 'tree-sitter-c-sharp';
|
||||
import Go from 'tree-sitter-go';
|
||||
import Rust from 'tree-sitter-rust';
|
||||
import Kotlin from 'tree-sitter-kotlin';
|
||||
import PHP from 'tree-sitter-php';
|
||||
import { createRequire } from 'node:module';
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
|
||||
// tree-sitter-swift is an optionalDependency — may not be installed
|
||||
const _require = createRequire(import.meta.url);
|
||||
let Swift: any = null;
|
||||
try { Swift = _require('tree-sitter-swift'); } catch {}
|
||||
|
||||
let parser: Parser | null = null;
|
||||
|
||||
const languageMap: Record<string, any> = {
|
||||
@@ -24,9 +31,14 @@ const languageMap: Record<string, any> = {
|
||||
[SupportedLanguages.CSharp]: CSharp,
|
||||
[SupportedLanguages.Go]: Go,
|
||||
[SupportedLanguages.Rust]: Rust,
|
||||
[SupportedLanguages.Kotlin]: Kotlin,
|
||||
[SupportedLanguages.PHP]: PHP.php_only,
|
||||
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
|
||||
};
|
||||
|
||||
export const isLanguageAvailable = (language: SupportedLanguages): boolean =>
|
||||
language in languageMap;
|
||||
|
||||
export const loadParser = async (): Promise<Parser> => {
|
||||
if (parser) return parser;
|
||||
parser = new Parser();
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { execSync, execFileSync } from 'child_process';
|
||||
|
||||
import {
|
||||
initWikiDb,
|
||||
@@ -712,8 +712,8 @@ export class WikiGenerator {
|
||||
|
||||
private getChangedFiles(fromCommit: string, toCommit: string): string[] {
|
||||
try {
|
||||
const output = execSync(
|
||||
`git diff ${fromCommit}..${toCommit} --name-only`,
|
||||
const output = execFileSync(
|
||||
'git', ['diff', `${fromCommit}..${toCommit}`, '--name-only'],
|
||||
{ cwd: this.repoPath },
|
||||
).toString().trim();
|
||||
return output ? output.split('\n').filter(Boolean) : [];
|
||||
|
||||
@@ -42,6 +42,10 @@ const INITIAL_CONNS_PER_REPO = 2;
|
||||
|
||||
let idleTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Saved real stdout.write — used to silence KuzuDB native output without race conditions */
|
||||
const realStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
let stdoutSilenceCount = 0;
|
||||
|
||||
/**
|
||||
* Start the idle cleanup timer (runs every 60s)
|
||||
*/
|
||||
@@ -50,7 +54,7 @@ function ensureIdleTimer(): void {
|
||||
idleTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [repoId, entry] of pool) {
|
||||
if (now - entry.lastUsed > IDLE_TIMEOUT_MS) {
|
||||
if (now - entry.lastUsed > IDLE_TIMEOUT_MS && entry.checkedOut === 0) {
|
||||
closeOne(repoId);
|
||||
}
|
||||
}
|
||||
@@ -69,7 +73,7 @@ function evictLRU(): void {
|
||||
let oldestId: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
for (const [id, entry] of pool) {
|
||||
if (entry.lastUsed < oldestTime) {
|
||||
if (entry.checkedOut === 0 && entry.lastUsed < oldestTime) {
|
||||
oldestTime = entry.lastUsed;
|
||||
oldestId = id;
|
||||
}
|
||||
@@ -86,9 +90,9 @@ function closeOne(repoId: string): void {
|
||||
const entry = pool.get(repoId);
|
||||
if (!entry) return;
|
||||
for (const conn of entry.available) {
|
||||
try { conn.close(); } catch {}
|
||||
try { conn.close(); } catch (e) { console.error('GitNexus [pool:close-conn]:', e instanceof Error ? e.message : e); }
|
||||
}
|
||||
try { entry.db.close(); } catch {}
|
||||
try { entry.db.close(); } catch (e) { console.error('GitNexus [pool:close-db]:', e instanceof Error ? e.message : e); }
|
||||
pool.delete(repoId);
|
||||
}
|
||||
|
||||
@@ -96,16 +100,33 @@ function closeOne(repoId: string): void {
|
||||
* Create a new Connection from a repo's Database.
|
||||
* Silences stdout to prevent native module output from corrupting MCP stdio.
|
||||
*/
|
||||
function silenceStdout(): void {
|
||||
if (stdoutSilenceCount++ === 0) {
|
||||
process.stdout.write = (() => true) as any;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreStdout(): void {
|
||||
if (--stdoutSilenceCount <= 0) {
|
||||
stdoutSilenceCount = 0;
|
||||
process.stdout.write = realStdoutWrite;
|
||||
}
|
||||
}
|
||||
|
||||
function createConnection(db: kuzu.Database): kuzu.Connection {
|
||||
const origWrite = process.stdout.write;
|
||||
process.stdout.write = (() => true) as any;
|
||||
silenceStdout();
|
||||
try {
|
||||
return new kuzu.Connection(db);
|
||||
} finally {
|
||||
process.stdout.write = origWrite;
|
||||
restoreStdout();
|
||||
}
|
||||
}
|
||||
|
||||
/** Query timeout in milliseconds */
|
||||
const QUERY_TIMEOUT_MS = 30_000;
|
||||
/** Waiter queue timeout in milliseconds */
|
||||
const WAITER_TIMEOUT_MS = 15_000;
|
||||
|
||||
const LOCK_RETRY_ATTEMPTS = 3;
|
||||
const LOCK_RETRY_DELAY_MS = 2000;
|
||||
|
||||
@@ -134,8 +155,7 @@ export const initKuzu = async (repoId: string, dbPath: string): Promise<void> =>
|
||||
// avoids lock conflicts when `gitnexus analyze` is writing.
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
|
||||
const origWrite = process.stdout.write;
|
||||
process.stdout.write = (() => true) as any;
|
||||
silenceStdout();
|
||||
try {
|
||||
const db = new kuzu.Database(
|
||||
dbPath,
|
||||
@@ -143,7 +163,7 @@ export const initKuzu = async (repoId: string, dbPath: string): Promise<void> =>
|
||||
false, // enableCompression (default)
|
||||
true, // readOnly
|
||||
);
|
||||
process.stdout.write = origWrite;
|
||||
restoreStdout();
|
||||
|
||||
// Pre-create a small pool of connections
|
||||
const available: kuzu.Connection[] = [];
|
||||
@@ -155,7 +175,7 @@ export const initKuzu = async (repoId: string, dbPath: string): Promise<void> =>
|
||||
ensureIdleTimer();
|
||||
return;
|
||||
} catch (err: any) {
|
||||
process.stdout.write = origWrite;
|
||||
restoreStdout();
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
const isLockError = lastError.message.includes('Could not set lock')
|
||||
|| lastError.message.includes('lock');
|
||||
@@ -189,10 +209,18 @@ function checkout(entry: PoolEntry): Promise<kuzu.Connection> {
|
||||
return Promise.resolve(createConnection(entry.db));
|
||||
}
|
||||
|
||||
// At capacity — queue the caller. checkin() will resolve this when
|
||||
// a connection is returned, handing it directly to the next waiter.
|
||||
return new Promise<kuzu.Connection>(resolve => {
|
||||
entry.waiters.push(resolve);
|
||||
// At capacity — queue the caller with a timeout.
|
||||
return new Promise<kuzu.Connection>((resolve, reject) => {
|
||||
const waiter = (conn: kuzu.Connection) => {
|
||||
clearTimeout(timer);
|
||||
resolve(conn);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
const idx = entry.waiters.indexOf(waiter);
|
||||
if (idx !== -1) entry.waiters.splice(idx, 1);
|
||||
reject(new Error(`Connection pool exhausted: timed out after ${WAITER_TIMEOUT_MS}ms waiting for a free connection`));
|
||||
}, WAITER_TIMEOUT_MS);
|
||||
entry.waiters.push(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -216,6 +244,15 @@ function checkin(entry: PoolEntry, conn: kuzu.Connection): void {
|
||||
* Execute a query on a specific repo's connection pool.
|
||||
* Automatically checks out a connection, runs the query, and returns it.
|
||||
*/
|
||||
/** Race a promise against a timeout */
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
export const executeQuery = async (repoId: string, cypher: string): Promise<any[]> => {
|
||||
const entry = pool.get(repoId);
|
||||
if (!entry) {
|
||||
@@ -226,7 +263,39 @@ export const executeQuery = async (repoId: string, cypher: string): Promise<any[
|
||||
|
||||
const conn = await checkout(entry);
|
||||
try {
|
||||
const queryResult = await conn.query(cypher);
|
||||
const queryResult = await withTimeout(conn.query(cypher), QUERY_TIMEOUT_MS, 'Query');
|
||||
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
||||
const rows = await result.getAll();
|
||||
return rows;
|
||||
} finally {
|
||||
checkin(entry, conn);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a parameterized query on a specific repo's connection pool.
|
||||
* Uses prepare/execute pattern to prevent Cypher injection.
|
||||
*/
|
||||
export const executeParameterized = async (
|
||||
repoId: string,
|
||||
cypher: string,
|
||||
params: Record<string, any>,
|
||||
): Promise<any[]> => {
|
||||
const entry = pool.get(repoId);
|
||||
if (!entry) {
|
||||
throw new Error(`KuzuDB not initialized for repo "${repoId}". Call initKuzu first.`);
|
||||
}
|
||||
|
||||
entry.lastUsed = Date.now();
|
||||
|
||||
const conn = await checkout(entry);
|
||||
try {
|
||||
const stmt = await withTimeout(conn.prepare(cypher), QUERY_TIMEOUT_MS, 'Prepare');
|
||||
if (!stmt.isSuccess()) {
|
||||
const errMsg = await stmt.getErrorMessage();
|
||||
throw new Error(`Prepare failed: ${errMsg}`);
|
||||
}
|
||||
const queryResult = await withTimeout(conn.execute(stmt, params), QUERY_TIMEOUT_MS, 'Execute');
|
||||
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
||||
const rows = await result.getAll();
|
||||
return rows;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { initKuzu, executeQuery, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js';
|
||||
import { initKuzu, executeQuery, executeParameterized, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js';
|
||||
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
|
||||
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
|
||||
// git utilities available if needed
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
* Quick test-file detection for filtering impact results.
|
||||
* Matches common test file patterns across all supported languages.
|
||||
*/
|
||||
function isTestFilePath(filePath: string): boolean {
|
||||
export function isTestFilePath(filePath: string): boolean {
|
||||
const p = filePath.toLowerCase().replace(/\\/g, '/');
|
||||
return (
|
||||
p.includes('.test.') || p.includes('.spec.') ||
|
||||
@@ -37,13 +37,30 @@ function isTestFilePath(filePath: string): boolean {
|
||||
}
|
||||
|
||||
/** Valid KuzuDB node labels for safe Cypher query construction */
|
||||
const VALID_NODE_LABELS = new Set([
|
||||
export const VALID_NODE_LABELS = new Set([
|
||||
'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement',
|
||||
'Community', 'Process', 'Struct', 'Enum', 'Macro', 'Typedef', 'Union',
|
||||
'Namespace', 'Trait', 'Impl', 'TypeAlias', 'Const', 'Static', 'Property',
|
||||
'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module',
|
||||
]);
|
||||
|
||||
/** Valid relation types for impact analysis filtering */
|
||||
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']);
|
||||
|
||||
/** Regex to detect write operations in user-supplied Cypher queries */
|
||||
export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;
|
||||
|
||||
/** Check if a Cypher query contains write operations */
|
||||
export function isWriteQuery(query: string): boolean {
|
||||
return CYPHER_WRITE_RE.test(query);
|
||||
}
|
||||
|
||||
/** Structured error logging for query failures — replaces empty catch blocks */
|
||||
function logQueryError(context: string, err: unknown): void {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`GitNexus [${context}]: ${msg}`);
|
||||
}
|
||||
|
||||
export interface CodebaseContext {
|
||||
projectName: string;
|
||||
stats: {
|
||||
@@ -387,46 +404,44 @@ export class LocalBackend {
|
||||
continue;
|
||||
}
|
||||
|
||||
const escaped = sym.nodeId.replace(/'/g, "''");
|
||||
|
||||
// Find processes this symbol participates in
|
||||
let processRows: any[] = [];
|
||||
try {
|
||||
processRows = await executeQuery(repo.id, `
|
||||
MATCH (n {id: '${escaped}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||||
processRows = await executeParameterized(repo.id, `
|
||||
MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||||
RETURN p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
|
||||
`);
|
||||
} catch { /* symbol might not be in any process */ }
|
||||
|
||||
`, { nodeId: sym.nodeId });
|
||||
} catch (e) { logQueryError('query:process-lookup', e); }
|
||||
|
||||
// Get cluster membership + cohesion (cohesion used as internal ranking signal)
|
||||
let cohesion = 0;
|
||||
let module: string | undefined;
|
||||
try {
|
||||
const cohesionRows = await executeQuery(repo.id, `
|
||||
MATCH (n {id: '${escaped}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||||
const cohesionRows = await executeParameterized(repo.id, `
|
||||
MATCH (n {id: $nodeId})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||||
RETURN c.cohesion AS cohesion, c.heuristicLabel AS module
|
||||
LIMIT 1
|
||||
`);
|
||||
`, { nodeId: sym.nodeId });
|
||||
if (cohesionRows.length > 0) {
|
||||
cohesion = (cohesionRows[0].cohesion ?? cohesionRows[0][0]) || 0;
|
||||
module = cohesionRows[0].module ?? cohesionRows[0][1];
|
||||
}
|
||||
} catch { /* no cluster info */ }
|
||||
|
||||
} catch (e) { logQueryError('query:cluster-info', e); }
|
||||
|
||||
// Optionally fetch content
|
||||
let content: string | undefined;
|
||||
if (includeContent) {
|
||||
try {
|
||||
const contentRows = await executeQuery(repo.id, `
|
||||
MATCH (n {id: '${escaped}'})
|
||||
const contentRows = await executeParameterized(repo.id, `
|
||||
MATCH (n {id: $nodeId})
|
||||
RETURN n.content AS content
|
||||
`);
|
||||
`, { nodeId: sym.nodeId });
|
||||
if (contentRows.length > 0) {
|
||||
content = contentRows[0].content ?? contentRows[0][0];
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch (e) { logQueryError('query:content-fetch', e); }
|
||||
}
|
||||
|
||||
|
||||
const symbolEntry = {
|
||||
id: sym.nodeId,
|
||||
name: sym.name,
|
||||
@@ -535,13 +550,12 @@ export class LocalBackend {
|
||||
for (const bm25Result of bm25Results) {
|
||||
const fullPath = bm25Result.filePath;
|
||||
try {
|
||||
const symbolQuery = `
|
||||
MATCH (n)
|
||||
WHERE n.filePath = '${fullPath.replace(/'/g, "''")}'
|
||||
const symbols = await executeParameterized(repo.id, `
|
||||
MATCH (n)
|
||||
WHERE n.filePath = $filePath
|
||||
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
||||
LIMIT 3
|
||||
`;
|
||||
const symbols = await executeQuery(repo.id, symbolQuery);
|
||||
`, { filePath: fullPath });
|
||||
|
||||
if (symbols.length > 0) {
|
||||
for (const sym of symbols) {
|
||||
@@ -619,12 +633,11 @@ export class LocalBackend {
|
||||
if (!VALID_NODE_LABELS.has(label)) continue;
|
||||
|
||||
try {
|
||||
const escapedId = nodeId.replace(/'/g, "''");
|
||||
const nodeQuery = label === 'File'
|
||||
? `MATCH (n:File {id: '${escapedId}'}) RETURN n.name AS name, n.filePath AS filePath`
|
||||
: `MATCH (n:\`${label}\` {id: '${escapedId}'}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
|
||||
|
||||
const nodeRows = await executeQuery(repo.id, nodeQuery);
|
||||
? `MATCH (n:File {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath`
|
||||
: `MATCH (n:\`${label}\` {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
|
||||
|
||||
const nodeRows = await executeParameterized(repo.id, nodeQuery, { nodeId });
|
||||
if (nodeRows.length > 0) {
|
||||
const nodeRow = nodeRows[0];
|
||||
results.push({
|
||||
@@ -659,6 +672,11 @@ export class LocalBackend {
|
||||
return { error: 'KuzuDB not ready. Index may be corrupted.' };
|
||||
}
|
||||
|
||||
// Block write operations (defense-in-depth — DB is already read-only)
|
||||
if (CYPHER_WRITE_RE.test(params.query)) {
|
||||
return { error: 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeQuery(repo.id, params.query);
|
||||
return result;
|
||||
@@ -817,31 +835,32 @@ export class LocalBackend {
|
||||
let symbols: any[];
|
||||
|
||||
if (uid) {
|
||||
const escaped = uid.replace(/'/g, "''");
|
||||
symbols = await executeQuery(repo.id, `
|
||||
MATCH (n {id: '${escaped}'})
|
||||
symbols = await executeParameterized(repo.id, `
|
||||
MATCH (n {id: $uid})
|
||||
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}
|
||||
LIMIT 1
|
||||
`);
|
||||
`, { uid });
|
||||
} else {
|
||||
const escaped = name!.replace(/'/g, "''");
|
||||
const isQualified = name!.includes('/') || name!.includes(':');
|
||||
|
||||
|
||||
let whereClause: string;
|
||||
let queryParams: Record<string, any>;
|
||||
if (file_path) {
|
||||
const fpEscaped = file_path.replace(/'/g, "''");
|
||||
whereClause = `WHERE n.name = '${escaped}' AND n.filePath CONTAINS '${fpEscaped}'`;
|
||||
whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`;
|
||||
queryParams = { symName: name!, filePath: file_path };
|
||||
} else if (isQualified) {
|
||||
whereClause = `WHERE n.id = '${escaped}' OR n.name = '${escaped}'`;
|
||||
whereClause = `WHERE n.id = $symName OR n.name = $symName`;
|
||||
queryParams = { symName: name! };
|
||||
} else {
|
||||
whereClause = `WHERE n.name = '${escaped}'`;
|
||||
whereClause = `WHERE n.name = $symName`;
|
||||
queryParams = { symName: name! };
|
||||
}
|
||||
|
||||
symbols = await executeQuery(repo.id, `
|
||||
|
||||
symbols = await executeParameterized(repo.id, `
|
||||
MATCH (n) ${whereClause}
|
||||
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}
|
||||
LIMIT 10
|
||||
`);
|
||||
`, queryParams);
|
||||
}
|
||||
|
||||
if (symbols.length === 0) {
|
||||
@@ -865,32 +884,32 @@ export class LocalBackend {
|
||||
|
||||
// Step 3: Build full context
|
||||
const sym = symbols[0];
|
||||
const symId = (sym.id || sym[0]).replace(/'/g, "''");
|
||||
|
||||
const symId = sym.id || sym[0];
|
||||
|
||||
// Categorized incoming refs
|
||||
const incomingRows = await executeQuery(repo.id, `
|
||||
MATCH (caller)-[r:CodeRelation]->(n {id: '${symId}'})
|
||||
const incomingRows = await executeParameterized(repo.id, `
|
||||
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
|
||||
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
||||
LIMIT 30
|
||||
`);
|
||||
|
||||
`, { symId });
|
||||
|
||||
// Categorized outgoing refs
|
||||
const outgoingRows = await executeQuery(repo.id, `
|
||||
MATCH (n {id: '${symId}'})-[r:CodeRelation]->(target)
|
||||
const outgoingRows = await executeParameterized(repo.id, `
|
||||
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
|
||||
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
|
||||
LIMIT 30
|
||||
`);
|
||||
|
||||
`, { symId });
|
||||
|
||||
// Process participation
|
||||
let processRows: any[] = [];
|
||||
try {
|
||||
processRows = await executeQuery(repo.id, `
|
||||
MATCH (n {id: '${symId}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||||
processRows = await executeParameterized(repo.id, `
|
||||
MATCH (n {id: $symId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||||
RETURN p.id AS pid, p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount
|
||||
`);
|
||||
} catch { /* no process info */ }
|
||||
`, { symId });
|
||||
} catch (e) { logQueryError('context:process-participation', e); }
|
||||
|
||||
// Helper to categorize refs
|
||||
const categorize = (rows: any[]) => {
|
||||
@@ -944,33 +963,31 @@ export class LocalBackend {
|
||||
}
|
||||
|
||||
if (type === 'cluster') {
|
||||
const escaped = name.replace(/'/g, "''");
|
||||
const clusterQuery = `
|
||||
const clusters = await executeParameterized(repo.id, `
|
||||
MATCH (c:Community)
|
||||
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
|
||||
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
|
||||
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
|
||||
`;
|
||||
const clusters = await executeQuery(repo.id, clusterQuery);
|
||||
`, { clusterName: name });
|
||||
if (clusters.length === 0) return { error: `Cluster '${name}' not found` };
|
||||
|
||||
|
||||
const rawClusters = clusters.map((c: any) => ({
|
||||
id: c.id || c[0], label: c.label || c[1], heuristicLabel: c.heuristicLabel || c[2],
|
||||
cohesion: c.cohesion || c[3], symbolCount: c.symbolCount || c[4],
|
||||
}));
|
||||
|
||||
|
||||
let totalSymbols = 0, weightedCohesion = 0;
|
||||
for (const c of rawClusters) {
|
||||
const s = c.symbolCount || 0;
|
||||
totalSymbols += s;
|
||||
weightedCohesion += (c.cohesion || 0) * s;
|
||||
}
|
||||
|
||||
const members = await executeQuery(repo.id, `
|
||||
|
||||
const members = await executeParameterized(repo.id, `
|
||||
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||||
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
|
||||
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
|
||||
RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
||||
LIMIT 30
|
||||
`);
|
||||
`, { clusterName: name });
|
||||
|
||||
return {
|
||||
cluster: {
|
||||
@@ -988,21 +1005,21 @@ export class LocalBackend {
|
||||
}
|
||||
|
||||
if (type === 'process') {
|
||||
const processes = await executeQuery(repo.id, `
|
||||
const processes = await executeParameterized(repo.id, `
|
||||
MATCH (p:Process)
|
||||
WHERE p.label = '${name.replace(/'/g, "''")}' OR p.heuristicLabel = '${name.replace(/'/g, "''")}'
|
||||
WHERE p.label = $processName OR p.heuristicLabel = $processName
|
||||
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
|
||||
LIMIT 1
|
||||
`);
|
||||
`, { processName: name });
|
||||
if (processes.length === 0) return { error: `Process '${name}' not found` };
|
||||
|
||||
|
||||
const proc = processes[0];
|
||||
const procId = proc.id || proc[0];
|
||||
const steps = await executeQuery(repo.id, `
|
||||
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: '${procId}'})
|
||||
const steps = await executeParameterized(repo.id, `
|
||||
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId})
|
||||
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
|
||||
ORDER BY r.step
|
||||
`);
|
||||
`, { procId });
|
||||
|
||||
return {
|
||||
process: {
|
||||
@@ -1029,30 +1046,30 @@ export class LocalBackend {
|
||||
await this.ensureInitialized(repo.id);
|
||||
|
||||
const scope = params.scope || 'unstaged';
|
||||
const { execSync } = await import('child_process');
|
||||
|
||||
// Build git diff command based on scope
|
||||
let diffCmd: string;
|
||||
const { execFileSync } = await import('child_process');
|
||||
|
||||
// Build git diff args based on scope (using execFileSync to avoid shell injection)
|
||||
let diffArgs: string[];
|
||||
switch (scope) {
|
||||
case 'staged':
|
||||
diffCmd = 'git diff --staged --name-only';
|
||||
diffArgs = ['diff', '--staged', '--name-only'];
|
||||
break;
|
||||
case 'all':
|
||||
diffCmd = 'git diff HEAD --name-only';
|
||||
diffArgs = ['diff', 'HEAD', '--name-only'];
|
||||
break;
|
||||
case 'compare':
|
||||
if (!params.base_ref) return { error: 'base_ref is required for "compare" scope' };
|
||||
diffCmd = `git diff ${params.base_ref} --name-only`;
|
||||
diffArgs = ['diff', params.base_ref, '--name-only'];
|
||||
break;
|
||||
case 'unstaged':
|
||||
default:
|
||||
diffCmd = 'git diff --name-only';
|
||||
diffArgs = ['diff', '--name-only'];
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
let changedFiles: string[];
|
||||
try {
|
||||
const output = execSync(diffCmd, { cwd: repo.repoPath, encoding: 'utf-8' });
|
||||
const output = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' });
|
||||
changedFiles = output.trim().split('\n').filter(f => f.length > 0);
|
||||
} catch (err: any) {
|
||||
return { error: `Git diff failed: ${err.message}` };
|
||||
@@ -1069,13 +1086,13 @@ export class LocalBackend {
|
||||
// Map changed files to indexed symbols
|
||||
const changedSymbols: any[] = [];
|
||||
for (const file of changedFiles) {
|
||||
const escaped = file.replace(/\\/g, '/').replace(/'/g, "''");
|
||||
const normalizedFile = file.replace(/\\/g, '/');
|
||||
try {
|
||||
const symbols = await executeQuery(repo.id, `
|
||||
MATCH (n) WHERE n.filePath CONTAINS '${escaped}'
|
||||
const symbols = await executeParameterized(repo.id, `
|
||||
MATCH (n) WHERE n.filePath CONTAINS $filePath
|
||||
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
||||
LIMIT 20
|
||||
`);
|
||||
`, { filePath: normalizedFile });
|
||||
for (const sym of symbols) {
|
||||
changedSymbols.push({
|
||||
id: sym.id || sym[0],
|
||||
@@ -1085,18 +1102,17 @@ export class LocalBackend {
|
||||
change_type: 'Modified',
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch (e) { logQueryError('detect-changes:file-symbols', e); }
|
||||
}
|
||||
|
||||
|
||||
// Find affected processes
|
||||
const affectedProcesses = new Map<string, any>();
|
||||
for (const sym of changedSymbols) {
|
||||
const escaped = (sym.id as string).replace(/'/g, "''");
|
||||
try {
|
||||
const procs = await executeQuery(repo.id, `
|
||||
MATCH (n {id: '${escaped}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||||
const procs = await executeParameterized(repo.id, `
|
||||
MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||||
RETURN p.id AS pid, p.heuristicLabel AS label, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
|
||||
`);
|
||||
`, { nodeId: sym.id });
|
||||
for (const proc of procs) {
|
||||
const pid = proc.pid || proc[0];
|
||||
if (!affectedProcesses.has(pid)) {
|
||||
@@ -1113,9 +1129,9 @@ export class LocalBackend {
|
||||
step: proc.step || proc[4],
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch (e) { logQueryError('detect-changes:process-lookup', e); }
|
||||
}
|
||||
|
||||
|
||||
const processCount = affectedProcesses.size;
|
||||
const risk = processCount === 0 ? 'low' : processCount <= 5 ? 'medium' : processCount <= 15 ? 'high' : 'critical';
|
||||
|
||||
@@ -1147,10 +1163,19 @@ export class LocalBackend {
|
||||
|
||||
const { new_name, file_path } = params;
|
||||
const dry_run = params.dry_run ?? true;
|
||||
|
||||
|
||||
if (!params.symbol_name && !params.symbol_uid) {
|
||||
return { error: 'Either symbol_name or symbol_uid is required.' };
|
||||
}
|
||||
|
||||
/** Guard: ensure a file path resolves within the repo root (prevents path traversal) */
|
||||
const assertSafePath = (filePath: string): string => {
|
||||
const full = path.resolve(repo.repoPath, filePath);
|
||||
if (!full.startsWith(repo.repoPath + path.sep) && full !== repo.repoPath) {
|
||||
throw new Error(`Path traversal blocked: ${filePath}`);
|
||||
}
|
||||
return full;
|
||||
};
|
||||
|
||||
// Step 1: Find the target symbol (reuse context's lookup)
|
||||
const lookupResult = await this.context(repo, {
|
||||
@@ -1186,15 +1211,16 @@ export class LocalBackend {
|
||||
// The definition itself
|
||||
if (sym.filePath && sym.startLine) {
|
||||
try {
|
||||
const content = await fs.readFile(path.join(repo.repoPath, sym.filePath), 'utf-8');
|
||||
const content = await fs.readFile(assertSafePath(sym.filePath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const lineIdx = sym.startLine - 1;
|
||||
if (lineIdx >= 0 && lineIdx < lines.length && lines[lineIdx].includes(oldName)) {
|
||||
addEdit(sym.filePath, sym.startLine, lines[lineIdx].trim(), lines[lineIdx].replace(oldName, new_name).trim(), 'graph');
|
||||
const defRegex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
|
||||
addEdit(sym.filePath, sym.startLine, lines[lineIdx].trim(), lines[lineIdx].replace(defRegex, new_name).trim(), 'graph');
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch (e) { logQueryError('rename:read-definition', e); }
|
||||
}
|
||||
|
||||
|
||||
// All incoming refs from graph (callers, importers, etc.)
|
||||
const allIncoming = [
|
||||
...(lookupResult.incoming.calls || []),
|
||||
@@ -1208,7 +1234,7 @@ export class LocalBackend {
|
||||
for (const ref of allIncoming) {
|
||||
if (!ref.filePath) continue;
|
||||
try {
|
||||
const content = await fs.readFile(path.join(repo.repoPath, ref.filePath), 'utf-8');
|
||||
const content = await fs.readFile(assertSafePath(ref.filePath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(oldName)) {
|
||||
@@ -1217,18 +1243,24 @@ export class LocalBackend {
|
||||
break; // one edit per file from graph refs
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch (e) { logQueryError('rename:read-ref', e); }
|
||||
}
|
||||
|
||||
|
||||
// Step 3: Text search for refs the graph might have missed
|
||||
let astSearchEdits = 0;
|
||||
const graphFiles = new Set([sym.filePath, ...allIncoming.map(r => r.filePath)].filter(Boolean));
|
||||
|
||||
// Simple text search across the repo for the old name (in files not already covered by graph)
|
||||
try {
|
||||
const { execSync } = await import('child_process');
|
||||
const rgCmd = `rg -l --type-add "code:*.{ts,tsx,js,jsx,py,go,rs,java}" -t code "\\b${oldName}\\b" .`;
|
||||
const output = execSync(rgCmd, { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000 });
|
||||
const { execFileSync } = await import('child_process');
|
||||
const rgArgs = [
|
||||
'-l',
|
||||
'--type-add', 'code:*.{ts,tsx,js,jsx,py,go,rs,java,c,h,cpp,cc,cxx,hpp,hxx,hh,cs,php,swift}',
|
||||
'-t', 'code',
|
||||
`\\b${oldName}\\b`,
|
||||
'.',
|
||||
];
|
||||
const output = execFileSync('rg', rgArgs, { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000 });
|
||||
const files = output.trim().split('\n').filter(f => f.length > 0);
|
||||
|
||||
for (const file of files) {
|
||||
@@ -1236,19 +1268,20 @@ export class LocalBackend {
|
||||
if (graphFiles.has(normalizedFile)) continue; // already covered by graph
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(path.join(repo.repoPath, normalizedFile), 'utf-8');
|
||||
const content = await fs.readFile(assertSafePath(normalizedFile), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
regex.lastIndex = 0;
|
||||
if (regex.test(lines[i])) {
|
||||
regex.lastIndex = 0;
|
||||
addEdit(normalizedFile, i + 1, lines[i].trim(), lines[i].replace(regex, new_name).trim(), 'text_search');
|
||||
astSearchEdits++;
|
||||
regex.lastIndex = 0; // reset regex
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch (e) { logQueryError('rename:text-search-read', e); }
|
||||
}
|
||||
} catch { /* rg not available or no additional matches */ }
|
||||
} catch (e) { logQueryError('rename:ripgrep', e); }
|
||||
|
||||
// Step 4: Apply or preview
|
||||
const allChanges = Array.from(changes.values());
|
||||
@@ -1258,12 +1291,12 @@ export class LocalBackend {
|
||||
// Apply edits to files
|
||||
for (const change of allChanges) {
|
||||
try {
|
||||
const fullPath = path.join(repo.repoPath, change.file_path);
|
||||
const fullPath = assertSafePath(change.file_path);
|
||||
let content = await fs.readFile(fullPath, 'utf-8');
|
||||
const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
|
||||
content = content.replace(regex, new_name);
|
||||
await fs.writeFile(fullPath, content, 'utf-8');
|
||||
} catch { /* skip failed files */ }
|
||||
} catch (e) { logQueryError('rename:apply-edit', e); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1292,22 +1325,22 @@ export class LocalBackend {
|
||||
|
||||
const { target, direction } = params;
|
||||
const maxDepth = params.maxDepth || 3;
|
||||
const relationTypes = params.relationTypes && params.relationTypes.length > 0
|
||||
? params.relationTypes
|
||||
const rawRelTypes = params.relationTypes && params.relationTypes.length > 0
|
||||
? params.relationTypes.filter(t => VALID_RELATION_TYPES.has(t))
|
||||
: ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
|
||||
const relationTypes = rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
|
||||
const includeTests = params.includeTests ?? false;
|
||||
const minConfidence = params.minConfidence ?? 0;
|
||||
|
||||
|
||||
const relTypeFilter = relationTypes.map(t => `'${t}'`).join(', ');
|
||||
const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
|
||||
|
||||
const targetQuery = `
|
||||
|
||||
const targets = await executeParameterized(repo.id, `
|
||||
MATCH (n)
|
||||
WHERE n.name = '${target.replace(/'/g, "''")}'
|
||||
WHERE n.name = $targetName
|
||||
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
||||
LIMIT 1
|
||||
`;
|
||||
const targets = await executeQuery(repo.id, targetQuery);
|
||||
`, { targetName: target });
|
||||
if (targets.length === 0) return { error: `Target '${target}' not found` };
|
||||
|
||||
const sym = targets[0];
|
||||
@@ -1349,7 +1382,7 @@ export class LocalBackend {
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { /* query failed for this depth level */ }
|
||||
} catch (e) { logQueryError('impact:depth-traversal', e); }
|
||||
|
||||
frontier = nextFrontier;
|
||||
}
|
||||
@@ -1511,13 +1544,11 @@ export class LocalBackend {
|
||||
const repo = await this.resolveRepo(repoName);
|
||||
await this.ensureInitialized(repo.id);
|
||||
|
||||
const escaped = name.replace(/'/g, "''");
|
||||
const clusterQuery = `
|
||||
const clusters = await executeParameterized(repo.id, `
|
||||
MATCH (c:Community)
|
||||
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
|
||||
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
|
||||
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
|
||||
`;
|
||||
const clusters = await executeQuery(repo.id, clusterQuery);
|
||||
`, { clusterName: name });
|
||||
if (clusters.length === 0) return { error: `Cluster '${name}' not found` };
|
||||
|
||||
const rawClusters = clusters.map((c: any) => ({
|
||||
@@ -1532,12 +1563,12 @@ export class LocalBackend {
|
||||
weightedCohesion += (c.cohesion || 0) * s;
|
||||
}
|
||||
|
||||
const members = await executeQuery(repo.id, `
|
||||
const members = await executeParameterized(repo.id, `
|
||||
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||||
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
|
||||
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
|
||||
RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
||||
LIMIT 30
|
||||
`);
|
||||
`, { clusterName: name });
|
||||
|
||||
return {
|
||||
cluster: {
|
||||
@@ -1562,22 +1593,21 @@ export class LocalBackend {
|
||||
const repo = await this.resolveRepo(repoName);
|
||||
await this.ensureInitialized(repo.id);
|
||||
|
||||
const escaped = name.replace(/'/g, "''");
|
||||
const processes = await executeQuery(repo.id, `
|
||||
const processes = await executeParameterized(repo.id, `
|
||||
MATCH (p:Process)
|
||||
WHERE p.label = '${escaped}' OR p.heuristicLabel = '${escaped}'
|
||||
WHERE p.label = $processName OR p.heuristicLabel = $processName
|
||||
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
|
||||
LIMIT 1
|
||||
`);
|
||||
`, { processName: name });
|
||||
if (processes.length === 0) return { error: `Process '${name}' not found` };
|
||||
|
||||
const proc = processes[0];
|
||||
const procId = proc.id || proc[0];
|
||||
const steps = await executeQuery(repo.id, `
|
||||
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: '${procId}'})
|
||||
const steps = await executeParameterized(repo.id, `
|
||||
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId})
|
||||
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
|
||||
ORDER BY r.step
|
||||
`);
|
||||
`, { procId });
|
||||
|
||||
return {
|
||||
process: {
|
||||
|
||||
+20
-11
@@ -11,6 +11,7 @@
|
||||
* Resources: repos, repo/{name}/context, repo/{name}/clusters, ...
|
||||
*/
|
||||
|
||||
import { createRequire } from 'module';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
@@ -80,10 +81,12 @@ function getNextStepHint(toolName: string, args: Record<string, any> | undefined
|
||||
* Transport-agnostic — caller connects the desired transport.
|
||||
*/
|
||||
export function createMCPServer(backend: LocalBackend): Server {
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkgVersion: string = require('../../package.json').version;
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'gitnexus',
|
||||
version: '1.1.9',
|
||||
version: pkgVersion,
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
@@ -277,16 +280,22 @@ export async function startMCPServer(backend: LocalBackend): Promise<void> {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
await backend.disconnect();
|
||||
await server.close();
|
||||
// Graceful shutdown helper
|
||||
let shuttingDown = false;
|
||||
const shutdown = async () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
try { await backend.disconnect(); } catch {}
|
||||
try { await server.close(); } catch {}
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await backend.disconnect();
|
||||
await server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// Handle stdio errors — stdin close means the parent process is gone
|
||||
process.stdin.on('end', shutdown);
|
||||
process.stdin.on('error', () => shutdown());
|
||||
process.stdout.on('error', () => shutdown());
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Returns a hint for the LLM to call analyze if stale.
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { execFileSync } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
export interface StalenessInfo {
|
||||
@@ -20,8 +20,8 @@ export interface StalenessInfo {
|
||||
export function checkStaleness(repoPath: string, lastCommit: string): StalenessInfo {
|
||||
try {
|
||||
// Get count of commits between lastCommit and HEAD
|
||||
const result = execSync(
|
||||
`git rev-list --count ${lastCommit}..HEAD`,
|
||||
const result = execFileSync(
|
||||
'git', ['rev-list', '--count', `${lastCommit}..HEAD`],
|
||||
{ cwd: repoPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
// Git utilities for repository detection, commit tracking, and diff analysis
|
||||
|
||||
@@ -24,9 +25,11 @@ export const getCurrentCommit = (repoPath: string): string => {
|
||||
*/
|
||||
export const getGitRoot = (fromPath: string): string | null => {
|
||||
try {
|
||||
return execSync('git rev-parse --show-toplevel', { cwd: fromPath })
|
||||
const raw = execSync('git rev-parse --show-toplevel', { cwd: fromPath })
|
||||
.toString()
|
||||
.trim();
|
||||
// On Windows, git returns /d/Projects/Foo — path.resolve normalizes to D:\Projects\Foo
|
||||
return path.resolve(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -201,9 +201,13 @@ export const registerRepo = async (repoPath: string, meta: RepoMeta): Promise<vo
|
||||
const { storagePath } = getStoragePaths(resolved);
|
||||
|
||||
const entries = await readRegistry();
|
||||
const existing = entries.findIndex(
|
||||
(e) => path.resolve(e.path) === resolved
|
||||
);
|
||||
const existing = entries.findIndex((e) => {
|
||||
const a = path.resolve(e.path);
|
||||
const b = resolved;
|
||||
return process.platform === 'win32'
|
||||
? a.toLowerCase() === b.toLowerCase()
|
||||
: a === b;
|
||||
});
|
||||
|
||||
const entry: RegistryEntry = {
|
||||
name,
|
||||
@@ -296,5 +300,10 @@ export const loadCLIConfig = async (): Promise<CLIConfig> => {
|
||||
export const saveCLIConfig = async (config: CLIConfig): Promise<void> => {
|
||||
const dir = getGlobalDir();
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(getGlobalConfigPath(), JSON.stringify(config, null, 2), 'utf-8');
|
||||
const configPath = getGlobalConfigPath();
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
// Restrict file permissions on Unix (config may contain API keys)
|
||||
if (process.platform !== 'win32') {
|
||||
try { await fs.chmod(configPath, 0o600); } catch { /* best-effort */ }
|
||||
}
|
||||
};
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import type { ValidationResult } from './validator';
|
||||
|
||||
export interface DbRecord {
|
||||
id: string;
|
||||
value: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export async function saveToDb(input: ValidationResult): Promise<DbRecord> {
|
||||
return {
|
||||
id: Math.random().toString(36),
|
||||
value: input.value,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function findById(id: string): Promise<DbRecord | null> {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { DbRecord } from './db';
|
||||
|
||||
export function formatResponse(record: DbRecord): string {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
data: record,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatError(message: string): string {
|
||||
return JSON.stringify({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { validateInput } from './validator';
|
||||
import { saveToDb } from './db';
|
||||
import { formatResponse } from './formatter';
|
||||
|
||||
export class RequestHandler {
|
||||
async handleRequest(input: string): Promise<string> {
|
||||
const validated = validateInput(input);
|
||||
const saved = await saveToDb(validated);
|
||||
return formatResponse(saved);
|
||||
}
|
||||
}
|
||||
|
||||
export function createHandler(): RequestHandler {
|
||||
return new RequestHandler();
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { RequestHandler, createHandler } from './handler';
|
||||
export { validateInput, sanitize } from './validator';
|
||||
export { formatResponse, formatError } from './formatter';
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function validateInput(input: string): ValidationResult {
|
||||
if (!input || input.trim().length === 0) {
|
||||
return { valid: false, value: '' };
|
||||
}
|
||||
return { valid: true, value: input.trim() };
|
||||
}
|
||||
|
||||
export function sanitize(input: string): string {
|
||||
return input.replace(/[<>]/g, '');
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#include <stdio.h>
|
||||
|
||||
int add(int a, int b) {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
static int internal_helper(void) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void print_message(const char* msg) {
|
||||
printf("%s\n", msg);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
#include <string>
|
||||
|
||||
class UserManager {
|
||||
public:
|
||||
void addUser(const std::string& name) {
|
||||
users_.push_back(name);
|
||||
}
|
||||
|
||||
int getCount() const {
|
||||
return static_cast<int>(users_.size());
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<std::string> users_;
|
||||
};
|
||||
|
||||
int helperFunction(int x) {
|
||||
return x * 2;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
|
||||
namespace SampleApp
|
||||
{
|
||||
public class Calculator
|
||||
{
|
||||
public int Add(int a, int b)
|
||||
{
|
||||
return a + b;
|
||||
}
|
||||
|
||||
private int Multiply(int a, int b)
|
||||
{
|
||||
return a * b;
|
||||
}
|
||||
}
|
||||
|
||||
internal class Helper
|
||||
{
|
||||
public void DoWork() { }
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ExportedFunction is a public function
|
||||
func ExportedFunction(name string) string {
|
||||
return fmt.Sprintf("Hello, %s", name)
|
||||
}
|
||||
|
||||
// unexportedFunction is a private function
|
||||
func unexportedFunction() int {
|
||||
return 42
|
||||
}
|
||||
|
||||
type UserService struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (s *UserService) GetName() string {
|
||||
return s.Name
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
public class UserService {
|
||||
private String name;
|
||||
|
||||
public UserService(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
private void reset() {
|
||||
this.name = "";
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
const path = require('path');
|
||||
|
||||
class EventEmitter {
|
||||
constructor() {
|
||||
this.listeners = {};
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = [];
|
||||
}
|
||||
this.listeners[event].push(callback);
|
||||
}
|
||||
|
||||
emit(event, ...args) {
|
||||
const handlers = this.listeners[event] || [];
|
||||
handlers.forEach(handler => handler(...args));
|
||||
}
|
||||
}
|
||||
|
||||
function createLogger(prefix) {
|
||||
return {
|
||||
log: (msg) => console.log(`[${prefix}] ${msg}`),
|
||||
error: (msg) => console.error(`[${prefix}] ${msg}`),
|
||||
};
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
module.exports = { EventEmitter, createLogger, formatDate };
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
function topLevelFunction(string $name): string {
|
||||
return "Hello, " . $name;
|
||||
}
|
||||
|
||||
class UserRepository {
|
||||
private array $users = [];
|
||||
|
||||
public function addUser(string $name): void {
|
||||
$this->users[] = $name;
|
||||
}
|
||||
|
||||
private function validateName(string $name): bool {
|
||||
return strlen($name) > 0;
|
||||
}
|
||||
|
||||
public function getUsers(): array {
|
||||
return $this->users;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
def public_function(x: int, y: int) -> int:
|
||||
"""A public function."""
|
||||
return x + y
|
||||
|
||||
def _private_helper(data: str) -> str:
|
||||
"""A private helper function."""
|
||||
return data.strip()
|
||||
|
||||
class Calculator:
|
||||
def add(self, a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
def _reset(self) -> None:
|
||||
pass
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
pub fn public_function(x: i32) -> i32 {
|
||||
x + 1
|
||||
}
|
||||
|
||||
fn private_function() -> &'static str {
|
||||
"private"
|
||||
}
|
||||
|
||||
pub struct Config {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Config { name: name.to_string() }
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
class UserManager {
|
||||
var users: [String] = []
|
||||
|
||||
init() {
|
||||
users = []
|
||||
}
|
||||
|
||||
func addUser(_ name: String) {
|
||||
users.append(name)
|
||||
}
|
||||
|
||||
public func getCount() -> Int {
|
||||
return users.count
|
||||
}
|
||||
}
|
||||
|
||||
func helperFunction() -> String {
|
||||
return "swift helper"
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
export interface UserConfig {
|
||||
name: string;
|
||||
email: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function validateUser(config: UserConfig): boolean {
|
||||
return config.name.length > 0 && config.email.includes('@');
|
||||
}
|
||||
|
||||
export class UserService {
|
||||
private users: UserConfig[] = [];
|
||||
|
||||
addUser(user: UserConfig): void {
|
||||
if (validateUser(user)) {
|
||||
this.users.push(user);
|
||||
}
|
||||
}
|
||||
|
||||
getUser(name: string): UserConfig | undefined {
|
||||
return this.users.find(u => u.name === name);
|
||||
}
|
||||
}
|
||||
|
||||
function internalHelper(): string {
|
||||
return 'helper';
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface ButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export class Counter extends React.Component<{}, { count: number }> {
|
||||
state = { count: 0 };
|
||||
|
||||
increment() {
|
||||
this.setState({ count: this.state.count + 1 });
|
||||
}
|
||||
|
||||
render() {
|
||||
return <button onClick={() => this.increment()}>{this.state.count}</button>;
|
||||
}
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
|
||||
return <button onClick={onClick}>{label}</button>;
|
||||
};
|
||||
|
||||
export function useCounter(initial: number = 0) {
|
||||
const [count, setCount] = useState(initial);
|
||||
const increment = () => setCount(c => c + 1);
|
||||
const decrement = () => setCount(c => c - 1);
|
||||
return { count, increment, decrement };
|
||||
}
|
||||
|
||||
const App = () => {
|
||||
const { count, increment } = useCounter();
|
||||
return (
|
||||
<div>
|
||||
<h1>Count: {count}</h1>
|
||||
<Button label="+" onClick={increment} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Test helper: Temporary KuzuDB factory
|
||||
*
|
||||
* Creates a temp directory, initializes KuzuDB with schema, and
|
||||
* optionally loads minimal test data. Returns a cleanup function.
|
||||
*/
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
export interface TestDBHandle {
|
||||
dbPath: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary directory for KuzuDB tests.
|
||||
* Returns the path and a cleanup function.
|
||||
*/
|
||||
export async function createTempDir(prefix: string = 'gitnexus-test-'): Promise<TestDBHandle> {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
return {
|
||||
dbPath: tmpDir,
|
||||
cleanup: async () => {
|
||||
try {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Test helper: In-memory knowledge graph builder
|
||||
*
|
||||
* Provides a convenient API for constructing test graphs
|
||||
* without touching the filesystem or KuzuDB.
|
||||
*/
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import type { KnowledgeGraph, GraphNode, NodeLabel, RelationshipType } from '../../src/core/graph/types.js';
|
||||
|
||||
export interface TestNodeInput {
|
||||
id: string;
|
||||
label: NodeLabel;
|
||||
name: string;
|
||||
filePath: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
isExported?: boolean;
|
||||
extra?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface TestRelInput {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
type: RelationshipType;
|
||||
confidence?: number;
|
||||
reason?: string;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a test graph from simple input arrays.
|
||||
*/
|
||||
export function buildTestGraph(
|
||||
nodes: TestNodeInput[],
|
||||
relationships: TestRelInput[] = [],
|
||||
): KnowledgeGraph {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
for (const n of nodes) {
|
||||
graph.addNode({
|
||||
id: n.id,
|
||||
label: n.label,
|
||||
properties: {
|
||||
name: n.name,
|
||||
filePath: n.filePath,
|
||||
startLine: n.startLine,
|
||||
endLine: n.endLine,
|
||||
isExported: n.isExported,
|
||||
...n.extra,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const r of relationships) {
|
||||
graph.addRelationship({
|
||||
id: `${r.sourceId}-${r.type}-${r.targetId}`,
|
||||
sourceId: r.sourceId,
|
||||
targetId: r.targetId,
|
||||
type: r.type,
|
||||
confidence: r.confidence ?? 1.0,
|
||||
reason: r.reason ?? '',
|
||||
step: r.step,
|
||||
});
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal graph with a few files, functions, and relationships.
|
||||
* Useful as a baseline for integration tests.
|
||||
*/
|
||||
export function createMinimalTestGraph(): KnowledgeGraph {
|
||||
return buildTestGraph(
|
||||
[
|
||||
{ id: 'file:src/index.ts', label: 'File', name: 'index.ts', filePath: 'src/index.ts' },
|
||||
{ id: 'file:src/utils.ts', label: 'File', name: 'utils.ts', filePath: 'src/utils.ts' },
|
||||
{ id: 'func:main', label: 'Function', name: 'main', filePath: 'src/index.ts', startLine: 1, endLine: 10, isExported: true },
|
||||
{ id: 'func:helper', label: 'Function', name: 'helper', filePath: 'src/utils.ts', startLine: 1, endLine: 5, isExported: true },
|
||||
{ id: 'class:App', label: 'Class', name: 'App', filePath: 'src/index.ts', startLine: 12, endLine: 30, isExported: true },
|
||||
{ id: 'folder:src', label: 'Folder', name: 'src', filePath: 'src' },
|
||||
],
|
||||
[
|
||||
{ sourceId: 'func:main', targetId: 'func:helper', type: 'CALLS' },
|
||||
{ sourceId: 'func:main', targetId: 'class:App', type: 'CALLS' },
|
||||
{ sourceId: 'file:src/index.ts', targetId: 'func:main', type: 'CONTAINS' },
|
||||
{ sourceId: 'file:src/utils.ts', targetId: 'func:helper', type: 'CONTAINS' },
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* P1 Integration Tests: CSV Pipeline
|
||||
*
|
||||
* Tests: streamAllCSVsToDisk with real graph data.
|
||||
* Covers hardening fixes: LRU cache (#24), BufferedCSVWriter flush
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
|
||||
import { buildTestGraph } from '../helpers/test-graph.js';
|
||||
import { streamAllCSVsToDisk } from '../../src/core/kuzu/csv-generator.js';
|
||||
|
||||
let tmpHandle: TestDBHandle;
|
||||
let csvDir: string;
|
||||
let repoDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpHandle = await createTempDir('csv-pipeline-test-');
|
||||
csvDir = path.join(tmpHandle.dbPath, 'csv');
|
||||
repoDir = path.join(tmpHandle.dbPath, 'repo');
|
||||
|
||||
// Create a fake repo directory with source files
|
||||
await fs.mkdir(path.join(repoDir, 'src'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoDir, 'src', 'index.ts'),
|
||||
'export function main() {\n console.log("hello");\n helper();\n}\n\nexport class App {\n run() {}\n}\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(repoDir, 'src', 'utils.ts'),
|
||||
'export function helper() {\n return 42;\n}\n',
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { await tmpHandle.cleanup(); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
describe('streamAllCSVsToDisk', () => {
|
||||
it('generates CSV files for all node types in the graph', async () => {
|
||||
const graph = buildTestGraph(
|
||||
[
|
||||
{ id: 'file:src/index.ts', label: 'File', name: 'index.ts', filePath: 'src/index.ts' },
|
||||
{ id: 'file:src/utils.ts', label: 'File', name: 'utils.ts', filePath: 'src/utils.ts' },
|
||||
{ id: 'func:main', label: 'Function', name: 'main', filePath: 'src/index.ts', startLine: 1, endLine: 4, isExported: true },
|
||||
{ id: 'func:helper', label: 'Function', name: 'helper', filePath: 'src/utils.ts', startLine: 1, endLine: 3, isExported: true },
|
||||
{ id: 'class:App', label: 'Class', name: 'App', filePath: 'src/index.ts', startLine: 6, endLine: 8, isExported: true },
|
||||
{ id: 'folder:src', label: 'Folder', name: 'src', filePath: 'src' },
|
||||
],
|
||||
[
|
||||
{ sourceId: 'func:main', targetId: 'func:helper', type: 'CALLS' },
|
||||
{ sourceId: 'file:src/index.ts', targetId: 'func:main', type: 'CONTAINS' },
|
||||
{ sourceId: 'file:src/utils.ts', targetId: 'func:helper', type: 'CONTAINS' },
|
||||
],
|
||||
);
|
||||
|
||||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||||
|
||||
// Check that CSV files were created
|
||||
expect(result.nodeFiles.size).toBeGreaterThan(0);
|
||||
expect(result.relRows).toBe(3);
|
||||
|
||||
// Verify File CSV
|
||||
const fileCsv = result.nodeFiles.get('File');
|
||||
expect(fileCsv).toBeDefined();
|
||||
expect(fileCsv!.rows).toBe(2);
|
||||
|
||||
// Verify Function CSV
|
||||
const funcCsv = result.nodeFiles.get('Function');
|
||||
expect(funcCsv).toBeDefined();
|
||||
expect(funcCsv!.rows).toBe(2);
|
||||
|
||||
// Verify Class CSV
|
||||
const classCsv = result.nodeFiles.get('Class');
|
||||
expect(classCsv).toBeDefined();
|
||||
expect(classCsv!.rows).toBe(1);
|
||||
|
||||
// Verify Folder CSV
|
||||
const folderCsv = result.nodeFiles.get('Folder');
|
||||
expect(folderCsv).toBeDefined();
|
||||
expect(folderCsv!.rows).toBe(1);
|
||||
|
||||
// Verify relations CSV exists
|
||||
const relContent = await fs.readFile(result.relCsvPath, 'utf-8');
|
||||
const relLines = relContent.trim().split('\n');
|
||||
expect(relLines.length).toBe(4); // header + 3 relationships
|
||||
});
|
||||
|
||||
it('CSV content is properly escaped', async () => {
|
||||
const graph = buildTestGraph([
|
||||
{
|
||||
id: 'file:src/index.ts',
|
||||
label: 'File',
|
||||
name: 'index.ts',
|
||||
filePath: 'src/index.ts',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||||
const fileCsv = result.nodeFiles.get('File');
|
||||
expect(fileCsv).toBeDefined();
|
||||
|
||||
const content = await fs.readFile(fileCsv!.csvPath, 'utf-8');
|
||||
// Content should be properly quoted
|
||||
expect(content).toContain('"file:src/index.ts"');
|
||||
expect(content).toContain('"index.ts"');
|
||||
});
|
||||
|
||||
it('handles community nodes with keywords', async () => {
|
||||
const graph = buildTestGraph([
|
||||
{
|
||||
id: 'comm:auth',
|
||||
label: 'Community' as any,
|
||||
name: 'Auth',
|
||||
filePath: '',
|
||||
extra: {
|
||||
heuristicLabel: 'Authentication',
|
||||
keywords: ['auth', 'login', 'pass,word'],
|
||||
description: 'Auth module',
|
||||
enrichedBy: 'heuristic',
|
||||
cohesion: 0.85,
|
||||
symbolCount: 5,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||||
const commCsv = result.nodeFiles.get('Community');
|
||||
expect(commCsv).toBeDefined();
|
||||
expect(commCsv!.rows).toBe(1);
|
||||
|
||||
const content = await fs.readFile(commCsv!.csvPath, 'utf-8');
|
||||
// Keywords with commas should be escaped with \,
|
||||
expect(content).toContain('pass\\,word');
|
||||
});
|
||||
|
||||
it('handles process nodes', async () => {
|
||||
const graph = buildTestGraph([
|
||||
{
|
||||
id: 'proc:flow',
|
||||
label: 'Process' as any,
|
||||
name: 'LoginFlow',
|
||||
filePath: '',
|
||||
extra: {
|
||||
heuristicLabel: 'User Login',
|
||||
processType: 'intra_community',
|
||||
stepCount: 3,
|
||||
communities: ['auth'],
|
||||
entryPointId: 'func:login',
|
||||
terminalId: 'func:validate',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||||
const procCsv = result.nodeFiles.get('Process');
|
||||
expect(procCsv).toBeDefined();
|
||||
expect(procCsv!.rows).toBe(1);
|
||||
});
|
||||
|
||||
it('deduplicates File nodes', async () => {
|
||||
const graph = buildTestGraph([
|
||||
{ id: 'file:src/index.ts', label: 'File', name: 'index.ts', filePath: 'src/index.ts' },
|
||||
// Duplicate (same id) — should not appear twice
|
||||
]);
|
||||
// Add the same node again manually
|
||||
graph.addNode({
|
||||
id: 'file:src/index.ts',
|
||||
label: 'File',
|
||||
properties: { name: 'index.ts', filePath: 'src/index.ts' },
|
||||
});
|
||||
|
||||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||||
const fileCsv = result.nodeFiles.get('File');
|
||||
expect(fileCsv).toBeDefined();
|
||||
expect(fileCsv!.rows).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { walkRepositoryPaths, readFileContents } from '../../src/core/ingestion/filesystem-walker.js';
|
||||
|
||||
describe('filesystem-walker', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-test-'));
|
||||
|
||||
// Create test directory structure
|
||||
await fs.mkdir(path.join(tmpDir, 'src'), { recursive: true });
|
||||
await fs.mkdir(path.join(tmpDir, 'src', 'components'), { recursive: true });
|
||||
await fs.mkdir(path.join(tmpDir, 'node_modules', 'lodash'), { recursive: true });
|
||||
await fs.mkdir(path.join(tmpDir, '.git'), { recursive: true });
|
||||
|
||||
await fs.writeFile(path.join(tmpDir, 'src', 'index.ts'), 'export const main = () => {}');
|
||||
await fs.writeFile(path.join(tmpDir, 'src', 'utils.ts'), 'export const helper = () => {}');
|
||||
await fs.writeFile(path.join(tmpDir, 'src', 'components', 'Button.tsx'), 'export const Button = () => <div/>');
|
||||
await fs.writeFile(path.join(tmpDir, 'node_modules', 'lodash', 'index.js'), 'module.exports = {}');
|
||||
await fs.writeFile(path.join(tmpDir, '.git', 'HEAD'), 'ref: refs/heads/main');
|
||||
await fs.writeFile(path.join(tmpDir, 'package.json'), '{}');
|
||||
await fs.writeFile(path.join(tmpDir, 'src', 'image.png'), Buffer.from([0x89, 0x50, 0x4E, 0x47]));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
describe('walkRepositoryPaths', () => {
|
||||
it('discovers source files', async () => {
|
||||
const files = await walkRepositoryPaths(tmpDir);
|
||||
const paths = files.map(f => f.path.replace(/\\/g, '/'));
|
||||
expect(paths.some(p => p.includes('src/index.ts'))).toBe(true);
|
||||
expect(paths.some(p => p.includes('src/utils.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
it('discovers nested files', async () => {
|
||||
const files = await walkRepositoryPaths(tmpDir);
|
||||
const paths = files.map(f => f.path.replace(/\\/g, '/'));
|
||||
expect(paths.some(p => p.includes('components/Button.tsx'))).toBe(true);
|
||||
});
|
||||
|
||||
it('skips node_modules', async () => {
|
||||
const files = await walkRepositoryPaths(tmpDir);
|
||||
const paths = files.map(f => f.path.replace(/\\/g, '/'));
|
||||
expect(paths.every(p => !p.includes('node_modules'))).toBe(true);
|
||||
});
|
||||
|
||||
it('skips .git directory', async () => {
|
||||
const files = await walkRepositoryPaths(tmpDir);
|
||||
const paths = files.map(f => f.path.replace(/\\/g, '/'));
|
||||
expect(paths.every(p => !p.includes('.git/'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns file sizes', async () => {
|
||||
const files = await walkRepositoryPaths(tmpDir);
|
||||
for (const file of files) {
|
||||
expect(typeof file.size).toBe('number');
|
||||
expect(file.size).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('calls progress callback', async () => {
|
||||
const onProgress = vi.fn();
|
||||
await walkRepositoryPaths(tmpDir, onProgress);
|
||||
expect(onProgress).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFileContents', () => {
|
||||
it('reads file contents by relative paths', async () => {
|
||||
const contents = await readFileContents(tmpDir, ['src/index.ts', 'src/utils.ts']);
|
||||
expect(contents.get('src/index.ts')).toContain('main');
|
||||
expect(contents.get('src/utils.ts')).toContain('helper');
|
||||
});
|
||||
|
||||
it('handles empty path list', async () => {
|
||||
const contents = await readFileContents(tmpDir, []);
|
||||
expect(contents.size).toBe(0);
|
||||
});
|
||||
|
||||
it('skips non-existent files gracefully', async () => {
|
||||
const contents = await readFileContents(tmpDir, ['nonexistent.ts']);
|
||||
expect(contents.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* P0 Integration Tests: KuzuDB Connection Pool
|
||||
*
|
||||
* Tests: initKuzu, executeQuery, executeParameterized, closeKuzu lifecycle
|
||||
* Covers hardening fixes: parameterized queries, query timeout,
|
||||
* waiter queue timeout, idle eviction guards, stdout silencing race
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import kuzu from 'kuzu';
|
||||
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
|
||||
import {
|
||||
initKuzu,
|
||||
executeQuery,
|
||||
executeParameterized,
|
||||
closeKuzu,
|
||||
isKuzuReady,
|
||||
} from '../../src/mcp/core/kuzu-adapter.js';
|
||||
import { NODE_SCHEMA_QUERIES, REL_SCHEMA_QUERIES } from '../../src/core/kuzu/schema.js';
|
||||
|
||||
let tmpHandle: TestDBHandle;
|
||||
let dbPath: string;
|
||||
const REPO_ID = 'test-repo';
|
||||
|
||||
/**
|
||||
* Create a writable KuzuDB with schema and seed data.
|
||||
* The pool opens it read-only, so we must create it separately.
|
||||
*/
|
||||
async function createTestDB(dbDir: string): Promise<void> {
|
||||
const db = new kuzu.Database(dbDir);
|
||||
const conn = new kuzu.Connection(db);
|
||||
|
||||
// Create schema
|
||||
for (const q of NODE_SCHEMA_QUERIES) {
|
||||
await conn.query(q);
|
||||
}
|
||||
for (const q of REL_SCHEMA_QUERIES) {
|
||||
await conn.query(q);
|
||||
}
|
||||
|
||||
// Insert test data
|
||||
await conn.query(`CREATE (f:File {id: 'file:index.ts', name: 'index.ts', filePath: 'src/index.ts', content: ''})`);
|
||||
await conn.query(`CREATE (fn:Function {id: 'func:main', name: 'main', filePath: 'src/index.ts', startLine: 1, endLine: 10, isExported: true, content: '', description: ''})`);
|
||||
await conn.query(`CREATE (fn2:Function {id: 'func:helper', name: 'helper', filePath: 'src/utils.ts', startLine: 1, endLine: 5, isExported: true, content: '', description: ''})`);
|
||||
await conn.query(`
|
||||
MATCH (a:Function), (b:Function)
|
||||
WHERE a.id = 'func:main' AND b.id = 'func:helper'
|
||||
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)
|
||||
`);
|
||||
|
||||
conn.close();
|
||||
db.close();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpHandle = await createTempDir('kuzu-pool-test-');
|
||||
dbPath = path.join(tmpHandle.dbPath, 'kuzu');
|
||||
// KuzuDB creates the directory itself — do NOT mkdir
|
||||
await createTestDB(dbPath);
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
// NOTE: We intentionally skip closeKuzu() here because KuzuDB native
|
||||
// cleanup in forked workers can cause segfaults on process exit.
|
||||
// The OS reclaims resources when the worker process terminates.
|
||||
try { await tmpHandle.cleanup(); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up specific repo IDs used in tests, not all
|
||||
try { await closeKuzu(REPO_ID); } catch { /* best-effort */ }
|
||||
try { await closeKuzu('repo1'); } catch { /* best-effort */ }
|
||||
try { await closeKuzu('repo2'); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
// ─── Lifecycle: init → query → close ─────────────────────────────────
|
||||
|
||||
describe('pool lifecycle', () => {
|
||||
it('initKuzu + executeQuery + closeKuzu', async () => {
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
expect(isKuzuReady(REPO_ID)).toBe(true);
|
||||
|
||||
const rows = await executeQuery(REPO_ID, 'MATCH (n:Function) RETURN n.name AS name');
|
||||
expect(rows.length).toBeGreaterThanOrEqual(2);
|
||||
const names = rows.map((r: any) => r.name);
|
||||
expect(names).toContain('main');
|
||||
expect(names).toContain('helper');
|
||||
|
||||
await closeKuzu(REPO_ID);
|
||||
expect(isKuzuReady(REPO_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it('initKuzu reuses existing pool entry', async () => {
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
await initKuzu(REPO_ID, dbPath); // second call should be no-op
|
||||
expect(isKuzuReady(REPO_ID)).toBe(true);
|
||||
});
|
||||
|
||||
it('closeKuzu is idempotent', async () => {
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
await closeKuzu(REPO_ID);
|
||||
await closeKuzu(REPO_ID); // second close should not throw
|
||||
expect(isKuzuReady(REPO_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it('closeKuzu with no args closes all repos', async () => {
|
||||
await initKuzu('repo1', dbPath);
|
||||
await initKuzu('repo2', dbPath);
|
||||
expect(isKuzuReady('repo1')).toBe(true);
|
||||
expect(isKuzuReady('repo2')).toBe(true);
|
||||
|
||||
await closeKuzu();
|
||||
expect(isKuzuReady('repo1')).toBe(false);
|
||||
expect(isKuzuReady('repo2')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Parameterized queries ───────────────────────────────────────────
|
||||
|
||||
describe('executeParameterized', () => {
|
||||
it('works with parameterized query', async () => {
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
const rows = await executeParameterized(
|
||||
REPO_ID,
|
||||
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
|
||||
{ name: 'main' },
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe('main');
|
||||
});
|
||||
|
||||
it('injection attempt is harmless with parameterized query', async () => {
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
const rows = await executeParameterized(
|
||||
REPO_ID,
|
||||
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
|
||||
{ name: "' OR 1=1 --" }, // SQL/Cypher injection attempt
|
||||
);
|
||||
// Should return 0 rows, not all rows
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error handling ──────────────────────────────────────────────────
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws when querying uninitialized repo', async () => {
|
||||
await expect(executeQuery('nonexistent-repo', 'MATCH (n) RETURN n'))
|
||||
.rejects.toThrow(/not initialized/);
|
||||
});
|
||||
|
||||
it('throws when db path does not exist', async () => {
|
||||
await expect(initKuzu('bad-repo', '/nonexistent/path/kuzu'))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
|
||||
it('read-only mode: write query throws', async () => {
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
await expect(executeQuery(REPO_ID, "CREATE (n:Function {id: 'new', name: 'new', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})"))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Relationship queries ────────────────────────────────────────────
|
||||
|
||||
describe('relationship queries', () => {
|
||||
it('can query relationships', async () => {
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
const rows = await executeQuery(
|
||||
REPO_ID,
|
||||
`MATCH (a:Function)-[r:CodeRelation {type: 'CALLS'}]->(b:Function) RETURN a.name AS caller, b.name AS callee`,
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
const row = rows.find((r: any) => r.caller === 'main');
|
||||
expect(row).toBeDefined();
|
||||
expect(row.callee).toBe('helper');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* P0 Integration Tests: Local Backend
|
||||
*
|
||||
* Tests tool implementations via direct KuzuDB queries.
|
||||
* The full LocalBackend.callTool() requires a global registry,
|
||||
* so here we test the security-critical behaviors directly:
|
||||
* - Write-operation blocking in cypher
|
||||
* - Query execution via the pool
|
||||
* - Parameterized queries preventing injection
|
||||
* - Read-only enforcement
|
||||
*
|
||||
* Covers hardening fixes: #1 (parameterized queries), #2 (write blocking),
|
||||
* #3 (path traversal), #4 (relation allowlist), #25 (regex lastIndex),
|
||||
* #26 (rename first-occurrence-only)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import kuzu from 'kuzu';
|
||||
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
|
||||
import {
|
||||
initKuzu,
|
||||
executeQuery,
|
||||
executeParameterized,
|
||||
closeKuzu,
|
||||
} from '../../src/mcp/core/kuzu-adapter.js';
|
||||
import { NODE_SCHEMA_QUERIES, REL_SCHEMA_QUERIES } from '../../src/core/kuzu/schema.js';
|
||||
import {
|
||||
CYPHER_WRITE_RE,
|
||||
VALID_RELATION_TYPES,
|
||||
isWriteQuery,
|
||||
} from '../../src/mcp/local/local-backend.js';
|
||||
|
||||
let tmpHandle: TestDBHandle;
|
||||
let dbPath: string;
|
||||
const REPO_ID = 'backend-test';
|
||||
|
||||
async function createTestDB(dbDir: string): Promise<void> {
|
||||
const db = new kuzu.Database(dbDir);
|
||||
const conn = new kuzu.Connection(db);
|
||||
|
||||
for (const q of NODE_SCHEMA_QUERIES) {
|
||||
await conn.query(q);
|
||||
}
|
||||
for (const q of REL_SCHEMA_QUERIES) {
|
||||
await conn.query(q);
|
||||
}
|
||||
|
||||
// Insert test data: files, functions, classes, relationships
|
||||
await conn.query(`CREATE (f:File {id: 'file:auth.ts', name: 'auth.ts', filePath: 'src/auth.ts', content: 'auth module'})`);
|
||||
await conn.query(`CREATE (f:File {id: 'file:utils.ts', name: 'utils.ts', filePath: 'src/utils.ts', content: 'utils module'})`);
|
||||
await conn.query(`CREATE (fn:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 15, isExported: true, content: 'function login() {}', description: 'User login'})`);
|
||||
await conn.query(`CREATE (fn:Function {id: 'func:validate', name: 'validate', filePath: 'src/auth.ts', startLine: 17, endLine: 25, isExported: true, content: 'function validate() {}', description: 'Validate input'})`);
|
||||
await conn.query(`CREATE (fn:Function {id: 'func:hash', name: 'hash', filePath: 'src/utils.ts', startLine: 1, endLine: 8, isExported: true, content: 'function hash() {}', description: 'Hash utility'})`);
|
||||
await conn.query(`CREATE (c:Class {id: 'class:AuthService', name: 'AuthService', filePath: 'src/auth.ts', startLine: 30, endLine: 60, isExported: true, content: 'class AuthService {}', description: 'Authentication service'})`);
|
||||
await conn.query(`CREATE (c:Community {id: 'comm:auth', label: 'Auth', heuristicLabel: 'Authentication', keywords: ['auth', 'login'], description: 'Auth module', enrichedBy: 'heuristic', cohesion: 0.8, symbolCount: 3})`);
|
||||
await conn.query(`CREATE (p:Process {id: 'proc:login-flow', label: 'LoginFlow', heuristicLabel: 'User Login', processType: 'intra_community', stepCount: 2, communities: ['auth'], entryPointId: 'func:login', terminalId: 'func:validate'})`);
|
||||
|
||||
// Relationships
|
||||
await conn.query(`
|
||||
MATCH (a:Function), (b:Function) WHERE a.id = 'func:login' AND b.id = 'func:validate'
|
||||
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)
|
||||
`);
|
||||
await conn.query(`
|
||||
MATCH (a:Function), (b:Function) WHERE a.id = 'func:login' AND b.id = 'func:hash'
|
||||
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.9, reason: 'import-resolved', step: 0}]->(b)
|
||||
`);
|
||||
await conn.query(`
|
||||
MATCH (a:Function), (c:Community) WHERE a.id = 'func:login' AND c.id = 'comm:auth'
|
||||
CREATE (a)-[:CodeRelation {type: 'MEMBER_OF', confidence: 1.0, reason: '', step: 0}]->(c)
|
||||
`);
|
||||
await conn.query(`
|
||||
MATCH (a:Function), (p:Process) WHERE a.id = 'func:login' AND p.id = 'proc:login-flow'
|
||||
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 1}]->(p)
|
||||
`);
|
||||
await conn.query(`
|
||||
MATCH (a:Function), (p:Process) WHERE a.id = 'func:validate' AND p.id = 'proc:login-flow'
|
||||
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 2}]->(p)
|
||||
`);
|
||||
|
||||
conn.close();
|
||||
db.close();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpHandle = await createTempDir('backend-test-');
|
||||
dbPath = path.join(tmpHandle.dbPath, 'kuzu');
|
||||
// KuzuDB creates the directory itself — do NOT mkdir
|
||||
await createTestDB(dbPath);
|
||||
await initKuzu(REPO_ID, dbPath);
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
// NOTE: We intentionally skip closeKuzu() here because KuzuDB native
|
||||
// cleanup in forked workers can cause segfaults on process exit.
|
||||
// The OS reclaims resources when the worker process terminates.
|
||||
try { await tmpHandle.cleanup(); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
// ─── Cypher write blocking ───────────────────────────────────────────
|
||||
|
||||
describe('cypher write blocking', () => {
|
||||
const allWriteKeywords = ['CREATE', 'DELETE', 'SET', 'MERGE', 'REMOVE', 'DROP', 'ALTER', 'COPY', 'DETACH'];
|
||||
|
||||
for (const keyword of allWriteKeywords) {
|
||||
it(`blocks ${keyword} query`, () => {
|
||||
const blocked = isWriteQuery(`MATCH (n) ${keyword} n.name = "x"`);
|
||||
expect(blocked).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
it('allows valid read queries through the pool', async () => {
|
||||
const rows = await executeQuery(REPO_ID, 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name');
|
||||
expect(rows.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Parameterized queries ───────────────────────────────────────────
|
||||
|
||||
describe('parameterized queries', () => {
|
||||
it('finds exact match with parameter', async () => {
|
||||
const rows = await executeParameterized(
|
||||
REPO_ID,
|
||||
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name, n.filePath AS filePath',
|
||||
{ name: 'login' },
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe('login');
|
||||
expect(rows[0].filePath).toBe('src/auth.ts');
|
||||
});
|
||||
|
||||
it('injection is harmless', async () => {
|
||||
const rows = await executeParameterized(
|
||||
REPO_ID,
|
||||
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
|
||||
{ name: "login' OR '1'='1" },
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Relation type filtering ─────────────────────────────────────────
|
||||
|
||||
describe('relation type filtering', () => {
|
||||
it('only allows valid relation types in queries', () => {
|
||||
const validTypes = ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
|
||||
const invalidTypes = ['CONTAINS', 'STEP_IN_PROCESS', 'MEMBER_OF', 'DROP_TABLE'];
|
||||
|
||||
for (const t of validTypes) {
|
||||
expect(VALID_RELATION_TYPES.has(t)).toBe(true);
|
||||
}
|
||||
for (const t of invalidTypes) {
|
||||
expect(VALID_RELATION_TYPES.has(t)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('can query relationships with valid types', async () => {
|
||||
const rows = await executeQuery(
|
||||
REPO_ID,
|
||||
`MATCH (a:Function)-[r:CodeRelation {type: 'CALLS'}]->(b:Function) RETURN a.name AS caller, b.name AS callee ORDER BY b.name`,
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Process queries ─────────────────────────────────────────────────
|
||||
|
||||
describe('process queries', () => {
|
||||
it('can find processes', async () => {
|
||||
const rows = await executeQuery(REPO_ID, 'MATCH (p:Process) RETURN p.heuristicLabel AS label, p.stepCount AS steps');
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rows[0].label).toBe('User Login');
|
||||
});
|
||||
|
||||
it('can trace process steps', async () => {
|
||||
const rows = await executeQuery(
|
||||
REPO_ID,
|
||||
`MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||||
WHERE p.id = 'proc:login-flow'
|
||||
RETURN s.name AS symbol, r.step AS step
|
||||
ORDER BY r.step`,
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].symbol).toBe('login');
|
||||
expect(rows[0].step).toBe(1);
|
||||
expect(rows[1].symbol).toBe('validate');
|
||||
expect(rows[1].step).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Community queries ───────────────────────────────────────────────
|
||||
|
||||
describe('community queries', () => {
|
||||
it('can find communities', async () => {
|
||||
const rows = await executeQuery(REPO_ID, 'MATCH (c:Community) RETURN c.heuristicLabel AS label');
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rows[0].label).toBe('Authentication');
|
||||
});
|
||||
|
||||
it('can find community members', async () => {
|
||||
const rows = await executeQuery(
|
||||
REPO_ID,
|
||||
`MATCH (f)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||||
WHERE c.heuristicLabel = 'Authentication'
|
||||
RETURN f.name AS name`,
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rows[0].name).toBe('login');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Read-only enforcement ───────────────────────────────────────────
|
||||
|
||||
describe('read-only database', () => {
|
||||
it('rejects write operations at DB level', async () => {
|
||||
await expect(
|
||||
executeQuery(REPO_ID, `CREATE (n:Function {id: 'new', name: 'new', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})`)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Regex lastIndex hardening (#25) ─────────────────────────────────
|
||||
|
||||
describe('regex lastIndex (hardening #25)', () => {
|
||||
it('CYPHER_WRITE_RE is non-global (no sticky lastIndex)', () => {
|
||||
expect(CYPHER_WRITE_RE.global).toBe(false);
|
||||
expect(CYPHER_WRITE_RE.sticky).toBe(false);
|
||||
});
|
||||
|
||||
it('works correctly across multiple consecutive calls', () => {
|
||||
// If the regex were global, lastIndex could cause false results
|
||||
const results = [
|
||||
isWriteQuery('CREATE (n)'), // true
|
||||
isWriteQuery('MATCH (n) RETURN n'), // false
|
||||
isWriteQuery('DELETE n'), // true
|
||||
isWriteQuery('MATCH (n) RETURN n'), // false
|
||||
isWriteQuery('SET n.x = 1'), // true
|
||||
];
|
||||
expect(results).toEqual([true, false, true, false, true]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Content queries (include_content equivalent) ────────────────────
|
||||
|
||||
describe('content queries', () => {
|
||||
it('can retrieve symbol content', async () => {
|
||||
const rows = await executeQuery(
|
||||
REPO_ID,
|
||||
`MATCH (n:Function) WHERE n.name = 'login' RETURN n.content AS content`,
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].content).toContain('function login');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* P1 Integration Tests: Tree-sitter Parsing
|
||||
*
|
||||
* Tests parsing of sample files via tree-sitter.
|
||||
* Covers hardening fixes: Swift init constructor (#18),
|
||||
* PHP export detection (#20), symbol ID with startLine (#19),
|
||||
* definition node range (#22).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import { isNodeExported } from '../../src/core/ingestion/parsing-processor.js';
|
||||
|
||||
const FIXTURES_DIR = path.join(process.cwd(), 'test', 'fixtures', 'sample-code');
|
||||
|
||||
// We test isNodeExported directly since it's a pure function
|
||||
// that only needs a mock AST node, name, and language string.
|
||||
|
||||
/**
|
||||
* Minimal mock of a tree-sitter AST node.
|
||||
*/
|
||||
function mockNode(type: string, text: string = '', parent?: any): any {
|
||||
return {
|
||||
type,
|
||||
text,
|
||||
parent: parent || null,
|
||||
childCount: 0,
|
||||
child: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── isNodeExported per-language ─────────────────────────────────────
|
||||
|
||||
describe('isNodeExported', () => {
|
||||
// TypeScript/JavaScript
|
||||
describe('typescript', () => {
|
||||
it('returns true when ancestor is export_statement', () => {
|
||||
const exportStmt = mockNode('export_statement', 'export function foo() {}');
|
||||
const fnDecl = mockNode('function_declaration', 'function foo() {}', exportStmt);
|
||||
const nameNode = mockNode('identifier', 'foo', fnDecl);
|
||||
expect(isNodeExported(nameNode, 'foo', 'typescript')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-exported function', () => {
|
||||
const fnDecl = mockNode('function_declaration', 'function foo() {}');
|
||||
const nameNode = mockNode('identifier', 'foo', fnDecl);
|
||||
expect(isNodeExported(nameNode, 'foo', 'typescript')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when text starts with "export "', () => {
|
||||
const parent = mockNode('lexical_declaration', 'export const foo = 1');
|
||||
const nameNode = mockNode('identifier', 'foo', parent);
|
||||
expect(isNodeExported(nameNode, 'foo', 'typescript')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Python
|
||||
describe('python', () => {
|
||||
it('public function (no underscore prefix)', () => {
|
||||
const node = mockNode('identifier', 'public_function');
|
||||
expect(isNodeExported(node, 'public_function', 'python')).toBe(true);
|
||||
});
|
||||
|
||||
it('private function (underscore prefix)', () => {
|
||||
const node = mockNode('identifier', '_private_helper');
|
||||
expect(isNodeExported(node, '_private_helper', 'python')).toBe(false);
|
||||
});
|
||||
|
||||
it('dunder method is private', () => {
|
||||
const node = mockNode('identifier', '__init__');
|
||||
expect(isNodeExported(node, '__init__', 'python')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Go
|
||||
describe('go', () => {
|
||||
it('uppercase first letter is exported', () => {
|
||||
const node = mockNode('identifier', 'ExportedFunction');
|
||||
expect(isNodeExported(node, 'ExportedFunction', 'go')).toBe(true);
|
||||
});
|
||||
|
||||
it('lowercase first letter is unexported', () => {
|
||||
const node = mockNode('identifier', 'unexportedFunction');
|
||||
expect(isNodeExported(node, 'unexportedFunction', 'go')).toBe(false);
|
||||
});
|
||||
|
||||
it('empty name is not exported', () => {
|
||||
const node = mockNode('identifier', '');
|
||||
expect(isNodeExported(node, '', 'go')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Rust
|
||||
describe('rust', () => {
|
||||
it('pub function is exported', () => {
|
||||
const visMod = mockNode('visibility_modifier', 'pub');
|
||||
const fnDecl = mockNode('function_item', 'pub fn foo() {}', visMod);
|
||||
// For rust, isNodeExported walks up parents checking for visibility_modifier
|
||||
// The visMod is a parent of the nameNode
|
||||
const nameNode = mockNode('identifier', 'foo', visMod);
|
||||
expect(isNodeExported(nameNode, 'foo', 'rust')).toBe(true);
|
||||
});
|
||||
|
||||
it('non-pub function is not exported', () => {
|
||||
const fnDecl = mockNode('function_item', 'fn foo() {}');
|
||||
const nameNode = mockNode('identifier', 'foo', fnDecl);
|
||||
expect(isNodeExported(nameNode, 'foo', 'rust')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// PHP (hardening fix #20)
|
||||
describe('php', () => {
|
||||
it('top-level function is exported (globally accessible)', () => {
|
||||
// PHP: top-level functions fall through all checks and return true
|
||||
const program = mockNode('program', '<?php function topLevel() {}');
|
||||
const fnDecl = mockNode('function_definition', 'function topLevel() {}', program);
|
||||
const nameNode = mockNode('name', 'topLevel', fnDecl);
|
||||
expect(isNodeExported(nameNode, 'topLevel', 'php')).toBe(true);
|
||||
});
|
||||
|
||||
it('class declaration is exported', () => {
|
||||
const classDecl = mockNode('class_declaration', 'class Foo {}');
|
||||
const nameNode = mockNode('name', 'Foo', classDecl);
|
||||
expect(isNodeExported(nameNode, 'Foo', 'php')).toBe(true);
|
||||
});
|
||||
|
||||
it('public method has visibility_modifier = public', () => {
|
||||
const visMod = mockNode('visibility_modifier', 'public');
|
||||
const nameNode = mockNode('name', 'addUser', visMod);
|
||||
expect(isNodeExported(nameNode, 'addUser', 'php')).toBe(true);
|
||||
});
|
||||
|
||||
it('private method has visibility_modifier = private', () => {
|
||||
const visMod = mockNode('visibility_modifier', 'private');
|
||||
const nameNode = mockNode('name', 'validate', visMod);
|
||||
expect(isNodeExported(nameNode, 'validate', 'php')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Swift
|
||||
describe('swift', () => {
|
||||
it('public function is exported', () => {
|
||||
const visMod = mockNode('modifiers', 'public');
|
||||
const nameNode = mockNode('identifier', 'getCount', visMod);
|
||||
expect(isNodeExported(nameNode, 'getCount', 'swift')).toBe(true);
|
||||
});
|
||||
|
||||
it('open function is exported', () => {
|
||||
const visMod = mockNode('modifiers', 'open');
|
||||
const nameNode = mockNode('identifier', 'doStuff', visMod);
|
||||
expect(isNodeExported(nameNode, 'doStuff', 'swift')).toBe(true);
|
||||
});
|
||||
|
||||
it('non-public function is not exported', () => {
|
||||
const fnDecl = mockNode('function_declaration', 'func helper() {}');
|
||||
const nameNode = mockNode('identifier', 'helper', fnDecl);
|
||||
expect(isNodeExported(nameNode, 'helper', 'swift')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// C/C++
|
||||
describe('c/cpp', () => {
|
||||
it('C functions are never exported', () => {
|
||||
const node = mockNode('identifier', 'add');
|
||||
expect(isNodeExported(node, 'add', 'c')).toBe(false);
|
||||
});
|
||||
|
||||
it('C++ functions are never exported', () => {
|
||||
const node = mockNode('identifier', 'helperFunction');
|
||||
expect(isNodeExported(node, 'helperFunction', 'cpp')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// C#
|
||||
describe('csharp', () => {
|
||||
it('public modifier means exported', () => {
|
||||
const modifier = mockNode('modifier', 'public');
|
||||
const nameNode = mockNode('identifier', 'Add', modifier);
|
||||
expect(isNodeExported(nameNode, 'Add', 'csharp')).toBe(true);
|
||||
});
|
||||
|
||||
it('no public modifier means not exported', () => {
|
||||
const classDecl = mockNode('class_declaration', 'class Helper {}');
|
||||
const nameNode = mockNode('identifier', 'Helper', classDecl);
|
||||
expect(isNodeExported(nameNode, 'Helper', 'csharp')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Unknown language
|
||||
describe('unknown language', () => {
|
||||
it('returns false for unknown language', () => {
|
||||
const node = mockNode('identifier', 'foo');
|
||||
expect(isNodeExported(node, 'foo', 'unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fixture files exist ─────────────────────────────────────────────
|
||||
|
||||
describe('fixture files', () => {
|
||||
const fixtures = ['simple.ts', 'simple.py', 'simple.go', 'simple.swift',
|
||||
'simple.php', 'simple.rs', 'simple.java', 'simple.c', 'simple.cpp', 'simple.cs'];
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
it(`${fixture} exists and is non-empty`, async () => {
|
||||
const content = await fs.readFile(path.join(FIXTURES_DIR, fixture), 'utf-8');
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import type { PipelineProgress } from '../../src/types/pipeline.js';
|
||||
|
||||
const MINI_REPO = path.resolve(__dirname, '..', 'fixtures', 'mini-repo');
|
||||
|
||||
describe('pipeline end-to-end', () => {
|
||||
it('indexes a mini repo and produces a valid graph', async () => {
|
||||
const progressCalls: PipelineProgress[] = [];
|
||||
const onProgress = (p: PipelineProgress) => progressCalls.push(p);
|
||||
|
||||
const result = await runPipelineFromRepo(MINI_REPO, onProgress);
|
||||
|
||||
// --- Graph should have nodes ---
|
||||
expect(result.graph.nodeCount).toBeGreaterThan(0);
|
||||
expect(result.graph.relationshipCount).toBeGreaterThan(0);
|
||||
|
||||
// --- Should find the 5 TypeScript files ---
|
||||
expect(result.totalFileCount).toBe(5);
|
||||
|
||||
// --- Verify File nodes exist for each source file ---
|
||||
const fileNodes: string[] = [];
|
||||
result.graph.forEachNode(n => {
|
||||
if (n.label === 'File') fileNodes.push(n.properties.filePath || n.properties.name);
|
||||
});
|
||||
expect(fileNodes).toContain('src/handler.ts');
|
||||
expect(fileNodes).toContain('src/validator.ts');
|
||||
expect(fileNodes).toContain('src/db.ts');
|
||||
expect(fileNodes).toContain('src/formatter.ts');
|
||||
expect(fileNodes).toContain('src/index.ts');
|
||||
|
||||
// --- Verify symbol nodes were created (functions, classes) ---
|
||||
const symbolNames: string[] = [];
|
||||
result.graph.forEachNode(n => {
|
||||
if (['Function', 'Method', 'Class', 'Interface'].includes(n.label)) {
|
||||
symbolNames.push(n.properties.name);
|
||||
}
|
||||
});
|
||||
expect(symbolNames).toContain('handleRequest');
|
||||
expect(symbolNames).toContain('validateInput');
|
||||
expect(symbolNames).toContain('saveToDb');
|
||||
expect(symbolNames).toContain('formatResponse');
|
||||
expect(symbolNames).toContain('RequestHandler');
|
||||
|
||||
// --- Verify relationships exist ---
|
||||
const relTypes = new Set<string>();
|
||||
for (const rel of result.graph.iterRelationships()) {
|
||||
relTypes.add(rel.type);
|
||||
}
|
||||
// Should have at least CONTAINS (structure) and CALLS (call graph)
|
||||
expect(relTypes).toContain('CONTAINS');
|
||||
|
||||
// --- Verify CALLS edges were detected ---
|
||||
const callEdges: { source: string; target: string }[] = [];
|
||||
for (const rel of result.graph.iterRelationships()) {
|
||||
if (rel.type === 'CALLS') {
|
||||
const sourceNode = result.graph.getNode(rel.sourceId);
|
||||
const targetNode = result.graph.getNode(rel.targetId);
|
||||
if (sourceNode && targetNode) {
|
||||
callEdges.push({
|
||||
source: sourceNode.properties.name,
|
||||
target: targetNode.properties.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(callEdges.length).toBeGreaterThan(0);
|
||||
|
||||
// handleRequest should call validateInput, saveToDb, formatResponse
|
||||
const handleRequestCalls = callEdges.filter(e => e.source === 'handleRequest');
|
||||
const calledByHandler = handleRequestCalls.map(e => e.target);
|
||||
expect(calledByHandler).toContain('validateInput');
|
||||
expect(calledByHandler).toContain('saveToDb');
|
||||
expect(calledByHandler).toContain('formatResponse');
|
||||
|
||||
// --- Verify IMPORTS edges ---
|
||||
let importsCount = 0;
|
||||
for (const rel of result.graph.iterRelationships()) {
|
||||
if (rel.type === 'IMPORTS') importsCount++;
|
||||
}
|
||||
expect(importsCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('detects communities', async () => {
|
||||
const result = await runPipelineFromRepo(MINI_REPO, () => {});
|
||||
|
||||
expect(result.communityResult).toBeDefined();
|
||||
expect(result.communityResult.stats.totalCommunities).toBeGreaterThan(0);
|
||||
|
||||
// Community nodes should be in the graph
|
||||
const communityNodes: string[] = [];
|
||||
result.graph.forEachNode(n => {
|
||||
if (n.label === 'Community') communityNodes.push(n.properties.name);
|
||||
});
|
||||
expect(communityNodes.length).toBeGreaterThan(0);
|
||||
|
||||
// MEMBER_OF relationships should exist
|
||||
let memberOfCount = 0;
|
||||
for (const rel of result.graph.iterRelationships()) {
|
||||
if (rel.type === 'MEMBER_OF') memberOfCount++;
|
||||
}
|
||||
expect(memberOfCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('detects execution flows (processes)', async () => {
|
||||
const result = await runPipelineFromRepo(MINI_REPO, () => {});
|
||||
|
||||
expect(result.processResult).toBeDefined();
|
||||
|
||||
// With a 4-function call chain (handler -> validator -> db -> formatter),
|
||||
// there should be at least one process detected
|
||||
if (result.processResult.stats.totalProcesses > 0) {
|
||||
const process = result.processResult.processes[0];
|
||||
|
||||
// Each process should have valid structure
|
||||
expect(process.id).toBeTruthy();
|
||||
expect(process.stepCount).toBeGreaterThanOrEqual(3); // minSteps default
|
||||
expect(process.trace.length).toBe(process.stepCount);
|
||||
expect(process.entryPointId).toBeTruthy();
|
||||
expect(process.terminalId).toBeTruthy();
|
||||
expect(process.processType).toMatch(/^(intra_community|cross_community)$/);
|
||||
|
||||
// Process nodes should be in the graph
|
||||
const processNode = result.graph.getNode(process.id);
|
||||
expect(processNode).toBeDefined();
|
||||
expect(processNode!.label).toBe('Process');
|
||||
|
||||
// STEP_IN_PROCESS relationships should exist
|
||||
let stepCount = 0;
|
||||
for (const rel of result.graph.iterRelationships()) {
|
||||
if (rel.type === 'STEP_IN_PROCESS' && rel.targetId === process.id) {
|
||||
stepCount++;
|
||||
expect(rel.step).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
expect(stepCount).toBe(process.stepCount);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports progress through all 6 phases', async () => {
|
||||
const phases = new Set<string>();
|
||||
const onProgress = (p: PipelineProgress) => phases.add(p.phase);
|
||||
|
||||
await runPipelineFromRepo(MINI_REPO, onProgress);
|
||||
|
||||
expect(phases).toContain('extracting');
|
||||
expect(phases).toContain('structure');
|
||||
expect(phases).toContain('parsing');
|
||||
expect(phases).toContain('communities');
|
||||
expect(phases).toContain('processes');
|
||||
expect(phases).toContain('complete');
|
||||
});
|
||||
|
||||
it('returns correct repoPath in result', async () => {
|
||||
const result = await runPipelineFromRepo(MINI_REPO, () => {});
|
||||
expect(result.repoPath).toBe(MINI_REPO);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js';
|
||||
import { LANGUAGE_QUERIES } from '../../src/core/ingestion/tree-sitter-queries.js';
|
||||
import { SupportedLanguages } from '../../src/config/supported-languages.js';
|
||||
import Parser from 'tree-sitter';
|
||||
|
||||
const fixturesDir = path.resolve(__dirname, '..', 'fixtures', 'sample-code');
|
||||
|
||||
function readFixture(filename: string): string {
|
||||
return fs.readFileSync(path.join(fixturesDir, filename), 'utf-8');
|
||||
}
|
||||
|
||||
function parseAndQuery(parser: Parser, content: string, queryStr: string) {
|
||||
const tree = parser.parse(content);
|
||||
const lang = parser.getLanguage();
|
||||
const query = new Parser.Query(lang, queryStr);
|
||||
const matches = query.matches(tree.rootNode);
|
||||
return { tree, matches };
|
||||
}
|
||||
|
||||
function extractDefinitions(matches: any[]) {
|
||||
const defs: { type: string; name: string }[] = [];
|
||||
for (const match of matches) {
|
||||
for (const capture of match.captures) {
|
||||
if (capture.name === 'name' && match.captures.some((c: any) =>
|
||||
c.name.startsWith('definition.'))) {
|
||||
const defType = match.captures.find((c: any) => c.name.startsWith('definition.'))!.name;
|
||||
defs.push({ type: defType, name: capture.node.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
return defs;
|
||||
}
|
||||
|
||||
describe('Tree-sitter multi-language parsing', () => {
|
||||
let parser: Parser;
|
||||
|
||||
beforeAll(async () => {
|
||||
parser = await loadParser();
|
||||
});
|
||||
|
||||
describe('TypeScript', () => {
|
||||
it('parses functions, classes, interfaces, methods, and arrow functions', async () => {
|
||||
await loadLanguage(SupportedLanguages.TypeScript, 'simple.ts');
|
||||
const content = readFixture('simple.ts');
|
||||
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.TypeScript]);
|
||||
const defs = extractDefinitions(matches);
|
||||
|
||||
const defTypes = defs.map(d => d.type);
|
||||
expect(defTypes).toContain('definition.class');
|
||||
expect(defTypes).toContain('definition.function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TSX', () => {
|
||||
it('parses JSX components with tsx grammar', async () => {
|
||||
await loadLanguage(SupportedLanguages.TypeScript, 'simple.tsx');
|
||||
const content = readFixture('simple.tsx');
|
||||
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.TypeScript]);
|
||||
const defs = extractDefinitions(matches);
|
||||
|
||||
expect(defs.length).toBeGreaterThan(0);
|
||||
// Should detect Counter class and Button/useCounter functions
|
||||
const names = defs.map(d => d.name);
|
||||
expect(names).toContain('Counter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JavaScript', () => {
|
||||
it('parses class and function declarations', async () => {
|
||||
await loadLanguage(SupportedLanguages.JavaScript);
|
||||
const content = readFixture('simple.js');
|
||||
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.JavaScript]);
|
||||
const defs = extractDefinitions(matches);
|
||||
|
||||
expect(defs.length).toBeGreaterThan(0);
|
||||
const names = defs.map(d => d.name);
|
||||
expect(names).toContain('EventEmitter');
|
||||
expect(names).toContain('createLogger');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Python', () => {
|
||||
it('parses class and function definitions', async () => {
|
||||
await loadLanguage(SupportedLanguages.Python);
|
||||
const content = readFixture('simple.py');
|
||||
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.Python]);
|
||||
const defs = extractDefinitions(matches);
|
||||
|
||||
const defTypes = defs.map(d => d.type);
|
||||
expect(defTypes).toContain('definition.class');
|
||||
expect(defTypes).toContain('definition.function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Java', () => {
|
||||
it('parses class, method, and constructor declarations', async () => {
|
||||
await loadLanguage(SupportedLanguages.Java);
|
||||
const content = readFixture('simple.java');
|
||||
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.Java]);
|
||||
const defs = extractDefinitions(matches);
|
||||
|
||||
expect(defs.length).toBeGreaterThan(0);
|
||||
const defTypes = defs.map(d => d.type);
|
||||
expect(defTypes).toContain('definition.class');
|
||||
expect(defTypes).toContain('definition.method');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go', () => {
|
||||
it('parses function and type declarations', async () => {
|
||||
await loadLanguage(SupportedLanguages.Go);
|
||||
const content = readFixture('simple.go');
|
||||
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.Go]);
|
||||
const defs = extractDefinitions(matches);
|
||||
|
||||
expect(defs.length).toBeGreaterThan(0);
|
||||
const defTypes = defs.map(d => d.type);
|
||||
expect(defTypes).toContain('definition.function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C', () => {
|
||||
it('parses function definitions and structs', async () => {
|
||||
await loadLanguage(SupportedLanguages.C);
|
||||
const content = readFixture('simple.c');
|
||||
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.C]);
|
||||
const defs = extractDefinitions(matches);
|
||||
|
||||
expect(defs.length).toBeGreaterThan(0);
|
||||
const defTypes = defs.map(d => d.type);
|
||||
expect(defTypes).toContain('definition.function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C++', () => {
|
||||
it('parses class, function, and namespace declarations', async () => {
|
||||
await loadLanguage(SupportedLanguages.CPlusPlus);
|
||||
const content = readFixture('simple.cpp');
|
||||
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.CPlusPlus]);
|
||||
const defs = extractDefinitions(matches);
|
||||
|
||||
expect(defs.length).toBeGreaterThan(0);
|
||||
const defTypes = defs.map(d => d.type);
|
||||
expect(defTypes).toContain('definition.class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C#', () => {
|
||||
it('parses class, method, and property declarations', async () => {
|
||||
await loadLanguage(SupportedLanguages.CSharp);
|
||||
const content = readFixture('simple.cs');
|
||||
try {
|
||||
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.CSharp]);
|
||||
const defs = extractDefinitions(matches);
|
||||
expect(defs.length).toBeGreaterThan(0);
|
||||
} catch (e: any) {
|
||||
// Some tree-sitter-c-sharp versions don't support all query node types
|
||||
expect(e.message).toContain('TSQueryError');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rust', () => {
|
||||
it('parses fn, struct, impl, trait, and enum', async () => {
|
||||
await loadLanguage(SupportedLanguages.Rust);
|
||||
const content = readFixture('simple.rs');
|
||||
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.Rust]);
|
||||
const defs = extractDefinitions(matches);
|
||||
|
||||
expect(defs.length).toBeGreaterThan(0);
|
||||
const defTypes = defs.map(d => d.type);
|
||||
expect(defTypes).toContain('definition.function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PHP', () => {
|
||||
it('parses class, function, and method declarations', async () => {
|
||||
await loadLanguage(SupportedLanguages.PHP);
|
||||
const content = readFixture('simple.php');
|
||||
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.PHP]);
|
||||
const defs = extractDefinitions(matches);
|
||||
|
||||
expect(defs.length).toBeGreaterThan(0);
|
||||
const defTypes = defs.map(d => d.type);
|
||||
expect(defTypes).toContain('definition.class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Swift', () => {
|
||||
it('parses class, struct, protocol, and function if tree-sitter-swift is available', async () => {
|
||||
try {
|
||||
await loadLanguage(SupportedLanguages.Swift);
|
||||
} catch {
|
||||
// tree-sitter-swift not installed — skip
|
||||
return;
|
||||
}
|
||||
|
||||
const content = readFixture('simple.swift');
|
||||
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.Swift]);
|
||||
const defs = extractDefinitions(matches);
|
||||
|
||||
expect(defs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('gracefully handles missing tree-sitter-swift', async () => {
|
||||
// If Swift is NOT available, loadLanguage should throw
|
||||
// If it IS available, this test just passes
|
||||
try {
|
||||
await loadLanguage(SupportedLanguages.Swift);
|
||||
} catch (e: any) {
|
||||
expect(e.message).toContain('Unsupported language');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-language assertions', () => {
|
||||
it('all supported languages produce at least one definition from fixtures', async () => {
|
||||
const langFixtures: [SupportedLanguages, string, string?][] = [
|
||||
[SupportedLanguages.TypeScript, 'simple.ts'],
|
||||
[SupportedLanguages.JavaScript, 'simple.js'],
|
||||
[SupportedLanguages.Python, 'simple.py'],
|
||||
[SupportedLanguages.Java, 'simple.java'],
|
||||
[SupportedLanguages.Go, 'simple.go'],
|
||||
[SupportedLanguages.C, 'simple.c'],
|
||||
[SupportedLanguages.CPlusPlus, 'simple.cpp'],
|
||||
[SupportedLanguages.CSharp, 'simple.cs'],
|
||||
[SupportedLanguages.Rust, 'simple.rs'],
|
||||
[SupportedLanguages.PHP, 'simple.php'],
|
||||
];
|
||||
|
||||
for (const [lang, fixture, filePath] of langFixtures) {
|
||||
await loadLanguage(lang, filePath || fixture);
|
||||
const content = readFixture(fixture);
|
||||
try {
|
||||
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[lang]);
|
||||
const defs = extractDefinitions(matches);
|
||||
expect(defs.length, `${lang} (${fixture}) should have definitions`).toBeGreaterThan(0);
|
||||
} catch (e: any) {
|
||||
// Some grammars may have query compatibility issues
|
||||
if (!e.message?.includes('TSQueryError')) throw e;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { generateAIContextFiles } from '../../src/cli/ai-context.js';
|
||||
|
||||
describe('generateAIContextFiles', () => {
|
||||
let tmpDir: string;
|
||||
let storagePath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-test-'));
|
||||
storagePath = path.join(tmpDir, '.gitnexus');
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
it('generates context files', async () => {
|
||||
const stats = {
|
||||
nodes: 100,
|
||||
edges: 200,
|
||||
processes: 10,
|
||||
};
|
||||
|
||||
const result = await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
||||
expect(result.files).toBeDefined();
|
||||
expect(result.files.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('creates or updates CLAUDE.md with GitNexus section', async () => {
|
||||
const stats = { nodes: 50, edges: 100, processes: 5 };
|
||||
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
||||
|
||||
const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
|
||||
const content = await fs.readFile(claudeMdPath, 'utf-8');
|
||||
expect(content).toContain('gitnexus:start');
|
||||
expect(content).toContain('gitnexus:end');
|
||||
expect(content).toContain('TestProject');
|
||||
});
|
||||
|
||||
it('handles empty stats', async () => {
|
||||
const stats = {};
|
||||
const result = await generateAIContextFiles(tmpDir, storagePath, 'EmptyProject', stats);
|
||||
expect(result.files).toBeDefined();
|
||||
});
|
||||
|
||||
it('updates existing CLAUDE.md without duplicating', async () => {
|
||||
const stats = { nodes: 10 };
|
||||
|
||||
// Run twice
|
||||
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
||||
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
||||
|
||||
const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
|
||||
const content = await fs.readFile(claudeMdPath, 'utf-8');
|
||||
|
||||
// Should only have one gitnexus section
|
||||
const starts = (content.match(/gitnexus:start/g) || []).length;
|
||||
expect(starts).toBe(1);
|
||||
});
|
||||
|
||||
it('installs skills files', async () => {
|
||||
const stats = { nodes: 10 };
|
||||
const result = await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
||||
|
||||
// Should have installed skill files
|
||||
const skillsDir = path.join(tmpDir, '.claude', 'skills', 'gitnexus');
|
||||
try {
|
||||
const entries = await fs.readdir(skillsDir, { recursive: true });
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
} catch {
|
||||
// Skills dir may not be created if skills source doesn't exist in test context
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createASTCache, type ASTCache } from '../../src/core/ingestion/ast-cache.js';
|
||||
|
||||
// Create a minimal mock tree object (mimics Parser.Tree interface)
|
||||
function mockTree(id: string): any {
|
||||
return { rootNode: { type: 'program', text: id }, delete: vi.fn() };
|
||||
}
|
||||
|
||||
describe('ASTCache', () => {
|
||||
let cache: ASTCache;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = createASTCache(3);
|
||||
});
|
||||
|
||||
describe('get / set', () => {
|
||||
it('returns undefined for cache miss', () => {
|
||||
expect(cache.get('nonexistent.ts')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns cached tree on hit', () => {
|
||||
const tree = mockTree('test');
|
||||
cache.set('src/index.ts', tree);
|
||||
expect(cache.get('src/index.ts')).toBe(tree);
|
||||
});
|
||||
|
||||
it('overwrites existing entry for same key', () => {
|
||||
const tree1 = mockTree('v1');
|
||||
const tree2 = mockTree('v2');
|
||||
cache.set('src/index.ts', tree1);
|
||||
cache.set('src/index.ts', tree2);
|
||||
expect(cache.get('src/index.ts')).toBe(tree2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LRU eviction', () => {
|
||||
it('evicts least recently used when capacity exceeded', () => {
|
||||
cache.set('a.ts', mockTree('a'));
|
||||
cache.set('b.ts', mockTree('b'));
|
||||
cache.set('c.ts', mockTree('c'));
|
||||
// Cache is full (maxSize=3). Adding one more evicts 'a'
|
||||
cache.set('d.ts', mockTree('d'));
|
||||
expect(cache.get('a.ts')).toBeUndefined();
|
||||
expect(cache.get('b.ts')).toBeDefined();
|
||||
expect(cache.get('d.ts')).toBeDefined();
|
||||
});
|
||||
|
||||
it('accessing an entry makes it recently used', () => {
|
||||
cache.set('a.ts', mockTree('a'));
|
||||
cache.set('b.ts', mockTree('b'));
|
||||
cache.set('c.ts', mockTree('c'));
|
||||
// Touch 'a' to make it recently used
|
||||
cache.get('a.ts');
|
||||
// Now 'b' is LRU
|
||||
cache.set('d.ts', mockTree('d'));
|
||||
expect(cache.get('a.ts')).toBeDefined();
|
||||
expect(cache.get('b.ts')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('removes all entries', () => {
|
||||
cache.set('a.ts', mockTree('a'));
|
||||
cache.set('b.ts', mockTree('b'));
|
||||
cache.clear();
|
||||
expect(cache.get('a.ts')).toBeUndefined();
|
||||
expect(cache.get('b.ts')).toBeUndefined();
|
||||
expect(cache.stats().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stats', () => {
|
||||
it('reports size and maxSize', () => {
|
||||
expect(cache.stats()).toEqual({ size: 0, maxSize: 3 });
|
||||
cache.set('a.ts', mockTree('a'));
|
||||
expect(cache.stats()).toEqual({ size: 1, maxSize: 3 });
|
||||
cache.set('b.ts', mockTree('b'));
|
||||
expect(cache.stats()).toEqual({ size: 2, maxSize: 3 });
|
||||
});
|
||||
|
||||
it('uses default maxSize of 50', () => {
|
||||
const defaultCache = createASTCache();
|
||||
expect(defaultCache.stats().maxSize).toBe(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { searchFTSFromKuzu, type BM25SearchResult } from '../../src/core/search/bm25-index.js';
|
||||
|
||||
describe('BM25 search', () => {
|
||||
describe('searchFTSFromKuzu', () => {
|
||||
it('returns empty array when KuzuDB is not initialized', async () => {
|
||||
// Without KuzuDB init, search should return empty (not crash)
|
||||
const results = await searchFTSFromKuzu('test query');
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles empty query', async () => {
|
||||
const results = await searchFTSFromKuzu('');
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts custom limit parameter', async () => {
|
||||
const results = await searchFTSFromKuzu('test', 5);
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BM25SearchResult type', () => {
|
||||
it('has correct shape', () => {
|
||||
const result: BM25SearchResult = {
|
||||
filePath: 'src/index.ts',
|
||||
score: 1.5,
|
||||
rank: 1,
|
||||
};
|
||||
expect(result.filePath).toBe('src/index.ts');
|
||||
expect(result.score).toBe(1.5);
|
||||
expect(result.rank).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { processCallsFromExtracted } from '../../src/core/ingestion/call-processor.js';
|
||||
import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js';
|
||||
import { createImportMap, type ImportMap } from '../../src/core/ingestion/import-processor.js';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import type { ExtractedCall } from '../../src/core/ingestion/workers/parse-worker.js';
|
||||
|
||||
describe('processCallsFromExtracted', () => {
|
||||
let graph: ReturnType<typeof createKnowledgeGraph>;
|
||||
let symbolTable: ReturnType<typeof createSymbolTable>;
|
||||
let importMap: ImportMap;
|
||||
|
||||
beforeEach(() => {
|
||||
graph = createKnowledgeGraph();
|
||||
symbolTable = createSymbolTable();
|
||||
importMap = createImportMap();
|
||||
});
|
||||
|
||||
it('creates CALLS relationship for same-file resolution', async () => {
|
||||
symbolTable.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function');
|
||||
|
||||
const calls: ExtractedCall[] = [{
|
||||
filePath: 'src/index.ts',
|
||||
calledName: 'helper',
|
||||
sourceId: 'Function:src/index.ts:main',
|
||||
}];
|
||||
|
||||
await processCallsFromExtracted(graph, calls, symbolTable, importMap);
|
||||
|
||||
const rels = graph.relationships.filter(r => r.type === 'CALLS');
|
||||
expect(rels).toHaveLength(1);
|
||||
expect(rels[0].sourceId).toBe('Function:src/index.ts:main');
|
||||
expect(rels[0].targetId).toBe('Function:src/index.ts:helper');
|
||||
expect(rels[0].confidence).toBe(0.85);
|
||||
expect(rels[0].reason).toBe('same-file');
|
||||
});
|
||||
|
||||
it('creates CALLS relationship for import-resolved resolution', async () => {
|
||||
symbolTable.add('src/utils.ts', 'format', 'Function:src/utils.ts:format', 'Function');
|
||||
importMap.set('src/index.ts', new Set(['src/utils.ts']));
|
||||
|
||||
const calls: ExtractedCall[] = [{
|
||||
filePath: 'src/index.ts',
|
||||
calledName: 'format',
|
||||
sourceId: 'Function:src/index.ts:main',
|
||||
}];
|
||||
|
||||
await processCallsFromExtracted(graph, calls, symbolTable, importMap);
|
||||
|
||||
const rels = graph.relationships.filter(r => r.type === 'CALLS');
|
||||
expect(rels).toHaveLength(1);
|
||||
expect(rels[0].confidence).toBe(0.9);
|
||||
expect(rels[0].reason).toBe('import-resolved');
|
||||
});
|
||||
|
||||
it('uses fuzzy-global with higher confidence for unique symbols', async () => {
|
||||
symbolTable.add('src/other.ts', 'uniqueFunc', 'Function:src/other.ts:uniqueFunc', 'Function');
|
||||
|
||||
const calls: ExtractedCall[] = [{
|
||||
filePath: 'src/index.ts',
|
||||
calledName: 'uniqueFunc',
|
||||
sourceId: 'Function:src/index.ts:main',
|
||||
}];
|
||||
|
||||
await processCallsFromExtracted(graph, calls, symbolTable, importMap);
|
||||
|
||||
const rels = graph.relationships.filter(r => r.type === 'CALLS');
|
||||
expect(rels).toHaveLength(1);
|
||||
expect(rels[0].confidence).toBe(0.5);
|
||||
expect(rels[0].reason).toBe('fuzzy-global');
|
||||
});
|
||||
|
||||
it('uses lower confidence for ambiguous fuzzy-global symbols', async () => {
|
||||
symbolTable.add('src/a.ts', 'render', 'Function:src/a.ts:render', 'Function');
|
||||
symbolTable.add('src/b.ts', 'render', 'Function:src/b.ts:render', 'Function');
|
||||
|
||||
const calls: ExtractedCall[] = [{
|
||||
filePath: 'src/index.ts',
|
||||
calledName: 'render',
|
||||
sourceId: 'Function:src/index.ts:main',
|
||||
}];
|
||||
|
||||
await processCallsFromExtracted(graph, calls, symbolTable, importMap);
|
||||
|
||||
const rels = graph.relationships.filter(r => r.type === 'CALLS');
|
||||
expect(rels).toHaveLength(1);
|
||||
expect(rels[0].confidence).toBe(0.3);
|
||||
});
|
||||
|
||||
it('skips unresolvable calls', async () => {
|
||||
const calls: ExtractedCall[] = [{
|
||||
filePath: 'src/index.ts',
|
||||
calledName: 'nonExistent',
|
||||
sourceId: 'Function:src/index.ts:main',
|
||||
}];
|
||||
|
||||
await processCallsFromExtracted(graph, calls, symbolTable, importMap);
|
||||
expect(graph.relationshipCount).toBe(0);
|
||||
});
|
||||
|
||||
it('prefers same-file over import-resolved', async () => {
|
||||
// Symbol exists both locally and in imported file
|
||||
symbolTable.add('src/index.ts', 'render', 'Function:src/index.ts:render', 'Function');
|
||||
symbolTable.add('src/utils.ts', 'render', 'Function:src/utils.ts:render', 'Function');
|
||||
importMap.set('src/index.ts', new Set(['src/utils.ts']));
|
||||
|
||||
const calls: ExtractedCall[] = [{
|
||||
filePath: 'src/index.ts',
|
||||
calledName: 'render',
|
||||
sourceId: 'Function:src/index.ts:main',
|
||||
}];
|
||||
|
||||
await processCallsFromExtracted(graph, calls, symbolTable, importMap);
|
||||
|
||||
const rels = graph.relationships.filter(r => r.type === 'CALLS');
|
||||
expect(rels).toHaveLength(1);
|
||||
// Same-file resolution takes priority
|
||||
expect(rels[0].targetId).toBe('Function:src/index.ts:render');
|
||||
expect(rels[0].reason).toBe('same-file');
|
||||
});
|
||||
|
||||
it('handles multiple calls from the same file', async () => {
|
||||
symbolTable.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function');
|
||||
symbolTable.add('src/index.ts', 'bar', 'Function:src/index.ts:bar', 'Function');
|
||||
|
||||
const calls: ExtractedCall[] = [
|
||||
{ filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' },
|
||||
{ filePath: 'src/index.ts', calledName: 'bar', sourceId: 'Function:src/index.ts:main' },
|
||||
];
|
||||
|
||||
await processCallsFromExtracted(graph, calls, symbolTable, importMap);
|
||||
expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('calls progress callback', async () => {
|
||||
symbolTable.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function');
|
||||
|
||||
const calls: ExtractedCall[] = [
|
||||
{ filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' },
|
||||
];
|
||||
|
||||
const onProgress = vi.fn();
|
||||
await processCallsFromExtracted(graph, calls, symbolTable, importMap, onProgress);
|
||||
|
||||
// Final progress call
|
||||
expect(onProgress).toHaveBeenCalledWith(1, 1);
|
||||
});
|
||||
|
||||
it('handles empty calls array', async () => {
|
||||
await processCallsFromExtracted(graph, [], symbolTable, importMap);
|
||||
expect(graph.relationshipCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,582 @@
|
||||
/**
|
||||
* Unit Tests: LocalBackend callTool dispatch & lifecycle
|
||||
*
|
||||
* Tests the callTool dispatch logic, resolveRepo, init/disconnect,
|
||||
* error cases, and silent failure patterns — all with mocked KuzuDB.
|
||||
*
|
||||
* These are pure unit tests that mock the KuzuDB layer to test
|
||||
* the dispatch and error handling logic in isolation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// We need to mock the KuzuDB adapter and repo-manager BEFORE importing LocalBackend
|
||||
vi.mock('../../src/mcp/core/kuzu-adapter.js', () => ({
|
||||
initKuzu: vi.fn().mockResolvedValue(undefined),
|
||||
executeQuery: vi.fn().mockResolvedValue([]),
|
||||
executeParameterized: vi.fn().mockResolvedValue([]),
|
||||
closeKuzu: vi.fn().mockResolvedValue(undefined),
|
||||
isKuzuReady: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
// Also mock the search modules to avoid loading onnxruntime
|
||||
vi.mock('../../src/core/search/bm25-index.js', () => ({
|
||||
searchFTSFromKuzu: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/mcp/core/embedder.js', () => ({
|
||||
embedQuery: vi.fn().mockResolvedValue([]),
|
||||
getEmbeddingDims: vi.fn().mockReturnValue(384),
|
||||
}));
|
||||
|
||||
import { LocalBackend, isWriteQuery, CYPHER_WRITE_RE } from '../../src/mcp/local/local-backend.js';
|
||||
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
||||
import { initKuzu, executeQuery, executeParameterized, isKuzuReady, closeKuzu } from '../../src/mcp/core/kuzu-adapter.js';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_REPO_ENTRY = {
|
||||
name: 'test-project',
|
||||
path: '/tmp/test-project',
|
||||
storagePath: '/tmp/.gitnexus/test-project',
|
||||
indexedAt: '2024-06-01T12:00:00Z',
|
||||
lastCommit: 'abc1234567890',
|
||||
stats: { files: 10, nodes: 50, edges: 100, communities: 3, processes: 5 },
|
||||
};
|
||||
|
||||
function setupSingleRepo() {
|
||||
(listRegisteredRepos as any).mockResolvedValue([MOCK_REPO_ENTRY]);
|
||||
}
|
||||
|
||||
function setupMultipleRepos() {
|
||||
(listRegisteredRepos as any).mockResolvedValue([
|
||||
MOCK_REPO_ENTRY,
|
||||
{
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'other-project',
|
||||
path: '/tmp/other-project',
|
||||
storagePath: '/tmp/.gitnexus/other-project',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function setupNoRepos() {
|
||||
(listRegisteredRepos as any).mockResolvedValue([]);
|
||||
}
|
||||
|
||||
// ─── LocalBackend lifecycle ──────────────────────────────────────────
|
||||
|
||||
describe('LocalBackend.init', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeEach(() => {
|
||||
backend = new LocalBackend();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns true when repos are available', async () => {
|
||||
setupSingleRepo();
|
||||
const result = await backend.init();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no repos are registered', async () => {
|
||||
setupNoRepos();
|
||||
const result = await backend.init();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('calls listRegisteredRepos with validate: true', async () => {
|
||||
setupSingleRepo();
|
||||
await backend.init();
|
||||
expect(listRegisteredRepos).toHaveBeenCalledWith({ validate: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('LocalBackend.disconnect', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeEach(() => {
|
||||
backend = new LocalBackend();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not throw when no repos are initialized', async () => {
|
||||
setupNoRepos();
|
||||
await backend.init();
|
||||
await expect(backend.disconnect()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('calls closeKuzu on disconnect', async () => {
|
||||
setupSingleRepo();
|
||||
await backend.init();
|
||||
await backend.disconnect();
|
||||
expect(closeKuzu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── callTool dispatch ───────────────────────────────────────────────
|
||||
|
||||
describe('LocalBackend.callTool', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
backend = new LocalBackend();
|
||||
setupSingleRepo();
|
||||
await backend.init();
|
||||
});
|
||||
|
||||
it('routes list_repos without needing repo param', async () => {
|
||||
const result = await backend.callTool('list_repos', {});
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result[0].name).toBe('test-project');
|
||||
});
|
||||
|
||||
it('throws for unknown tool name', async () => {
|
||||
await expect(backend.callTool('nonexistent_tool', {}))
|
||||
.rejects.toThrow('Unknown tool: nonexistent_tool');
|
||||
});
|
||||
|
||||
it('dispatches query tool', async () => {
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
const result = await backend.callTool('query', { query: 'auth' });
|
||||
expect(result).toHaveProperty('processes');
|
||||
expect(result).toHaveProperty('definitions');
|
||||
});
|
||||
|
||||
it('query tool returns error for empty query', async () => {
|
||||
const result = await backend.callTool('query', { query: '' });
|
||||
expect(result.error).toContain('query parameter is required');
|
||||
});
|
||||
|
||||
it('query tool returns error for whitespace-only query', async () => {
|
||||
const result = await backend.callTool('query', { query: ' ' });
|
||||
expect(result.error).toContain('query parameter is required');
|
||||
});
|
||||
|
||||
it('dispatches cypher tool and blocks write queries', async () => {
|
||||
const result = await backend.callTool('cypher', { query: 'CREATE (n:Test)' });
|
||||
expect(result).toHaveProperty('error');
|
||||
expect(result.error).toContain('Write operations');
|
||||
});
|
||||
|
||||
it('dispatches cypher tool with valid read query', async () => {
|
||||
(executeQuery as any).mockResolvedValue([
|
||||
{ name: 'test', filePath: 'src/test.ts' },
|
||||
]);
|
||||
const result = await backend.callTool('cypher', {
|
||||
query: 'MATCH (n:Function) RETURN n.name AS name, n.filePath AS filePath LIMIT 5',
|
||||
});
|
||||
// formatCypherAsMarkdown returns { markdown, row_count } for tabular results
|
||||
expect(result).toHaveProperty('markdown');
|
||||
expect(result).toHaveProperty('row_count');
|
||||
expect(result.row_count).toBe(1);
|
||||
});
|
||||
|
||||
it('dispatches context tool', async () => {
|
||||
(executeParameterized as any).mockResolvedValue([
|
||||
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts', startLine: 1, endLine: 10 },
|
||||
]);
|
||||
const result = await backend.callTool('context', { name: 'main' });
|
||||
expect(result.status).toBe('found');
|
||||
expect(result.symbol.name).toBe('main');
|
||||
});
|
||||
|
||||
it('context tool returns error when name and uid are both missing', async () => {
|
||||
const result = await backend.callTool('context', {});
|
||||
expect(result.error).toContain('Either "name" or "uid"');
|
||||
});
|
||||
|
||||
it('context tool returns not-found for missing symbol', async () => {
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
const result = await backend.callTool('context', { name: 'doesNotExist' });
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('context tool returns disambiguation for multiple matches', async () => {
|
||||
(executeParameterized as any).mockResolvedValue([
|
||||
{ id: 'func:main:1', name: 'main', type: 'Function', filePath: 'src/a.ts', startLine: 1, endLine: 5 },
|
||||
{ id: 'func:main:2', name: 'main', type: 'Function', filePath: 'src/b.ts', startLine: 1, endLine: 5 },
|
||||
]);
|
||||
const result = await backend.callTool('context', { name: 'main' });
|
||||
expect(result.status).toBe('ambiguous');
|
||||
expect(result.candidates).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('dispatches impact tool', async () => {
|
||||
// impact() calls executeParameterized to find target, then executeQuery for traversal
|
||||
(executeParameterized as any).mockResolvedValue([
|
||||
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||||
]);
|
||||
(executeQuery as any).mockResolvedValue([]);
|
||||
|
||||
const result = await backend.callTool('impact', { target: 'main', direction: 'upstream' });
|
||||
expect(result).toBeDefined();
|
||||
expect(result.target).toBeDefined();
|
||||
});
|
||||
|
||||
it('dispatches detect_changes tool', async () => {
|
||||
// detect_changes calls execFileSync which we haven't mocked at module level,
|
||||
// so it will throw a git error — that's fine, we test the error path
|
||||
const result = await backend.callTool('detect_changes', { scope: 'unstaged' });
|
||||
// Should either return changes or a git error
|
||||
expect(result).toBeDefined();
|
||||
expect(result.error || result.summary).toBeDefined();
|
||||
});
|
||||
|
||||
it('dispatches rename tool', async () => {
|
||||
(executeParameterized as any)
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'func:oldName', name: 'oldName', type: 'Function', filePath: 'src/test.ts', startLine: 1, endLine: 5 },
|
||||
])
|
||||
.mockResolvedValue([]);
|
||||
|
||||
const result = await backend.callTool('rename', {
|
||||
symbol_name: 'oldName',
|
||||
new_name: 'newName',
|
||||
dry_run: true,
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('rename returns error when both symbol_name and symbol_uid are missing', async () => {
|
||||
const result = await backend.callTool('rename', { new_name: 'newName' });
|
||||
expect(result.error).toContain('Either symbol_name or symbol_uid');
|
||||
});
|
||||
|
||||
// Legacy tool aliases
|
||||
it('dispatches "search" as alias for query', async () => {
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
const result = await backend.callTool('search', { query: 'auth' });
|
||||
expect(result).toHaveProperty('processes');
|
||||
});
|
||||
|
||||
it('dispatches "explore" as alias for context', async () => {
|
||||
(executeParameterized as any).mockResolvedValue([
|
||||
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts', startLine: 1, endLine: 10 },
|
||||
]);
|
||||
const result = await backend.callTool('explore', { name: 'main' });
|
||||
// explore calls context — which may return found or ambiguous depending on mock
|
||||
expect(result).toBeDefined();
|
||||
expect(result.status === 'found' || result.symbol || result.error === undefined).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Repo resolution ────────────────────────────────────────────────
|
||||
|
||||
describe('LocalBackend.resolveRepo', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
backend = new LocalBackend();
|
||||
});
|
||||
|
||||
it('resolves single repo without param', async () => {
|
||||
setupSingleRepo();
|
||||
await backend.init();
|
||||
const result = await backend.callTool('list_repos', {});
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws when no repos are registered', async () => {
|
||||
setupNoRepos();
|
||||
await backend.init();
|
||||
await expect(backend.callTool('query', { query: 'test' }))
|
||||
.rejects.toThrow('No indexed repositories');
|
||||
});
|
||||
|
||||
it('throws for ambiguous repos without param', async () => {
|
||||
setupMultipleRepos();
|
||||
await backend.init();
|
||||
await expect(backend.callTool('query', { query: 'test' }))
|
||||
.rejects.toThrow('Multiple repositories indexed');
|
||||
});
|
||||
|
||||
it('resolves repo by name parameter', async () => {
|
||||
setupMultipleRepos();
|
||||
await backend.init();
|
||||
// With repo param, it should resolve correctly
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
const result = await backend.callTool('query', {
|
||||
query: 'auth',
|
||||
repo: 'test-project',
|
||||
});
|
||||
expect(result).toHaveProperty('processes');
|
||||
});
|
||||
|
||||
it('throws for unknown repo name', async () => {
|
||||
setupSingleRepo();
|
||||
await backend.init();
|
||||
await expect(backend.callTool('query', { query: 'test', repo: 'nonexistent' }))
|
||||
.rejects.toThrow('not found');
|
||||
});
|
||||
|
||||
it('resolves repo case-insensitively', async () => {
|
||||
setupSingleRepo();
|
||||
await backend.init();
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
// Should match even with different case
|
||||
const result = await backend.callTool('query', {
|
||||
query: 'test',
|
||||
repo: 'Test-Project',
|
||||
});
|
||||
expect(result).toHaveProperty('processes');
|
||||
});
|
||||
|
||||
it('refreshes registry on repo miss', async () => {
|
||||
setupNoRepos();
|
||||
await backend.init();
|
||||
|
||||
// Now make a repo appear
|
||||
(listRegisteredRepos as any).mockResolvedValue([MOCK_REPO_ENTRY]);
|
||||
|
||||
// The resolve should re-read the registry and find the new repo
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
const result = await backend.callTool('query', {
|
||||
query: 'test',
|
||||
repo: 'test-project',
|
||||
});
|
||||
expect(result).toHaveProperty('processes');
|
||||
// listRegisteredRepos should have been called again
|
||||
expect(listRegisteredRepos).toHaveBeenCalledTimes(2); // once in init, once in refreshRepos
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getContext ──────────────────────────────────────────────────────
|
||||
|
||||
describe('LocalBackend.getContext', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
backend = new LocalBackend();
|
||||
setupSingleRepo();
|
||||
await backend.init();
|
||||
});
|
||||
|
||||
it('returns context for single repo without specifying id', () => {
|
||||
const ctx = backend.getContext();
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx!.projectName).toBe('test-project');
|
||||
expect(ctx!.stats.fileCount).toBe(10);
|
||||
expect(ctx!.stats.functionCount).toBe(50);
|
||||
});
|
||||
|
||||
it('returns context by repo id', () => {
|
||||
const ctx = backend.getContext('test-project');
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx!.projectName).toBe('test-project');
|
||||
});
|
||||
|
||||
it('returns single repo context even with unknown id (single-repo fallback)', () => {
|
||||
// When only 1 repo is registered, getContext falls through the id check
|
||||
// and returns the single repo's context. This is intentional behavior.
|
||||
const ctx = backend.getContext('nonexistent');
|
||||
// The id doesn't match, but since repos.size === 1, it returns that single context
|
||||
// This is the actual behavior — test documents it
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx!.projectName).toBe('test-project');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── KuzuDB lazy initialization ──────────────────────────────────────
|
||||
|
||||
describe('ensureInitialized', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
backend = new LocalBackend();
|
||||
setupSingleRepo();
|
||||
await backend.init();
|
||||
});
|
||||
|
||||
it('calls initKuzu on first tool call', async () => {
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
await backend.callTool('query', { query: 'test' });
|
||||
expect(initKuzu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries initKuzu if connection was evicted', async () => {
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
// First call initializes
|
||||
await backend.callTool('query', { query: 'test' });
|
||||
expect(initKuzu).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate idle eviction
|
||||
(isKuzuReady as any).mockReturnValueOnce(false);
|
||||
await backend.callTool('query', { query: 'test' });
|
||||
expect(initKuzu).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles initKuzu failure gracefully', async () => {
|
||||
(initKuzu as any).mockRejectedValueOnce(new Error('DB locked'));
|
||||
await expect(backend.callTool('query', { query: 'test' }))
|
||||
.rejects.toThrow('DB locked');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cypher write blocking through callTool ──────────────────────────
|
||||
|
||||
describe('callTool cypher write blocking', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
backend = new LocalBackend();
|
||||
setupSingleRepo();
|
||||
await backend.init();
|
||||
});
|
||||
|
||||
const writeQueries = [
|
||||
'CREATE (n:Function {name: "test"})',
|
||||
'MATCH (n) DELETE n',
|
||||
'MATCH (n) SET n.name = "hacked"',
|
||||
'MERGE (n:Function {name: "test"})',
|
||||
'MATCH (n) REMOVE n.name',
|
||||
'DROP TABLE Function',
|
||||
'ALTER TABLE Function ADD COLUMN foo STRING',
|
||||
'COPY Function FROM "file.csv"',
|
||||
'MATCH (n) DETACH DELETE n',
|
||||
];
|
||||
|
||||
for (const query of writeQueries) {
|
||||
it(`blocks write query: ${query.slice(0, 30)}...`, async () => {
|
||||
const result = await backend.callTool('cypher', { query });
|
||||
expect(result).toHaveProperty('error');
|
||||
expect(result.error).toContain('Write operations');
|
||||
});
|
||||
}
|
||||
|
||||
it('allows read query through callTool', async () => {
|
||||
(executeQuery as any).mockResolvedValue([]);
|
||||
const result = await backend.callTool('cypher', {
|
||||
query: 'MATCH (n:Function) RETURN n.name LIMIT 5',
|
||||
});
|
||||
// Should not have error property with write-block message
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── listRepos ──────────────────────────────────────────────────────
|
||||
|
||||
describe('LocalBackend.listRepos', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
backend = new LocalBackend();
|
||||
});
|
||||
|
||||
it('returns empty array when no repos', async () => {
|
||||
setupNoRepos();
|
||||
await backend.init();
|
||||
const repos = await backend.callTool('list_repos', {});
|
||||
expect(repos).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns repo metadata', async () => {
|
||||
setupSingleRepo();
|
||||
await backend.init();
|
||||
const repos = await backend.callTool('list_repos', {});
|
||||
expect(repos).toHaveLength(1);
|
||||
expect(repos[0]).toEqual(expect.objectContaining({
|
||||
name: 'test-project',
|
||||
path: '/tmp/test-project',
|
||||
indexedAt: expect.any(String),
|
||||
lastCommit: expect.any(String),
|
||||
}));
|
||||
});
|
||||
|
||||
it('re-reads registry on each listRepos call', async () => {
|
||||
setupSingleRepo();
|
||||
await backend.init();
|
||||
await backend.callTool('list_repos', {});
|
||||
await backend.callTool('list_repos', {});
|
||||
// listRegisteredRepos called: once in init, once per listRepos
|
||||
expect(listRegisteredRepos).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cypher KuzuDB not ready ────────────────────────────────────────
|
||||
|
||||
describe('cypher tool KuzuDB not ready', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
backend = new LocalBackend();
|
||||
setupSingleRepo();
|
||||
await backend.init();
|
||||
});
|
||||
|
||||
it('returns error when KuzuDB is not ready', async () => {
|
||||
(isKuzuReady as any).mockReturnValue(false);
|
||||
// initKuzu will succeed but isKuzuReady returns false after ensureInitialized
|
||||
// Actually ensureInitialized checks isKuzuReady and re-inits — let's make that pass
|
||||
// then the cypher method checks isKuzuReady again
|
||||
(isKuzuReady as any)
|
||||
.mockReturnValueOnce(false) // ensureInitialized check
|
||||
.mockReturnValueOnce(false); // cypher's own check
|
||||
|
||||
const result = await backend.callTool('cypher', {
|
||||
query: 'MATCH (n) RETURN n LIMIT 1',
|
||||
});
|
||||
expect(result.error).toContain('KuzuDB not ready');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatCypherAsMarkdown ──────────────────────────────────────────
|
||||
|
||||
describe('cypher result formatting', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Full reset of all mocks to prevent state leaking from other tests
|
||||
vi.resetAllMocks();
|
||||
(listRegisteredRepos as any).mockResolvedValue([MOCK_REPO_ENTRY]);
|
||||
(initKuzu as any).mockResolvedValue(undefined);
|
||||
(isKuzuReady as any).mockReturnValue(true);
|
||||
(closeKuzu as any).mockResolvedValue(undefined);
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
|
||||
backend = new LocalBackend();
|
||||
await backend.init();
|
||||
});
|
||||
|
||||
it('formats tabular results as markdown table', async () => {
|
||||
(executeQuery as any).mockResolvedValue([
|
||||
{ name: 'main', filePath: 'src/index.ts' },
|
||||
{ name: 'helper', filePath: 'src/utils.ts' },
|
||||
]);
|
||||
const result = await backend.callTool('cypher', {
|
||||
query: 'MATCH (n:Function) RETURN n.name AS name, n.filePath AS filePath',
|
||||
});
|
||||
expect(result).toHaveProperty('markdown');
|
||||
expect(result.markdown).toContain('name');
|
||||
expect(result.markdown).toContain('main');
|
||||
expect(result.row_count).toBe(2);
|
||||
});
|
||||
|
||||
it('returns empty array as-is', async () => {
|
||||
(executeQuery as any).mockResolvedValue([]);
|
||||
const result = await backend.callTool('cypher', {
|
||||
query: 'MATCH (n:Function) RETURN n.name LIMIT 0',
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns error object when cypher fails', async () => {
|
||||
(executeQuery as any).mockRejectedValue(new Error('Syntax error'));
|
||||
const result = await backend.callTool('cypher', {
|
||||
query: 'INVALID CYPHER SYNTAX',
|
||||
});
|
||||
expect(result).toHaveProperty('error');
|
||||
expect(result.error).toContain('Syntax error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock all the heavy imports before importing index
|
||||
vi.mock('../../src/cli/analyze.js', () => ({
|
||||
analyzeCommand: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../src/cli/mcp.js', () => ({
|
||||
mcpCommand: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../src/cli/setup.js', () => ({
|
||||
setupCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('CLI commands', () => {
|
||||
describe('version', () => {
|
||||
it('package.json has a valid version string', async () => {
|
||||
const pkg = await import('../../package.json', { with: { type: 'json' } });
|
||||
expect(pkg.default.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('package.json scripts', () => {
|
||||
it('has test scripts configured', async () => {
|
||||
const pkg = await import('../../package.json', { with: { type: 'json' } });
|
||||
expect(pkg.default.scripts.test).toBeDefined();
|
||||
expect(pkg.default.scripts['test:integration']).toBeDefined();
|
||||
expect(pkg.default.scripts['test:all']).toBeDefined();
|
||||
});
|
||||
|
||||
it('has build script', async () => {
|
||||
const pkg = await import('../../package.json', { with: { type: 'json' } });
|
||||
expect(pkg.default.scripts.build).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('package.json bin entry', () => {
|
||||
it('exposes gitnexus binary', async () => {
|
||||
const pkg = await import('../../package.json', { with: { type: 'json' } });
|
||||
expect(pkg.default.bin).toBeDefined();
|
||||
expect(pkg.default.bin.gitnexus || pkg.default.bin).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeCommand', () => {
|
||||
it('is a function', async () => {
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
expect(typeof analyzeCommand).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mcpCommand', () => {
|
||||
it('is a function', async () => {
|
||||
const { mcpCommand } = await import('../../src/cli/mcp.js');
|
||||
expect(typeof mcpCommand).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupCommand', () => {
|
||||
it('is a function', async () => {
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
expect(typeof setupCommand).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getCommunityColor, COMMUNITY_COLORS } from '../../src/core/ingestion/community-processor.js';
|
||||
|
||||
describe('community-processor', () => {
|
||||
describe('COMMUNITY_COLORS', () => {
|
||||
it('has 12 colors', () => {
|
||||
expect(COMMUNITY_COLORS).toHaveLength(12);
|
||||
});
|
||||
|
||||
it('contains valid hex color strings', () => {
|
||||
for (const color of COMMUNITY_COLORS) {
|
||||
expect(color).toMatch(/^#[0-9a-fA-F]{6}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('has no duplicate colors', () => {
|
||||
const unique = new Set(COMMUNITY_COLORS);
|
||||
expect(unique.size).toBe(COMMUNITY_COLORS.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCommunityColor', () => {
|
||||
it('returns first color for index 0', () => {
|
||||
expect(getCommunityColor(0)).toBe(COMMUNITY_COLORS[0]);
|
||||
});
|
||||
|
||||
it('wraps around when index exceeds color count', () => {
|
||||
expect(getCommunityColor(12)).toBe(COMMUNITY_COLORS[0]);
|
||||
expect(getCommunityColor(13)).toBe(COMMUNITY_COLORS[1]);
|
||||
});
|
||||
|
||||
it('returns different colors for different indices', () => {
|
||||
const c0 = getCommunityColor(0);
|
||||
const c1 = getCommunityColor(1);
|
||||
expect(c0).not.toBe(c1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* P0 Unit Tests: CSV Escaping Functions
|
||||
*
|
||||
* Tests: escapeCSVField, escapeCSVNumber, sanitizeUTF8, isBinaryContent
|
||||
* Covers hardening fix #23 (keyword arrays with backslashes and commas)
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
escapeCSVField,
|
||||
escapeCSVNumber,
|
||||
sanitizeUTF8,
|
||||
isBinaryContent,
|
||||
} from '../../src/core/kuzu/csv-generator.js';
|
||||
|
||||
// ─── escapeCSVField ──────────────────────────────────────────────────
|
||||
|
||||
describe('escapeCSVField', () => {
|
||||
it('returns empty quoted string for null', () => {
|
||||
expect(escapeCSVField(null)).toBe('""');
|
||||
});
|
||||
|
||||
it('returns empty quoted string for undefined', () => {
|
||||
expect(escapeCSVField(undefined)).toBe('""');
|
||||
});
|
||||
|
||||
it('returns quoted empty string for empty input', () => {
|
||||
expect(escapeCSVField('')).toBe('""');
|
||||
});
|
||||
|
||||
it('wraps simple string in quotes', () => {
|
||||
expect(escapeCSVField('hello')).toBe('"hello"');
|
||||
});
|
||||
|
||||
it('doubles internal double quotes', () => {
|
||||
expect(escapeCSVField('say "hello"')).toBe('"say ""hello"""');
|
||||
});
|
||||
|
||||
it('handles strings with commas', () => {
|
||||
expect(escapeCSVField('a,b,c')).toBe('"a,b,c"');
|
||||
});
|
||||
|
||||
it('handles strings with newlines', () => {
|
||||
expect(escapeCSVField('line1\nline2')).toBe('"line1\nline2"');
|
||||
});
|
||||
|
||||
it('converts numbers to quoted strings', () => {
|
||||
expect(escapeCSVField(42)).toBe('"42"');
|
||||
});
|
||||
|
||||
it('handles strings with both quotes and commas', () => {
|
||||
expect(escapeCSVField('"hello",world')).toBe('"""hello"",world"');
|
||||
});
|
||||
|
||||
// Hardening fix #23: keyword arrays with backslashes
|
||||
it('handles strings with backslashes', () => {
|
||||
const result = escapeCSVField('path\\to\\file');
|
||||
expect(result).toBe('"path\\to\\file"');
|
||||
});
|
||||
|
||||
it('handles code content with special characters', () => {
|
||||
const code = 'function foo() {\n return "bar";\n}';
|
||||
const result = escapeCSVField(code);
|
||||
expect(result).toContain('function foo()');
|
||||
expect(result).toContain('""bar""');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── escapeCSVNumber ─────────────────────────────────────────────────
|
||||
|
||||
describe('escapeCSVNumber', () => {
|
||||
it('returns default value for null', () => {
|
||||
expect(escapeCSVNumber(null)).toBe('-1');
|
||||
});
|
||||
|
||||
it('returns default value for undefined', () => {
|
||||
expect(escapeCSVNumber(undefined)).toBe('-1');
|
||||
});
|
||||
|
||||
it('returns custom default value', () => {
|
||||
expect(escapeCSVNumber(null, 0)).toBe('0');
|
||||
});
|
||||
|
||||
it('returns string representation of number', () => {
|
||||
expect(escapeCSVNumber(42)).toBe('42');
|
||||
});
|
||||
|
||||
it('handles zero', () => {
|
||||
expect(escapeCSVNumber(0)).toBe('0');
|
||||
});
|
||||
|
||||
it('handles negative numbers', () => {
|
||||
expect(escapeCSVNumber(-5)).toBe('-5');
|
||||
});
|
||||
|
||||
it('handles floating point', () => {
|
||||
expect(escapeCSVNumber(3.14)).toBe('3.14');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── sanitizeUTF8 ────────────────────────────────────────────────────
|
||||
|
||||
describe('sanitizeUTF8', () => {
|
||||
it('passes through clean strings unchanged', () => {
|
||||
expect(sanitizeUTF8('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('normalizes CRLF to LF', () => {
|
||||
expect(sanitizeUTF8('line1\r\nline2')).toBe('line1\nline2');
|
||||
});
|
||||
|
||||
it('normalizes lone CR to LF', () => {
|
||||
expect(sanitizeUTF8('line1\rline2')).toBe('line1\nline2');
|
||||
});
|
||||
|
||||
it('strips null bytes', () => {
|
||||
expect(sanitizeUTF8('hello\x00world')).toBe('helloworld');
|
||||
});
|
||||
|
||||
it('strips control characters', () => {
|
||||
expect(sanitizeUTF8('hello\x01\x02\x03world')).toBe('helloworld');
|
||||
});
|
||||
|
||||
it('preserves tabs', () => {
|
||||
expect(sanitizeUTF8('hello\tworld')).toBe('hello\tworld');
|
||||
});
|
||||
|
||||
it('preserves newlines', () => {
|
||||
expect(sanitizeUTF8('hello\nworld')).toBe('hello\nworld');
|
||||
});
|
||||
|
||||
it('strips lone surrogates', () => {
|
||||
expect(sanitizeUTF8('hello\uD800world')).toBe('helloworld');
|
||||
});
|
||||
|
||||
it('strips BOM-like characters (FFFE/FFFF)', () => {
|
||||
expect(sanitizeUTF8('hello\uFFFEworld')).toBe('helloworld');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isBinaryContent ─────────────────────────────────────────────────
|
||||
|
||||
describe('isBinaryContent', () => {
|
||||
it('returns false for empty string', () => {
|
||||
expect(isBinaryContent('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for normal text', () => {
|
||||
expect(isBinaryContent('hello world\nline two')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for code content', () => {
|
||||
const code = 'function foo() {\n return 42;\n}\n';
|
||||
expect(isBinaryContent(code)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when >10% non-printable characters', () => {
|
||||
// Create a string that's ~20% null bytes
|
||||
const binary = 'a'.repeat(80) + '\x00'.repeat(20);
|
||||
expect(isBinaryContent(binary)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when just under 10% threshold', () => {
|
||||
// 9% non-printable should not be binary
|
||||
const borderline = 'a'.repeat(91) + '\x01'.repeat(9);
|
||||
expect(isBinaryContent(borderline)).toBe(false);
|
||||
});
|
||||
|
||||
it('only samples first 1000 characters', () => {
|
||||
// Binary content past 1000 chars should be ignored
|
||||
const text = 'a'.repeat(1000) + '\x00'.repeat(500);
|
||||
expect(isBinaryContent(text)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getEmbeddingDims, isEmbedderReady } from '../../src/mcp/core/embedder.js';
|
||||
|
||||
describe('embedder', () => {
|
||||
describe('getEmbeddingDims', () => {
|
||||
it('returns 384 (MiniLM default)', () => {
|
||||
expect(getEmbeddingDims()).toBe(384);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmbedderReady', () => {
|
||||
it('returns false before initialization', () => {
|
||||
expect(isEmbedderReady()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateEntryPointScore, isTestFile, isUtilityFile } from '../../src/core/ingestion/entry-point-scoring.js';
|
||||
|
||||
describe('calculateEntryPointScore', () => {
|
||||
describe('base scoring', () => {
|
||||
it('returns 0 for functions with no outgoing calls', () => {
|
||||
const result = calculateEntryPointScore('handler', 'typescript', true, 0, 0);
|
||||
expect(result.score).toBe(0);
|
||||
expect(result.reasons).toContain('no-outgoing-calls');
|
||||
});
|
||||
|
||||
it('calculates base score as calleeCount / (callerCount + 1)', () => {
|
||||
const result = calculateEntryPointScore('doStuff', 'typescript', false, 0, 5);
|
||||
// base = 5 / (0 + 1) = 5, no export bonus, no name bonus
|
||||
expect(result.score).toBe(5);
|
||||
});
|
||||
|
||||
it('reduces score for functions with many callers', () => {
|
||||
const few = calculateEntryPointScore('doStuff', 'typescript', false, 1, 5);
|
||||
const many = calculateEntryPointScore('doStuff', 'typescript', false, 10, 5);
|
||||
expect(few.score).toBeGreaterThan(many.score);
|
||||
});
|
||||
});
|
||||
|
||||
describe('export multiplier', () => {
|
||||
it('applies 2.0 multiplier for exported functions', () => {
|
||||
const exported = calculateEntryPointScore('doStuff', 'typescript', true, 0, 4);
|
||||
const notExported = calculateEntryPointScore('doStuff', 'typescript', false, 0, 4);
|
||||
expect(exported.score).toBe(notExported.score * 2);
|
||||
expect(exported.reasons).toContain('exported');
|
||||
});
|
||||
|
||||
it('does not add exported reason when not exported', () => {
|
||||
const result = calculateEntryPointScore('doStuff', 'typescript', false, 0, 4);
|
||||
expect(result.reasons).not.toContain('exported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('universal name patterns', () => {
|
||||
it.each([
|
||||
'main', 'init', 'bootstrap', 'start', 'run', 'setup', 'configure',
|
||||
])('recognizes "%s" as entry point pattern', (name) => {
|
||||
const result = calculateEntryPointScore(name, 'typescript', false, 0, 3);
|
||||
expect(result.reasons).toContain('entry-pattern');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'handleLogin', 'handleSubmit', 'onClick', 'onSubmit',
|
||||
'RequestHandler', 'UserController',
|
||||
'processPayment', 'executeQuery', 'performAction',
|
||||
'dispatchEvent', 'triggerAction', 'fireEvent', 'emitEvent',
|
||||
])('recognizes "%s" as entry point pattern', (name) => {
|
||||
const result = calculateEntryPointScore(name, 'typescript', false, 0, 3);
|
||||
expect(result.reasons).toContain('entry-pattern');
|
||||
});
|
||||
|
||||
it('applies 1.5x name multiplier for entry patterns', () => {
|
||||
const matching = calculateEntryPointScore('handleLogin', 'typescript', false, 0, 4);
|
||||
const plain = calculateEntryPointScore('doStuff', 'typescript', false, 0, 4);
|
||||
// matching gets 1.5x, plain gets 1.0x
|
||||
expect(matching.score).toBe(plain.score * 1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('language-specific patterns', () => {
|
||||
it('recognizes React hooks for TypeScript', () => {
|
||||
const result = calculateEntryPointScore('useEffect', 'typescript', false, 0, 2);
|
||||
expect(result.reasons).toContain('entry-pattern');
|
||||
});
|
||||
|
||||
it('recognizes React hooks for JavaScript', () => {
|
||||
const result = calculateEntryPointScore('useState', 'javascript', false, 0, 2);
|
||||
expect(result.reasons).toContain('entry-pattern');
|
||||
});
|
||||
|
||||
it('recognizes Python REST patterns', () => {
|
||||
const result = calculateEntryPointScore('get_users', 'python', false, 0, 2);
|
||||
expect(result.reasons).toContain('entry-pattern');
|
||||
});
|
||||
|
||||
it('recognizes Java servlet patterns', () => {
|
||||
const result = calculateEntryPointScore('doGet', 'java', false, 0, 2);
|
||||
expect(result.reasons).toContain('entry-pattern');
|
||||
});
|
||||
|
||||
it('recognizes Go handler patterns', () => {
|
||||
const result = calculateEntryPointScore('NewServer', 'go', false, 0, 2);
|
||||
expect(result.reasons).toContain('entry-pattern');
|
||||
});
|
||||
|
||||
it('recognizes Rust entry patterns', () => {
|
||||
const result = calculateEntryPointScore('handle_request', 'rust', false, 0, 2);
|
||||
expect(result.reasons).toContain('entry-pattern');
|
||||
});
|
||||
|
||||
it('recognizes Swift UIKit lifecycle', () => {
|
||||
const result = calculateEntryPointScore('viewDidLoad', 'swift', false, 0, 2);
|
||||
expect(result.reasons).toContain('entry-pattern');
|
||||
});
|
||||
|
||||
it('recognizes Swift SwiftUI body', () => {
|
||||
const result = calculateEntryPointScore('body', 'swift', false, 0, 2);
|
||||
expect(result.reasons).toContain('entry-pattern');
|
||||
});
|
||||
|
||||
it('recognizes PHP Laravel patterns', () => {
|
||||
// __invoke starts with '_' which matches utility pattern first
|
||||
const result = calculateEntryPointScore('handle', 'php', false, 0, 2);
|
||||
expect(result.reasons).toContain('entry-pattern');
|
||||
});
|
||||
|
||||
it('recognizes PHP RESTful resource methods', () => {
|
||||
const result = calculateEntryPointScore('index', 'php', false, 0, 2);
|
||||
expect(result.reasons).toContain('entry-pattern');
|
||||
});
|
||||
|
||||
it('recognizes C# ASP.NET patterns', () => {
|
||||
const result = calculateEntryPointScore('GetUsers', 'csharp', false, 0, 2);
|
||||
expect(result.reasons).toContain('entry-pattern');
|
||||
});
|
||||
|
||||
it('recognizes C main entry point', () => {
|
||||
const result = calculateEntryPointScore('main', 'c', false, 0, 2);
|
||||
expect(result.reasons).toContain('entry-pattern');
|
||||
});
|
||||
});
|
||||
|
||||
describe('utility pattern penalty', () => {
|
||||
it.each([
|
||||
'getUser', 'setName', 'isValid', 'hasPermission', 'canEdit',
|
||||
'formatDate', 'parseJSON', 'validateInput',
|
||||
'toString', 'fromJSON', 'encodeBase64', 'serializeData',
|
||||
'cloneDeep', 'mergeObjects',
|
||||
])('penalizes utility function "%s"', (name) => {
|
||||
const result = calculateEntryPointScore(name, 'typescript', false, 0, 3);
|
||||
expect(result.reasons).toContain('utility-pattern');
|
||||
// 0.3 multiplier
|
||||
const plain = calculateEntryPointScore('doStuff', 'typescript', false, 0, 3);
|
||||
expect(result.score).toBeLessThan(plain.score);
|
||||
});
|
||||
|
||||
it('penalizes private-by-convention functions', () => {
|
||||
const result = calculateEntryPointScore('_internal', 'typescript', false, 0, 3);
|
||||
expect(result.reasons).toContain('utility-pattern');
|
||||
});
|
||||
});
|
||||
|
||||
describe('framework detection from path', () => {
|
||||
it('boosts Next.js page entry points', () => {
|
||||
const result = calculateEntryPointScore('render', 'typescript', true, 0, 3, 'pages/users.tsx');
|
||||
expect(result.reasons.some(r => r.includes('framework:'))).toBe(true);
|
||||
expect(result.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not apply framework bonus for non-framework paths', () => {
|
||||
const result = calculateEntryPointScore('render', 'typescript', true, 0, 3, 'src/lib/utils.ts');
|
||||
expect(result.reasons.every(r => !r.includes('framework:'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined scoring', () => {
|
||||
it('multiplies all factors together', () => {
|
||||
// handleLogin: entry pattern (1.5x) + exported (2.0x) + base
|
||||
const result = calculateEntryPointScore('handleLogin', 'typescript', true, 0, 4, 'routes/auth.ts');
|
||||
expect(result.score).toBeGreaterThan(0);
|
||||
expect(result.reasons).toContain('exported');
|
||||
expect(result.reasons).toContain('entry-pattern');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTestFile', () => {
|
||||
it.each([
|
||||
'src/utils.test.ts',
|
||||
'src/utils.spec.ts',
|
||||
'__tests__/utils.ts',
|
||||
'__mocks__/api.ts',
|
||||
'src/test/integration/db.ts',
|
||||
'src/tests/unit/helper.ts',
|
||||
'src/testing/setup.ts',
|
||||
'lib/test_utils.py',
|
||||
'pkg/handler_test.go',
|
||||
'src/test/java/com/example/Test.java',
|
||||
'MyViewTests.swift',
|
||||
'MyViewTest.swift',
|
||||
'UITests/LoginTest.swift',
|
||||
'App.Tests/MyTest.cs',
|
||||
'tests/Feature/UserTest.php',
|
||||
'tests/Unit/AuthSpec.php',
|
||||
])('returns true for test file "%s"', (filePath) => {
|
||||
expect(isTestFile(filePath)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'src/utils.ts',
|
||||
'src/controllers/auth.ts',
|
||||
'src/main.py',
|
||||
'cmd/server.go',
|
||||
'src/main/java/App.java',
|
||||
])('returns false for non-test file "%s"', (filePath) => {
|
||||
expect(isTestFile(filePath)).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes Windows backslashes', () => {
|
||||
expect(isTestFile('src\\__tests__\\utils.ts')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUtilityFile', () => {
|
||||
it.each([
|
||||
'src/utils/format.ts',
|
||||
'src/util/helpers.ts',
|
||||
'src/helpers/date.ts',
|
||||
'src/helper/string.ts',
|
||||
'src/common/types.ts',
|
||||
'src/shared/constants.ts',
|
||||
'src/lib/crypto.ts',
|
||||
'src/utils.ts',
|
||||
'src/utils.js',
|
||||
'src/helpers.ts',
|
||||
'lib/date_utils.py',
|
||||
'lib/date_helpers.py',
|
||||
])('returns true for utility file "%s"', (filePath) => {
|
||||
expect(isUtilityFile(filePath)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'src/controllers/auth.ts',
|
||||
'src/routes/api.ts',
|
||||
'src/main.ts',
|
||||
'src/app.ts',
|
||||
])('returns false for non-utility file "%s"', (filePath) => {
|
||||
expect(isUtilityFile(filePath)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* P1 Unit Tests: Eval Server Formatters
|
||||
*
|
||||
* Tests: formatQueryResult, formatContextResult, formatImpactResult,
|
||||
* formatCypherResult, formatDetectChangesResult, formatListReposResult, MAX_BODY_SIZE
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatQueryResult,
|
||||
formatContextResult,
|
||||
formatImpactResult,
|
||||
formatCypherResult,
|
||||
formatDetectChangesResult,
|
||||
formatListReposResult,
|
||||
MAX_BODY_SIZE,
|
||||
} from '../../src/cli/eval-server.js';
|
||||
|
||||
// ─── MAX_BODY_SIZE ───────────────────────────────────────────────────
|
||||
|
||||
describe('MAX_BODY_SIZE', () => {
|
||||
it('is 1MB', () => {
|
||||
expect(MAX_BODY_SIZE).toBe(1024 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatQueryResult ───────────────────────────────────────────────
|
||||
|
||||
describe('formatQueryResult', () => {
|
||||
it('returns error message for error input', () => {
|
||||
expect(formatQueryResult({ error: 'something failed' })).toBe('Error: something failed');
|
||||
});
|
||||
|
||||
it('returns no-match message for empty results', () => {
|
||||
const result = formatQueryResult({ processes: [], definitions: [] });
|
||||
expect(result).toContain('No matching execution flows');
|
||||
});
|
||||
|
||||
it('formats processes with symbols', () => {
|
||||
const result = formatQueryResult({
|
||||
processes: [
|
||||
{ id: 'p1', summary: 'User Login Flow', step_count: 3, symbol_count: 2 },
|
||||
],
|
||||
process_symbols: [
|
||||
{ process_id: 'p1', type: 'Function', name: 'login', filePath: 'src/auth.ts', startLine: 10 },
|
||||
{ process_id: 'p1', type: 'Function', name: 'validate', filePath: 'src/auth.ts', startLine: 20 },
|
||||
],
|
||||
definitions: [],
|
||||
});
|
||||
expect(result).toContain('1 execution flow');
|
||||
expect(result).toContain('User Login Flow');
|
||||
expect(result).toContain('login');
|
||||
expect(result).toContain(':10');
|
||||
});
|
||||
|
||||
it('truncates symbols per process at 6', () => {
|
||||
const symbols = Array.from({ length: 10 }, (_, i) => ({
|
||||
process_id: 'p1',
|
||||
type: 'Function',
|
||||
name: `fn${i}`,
|
||||
filePath: 'src/test.ts',
|
||||
}));
|
||||
const result = formatQueryResult({
|
||||
processes: [{ id: 'p1', summary: 'Flow', step_count: 10, symbol_count: 10 }],
|
||||
process_symbols: symbols,
|
||||
definitions: [],
|
||||
});
|
||||
expect(result).toContain('and 4 more');
|
||||
});
|
||||
|
||||
it('formats standalone definitions', () => {
|
||||
const result = formatQueryResult({
|
||||
processes: [],
|
||||
definitions: [
|
||||
{ type: 'Interface', name: 'Config', filePath: 'src/types.ts' },
|
||||
],
|
||||
});
|
||||
expect(result).toContain('Standalone definitions');
|
||||
expect(result).toContain('Config');
|
||||
});
|
||||
|
||||
it('truncates definitions at 8', () => {
|
||||
const defs = Array.from({ length: 12 }, (_, i) => ({
|
||||
type: 'Interface',
|
||||
name: `Type${i}`,
|
||||
filePath: 'src/types.ts',
|
||||
}));
|
||||
const result = formatQueryResult({ processes: [], definitions: defs });
|
||||
expect(result).toContain('and 4 more');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatContextResult ─────────────────────────────────────────────
|
||||
|
||||
describe('formatContextResult', () => {
|
||||
it('returns error message for error input', () => {
|
||||
expect(formatContextResult({ error: 'not found' })).toBe('Error: not found');
|
||||
});
|
||||
|
||||
it('handles ambiguous results', () => {
|
||||
const result = formatContextResult({
|
||||
status: 'ambiguous',
|
||||
candidates: [
|
||||
{ name: 'foo', kind: 'Function', filePath: 'src/a.ts', line: 10, uid: 'uid1' },
|
||||
{ name: 'foo', kind: 'Function', filePath: 'src/b.ts', line: 5, uid: 'uid2' },
|
||||
],
|
||||
});
|
||||
expect(result).toContain('Multiple symbols');
|
||||
expect(result).toContain('uid1');
|
||||
expect(result).toContain('uid2');
|
||||
});
|
||||
|
||||
it('returns "Symbol not found" when no symbol', () => {
|
||||
expect(formatContextResult({})).toBe('Symbol not found.');
|
||||
});
|
||||
|
||||
it('formats symbol with incoming/outgoing refs', () => {
|
||||
const result = formatContextResult({
|
||||
symbol: { kind: 'Function', name: 'foo', filePath: 'src/a.ts', startLine: 1, endLine: 10 },
|
||||
incoming: {
|
||||
CALLS: [{ kind: 'Function', name: 'bar', filePath: 'src/b.ts' }],
|
||||
},
|
||||
outgoing: {
|
||||
IMPORTS: [{ kind: 'Module', name: 'utils', filePath: 'src/utils.ts' }],
|
||||
},
|
||||
processes: [],
|
||||
});
|
||||
expect(result).toContain('Function foo');
|
||||
expect(result).toContain('Called/imported by (1)');
|
||||
expect(result).toContain('Calls/imports (1)');
|
||||
});
|
||||
|
||||
it('formats process participation', () => {
|
||||
const result = formatContextResult({
|
||||
symbol: { kind: 'Function', name: 'foo', filePath: 'src/a.ts' },
|
||||
incoming: {},
|
||||
outgoing: {},
|
||||
processes: [
|
||||
{ name: 'Auth Flow', step_index: 2, step_count: 5 },
|
||||
],
|
||||
});
|
||||
expect(result).toContain('1 execution flow');
|
||||
expect(result).toContain('Auth Flow');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatImpactResult ──────────────────────────────────────────────
|
||||
|
||||
describe('formatImpactResult', () => {
|
||||
it('returns error message for error input', () => {
|
||||
expect(formatImpactResult({ error: 'bad request' })).toBe('Error: bad request');
|
||||
});
|
||||
|
||||
it('handles zero impact', () => {
|
||||
const result = formatImpactResult({
|
||||
target: { name: 'foo' },
|
||||
direction: 'upstream',
|
||||
impactedCount: 0,
|
||||
byDepth: {},
|
||||
});
|
||||
expect(result).toContain('No upstream dependencies');
|
||||
});
|
||||
|
||||
it('formats impact by depth', () => {
|
||||
const result = formatImpactResult({
|
||||
target: { kind: 'Function', name: 'foo' },
|
||||
direction: 'upstream',
|
||||
impactedCount: 3,
|
||||
byDepth: {
|
||||
1: [
|
||||
{ type: 'Function', name: 'caller1', filePath: 'src/a.ts', relationType: 'CALLS', confidence: 1 },
|
||||
{ type: 'Function', name: 'caller2', filePath: 'src/b.ts', relationType: 'CALLS', confidence: 0.8 },
|
||||
],
|
||||
2: [
|
||||
{ type: 'Class', name: 'App', filePath: 'src/app.ts', relationType: 'IMPORTS', confidence: 1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result).toContain('Blast radius');
|
||||
expect(result).toContain('WILL BREAK');
|
||||
expect(result).toContain('caller1');
|
||||
expect(result).toContain('conf: 0.8');
|
||||
expect(result).toContain('LIKELY AFFECTED');
|
||||
});
|
||||
|
||||
it('truncates items per depth at 12', () => {
|
||||
const items = Array.from({ length: 15 }, (_, i) => ({
|
||||
type: 'Function',
|
||||
name: `fn${i}`,
|
||||
filePath: 'src/test.ts',
|
||||
relationType: 'CALLS',
|
||||
confidence: 1,
|
||||
}));
|
||||
const result = formatImpactResult({
|
||||
target: { kind: 'Function', name: 'foo' },
|
||||
direction: 'upstream',
|
||||
impactedCount: 15,
|
||||
byDepth: { 1: items },
|
||||
});
|
||||
expect(result).toContain('and 3 more');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatCypherResult ──────────────────────────────────────────────
|
||||
|
||||
describe('formatCypherResult', () => {
|
||||
it('returns error message for error input', () => {
|
||||
expect(formatCypherResult({ error: 'syntax error' })).toBe('Error: syntax error');
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
expect(formatCypherResult([])).toBe('Query returned 0 rows.');
|
||||
});
|
||||
|
||||
it('formats array of objects as table', () => {
|
||||
const result = formatCypherResult([
|
||||
{ name: 'foo', filePath: 'src/a.ts' },
|
||||
{ name: 'bar', filePath: 'src/b.ts' },
|
||||
]);
|
||||
expect(result).toContain('2 row(s)');
|
||||
expect(result).toContain('name: foo');
|
||||
expect(result).toContain('name: bar');
|
||||
});
|
||||
|
||||
it('truncates at 30 rows', () => {
|
||||
const rows = Array.from({ length: 35 }, (_, i) => ({ id: i }));
|
||||
const result = formatCypherResult(rows);
|
||||
expect(result).toContain('5 more rows');
|
||||
});
|
||||
|
||||
it('handles string result', () => {
|
||||
expect(formatCypherResult('some text')).toBe('some text');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatDetectChangesResult ───────────────────────────────────────
|
||||
|
||||
describe('formatDetectChangesResult', () => {
|
||||
it('returns error message for error input', () => {
|
||||
expect(formatDetectChangesResult({ error: 'git error' })).toBe('Error: git error');
|
||||
});
|
||||
|
||||
it('handles no changes', () => {
|
||||
const result = formatDetectChangesResult({ summary: { changed_count: 0 } });
|
||||
expect(result).toBe('No changes detected.');
|
||||
});
|
||||
|
||||
it('formats changes with affected processes', () => {
|
||||
const result = formatDetectChangesResult({
|
||||
summary: { changed_files: 2, changed_count: 3, affected_count: 1, risk_level: 'MEDIUM' },
|
||||
changed_symbols: [
|
||||
{ type: 'Function', name: 'foo', filePath: 'src/a.ts' },
|
||||
],
|
||||
affected_processes: [
|
||||
{ name: 'Auth Flow', step_count: 5, changed_steps: [{ symbol: 'foo' }] },
|
||||
],
|
||||
});
|
||||
expect(result).toContain('2 files');
|
||||
expect(result).toContain('MEDIUM');
|
||||
expect(result).toContain('Auth Flow');
|
||||
});
|
||||
|
||||
it('truncates changed symbols at 15', () => {
|
||||
const symbols = Array.from({ length: 20 }, (_, i) => ({
|
||||
type: 'Function',
|
||||
name: `fn${i}`,
|
||||
filePath: 'src/test.ts',
|
||||
}));
|
||||
const result = formatDetectChangesResult({
|
||||
summary: { changed_files: 1, changed_count: 20, affected_count: 0, risk_level: 'HIGH' },
|
||||
changed_symbols: symbols,
|
||||
affected_processes: [],
|
||||
});
|
||||
expect(result).toContain('and 5 more');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatListReposResult ───────────────────────────────────────────
|
||||
|
||||
describe('formatListReposResult', () => {
|
||||
it('handles empty/null input', () => {
|
||||
expect(formatListReposResult([])).toBe('No indexed repositories.');
|
||||
expect(formatListReposResult(null)).toBe('No indexed repositories.');
|
||||
});
|
||||
|
||||
it('formats repo list', () => {
|
||||
const result = formatListReposResult([
|
||||
{
|
||||
name: 'my-project',
|
||||
path: '/home/user/my-project',
|
||||
indexedAt: '2024-01-01',
|
||||
stats: { nodes: 100, edges: 200, processes: 10 },
|
||||
},
|
||||
]);
|
||||
expect(result).toContain('Indexed repositories');
|
||||
expect(result).toContain('my-project');
|
||||
expect(result).toContain('100 symbols');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user