diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 00000000..cc5111c1 --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,34 @@ +# CodeGraph + +This project has a CodeGraph MCP server configured. CodeGraph is a tree-sitter +knowledge graph of every symbol, edge, and file in the workspace. Reads are +sub-millisecond and return structural information grep cannot. + +## When to prefer codegraph + +Use codegraph for **structural** questions — what calls what, what would +break, where is X defined, what is X's signature. Use native grep/read only +for literal text queries. + +| Question | Tool | +|---|---| +| "Where is X defined?" | `codegraph_search` | +| "What calls Y?" | `codegraph_callers` | +| "What does Y call?" | `codegraph_callees` | +| "What would break if I changed Z?" | `codegraph_impact` | +| "Show me Y's signature / source" | `codegraph_node` | +| "Give me focused context for a task" | `codegraph_context` | +| "What files exist under path/" | `codegraph_files` | +| "Is the index healthy?" | `codegraph_status` | + +## Rules of thumb + +- **Trust codegraph results.** They come from a full AST parse. Do NOT + re-verify with grep. +- **Don't grep first** when looking up a symbol by name. +- **`codegraph_context` is one call** — don't chain search + node yourself. + +## If `.codegraph/` doesn't exist + +The MCP server returns "not initialized." Run `codegraph init -i` to build +the index. diff --git a/.agents/mcp_config.json b/.agents/mcp_config.json new file mode 100644 index 00000000..3a0b0b2a --- /dev/null +++ b/.agents/mcp_config.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "codegraph": { + "args": [ + "serve", + "--mcp", + "--path", + "/home/cleboost/Code/codegraph" + ], + "command": "/home/cleboost/Code/codegraph/target/debug/codegraph" + } + } +} diff --git a/.claude/skills/add-lang/SKILL.md b/.claude/skills/add-lang/SKILL.md deleted file mode 100644 index 37cbdce5..00000000 --- a/.claude/skills/add-lang/SKILL.md +++ /dev/null @@ -1,219 +0,0 @@ ---- -name: add-lang -description: Add tree-sitter language support to codegraph end-to-end — wire the grammar + extractor, write tests, then benchmark extraction quality and retrieval value on 3 popular real-world repos. Use when the user runs /add-lang or asks to add/support a new language (e.g. Lua, Elixir, Zig, OCaml) in codegraph. ---- - -# Add a language to CodeGraph - -Wire a new tree-sitter language into codegraph's extraction pipeline, prove it -extracts real symbols on popular repos, and prove it beats no-codegraph for an -agent. Runs **fully autonomously** — pick repos, benchmark, update docs, then -report. **Never commit, push, publish, or tag** (house rule); leave all changes -for the user to review. - -The argument is the language token used throughout the `Language` union, e.g. -`lua`, `elixir`, `zig`. If none was given, ask which language. Use the lowercase -single-token form everywhere (`csharp`, not `c#`). - -## Prerequisites -- Run from the codegraph repo root. `node`, `git`, `gh`, and a logged-in - `claude` CLI (the benchmark spawns real `claude -p` runs). -- The benchmark uses the local dev build — Step 8 builds + links it on PATH. - -## Workflow - -Copy this checklist and work through it in order: -``` -- [ ] 1. Resolve language; bail early if already supported (just benchmark) -- [ ] 2. Find a grammar + health-check it (ABI / heap corruption) -- [ ] 3. Discover the grammar's AST node types (dump-ast.mjs) -- [ ] 4. Wire the language (4 files; sometimes a 5th core touch) -- [ ] 5. Build + verify-extraction loop until PASS -- [ ] 6. Add extraction tests; make them green -- [ ] 7. Auto-pick 3 popular repos by size tier; add to corpus.json -- [ ] 8. Benchmark all 3: extraction + with/without A/B -- [ ] 9. Update README + CHANGELOG -- [ ] 10. Report; do NOT commit -``` - -### Step 1 — Resolve + short-circuit - -Check whether the language is already wired: look for the token in the -`LANGUAGES` const (`src/types.ts`) and the `EXTRACTORS` map -(`src/extraction/languages/index.ts`). If it is already supported (e.g. -`typescript`, `rust`), **skip Steps 2–6** and go straight to benchmarking -(Steps 7–8) to validate/measure it — note in the report that no code changed. - -### Step 2 — Find a grammar, then health-check it - -```bash -ls node_modules/tree-sitter-wasms/out/ | grep -i # csharp -> c_sharp -``` -- **Present** → likely off-the-shelf; `grammars.ts` resolves it from - `tree-sitter-wasms` automatically. (Many languages: elixir, zig, ocaml, - solidity, toml, yaml, …) -- **Absent** → vendor a `.wasm` into `src/extraction/wasm/` (like `pascal` / - `scala` / `lua`) and add the token to the vendored branch in Step 4. - -**Always health-check before writing an extractor — a *present* grammar can -still be unusable:** -```bash -node scripts/add-lang/check-grammar.mjs path/to/valid-sample. -``` -It prints the grammar's ABI version and parses a valid sample many times in a -multi-grammar runtime. If it **FAILs** (ERROR trees on valid code — an old ABI -corrupting the shared WASM heap, which silently drops nested calls/imports on -every file after the first; e.g. the tree-sitter-wasms **Lua** grammar is ABI 13 -and fails), do NOT use that wasm. **Vendor a newer (ABI 14/15) build instead:** -```bash -npm pack @tree-sitter-grammars/tree-sitter- # often ships a prebuilt *.wasm -# or build one: npx tree-sitter build --wasm (needs Docker/emscripten) -cp .wasm src/extraction/wasm/tree-sitter-.wasm -``` -then add the token to the vendored branch in Step 4 and re-run check-grammar on -the vendored path until it PASSes. **If you cannot obtain a healthy wasm, STOP -and tell the user.** - -### Step 3 — Discover AST node types - -Get a representative source file (write a small sample covering functions, -classes/structs, imports, enums; or `curl` a raw file from a known repo), then: -```bash -node scripts/add-lang/dump-ast.mjs path/to/sample. -# vendored grammar: pass the wasm path instead of the token -node scripts/add-lang/dump-ast.mjs src/extraction/wasm/tree-sitter-.wasm sample. -``` -The frequency table + field names (`name:`, `parameters:`, `body:`, -`return_type:`) tell you what to map. Open the existing extractor closest to the -language's paradigm as a model: `rust.ts`/`scala.ts` (functional, traits), -`java.ts`/`csharp.ts` (OO), `python.ts`/`ruby.ts` (scripting), `go.ts` -(top-level methods + receivers). - -### Step 4 — Wire the language (4 files) - -These are exact, fragile wiring — match the existing style precisely: - -1. **`src/types.ts`** — TWO edits: - - add `'',` to the `LANGUAGES` const (before `'unknown'`); - - add `'**/*.',` to `DEFAULT_CONFIG.include`. **Don't skip this** — it's - the file-scan allowlist; without the glob, `codegraph init` finds **0 - files** even though detection/extraction are wired. -2. **`src/extraction/grammars.ts`** — three maps: - - `WASM_GRAMMAR_FILES`: `: 'tree-sitter-.wasm',` - - `EXTENSION_MAP`: each file extension → `''` (e.g. `'.lua': 'lua',`) - - `getLanguageDisplayName`: `: '',` - - **vendored only**: add `` to the - `(lang === 'pascal' || lang === 'scala' || …)` wasm-path branch. -3. **`src/extraction/languages/.ts`** — new file exporting - `export const Extractor: LanguageExtractor = { … }`. Map the node types - from Step 3. Required fields: `functionTypes`, `classTypes`, `methodTypes`, - `interfaceTypes`, `structTypes`, `enumTypes`, `typeAliasTypes`, - `importTypes`, `callTypes`, `variableTypes`, `nameField`, `bodyField`, - `paramsField`. Add hooks as the grammar needs them (`getSignature`, - `getVisibility`, `isExported`, `extractImport`, `visitNode`, `getReceiverType`, - `interfaceKind`, `enumMemberTypes`, etc. — see - `src/extraction/tree-sitter-types.ts`). -4. **`src/extraction/languages/index.ts`** — `import { Extractor } from - './';` and add `: Extractor,` to `EXTRACTORS`. - -**Sometimes a 5th, core touch in `src/extraction/tree-sitter.ts`** — variable -extraction has per-language branches in `extractVariable` (the generic fallback -only finds direct `identifier`/`variable_declarator` children). If the grammar -nests declared names (e.g. Lua's `variable_declaration → variable_list`), add a -`} else if (this.language === '')` branch there, mirroring the existing -ts/python/go ones. Import forms that aren't a distinct node (Lua/Ruby `require` -is a *call*) are handled in the extractor's `visitNode` hook instead. - -### Step 5 — Build + verify loop - -```bash -npm run build # tsc + copy-assets (copies any vendored *.wasm into dist/) -``` -Index a small sample repo and check extraction: -```bash -( cd && codegraph init -i ) -node scripts/add-lang/verify-extraction.mjs -``` -`verify-extraction.mjs` fails (exit 1) if the language isn't detected or only -`file`/`import` nodes were produced — the classic symptom of wrong node-type -names. On FAIL or a thin WARN: re-run `dump-ast.mjs` on a richer file, fix the -mappings in `.ts`, `npm run build`, re-index, re-verify. **Repeat until -PASS.** - -### Step 6 — Tests - -Add to `__tests__/extraction.test.ts`, modeled on the `Rust Extraction` block: -- a `detectLanguage` assertion in `describe('Language Detection')` -- a `describe(' Extraction')` block asserting functions/classes/imports - are extracted from an inline source string. -```bash -npx vitest run __tests__/extraction.test.ts -``` -Green before continuing. - -### Step 7 — Auto-pick 3 repos + corpus - -Pick **without asking**. Find candidates, then curate 3 that are genuinely -``-dominant, one per size tier: -```bash -gh search repos --language= --sort=stars --limit 40 \ - --json fullName,stargazerCount,description -``` -Tiers (match `corpus.json`): **Small** <~150 files · **Medium** ~150–1500 · -**Large** >~1500. Skip repos that are tagged `` but mostly another -language. Write one cross-file architecture **question** per repo (the kind that -needs tracing across files). Add a `""` block to -`.claude/skills/agent-eval/corpus.json` (fields: `name`, `repo`, `size`, -`files`, `question`) so `/agent-eval` can reuse them. - -### Step 8 — Benchmark all 3 (extraction + A/B) - -Make the dev build the codegraph on PATH **once**, then loop: -```bash -npm run build && ./scripts/local-install.sh -scripts/add-lang/bench.sh "" headless # ×3 -``` -`bench.sh` clones (shared `/tmp/codegraph-corpus`), wipes + indexes, runs -`verify-extraction.mjs`, then the with/without retrieval A/B via -`scripts/agent-eval/run-all.sh` (skips the paid A/B if extraction is broken). -Read each `parse-run.mjs` summary printed by `run-all.sh`: tool calls, file -`Read`s, Grep/Bash, codegraph-tool calls, duration, and **cost** — for both the -`with` and `without` arms. After the loop, restore the dev link if needed: -`./scripts/local-install.sh`. - -### Step 9 — Docs + CHANGELOG - -- **README.md**: add `` to the "19+ Languages" feature bullet, and add a - row to the **Supported Languages** table: - `| | \`.ext\` | Full support (classes, methods, …) |`. -- **CHANGELOG.md**: add an `## [Unreleased]` section at the top (above the - latest version) with `### Added` → a user-perspective bullet, e.g. - *"CodeGraph now indexes **** (`.ext`) — functions, classes, imports, and - call edges."* If `## [Unreleased]` already exists, append under it. (It's - folded into the next versioned block at release time.) - -### Step 10 — Report (do NOT commit) - -Summarize for review: -- **Files changed**: the 4 wiring edits + new extractor + tests + README + - CHANGELOG + corpus.json (+ any vendored `.wasm`). -- **Extraction** per repo: files / nodes / edges / `verify-extraction` result. -- **A/B** per repo: `with` vs `without` (tool calls, file Reads, cost) and a - one-line verdict — did codegraph reduce effort, and did both arms reach a - correct answer? -- **Gaps / follow-ups** (node types not yet mapped, resolution edges missing, - framework routes, etc.). - -Hand the changes to the user. **Do not** run `git commit`/`push` or publish — -releases go through the GitHub Actions Release workflow. - -## Notes -- The A/B spawns real **paid** `claude -p` runs (opus, `--max-budget-usd`), - 2 arms × 3 repos. The corpus dir `/tmp/codegraph-corpus` is shared with - `/agent-eval`, so clones are reused across runs. -- Any new `*.wasm` must live in `src/extraction/wasm/` — `copy-assets` (run by - `npm run build`) ships it; otherwise it won't be in `dist/`. -- An index must be served by the **same** binary that built it. Step 8 builds + - links the dev build first, so this holds. -- If a grammar can't be obtained, or extraction can't reach PASS, **STOP and - report** — don't ship a half-wired language. diff --git a/.claude/skills/agent-eval/SKILL.md b/.claude/skills/agent-eval/SKILL.md deleted file mode 100644 index 2e894a75..00000000 --- a/.claude/skills/agent-eval/SKILL.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -name: agent-eval -description: Benchmark CodeGraph retrieval quality on a real codebase by comparing agent behavior with vs without CodeGraph. Use when the user runs /agent-eval or asks to test, benchmark, audit, or validate a codegraph version (the local dev build or a published npm version) against a language's repo. ---- - -# CodeGraph Quality Audit - -Measures how much CodeGraph helps an agent versus plain grep/read, for a chosen -codegraph version on a chosen real-world repo. Drives the harness in -`scripts/agent-eval/`. - -## Prerequisites -- `tmux` 3+, a logged-in `claude` CLI, `node`, `git` (macOS/Linux). -- Run from the codegraph repo root. - -## Workflow - -Copy this checklist: -``` -- [ ] 1. Pick version (local or npm) -- [ ] 2. Pick language -- [ ] 3. Pick repo by size -- [ ] 4. Pick harness (headless / tmux / both) -- [ ] 5. Run audit.sh in the background -- [ ] 6. Report results -``` - -**Step 1 — version.** Ask with `AskUserQuestion`: which codegraph version to test. -Offer "Local dev build" and "Latest published"; the free-text "Other" lets the -user type a specific version (e.g. `0.7.10`). Map the answer to a VERSION token: -- "Local dev build" → `local` -- "Latest published" → `latest` -- a typed version → that string (e.g. `0.7.10`) - -**Step 2 — language.** Read `.claude/skills/agent-eval/corpus.json`. Ask with -`AskUserQuestion` which language to test, listing the languages that have entries. - -**Step 3 — repo.** From the chosen language's entries, ask which repo. Label each -option with its size and file count, e.g. `excalidraw — Medium (~600 files)`. -Each entry carries the `repo` URL and a representative `question`. - -**Step 4 — harness.** Ask with `AskUserQuestion` which harness to run, and map -the answer to a MODE token: -- "Headless" → `headless` — `claude -p` with stream-json: exact tokens/cost and a - clean tool sequence (2 runs, fast, no TTY). -- "Interactive (tmux)" → `tmux` — drives the real Claude TUI in tmux: faithful - Explore-subagent behavior, metrics from session logs (2 runs, slower). -- "Both" → `all` — headless + interactive (4 runs). - -**Step 5 — run.** Launch in the background (sets the version, clones if missing, -wipes + re-indexes, runs the chosen arms — several minutes): -```bash -scripts/agent-eval/audit.sh "" -``` - -**Step 6 — report.** When the job finishes, read the log and report per arm: -- Headless (`parse-run.mjs`): total tool calls, file `Read`s, Grep/Bash, - codegraph-tool calls, duration, **total cost**. -- Interactive (`parse-session.mjs`): the `VERDICT: codegraph_explore used Nx | - Read N | Grep/Bash N` and `TOKENS:` lines. - -Lead with cost + tool/Read counts — they are the reliable signals; raw token -in/out are confounded by subagent delegation and prompt caching. State whether -codegraph reduced effort and whether both arms reached a correct answer. - -## Notes -- The index is rebuilt every run (`audit.sh` wipes `.codegraph`) — different - versions extract differently, so an index must be served by the same binary - that built it. -- `audit.sh` temporarily mutates the global `codegraph` install for the test, - then restores your dev link via `local-install.sh`. -- Corpus repos are cloned to `/tmp/codegraph-corpus` (reused if already present). -- Add or edit repos in `corpus.json` (fields: `name`, `repo`, `size`, `files`, - `question`). diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json deleted file mode 100644 index 3dcc8752..00000000 --- a/.claude/skills/agent-eval/corpus.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "_comment": "Test corpus for /agent-eval. Add entries freely. size: Small (<~150 files), Medium (~150-1500), Large (>~1500). 'question' is a representative architectural question that exercises cross-file understanding.", - "TypeScript": [ - { "name": "ky", "repo": "https://github.com/sindresorhus/ky", "size": "Small", "files": "~25", "question": "How does ky implement request retries and timeouts?" }, - { "name": "excalidraw", "repo": "https://github.com/excalidraw/excalidraw", "size": "Medium", "files": "~600", "question": "How does Excalidraw render and update canvas elements?" }, - { "name": "vscode", "repo": "https://github.com/microsoft/vscode", "size": "Large", "files": "~10000", "question": "How does the extension host communicate with the main process?" } - ], - "JavaScript": [ - { "name": "express", "repo": "https://github.com/expressjs/express", "size": "Small", "files": "~50", "question": "How does Express route a request through its middleware stack?" } - ], - "Go": [ - { "name": "cobra", "repo": "https://github.com/spf13/cobra", "size": "Small", "files": "~50", "question": "How does cobra parse commands and flags?" }, - { "name": "gin", "repo": "https://github.com/gin-gonic/gin", "size": "Medium", "files": "~150", "question": "How does gin route requests through its middleware chain?" }, - { "name": "terraform", "repo": "https://github.com/hashicorp/terraform", "size": "Large", "files": "~4000", "question": "How does Terraform build and walk the resource dependency graph?" } - ], - "Python": [ - { "name": "click", "repo": "https://github.com/pallets/click", "size": "Small", "files": "~60", "question": "How does click parse command-line arguments into commands?" }, - { "name": "flask", "repo": "https://github.com/pallets/flask", "size": "Medium", "files": "~90", "question": "How does Flask dispatch a request to a view function?" }, - { "name": "django", "repo": "https://github.com/django/django", "size": "Large", "files": "~2700", "question": "How does Django's ORM build and execute a query from a QuerySet?" } - ], - "Rust": [ - { "name": "clap", "repo": "https://github.com/clap-rs/clap", "size": "Medium", "files": "~200", "question": "How does clap parse arguments against a derived command definition?" }, - { "name": "tokio", "repo": "https://github.com/tokio-rs/tokio", "size": "Large", "files": "~700", "question": "How does tokio schedule and run async tasks on its runtime?" }, - { "name": "deno", "repo": "https://github.com/denoland/deno", "size": "Large", "files": "~1500", "question": "How does Deno load and execute a TypeScript module?" } - ], - "Java": [ - { "name": "gson", "repo": "https://github.com/google/gson", "size": "Medium", "files": "~200", "question": "How does Gson serialize an object to JSON?" }, - { "name": "okhttp", "repo": "https://github.com/square/okhttp", "size": "Medium", "files": "~640", "question": "How does OkHttp process a request through its interceptor chain?" }, - { "name": "guava", "repo": "https://github.com/google/guava", "size": "Large", "files": "~3000", "question": "How does Guava's CacheBuilder build and configure a cache?" } - ], - "Kotlin": [ - { "name": "koin", "repo": "https://github.com/InsertKoinIO/koin", "size": "Medium", "files": "~300", "question": "How does Koin resolve and inject dependencies?" }, - { "name": "leakcanary", "repo": "https://github.com/square/leakcanary", "size": "Medium", "files": "~250", "question": "How does LeakCanary detect and analyze a memory leak?" } - ], - "Swift": [ - { "name": "alamofire", "repo": "https://github.com/Alamofire/Alamofire", "size": "Small", "files": "~100", "question": "How does Alamofire build, send, and validate a request?" } - ], - "C#": [ - { "name": "serilog", "repo": "https://github.com/serilog/serilog", "size": "Medium", "files": "~250", "question": "How does Serilog route a log event to its sinks?" }, - { "name": "jellyfin", "repo": "https://github.com/jellyfin/jellyfin", "size": "Large", "files": "~2500", "question": "How does Jellyfin scan and identify items in a media library?" } - ], - "Ruby": [ - { "name": "sinatra", "repo": "https://github.com/sinatra/sinatra", "size": "Small", "files": "~60", "question": "How does Sinatra match a request to a route handler?" }, - { "name": "discourse", "repo": "https://github.com/discourse/discourse", "size": "Large", "files": "~3000", "question": "How does Discourse create and render a new post?" } - ], - "PHP": [ - { "name": "slim", "repo": "https://github.com/slimphp/Slim", "size": "Small", "files": "~80", "question": "How does Slim handle a request through its middleware?" }, - { "name": "laravel", "repo": "https://github.com/laravel/framework", "size": "Large", "files": "~3000", "question": "How does Laravel resolve and dispatch a route to a controller?" } - ], - "C": [ - { "name": "redis", "repo": "https://github.com/redis/redis", "size": "Large", "files": "~600", "question": "How does Redis parse and dispatch a client command?" } - ], - "C++": [ - { "name": "json", "repo": "https://github.com/nlohmann/json", "size": "Small", "files": "~100", "question": "How does nlohmann::json parse a JSON string into a value?" }, - { "name": "grpc", "repo": "https://github.com/grpc/grpc", "size": "Large", "files": "~3000", "question": "How does gRPC dispatch an incoming RPC to its handler?" } - ], - "Dart": [ - { "name": "flutter", "repo": "https://github.com/flutter/flutter", "size": "Large", "files": "~6000", "question": "How does Flutter build and lay out a widget tree?" } - ], - "Svelte": [ - { "name": "shadcn-svelte", "repo": "https://github.com/huntabyte/shadcn-svelte", "size": "Medium", "files": "~600", "question": "How do shadcn-svelte components compose and apply their styling?" } - ], - "Lua": [ - { "name": "lualine.nvim", "repo": "https://github.com/nvim-lualine/lualine.nvim", "size": "Small", "files": "~120", "question": "How does lualine assemble and render its statusline sections and components?" }, - { "name": "telescope.nvim", "repo": "https://github.com/nvim-telescope/telescope.nvim", "size": "Medium", "files": "~80", "question": "How does Telescope wire a picker to its finder, sorter, and previewer?" }, - { "name": "kong", "repo": "https://github.com/Kong/kong", "size": "Large", "files": "~1330", "question": "How does Kong execute plugins across a request's lifecycle phases?" } - ], - "Luau": [ - { "name": "Knit", "repo": "https://github.com/Sleitnick/Knit", "size": "Small", "files": "~10", "question": "How does Knit register services and expose them to clients?" }, - { "name": "vide", "repo": "https://github.com/centau/vide", "size": "Small", "files": "~40", "question": "How does vide track reactive sources and re-run effects when state changes?" }, - { "name": "Fusion", "repo": "https://github.com/dphfox/Fusion", "size": "Medium", "files": "~115", "question": "How does Fusion build and update its reactive UI graph from state objects?" } - ] -} diff --git a/.cursor/rules/codegraph.mdc b/.cursor/rules/codegraph.mdc deleted file mode 100644 index 3f23cf6b..00000000 --- a/.cursor/rules/codegraph.mdc +++ /dev/null @@ -1,38 +0,0 @@ ---- -description: CodeGraph MCP usage guide — when to use which tool -alwaysApply: true ---- - -## CodeGraph - -This project has a CodeGraph MCP server (`codegraph_*` tools) configured. CodeGraph is a tree-sitter-parsed knowledge graph of every symbol, edge, and file. Reads are sub-millisecond and return structural information grep cannot. - -### When to prefer codegraph over native search - -Use codegraph for **structural** questions — what calls what, what would break, where is X defined, what is X's signature. Use native grep/read only for **literal text** queries (string contents, comments, log messages) or after you already have a specific file open. - -| Question | Tool | -|---|---| -| "Where is X defined?" / "Find symbol named X" | `codegraph_search` | -| "What calls function Y?" | `codegraph_callers` | -| "What does Y call?" | `codegraph_callees` | -| "What would break if I changed Z?" | `codegraph_impact` | -| "Show me Y's signature / source / docstring" | `codegraph_node` | -| "Give me focused context for a task/area" | `codegraph_context` | -| "See several related symbols' source at once" | `codegraph_explore` | -| "What files exist under path/" | `codegraph_files` | -| "Is the index healthy?" | `codegraph_status` | - -### Rules of thumb - -- **Answer directly — don't delegate exploration.** For "how does X work" / architecture / trace questions, answer with 2-3 codegraph calls: `codegraph_context` first, then ONE `codegraph_explore` for the source of the symbols it surfaces. Codegraph IS the pre-built index, so spawning a separate file-reading sub-task/agent — or running a grep + read loop — repeats work codegraph already did and costs more for the same answer. -- **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context. -- **Don't grep first** when looking up a symbol by name. `codegraph_search` is faster and returns kind + location + signature in one call. -- **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call. -- **Don't loop `codegraph_node` over many symbols** — one `codegraph_explore` call returns several symbols' source grouped in a single capped call, while each separate node/Read call re-reads the whole context and costs far more. -- **Index lag**: the file watcher debounces ~500ms behind writes; don't re-query immediately after editing a file in the same turn. - -### If `.codegraph/` doesn't exist - -The MCP server returns "not initialized." Ask the user: *"I notice this project doesn't have CodeGraph initialized. Want me to run `codegraph init -i` to build the index?"* - diff --git a/.github/workflows/aur-git.yml b/.github/workflows/aur-git.yml new file mode 100644 index 00000000..8d2eb572 --- /dev/null +++ b/.github/workflows/aur-git.yml @@ -0,0 +1,27 @@ +name: Publish AUR (codegraph-rs-git) + +on: + push: + branches: [main] + paths: + - packaging/aur/codegraph-rs-git/PKGBUILD + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Publish to AUR + uses: KSXGitHub/github-actions-deploy-aur@v4.1.3 + with: + pkgname: codegraph-rs-git + pkgbuild: packaging/aur/codegraph-rs-git/PKGBUILD + commit_username: ${{ secrets.AUR_USERNAME }} + commit_email: ${{ secrets.AUR_EMAIL }} + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: "Update PKGBUILD" + ssh_keyscan_types: rsa,ecdsa,ed25519 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..23355b0b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings + +jobs: + fmt: + name: rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all -- --check + + clippy: + name: clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --workspace --all-targets -- -D warnings + + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --workspace --no-fail-fast diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51dea151..9cb923b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,121 +1,166 @@ name: Release -# Manually triggered ("Run workflow"). On trigger it: -# 1. reads the version from package.json, -# 2. builds a self-contained bundle for every platform (one runner — there's no -# native compilation, so cross-packaging is fine), -# 3. creates the GitHub Release (tag v) with all archives, using the -# release notes from CHANGELOG.md, -# 4. publishes the npm thin-installer (shim + per-platform packages). -# -# Before triggering: bump package.json and make sure CHANGELOG.md has the matching -# section (## [], or ## [Unreleased]). Set the NPM_TOKEN repo secret. on: - workflow_dispatch: {} + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Tag (e.g. v0.1.0). Required for manual runs." + required: false permissions: - contents: write # create the GitHub Release + tag + contents: write + +env: + CARGO_TERM_COLOR: always jobs: - release: - runs-on: ubuntu-latest + build: + name: build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu, ext: "" } + - { os: ubuntu-latest, target: x86_64-unknown-linux-musl, ext: "" } + - { os: ubuntu-latest, target: aarch64-unknown-linux-gnu, ext: "", cross: true } + - { os: macos-latest, target: x86_64-apple-darwin, ext: "" } + - { os: macos-latest, target: aarch64-apple-darwin, ext: "" } + - { os: windows-latest, target: x86_64-pc-windows-msvc, ext: ".exe" } steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 with: - node-version: 22 - registry-url: https://registry.npmjs.org - - run: npm ci - - name: Ensure zip/unzip - run: sudo apt-get update -qq && sudo apt-get install -y -qq zip unzip + key: ${{ matrix.target }} - - name: Build all platform bundles - run: | - for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64 win32-x64 win32-arm64; do - bash scripts/build-bundle.sh "$t" - done - ls -lh release - - - name: Generate SHA256SUMS - # Published as a release asset; the npm launcher verifies downloaded - # bundles against it (basenames only, so its path.basename match works). - run: | - ( cd release && sha256sum codegraph-* > SHA256SUMS ) - cat release/SHA256SUMS + - name: Install musl tools + if: matrix.target == 'x86_64-unknown-linux-musl' + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Install cross + if: matrix.cross + run: cargo install cross --locked + + - name: Build (cross) + if: matrix.cross + run: cross build --release --target ${{ matrix.target }} -p codegraph - - name: Resolve version - id: ver - run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + - name: Build (native) + if: ${{ !matrix.cross }} + run: cargo build --release --target ${{ matrix.target }} -p codegraph - - name: Release notes from CHANGELOG.md + - name: Package + shell: bash run: | - V="${{ steps.ver.outputs.version }}" - node scripts/extract-release-notes.mjs "$V" > notes.md 2>/dev/null \ - || node scripts/extract-release-notes.mjs Unreleased > notes.md 2>/dev/null || true - if [ ! -s notes.md ]; then - echo "::error::No release notes in CHANGELOG.md for [$V] or [Unreleased]." - exit 1 + set -euo pipefail + bin="target/${{ matrix.target }}/release/codegraph${{ matrix.ext }}" + name="codegraph-${{ matrix.target }}" + mkdir -p dist staging + cp "$bin" staging/ + [ -f README.md ] && cp README.md staging/ || true + [ -f LICENSE ] && cp LICENSE staging/ || true + if [[ "${{ matrix.ext }}" == ".exe" ]]; then + ( cd staging && 7z a "../dist/${name}.zip" . ) + else + tar -czf "dist/${name}.tar.gz" -C staging . fi - echo "----- release notes -----"; cat notes.md - - name: Create GitHub Release - env: - GH_TOKEN: ${{ github.token }} + - uses: actions/upload-artifact@v6 + with: + name: codegraph-${{ matrix.target }} + path: dist/* + + release: + name: GitHub Release + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + outputs: + tag: ${{ steps.tag.outputs.tag }} + version: ${{ steps.tag.outputs.version }} + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v6 + with: + path: artifacts + merge-multiple: true + + - name: Resolve tag + id: tag run: | - TAG="v${{ steps.ver.outputs.version }}" - # Idempotent: create the release once, otherwise (re-run) refresh assets. - if gh release view "$TAG" >/dev/null 2>&1; then - gh release upload "$TAG" release/codegraph-* release/SHA256SUMS --clobber + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + tag="${{ inputs.tag }}" + [ -n "$tag" ] || { echo "manual run requires inputs.tag" >&2; exit 1; } else - gh release create "$TAG" release/codegraph-* release/SHA256SUMS --title "$TAG" --notes-file notes.md + tag="${GITHUB_REF#refs/tags/}" fi + [[ "$tag" =~ ^v ]] || { echo "tag must start with v" >&2; exit 1; } + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=${tag#v}" >> "$GITHUB_OUTPUT" - - name: Publish to npm + - name: Create release env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - V="${{ steps.ver.outputs.version }}" - bash scripts/pack-npm.sh "$V" - # Platform packages first, then the main shim (which depends on them). - # Skip any already on the registry so a re-run only fills in gaps. - for dir in release/npm/codegraph-* release/npm/main; do - name=$(node -p "require('./$dir/package.json').name") - if npm view "$name@$V" version >/dev/null 2>&1; then - echo "skip $name@$V (already published)" - else - echo "publishing $name@$V" - ( cd "$dir" && npm publish --access public ) - fi - done - - - name: Verify every package is actually on the registry + tag="${{ steps.tag.outputs.tag }}" + gh release view "$tag" >/dev/null 2>&1 \ + && gh release upload "$tag" artifacts/* --clobber \ + || gh release create "$tag" --draft --title "$tag" --generate-notes artifacts/* + + aur: + name: Publish AUR (codegraph-rs-bin) + needs: release + runs-on: ubuntu-latest + if: ${{ needs.release.outputs.tag != '' }} + steps: + - uses: actions/checkout@v6 + + - name: Download release archives + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.release.outputs.tag }} run: | - V="${{ steps.ver.outputs.version }}" - # npm publish can print success without persisting; confirm against the - # registry (with retries for propagation) so green means really shipped. - for dir in release/npm/codegraph-* release/npm/main; do - name=$(node -p "require('./$dir/package.json').name") - ok= - for i in 1 2 3 4 5 6; do - if npm view "$name@$V" version >/dev/null 2>&1; then ok=1; break; fi - echo "waiting for $name@$V to appear ($i)…"; sleep 10 - done - [ -n "$ok" ] || { echo "::error::$name@$V never appeared on the registry"; exit 1; } - echo "verified $name@$V" - done - - - name: Sync packages to npmmirror - # npmmirror/cnpm mirror lazily and frequently never pull the per-platform - # optionalDependencies on their own, so `npm i` there fails with - # "no prebuilt bundle" (issue #303). Nudge a sync now so mirror users get - # the bundle without waiting. Best-effort — the launcher also self-heals - # from GitHub Releases — so a mirror hiccup never fails the release. - continue-on-error: true + set -euo pipefail + mkdir -p dl + gh release download "$TAG" \ + --repo "${{ github.repository }}" \ + --pattern 'codegraph-x86_64-unknown-linux-musl.tar.gz' \ + --pattern 'codegraph-aarch64-unknown-linux-gnu.tar.gz' \ + --dir dl + cd dl + sha256sum codegraph-x86_64-unknown-linux-musl.tar.gz | awk '{print $1}' > x86_64.sha256 + sha256sum codegraph-aarch64-unknown-linux-gnu.tar.gz | awk '{print $1}' > aarch64.sha256 + + - name: Render PKGBUILD + env: + VERSION: ${{ needs.release.outputs.version }} run: | - for dir in release/npm/codegraph-* release/npm/main; do - name=$(node -p "require('./$dir/package.json').name") - enc=$(node -p "encodeURIComponent(require('./$dir/package.json').name)") - echo "sync $name" - curl -s -X PUT "https://registry.npmmirror.com/-/package/$enc/syncs" || true - echo - done + set -euo pipefail + x86_sha=$(cat dl/x86_64.sha256) + arm_sha=$(cat dl/aarch64.sha256) + cd packaging/aur/codegraph-rs-bin + + sed -i \ + -e "s/^pkgver=.*/pkgver=$VERSION/" \ + -e "s/^pkgrel=.*/pkgrel=1/" \ + -e "s/^sha256sums_x86_64=.*/sha256sums_x86_64=('$x86_sha')/" \ + -e "s/^sha256sums_aarch64=.*/sha256sums_aarch64=('$arm_sha')/" \ + PKGBUILD + echo "--- PKGBUILD ---"; cat PKGBUILD + + - name: Publish to AUR + uses: KSXGitHub/github-actions-deploy-aur@v4.1.3 + with: + pkgname: codegraph-rs-bin + pkgbuild: packaging/aur/codegraph-rs-bin/PKGBUILD + commit_username: ${{ secrets.AUR_USERNAME }} + commit_email: ${{ secrets.AUR_EMAIL }} + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: "Update to ${{ needs.release.outputs.tag }}" + ssh_keyscan_types: rsa,ecdsa,ed25519 diff --git a/.gitignore b/.gitignore index 435882b3..707c6eb7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,52 +1,7 @@ -# Dependencies -node_modules/ - -# Build output -dist/ - -.cmem - -# IDE -.idea/ -.vscode/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Test coverage -/coverage/ -.nyc_output/ - -# Environment -.env -.env.local -.env.*.local - -# Logs -*.log -npm-debug.log* - -# TypeScript build info -*.tsbuildinfo - -# SQLite WAL mode files -*.db-wal -*.db-shm - -# Local Claude settings -.claude/settings.local.json - -# CodeGraph data directories (in test projects) +/target +**/*.rs.bk +Cargo.lock.bak .codegraph/ - -test_frameworks - -# Test language repos for manual testing -test-languages/ - -nul -release/ +.DS_Store +.idea/ +bun.lock diff --git a/BUNDLING.md b/BUNDLING.md deleted file mode 100644 index dc21ab53..00000000 --- a/BUNDLING.md +++ /dev/null @@ -1,74 +0,0 @@ -# Distribution: self-contained bundles - -CodeGraph ships a **vendored Node runtime** alongside the app. Because Node 22.5+ -has a built-in real SQLite (`node:sqlite`, with WAL + FTS5), bundling Node means: - -- **No native build** — `better-sqlite3` is gone, so there are zero native addons - to compile or rebuild. -- **No wasm fallback** — and therefore no more `database is locked` (issue #238). -- **No Node-version dependence** — the app always runs on the bundled Node, - whatever the user has (or doesn't have) installed. - -## What's in a bundle - -Built by [`scripts/build-bundle.sh`](scripts/build-bundle.sh) — one archive per -platform, identical recipe (only the Node download differs): - -``` -codegraph-/ - node | node.exe # official Node runtime for - lib/ - dist/ # compiled app (+ tree-sitter .wasm grammars, schema.sql) - node_modules/ # production deps only (pure JS / wasm — portable) - bin/ - codegraph | codegraph.cmd # launcher → runs the bundled Node with the app -``` - -Targets: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64`, -`win32-arm64`. Unix targets produce `.tar.gz` (shell launcher); Windows produces -`.zip` (`node.exe` + a `.cmd` launcher). - -```bash -scripts/build-bundle.sh linux-x64 # -> release/codegraph-linux-x64.tar.gz -scripts/build-bundle.sh win32-x64 # -> release/codegraph-win32-x64.zip -``` - -Because dropping better-sqlite3 left **zero native addons**, building a bundle is -pure file-packaging — **any** target builds on **any** OS (the whole matrix builds -on one Linux runner). Cross-compilation isn't a concern; only *run-testing* a -bundle needs the target platform (or emulation, e.g. `docker run --platform -linux/amd64`). - -## Install channels (all deliver the same bundle) - -1. **`curl | sh`** ([`install.sh`](install.sh)) — no Node required; ideal for a - fresh Linux VPS over SSH. Detects os/arch, pulls the archive from GitHub - Releases, symlinks `codegraph` onto PATH. Re-run to upgrade; `--uninstall` to - remove. -2. **npm** ([`scripts/npm-shim.js`](scripts/npm-shim.js)) — preserves - `npm i -g @colbymchenry/codegraph`. The main package is a tiny shim; the - bundles ship as per-platform `optionalDependencies` - (`@colbymchenry/codegraph-` with `os`/`cpu`), so npm installs only the - matching one. The shim — run by the user's Node — execs the bundle, so the - real work runs on the bundled Node 24. Works even on old Node. On Windows it - invokes the bundled `node.exe` against the app entry directly (not the `.cmd` - launcher) — modern Node throws `EINVAL` when asked to spawn a `.cmd`/`.bat`. -3. **Windows** ([`install.ps1`](install.ps1)) — `irm … | iex`; same flow as - install.sh (detect arch, pull the `.zip` from Releases, add to PATH). -4. **Homebrew / Scoop** — TODO (tap + cask pointing at the Release archives). - -## Release pipeline - -[`.github/workflows/release.yml`](.github/workflows/release.yml) — manually -triggered. Reads the version from `package.json`, builds every platform bundle on -one runner, creates the GitHub Release (notes from `CHANGELOG.md`), and publishes -the npm shim + per-platform packages. Requires the `NPM_TOKEN` repo secret. - -Still TODO: -- **Code signing** — the main gap for "download & run": macOS Gatekeeper needs a - Developer ID + notarization; Windows needs Authenticode. Homebrew softens the - macOS case (handles quarantine). -- Retire the now-vestigial Node-version gate in `src/bin/codegraph.ts` — the - bundle always runs Node 24, and the npm shim does no tree-sitter work. -- Re-wire `npm uninstall` cleanup (the agent-config `preuninstall`) through the - shim — the generated main package doesn't carry it. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 535b0ce9..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,576 +0,0 @@ -# Changelog - -All notable changes to CodeGraph are documented here. Each entry also ships as -a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged -`vX.Y.Z`, which is where most people will look. - -This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) -and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [0.9.4] - 2026-05-22 - -### Added -- **Release archives now ship with a `SHA256SUMS` file**, and the npm launcher - verifies the bundle it downloads against it — a mismatch aborts before - anything runs. Releases published before this change have no checksum file, so - the verification is skipped (not failed) when none is available. - -### Fixed -- **`codegraph: no prebuilt bundle for ` after installing through a - registry mirror.** Installing `@colbymchenry/codegraph` from a registry that - hadn't mirrored the matching per-platform package — most often the - npmmirror/cnpm mirrors, but any lazily-syncing mirror or corporate proxy can - do it — left every command failing with `no prebuilt bundle for `. - The runtime ships as a per-platform `optionalDependency`, and npm treats an - optional package it can't fetch as a success and silently skips it, so the - bundle simply went missing. The launcher now self-heals: when the platform - bundle isn't installed, it downloads the same archive from GitHub Releases - (cached under `~/.codegraph/bundles/` for next time) and runs that — so a - global install works even on a mirror that never carried the platform package. - Set `CODEGRAPH_NO_DOWNLOAD=1` to disable the network fallback, or - `CODEGRAPH_DOWNLOAD_BASE=` to point it at your own mirror of the release - archives; the standalone `install.sh` remains the no-Node alternative. Resolves - [#303](https://github.com/colbymchenry/codegraph/issues/303). - -## [0.9.3] - 2026-05-22 - -### Added -- **`codegraph uninstall` command.** Cleanly removes CodeGraph from every agent - it's configured on — Claude Code, Cursor, Codex CLI, opencode, and Hermes - Agent — in one step. It asks up front whether to remove the global config - (`~/.claude`, `~/.codex`, …) or just this project's local config (no flags - required), then prints exactly which agents it touched so you can see what - changed. `--location`, `--target`, and `--yes` are accepted for scripted / - non-interactive use. It removes only what `install` wrote (MCP server entry, - instructions block, permissions) and leaves your `.codegraph/` index alone - (use `codegraph uninit` for that). Resolves - [#313](https://github.com/colbymchenry/codegraph/issues/313) — previously the - only cleanup path was an npm `preuninstall` hook that the published bundle - never shipped, so `npm uninstall -g` left every agent pointing at a CodeGraph - MCP server that no longer existed. - -### Fixed -- **`Fatal process out of memory: Zone` crash while indexing large projects.** - On Node.js 22 and 24 — including CodeGraph's own bundled runtime — running - `codegraph index` / `codegraph init` on a large multi-language repo could - abort the entire process partway through parsing with - `Fatal process out of memory: Zone`, even with tens of GB of RAM free (the - failure is in a V8-internal compilation arena, not the JS heap). The cause is - V8's "turboshaft" optimizing WASM compiler exhausting its Zone budget while - compiling tree-sitter's large WebAssembly grammars on a background thread. - CodeGraph now runs with V8's `--liftoff-only`, which keeps grammar compilation - on the baseline compiler and never reaches the optimizing tier, eliminating - the crash; indexing output is otherwise unchanged. The bundled launcher passes - the flag directly, and any other launch path (from source, `npx`, a globally - linked dev build) re-execs once with it automatically. Resolves - [#298](https://github.com/colbymchenry/codegraph/issues/298) and - [#293](https://github.com/colbymchenry/codegraph/issues/293). (Node 25 stays - blocked — its variant of this V8 bug is not resolved by `--liftoff-only`.) -- **Cursor uninstall left an orphaned `.cursor/rules/codegraph.mdc`.** It - stripped the rule body but left the file and its `description: CodeGraph …` - frontmatter behind. The dedicated rules file is now deleted outright on - uninstall, while any content you added outside CodeGraph's markers is kept. - -## [0.9.2] - 2026-05-21 - -### Added -- **Installer target: Hermes Agent (Nous Research).** `codegraph install` now - supports Hermes Agent — it writes the `mcp_servers.codegraph` entry and ensures - `platform_toolsets.cli` includes `mcp-codegraph` in `$HERMES_HOME/config.yaml`, - so Hermes can drive the CodeGraph knowledge graph like the other agents. -- **Framework support: Drupal 8/9/10/11** — CodeGraph now detects Drupal - projects (via a `drupal/*` dependency in `composer.json`) and adds three - levels of intelligence: - - **Route extraction**: `*.routing.yml` files emit a `route` node per route, - linked by a `references` edge to the `_controller`, `_form`, or - entity-handler class/method, so querying a controller method surfaces the - URL route that binds it. - - **Hook detection**: hook implementations in `.module`, `.install`, `.theme`, - and `.inc` files are detected via docblock (`Implements hook_X()`) with a - module-name-prefix fallback. Each emits a `references` edge to the canonical - `hook_X` name so `codegraph_callers("hook_form_alter")` returns every - implementation across modules. - - **Resolution**: `_controller`/`_form` FQCNs resolve to their PHP - class/method nodes. - New `yaml`/`twig` languages are tracked at the file level, the Drupal PHP - extensions (`.module`/`.install`/`.theme`/`.inc`) are indexed with the PHP - grammar, and `web/core`, `web/modules/contrib`, `web/themes/contrib` are - excluded by default. Resolves [#268](https://github.com/colbymchenry/codegraph/issues/268). - -### Changed -- **Zero-config indexing that respects `.gitignore`.** CodeGraph no longer has a - config file. It indexes every file whose extension maps to a supported language - and honors your `.gitignore` everywhere: in git repos via git itself, and in - non-git projects (e.g. a freshly-scaffolded app before `git init`) by reading - `.gitignore` files directly — root and nested, the same way git does (via the - `ignore` library, so negation/anchoring/nested rules all behave correctly). To - keep something out of the graph, add it to `.gitignore`. **Behavior change:** - committed files that are *not* gitignored are now indexed even under `vendor/`, - `Pods/`, or a committed `dist/` — previously a hardcoded exclude list skipped - those names; now `.gitignore` is the single source of truth. Resolves - [#283](https://github.com/colbymchenry/codegraph/issues/283). - -### Fixed -- **Windows: `npm i -g @colbymchenry/codegraph` then any `codegraph` command - failed with `spawnSync …\codegraph.cmd EINVAL`.** The npm launcher spawned the - bundle's `.cmd` file directly, which modern Node refuses to do on Windows - (the CVE-2024-27980 hardening — seen on Node 24). The launcher now invokes the - bundled `node.exe` against the app directly, so `codegraph` works on Windows - regardless of your Node version. Resolves - [#289](https://github.com/colbymchenry/codegraph/issues/289). - -### Removed -- **`.codegraph/config.json` and the entire config surface.** Every field was - either inert or now redundant with `.gitignore`: - - `languages`/`frameworks` never affected indexing (languages are detected per - file from extensions; frameworks are auto-detected). `languages` was also - broken — its validator only knew the original 8 languages, so setting it to - anything newer (C#, PHP, Ruby, C/C++, Swift, Kotlin, Dart, Vue, Scala, Lua, …) - threw `Invalid configuration format`. - - `extractDocstrings`/`trackCallSites`/`customPatterns` were never read by any - extractor. - - `include` is now derived from the supported language extensions, `exclude` is - replaced by `.gitignore`, and `maxFileSize` (1 MB) is a constant. - - **Breaking (library API):** the `CodeGraphConfig` type, the `config` option on - `CodeGraph.init()`, and the `getConfig()`/`updateConfig()`/`getConfigPath` - exports are gone. Existing `.codegraph/config.json` files are simply ignored. - The `.codegraphignore` marker is no longer supported — use `.gitignore`. - -### Security -- **MCP session marker no longer follows symlinks** (CWE-59). Every - `codegraph_context` call writes a `codegraph-consulted-*` marker into the - system temp dir; the previous write followed symlinks, so on a multi-user - system another local user could pre-plant that path as a symlink and redirect - the write onto a victim-writable file. The marker is now opened with - `O_NOFOLLOW` and mode `0600`, and a planted symlink is refused rather than - followed. Resolves [#280](https://github.com/colbymchenry/codegraph/issues/280). - -## [0.9.1] - 2026-05-21 - -### Fixed -- **Standalone installers** (`curl … | sh`, `irm … | iex`): the bundled launcher - failed with `exec: …/node: not found` because it didn't resolve the symlink the - installer puts on your PATH. Installing on a machine with **no Node** now works. -- **npm**: `@colbymchenry/codegraph-linux-x64` is now published — the 0.9.0 - release silently shipped 6 of 7 packages, so `npm i -g` on linux-x64 couldn't - find its bundle. The release pipeline now verifies every package reached the - registry (and is idempotent), so a release can't pass green-but-broken again. - -[0.9.4]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.4 -[0.9.3]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.3 -[0.9.2]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.2 -[0.9.1]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.1 - -## [0.9.0] - 2026-05-21 - -### 🎉 Self-contained: CodeGraph bundles its own runtime — install anywhere, on any Node (or none) - -**No more `database is locked`. No more native build failures. No more "WASM fallback active."** - -CodeGraph used to need `better-sqlite3`, a native module compiled against your exact -Node version. When that build failed (common on Windows and locked-down machines) it -silently dropped to a slow WASM SQLite build with **no WAL** — the root cause of the -intermittent `database is locked` errors on concurrent MCP tool calls -([#238](https://github.com/colbymchenry/codegraph/issues/238)). That entire class of -problem is **gone**: CodeGraph now ships a self-contained Node runtime and uses Node's -built-in `node:sqlite` (real SQLite, full WAL + FTS5). - -- ✅ **Zero native compilation** — nothing to build, ever; nothing to rebuild when Node changes. -- ✅ **Runs on any Node version — or with no Node at all.** Install via the standalone installers with no Node present, or keep using `npm`/`npx` on any version (your Node only launches the bundled runtime). -- ✅ **`database is locked` fixed at the root** — real WAL means readers never block on a writer. -- ⚡ **5–10× faster** than the old WASM fallback for anyone who was stuck on it. - -```bash -# macOS / Linux — no Node required -curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh -# Windows (PowerShell) — no Node required -irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex -# or, if you have Node (any version): -npm i -g @colbymchenry/codegraph -``` - -### Added -- **Standalone installers** — one-line install with no Node.js required: - `curl -fsSL .../install.sh | sh` (macOS/Linux) and `irm .../install.ps1 | iex` - (Windows). They fetch the matching self-contained bundle from GitHub Releases - and put `codegraph` on your PATH. -- **Lua**: CodeGraph now indexes Lua (`.lua`) — functions, methods (table `t.f` - and `t:m` definitions become methods with a `t::f` receiver-qualified name), - local variables, `require(...)` imports, and the call edges between them. - Querying a Lua project (Neovim plugins, Kong, OpenResty, game code) now - surfaces its modules, methods, and call graph. -- **Luau** ([#232](https://github.com/colbymchenry/codegraph/issues/232)): - CodeGraph now indexes Luau (`.luau`), Roblox's typed superset of Lua — - everything Lua extracts, plus `type` / `export type` aliases, typed function - signatures, generics, and Roblox instance-path `require(script.Parent.X)` - imports. - -### Changed -- **SQLite backend is now Node's built-in `node:sqlite`** (real SQLite, WAL + - FTS5), shipped inside a bundled Node runtime. This fixes the concurrent-read - `database is locked` errors ([#238](https://github.com/colbymchenry/codegraph/issues/238)) - at the root and removes the native build step entirely. -- **`npm i -g` / `npx` now install a self-contained bundle.** The main package is - a tiny shim; the runtime ships as per-platform `optionalDependencies`, so the - install works on any Node version (your Node only launches the bundle). -- **`codegraph status`** now reports the effective journal mode (`wal` vs not), - so a `database is locked` report is triageable at a glance. - -### Removed -- **`better-sqlite3`** (optional native dependency) and **`node-sqlite3-wasm`** - (WASM fallback) — along with the native-build banner, the WASM fallback path, - and the no-WAL lock retries they required. The dependency tree now has zero - native addons. - -### Fixed -- **Installer**: re-running `codegraph install` now removes the broken - auto-sync hooks that pre-0.8 versions wrote to Claude Code's - `settings.json`. Those builds added a `Stop → codegraph sync-if-dirty` - hook (and a `PostToolUse → codegraph mark-dirty` partner); both - subcommands were later removed from the CLI, so Claude Code reported - `Stop hook error: ... unknown command 'sync-if-dirty'` on every turn. - The cleanup is surgical — only codegraph's own hook entries are - stripped, so unrelated hooks sharing the same file or event (e.g. a - GitKraken `gk ai hook run` hook) are left untouched — and it also runs - on uninstall, so the npm `preuninstall` step fully reverses a legacy - install. Re-run `codegraph install` once on an affected machine to - clear the error. - -[0.9.0]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.0 - -## [0.8.0] - 2026-05-20 - -### Added -- **Framework routes (NestJS)**: CodeGraph now recognises NestJS projects and - emits `route` nodes — each linked by a `references` edge to its handler - method — across all four transport layers: HTTP controllers (the - `@Controller` prefix joined with `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete`/ - `@Head`/`@Options`/`@All`, including empty `@Controller()`/`@Get()`), - GraphQL resolvers (`@Query`/`@Mutation`/`@Subscription`), microservice - handlers (`@MessagePattern`/`@EventPattern`), and WebSocket gateways - (`@SubscribeMessage`, prefixed with the gateway namespace). Detected - automatically from any `@nestjs/*` dependency in `package.json`. Querying a - controller method or resolver now surfaces the route that binds it. - Resolves [#220](https://github.com/colbymchenry/codegraph/issues/220). -- **MCP / explore**: `codegraph_explore` source sections now carry line - numbers (cat -n style `\t`, matching the Read tool). This lets - the agent cite `file:line` straight from the explore payload instead of - re-opening the file just to find a line number — the dominant residual - cost on precise-tracing questions. In an isolated A/B (answer a - "which exact line" question with the relevant code already in the - payload), the no-line-numbers arm spent 2 file Reads + a grep recovering - the line number while the line-numbered arm answered with zero follow-up - tool calls. Payload cost is small (~3-5%). Set - `CODEGRAPH_EXPLORE_LINENUMS=0` to disable. -- **MCP / watcher**: CodeGraph now skips the live file watcher on WSL2 - `/mnt/*` drives, where recursive `fs.watch` is slow enough to break MCP - startup (see Fixed). When the watcher is off, `codegraph init` / - `codegraph install` offer to keep the index fresh via git hooks - (`post-commit`, `post-merge`, `post-checkout`) that run `codegraph sync` - in the background — accept for automatic refresh on commit / pull / - checkout, or decline and sync by hand. Either way you're told the index - stays frozen until it's re-synced. New controls: `CODEGRAPH_NO_WATCH=1` - (or `codegraph serve --mcp --no-watch`) forces the watcher off anywhere; - `CODEGRAPH_FORCE_WATCH=1` overrides the WSL auto-detect when your `/mnt` - setup is actually fast. `codegraph uninit` removes any hooks it installed. - -### Changed -- **MCP / agent guidance**: CodeGraph now tells agents to answer "how does X - work" / architecture questions *directly* — `codegraph_context`, then one - `codegraph_explore` for the surfaced symbols — instead of delegating to a - file-reading sub-agent or a grep+read loop. The server instructions and the - installed instruction files (`CLAUDE.md`, `.cursor/rules/codegraph.mdc`, - `AGENTS.md`) previously suggested *spawning a sub-agent* for explore-class - questions, which produced the opposite, more expensive behavior: the - sub-agent reads files regardless of the index, so CodeGraph became overhead - stacked on top of the reads. In rigorous N≥4-per-arm benchmarks this cut the - cost of an architecture question by ~42–47% versus a no-CodeGraph agent on - medium and large repos (Excalidraw ~600 files, VS Code ~10k), with - equal-or-better, `file:line`-cited answers and ~6× fewer tool calls; on a - tiny repo (~25 files) it's a wash, since native grep is already trivially - cheap there. -- **MCP / codegraph_node**: `includeCode=true` on a class/interface/struct/enum - now returns a compact member outline (fields + method signatures + line - numbers) instead of the entire class body — which could be thousands of - characters and was rarely needed in full. Functions and methods still return - their full body; request a specific member for its source. -- **Minimum Node.js is now 20** (was 18). Node 18 is end-of-life and the - native SQLite binding (`better-sqlite3` 12.x) no longer ships a Node 18 - prebuilt binary. Node 22 LTS and Node 24 get the native backend out of the - box; on other Node versions CodeGraph still runs via the WASM fallback - (slower, but functional). Node 25+ remains blocked (V8 WASM JIT crash, see - [#81](https://github.com/colbymchenry/codegraph/issues/81)). -- **MCP / explore**: `codegraph_explore` output is now adaptive to project - size. The tool used to apply a fixed 35KB cap regardless of how large the - codebase was, which on small projects (~100 files) produced bigger - responses than the agent's native grep+Read flow would have — exactly the - scenario reported in - [#185](https://github.com/colbymchenry/codegraph/issues/185). The budget - now scales with indexed file count: small projects (<500 files) cap at - ~18KB and skip the "Additional relevant files" / completeness / explore- - budget reminders that earn their keep on bigger codebases; medium - (<5,000) caps at ~13KB; large (<15,000) keeps the historical ~35KB; very - large goes up to ~38KB. A new per-file char cap also prevents a single - file with many adjacent symbols from collapsing into one whole-file dump - (the Alamofire `Session.swift` case from #185). Per-file cluster - selection ranks clusters that contain a query entry point ahead of dense - declaration blocks, and whole-file "envelope" nodes (a class/struct that - spans most of the file) are excluded from clustering so the methods the - query asked about aren't buried under the container's opening lines. - Measured against the same repos used in the README benchmark, end state - with line numbers on: Alamofire ~60% smaller per call, Excalidraw ~32%, - VS Code ~12%. Agent-trust floor still holds — the Relationships section, - scored cluster selection, and structured-source output are all retained. - Thanks to [@essopsp](https://github.com/essopsp) for the repro. -- **Search ranking (Kotlin / Swift / Scala / C#)**: test files in these - languages are now correctly de-prioritized in `codegraph_search`, - `codegraph_context`, and `codegraph affected`. Detection previously only - recognized `snake_case`/`.test.`-style names plus a handful of Java - suffixes, so CamelCase test files (`FooTest.kt`, `BarTests.swift`, - `BazSpec.scala`, `QuxTestCase.cs`) and Gradle / Kotlin-Multiplatform / - Xcode test source-set directories (`jvmTest/`, `commonTest/`, - `androidTest/`, `iosTest/`, `integrationTest/`) were treated as production - code and could outrank the real implementation. Detection now matches - capital-led `*Test` / `*Tests` / `*Spec` / `*TestCase` filenames and - source-set directories — deliberately capital-led so lowercase look-alikes - like `latest.kt` and `manifest.kt` are not misclassified. - -### Fixed -- **MCP / explore**: `codegraph_explore` output is now hard-capped to its - adaptive size budget. It could previously overrun (e.g. ~30K against a 28K - cap) once the relationship map and trailer sections were appended; the - oversized payload then sat in the agent's context and was re-read on every - later turn. -- **Sync / status**: git-untracked files are no longer reported as pending - "Added" forever. After `codegraph sync` indexed a newly-created untracked - source file, `codegraph status` kept listing it under Pending Changes and - every subsequent `sync` re-indexed it from scratch — even though its symbols - were already queryable. Change detection trusted `git status` and counted - every untracked (`??`) entry as new without checking the index, but indexing - a file doesn't make git track it, so the file stayed `??` and got re-added on - each run. CodeGraph now hash-compares untracked files against the index the - same way it does tracked files: a file counts as "added" only if it's missing - from the index, "modified" if its contents changed, and is skipped otherwise. - Closes [#206](https://github.com/colbymchenry/codegraph/issues/206). Thanks to - [@15290391025](https://github.com/15290391025) for the report. -- **Indexing**: `codegraph init -i` now finds source inside nested, independent - git repositories — separate clones living inside the workspace that are **not** - git submodules (common in CMake "super-repo" layouts). When the top-level - workspace is itself a git repo, `git ls-files` reports an embedded repo only as - an opaque `subdir/` entry and never lists its files, so indexing from the - workspace root reported "No files found to index" even though indexing each - sub-repo individually worked. CodeGraph now detects these embedded repos and - indexes their tracked and untracked source, honoring each repo's own - `.gitignore`. Closes - [#193](https://github.com/colbymchenry/codegraph/issues/193). Thanks to - [@timxx](https://github.com/timxx) for the report. -- **Native SQLite backend on Node 24**: indexing on Node 24 always dropped to - the 5-10x-slower WASM backend, printing a `better-sqlite3 unavailable` - warning that `npm rebuild better-sqlite3` / `xcode-select --install` could - not clear ([#203](https://github.com/colbymchenry/codegraph/issues/203)). - The bundled `better-sqlite3` was pinned to a v11 release that ships no - prebuilt binary for Node 24's ABI (`node-v137`), so every Node 24 install - silently degraded — and because CodeGraph is usually installed globally, the - `npm install` / `npm rebuild` people ran in their own project never touched - CodeGraph's copy. CodeGraph now requires `better-sqlite3` `^12.4.1`, whose - prebuilds include Node 24, so a fresh install on Node 22 or Node 24 gets the - native backend with no compiler. On an already-broken install, reinstall - CodeGraph (e.g. `npm install -g @colbymchenry/codegraph`) to pull the new - binding; `codegraph status` should then report `Backend: native`. Thanks to - [@Finndersen](https://github.com/Finndersen) for the report. -- **MCP**: tools no longer fail with "CodeGraph not initialized" when the index - actually exists. This hit clients that launch the MCP server from a directory - other than your project and don't report a workspace root in `initialize` - (some IDE/JetBrains-family integrations) — the server fell back to its own - working directory, missed the project's `.codegraph/`, and returned the - misleading "Run 'codegraph init' first" on every call. The only workaround - was passing `projectPath` to each tool by hand. Now, when no project path is - supplied, the server asks the client for its workspace root via the standard - MCP `roots/list` request (when the client advertises the `roots` capability) - before falling back to the working directory — so detection just works for - spec-compliant clients. When it still can't resolve a project, the error is - now actionable: it names the directory it searched and tells you to pass - `projectPath` or add `--path /abs/project` to the server's MCP config args, - instead of pointing you at a re-init you don't need. Closes - [#196](https://github.com/colbymchenry/codegraph/issues/196). Thanks to - [@zhangyu1197](https://github.com/zhangyu1197) for the report and the - `projectPath` workaround. -- **MCP**: the server no longer hangs on startup under WSL2 when the project - lives on an NTFS `/mnt/*` mount. Setting up the recursive file watcher - there took tens of seconds — every directory read crosses the Windows/9p - boundary — which blew past the host's initialization timeout (opencode's - 30s), so the codegraph tools silently never appeared, even on small - projects. This is the file-watcher half of the - [#172](https://github.com/colbymchenry/codegraph/issues/172) startup fix: - that one moved the database/WASM open off the handshake, but the watcher - setup was still on the critical path. CodeGraph now auto-skips the watcher - on those mounts, with manual and git-hook sync fallbacks (see Added). - Closes [#199](https://github.com/colbymchenry/codegraph/issues/199). - Thanks to [@mengfanbo123](https://github.com/mengfanbo123) for the precise - root-cause analysis and workaround. -- **Installer (Claude Code)**: project-local installs (`Just this project`) - now write the MCP server to `.mcp.json` in the project root — the file - Claude Code actually reads for project-scoped servers. Previously they - wrote `.claude.json`, which Claude Code ignores, so the codegraph tools - silently never appeared and you had to rename the file by hand to make it - work. Re-running `codegraph install` (or `codegraph init`) on an affected - project migrates the stale `.claude.json` entry into `.mcp.json` - automatically; uninstall cleans up both. Global (`All projects`) installs - were unaffected — they correctly target `~/.claude.json`. Closes - [#207](https://github.com/colbymchenry/codegraph/issues/207). Thanks to - [@Jhsmit](https://github.com/Jhsmit) for the report and the workaround. -- **MCP**: source-omission markers in `codegraph_explore` and - `codegraph_context` output are now language-neutral (`... (gap) ...`, - `... (trimmed) ...`, `... (truncated) ...`) instead of C-style `//` - comments, which were misleading inside Python, Ruby, and other non-C - fenced source blocks. - -## [0.7.10] - 2026-05-19 - -### Fixed -- **MCP**: tools no longer silently fail to appear in clients on slow - filesystems (Docker Desktop VirtioFS on macOS, WSL2). The `initialize` - handshake was blocking on opening the SQLite database and bootstrapping - the tree-sitter WASM runtime, which on slow I/O could exceed Claude - Code's ~30s handshake timeout — leaving the codegraph process alive but - unresponsive and no tools visible. The handshake now returns immediately - and defers project open to the background; tool calls wait on the - in-flight init rather than racing it with a second open. Closes - [#172](https://github.com/colbymchenry/codegraph/issues/172). Thanks to - [@sashanclrp](https://github.com/sashanclrp) for the original report and - detailed reproduction, and [@sgrimm](https://github.com/sgrimm) for the - decisive wire capture that isolated the actual root cause. -- **CLI**: terminal output no longer mojibakes on Windows PowerShell / - cmd.exe during `codegraph index` and `codegraph sync`. The shimmer - progress renderer writes from a worker thread via `fs.writeSync(1, …)` - to keep the animation smooth while the main thread is busy in SQLite, - which bypasses Node's TTY-aware UTF-8→codepage conversion — so glyphs - like `│ ◆ —` were emitted as raw UTF-8 bytes and reinterpreted as the - console's OEM codepage (CP437, CP936, …), producing strings like - `鋍?[0m 鉒?[0m Scanning files 鈥?N found`. CodeGraph now picks an ASCII - glyph set on Windows by default (`| * -` instead of `│ ◆ —`); set - `CODEGRAPH_UNICODE=1` to opt back into the Unicode glyphs (e.g. on - pwsh 7 with UTF-8 codepage), or `CODEGRAPH_ASCII=1` on any platform to - force ASCII (useful for log collectors / non-TTY pipelines). Closes - [#168](https://github.com/colbymchenry/codegraph/issues/168). Thanks to - [@starkleek](https://github.com/starkleek) for the report and to - [@Bortlesboat](https://github.com/Bortlesboat) for the initial PR. -- **MCP / search**: module-qualified symbol lookups now resolve. The - MCP tools (`codegraph_node`, `codegraph_callees`, `codegraph_impact`, - …) accept `module::symbol` (Rust / C++ / Ruby), `Module.symbol` - (TS / JS / Python), and `module/symbol` (path-style) — multi-level - forms (`crate::configurator::stage_apply::run`) and Rust path - prefixes (`crate`, `super`, `self`) are handled. Closes - [#173](https://github.com/colbymchenry/codegraph/issues/173). Thanks - to [@joselhurtado](https://github.com/joselhurtado) for the detailed - reproduction. Three underlying fixes: - - The FTS5 query builder now treats `::` as a token separator - instead of stripping it to nothing, so `stage_apply::run` no - longer collapses to the unsearchable `stage_applyrun`. - - `matchesSymbol` falls back to a file-path containment check when - `qualifiedName` doesn't carry the module hierarchy (Rust - file-level functions, Python free functions in a package): a - `run` in `src/configurator/stage_apply.rs` now matches - `stage_apply::run` because `stage_apply` appears as a path - segment. - - Qualified lookups that don't match the qualifier no longer fall - through to fuzzy text matches — `stage_apply::nonexistent_fn` - returns `null` instead of resolving to an unrelated `rollback` - in the same file. - -[0.8.0]: https://github.com/colbymchenry/codegraph/releases/tag/v0.8.0 -[0.7.10]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10 - -## [0.7.8] - 2026-05-17 - -### Fixed -- **opencode**: install actually wires up the MCP server now. v0.7.7 wrote - `~/.config/opencode/opencode.json`, but opencode reads `opencode.jsonc` by - default — so the `codegraph` entry never showed up in any opencode session. - The installer now prefers an existing `.jsonc`, falls back to `.json` when - only that exists, and creates `.jsonc` for greenfield installs. **Re-run - `codegraph install --target=opencode` after upgrading** so the entry lands - in the file opencode actually reads. - -### Added -- **opencode**: installer now writes `AGENTS.md` (global - `~/.config/opencode/AGENTS.md`, local `./AGENTS.md`) with the same - codegraph usage guidance the other agents already received. Without it, - opencode's model would call native `Grep` instead of the `codegraph_*` - tools it could see in its MCP list. -- User comments and formatting in `opencode.jsonc` survive install / - re-install / uninstall round-trips — surgical edits via `jsonc-parser` - rather than full-file rewrites. - -[0.7.8]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.8 - -## [0.7.7] - 2026-05-17 - -### Added -- **Multi-agent installer** (closes [#137](https://github.com/colbymchenry/codegraph/issues/137)). - `codegraph install` now opens with a multi-select prompt for **Claude Code**, - **Cursor**, **Codex CLI**, and **opencode** — detected agents are pre-checked. - Each writes its native MCP config + instructions file (e.g. `~/.cursor/mcp.json` - + `.cursor/rules/codegraph.mdc`, `~/.codex/config.toml` + `~/.codex/AGENTS.md`, - `~/.config/opencode/opencode.json`). The runtime MCP server was already - agent-agnostic; this brings the installer to parity. -- Non-interactive install flags for scripting / CI: - `--target=`, `--location=`, `--yes`, - `--no-permissions`, `--print-config `. -- `codegraph init` now auto-wires project-local agent surfaces for any agent - configured globally. In practice: Cursor's `.cursor/rules/codegraph.mdc` - is dropped on `init` so a single global `codegraph install` works in every - project you open — no per-project re-install needed. - -### Fixed -- **Cursor**: globally-installed codegraph reported "not initialized" in every - workspace because Cursor launches MCP-server subprocesses with the wrong - working directory and doesn't pass `rootUri` in the MCP initialize call. - We now inject `--path` into Cursor's MCP args — absolute path for local - installs, `${workspaceFolder}` for global installs. - -### Changed -- Agent-instructions template is now agent-agnostic. The previous template was - inherited from the Claude-only era and prescribed "spawn an Explore agent" — - a Claude Code-specific concept that confused Cursor's and Codex's agents and - caused them to fall back to native grep even with codegraph available. The - new template adds explicit "trust codegraph results, don't re-verify with - grep" guidance and a clear tool-by-question matrix. Applies to - `~/.claude/CLAUDE.md`, `.cursor/rules/codegraph.mdc`, and `~/.codex/AGENTS.md`. -- `codegraph install` prompt order: agent picker is now step 1, before the - PATH-install and location prompts. -- Disambiguated "global" wording in install prompts ("Install codegraph CLI on - your PATH?" vs "Apply agent configs to all your projects, or just this one?") - — both used to say "Global" and read as duplicates. - -### Internal -- New `AgentTarget` interface in `src/installer/targets/` — adding a 5th agent - (Continue, Zed, Windsurf, …) is a new file + one entry in `registry.ts`. -- Hand-rolled TOML serializer for Codex (`src/installer/targets/toml.ts`) — no - new dependency, scoped to the `[mcp_servers.codegraph]` table only, sibling - tables and `[[array_of_tables]]` preserved verbatim. -- +47 parameterized contract tests across the 4 targets — install idempotency, - sibling preservation, uninstall reverses install, byte-equal re-runs return - `unchanged`, partial-state recovery for Codex. - -Based on substantive draft by [@andreinknv](https://github.com/andreinknv) -([fork commit `c5165e4`](https://github.com/andreinknv/codegraph/commit/c5165e4)). -Thank you. - -[0.7.7]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.7 - -## [0.7.6] - 2026-05-13 - -### Fixed -- `codegraph` CLI failing with `zsh: permission denied: codegraph` after a fresh - global install. The published 0.7.5 tarball shipped `dist/bin/codegraph.js` - without the executable bit, so the shell refused to run it through the npm - symlink. The build now `chmod +x`'s the binary before packing. - - Already on 0.7.5? Either upgrade to 0.7.6, or unblock yourself in place: - ```bash - chmod +x "$(npm root -g)/@colbymchenry/codegraph/dist/bin/codegraph.js" - ``` - -[0.7.6]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.6 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index d5222f37..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,146 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -CodeGraph is a local-first code intelligence library + CLI + MCP server. It parses any supported codebase with tree-sitter, stores symbols/edges/files in SQLite (FTS5), and exposes a knowledge graph to AI agents (Claude Code, Cursor, Codex CLI, opencode) over MCP. Per-project data lives in `.codegraph/`. Extraction is deterministic — derived from AST, not LLM-summarized. - -Distributed as `@colbymchenry/codegraph` on npm; same binary serves as installer, indexer, and MCP server. - -## Build, Test, Run - -```bash -npm run build # tsc + copy schema.sql and *.wasm into dist/; chmods dist/bin/codegraph.js -npm run dev # tsc --watch -npm run clean # rm -rf dist - -npm test # vitest run (all) -npm run test:watch -npm run test:eval # only __tests__/evaluation/ -npm run eval # build then run __tests__/evaluation/runner.ts via tsx - -npm run cli # build then run the local dist binary - -# Single test file / pattern -npx vitest run __tests__/installer-targets.test.ts -npx vitest run __tests__/extraction.test.ts -t "TypeScript" -``` - -`copy-assets` (called from `build`) copies `src/db/schema.sql` and all `src/extraction/wasm/*.wasm` files into `dist/`. **Any new SQL or grammar wasm must be copied or it won't ship.** - -Node engines: `>=18.0.0 <25.0.0`. There is a hard exit on Node 25.x (see `src/bin/node-version-check.ts`). - -## Architecture - -### Layered pipeline - -``` -files → ExtractionOrchestrator (tree-sitter) → DB (nodes/edges/files) - ↓ - ReferenceResolver (imports, name-matching, framework patterns) - ↓ - GraphQueryManager / GraphTraverser (callers, callees, impact) - ↓ - ContextBuilder (markdown/JSON for AI consumption) -``` - -The public API surface is `src/index.ts` — the `CodeGraph` class wires all the layers and re-exports types. Library users only touch this file; the MCP server and CLI also drive it. - -### Module layout - -- `src/index.ts` — `CodeGraph` class: `init`/`open`/`close`, `indexAll`, `sync`, `searchNodes`, `getCallers`/`getCallees`, `getImpactRadius`, `buildContext`, `watch`/`unwatch`. -- `src/db/` — `DatabaseConnection`, `QueryBuilder` (prepared statements), `schema.sql`. Backed by `better-sqlite3` (native) when available, transparently falls back to `node-sqlite3-wasm`. `codegraph status` surfaces which backend is live; wasm is the slow path. -- `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread. -- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. -- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries). -- `src/context/` — `ContextBuilder` + formatter for markdown/JSON output. -- `src/search/` — full-text query parser and helpers for FTS5. -- `src/sync/` — `FileWatcher` (native FSEvents/inotify/RDCW) with debounce + filter, and git-hook helpers. -- `src/mcp/` — MCP server (`MCPServer`, `tools.ts`, `transport.ts`). `server-instructions.ts` is what the server returns in the MCP `initialize` response — keep it in sync with the user-facing tool guidance. -- `src/installer/` — see below. -- `src/bin/codegraph.ts` — CLI (commander). Subcommands: `install`, `init`, `uninit`, `index`, `sync`, `status`, `query`, `files`, `context`, `affected`, `serve --mcp`. -- `src/ui/` — terminal UI (shimmer progress, worker). - -### NodeKind / EdgeKind - -Defined in `src/types.ts`. Both extractors and resolvers must use these exact strings. - -- **NodeKind**: `file`, `module`, `class`, `struct`, `interface`, `trait`, `protocol`, `function`, `method`, `property`, `field`, `variable`, `constant`, `enum`, `enum_member`, `type_alias`, `namespace`, `parameter`, `import`, `export`, `route`, `component`. -- **EdgeKind**: `contains`, `calls`, `imports`, `exports`, `extends`, `implements`, `references`, `type_of`, `returns`, `instantiates`, `overrides`, `decorates`. - -### Multi-agent installer - -`src/installer/` is the entry point for `codegraph install` (and the bare `codegraph`/`npx @colbymchenry/codegraph` invocation). Architecture: - -- `targets/registry.ts` lists every supported agent. -- `targets/types.ts` defines the `AgentTarget` interface — adding a 5th agent (Continue, Zed, Windsurf…) is **one new file in `targets/` + one entry in `registry.ts`**. Each target owns its config-file location, MCP-server JSON/TOML/JSONC writing, and instructions-file path. -- Current targets: `claude.ts`, `cursor.ts`, `codex.ts`, `opencode.ts`. -- `targets/toml.ts` is a hand-rolled TOML serializer scoped to `[mcp_servers.codegraph]` (used by Codex). Sibling tables and `[[array_of_tables]]` are preserved verbatim. No new dependency. -- opencode reads `opencode.jsonc` by default; the installer prefers existing `.jsonc`, falls back to `.json`, and creates `.jsonc` for greenfield installs. Edits are surgical via `jsonc-parser` so user comments and formatting survive install/re-install/uninstall round-trips. -- `instructions-template.ts` is the agent-agnostic instructions file written to each target (e.g. `CLAUDE.md`, `.cursor/rules/codegraph.mdc`, `~/.codex/AGENTS.md`, `~/.config/opencode/AGENTS.md`). It explicitly says "trust codegraph results, don't re-verify with grep" — earlier versions prescribed Claude-specific "spawn an Explore agent" and confused other agents. -- `claude-md-template.ts` is the legacy Claude-only template, retained for compatibility paths. -- All installer changes need matching coverage in `__tests__/installer-targets.test.ts` — there are ~47 parameterized contract tests covering install idempotency, sibling preservation, uninstall reverses install, byte-equal re-runs returning `unchanged`, and partial-state recovery for Codex. - -### Cursor MCP working-directory quirk - -Cursor launches MCP subprocesses with the wrong cwd and doesn't pass `rootUri` in `initialize`. The installer injects `--path` into Cursor's MCP args — absolute path for local installs, `${workspaceFolder}` for global installs. If you touch Cursor wiring, preserve this. - -### MCP server instructions - -`src/mcp/server-instructions.ts` is sent back to the agent in the MCP `initialize` response. This is the *first* thing every agent sees about how to use the tools — treat it as the authoritative tool guidance and keep it in sync with `instructions-template.ts` and `.cursor/rules/codegraph.mdc`. - -## Tests - -Tests live in `__tests__/` and mirror the module they cover. Notable ones beyond the obvious: - -- `installer-targets.test.ts` — parameterized contract suite across all 4 agent targets (see installer notes above). -- `evaluation/` — `runner.ts` + `test-cases.ts` exercise codegraph against synthetic projects and score the results; run via `npm run eval` (builds first). Not part of `npm test`. -- `sqlite-backend.test.ts` — covers native + wasm backend selection and fallback. -- `pr19-improvements.test.ts`, `frameworks-integration.test.ts` — regression coverage for specific past PRs/incidents; don't rename these, the names anchor to git history. - -Tests create temp dirs with `fs.mkdtempSync` and clean up in `afterEach`. They write real files and exercise real SQLite — there is no DB mocking. - -## Releases - -Released to npm and mirrored as [GitHub Releases](https://github.com/colbymchenry/codegraph/releases). `CHANGELOG.md` is the source of truth; GitHub Release notes are extracted from it. - -### Writing changelog entries - -When asked for an entry for a new version: - -1. Add a new `## [X.Y.Z] - YYYY-MM-DD` block at the **top** of `CHANGELOG.md` (under the intro, above the previous version). -2. Group under `### Added`, `### Changed`, `### Fixed`, `### Removed`, `### Deprecated`, `### Security` — omit empty sections. -3. Write from the **user's perspective**, not the implementation's. Lead with the observable symptom or capability; mention internals only if a user needs them (e.g., to work around an existing bad install). -4. Add the link reference at the bottom: `[X.Y.Z]: https://github.com/colbymchenry/codegraph/releases/tag/vX.Y.Z`. - -### Release flow (the user runs these) - -Releases are built and published by the **GitHub Actions "Release" workflow** -(`.github/workflows/release.yml`). It bundles a Node runtime per platform -(`scripts/build-bundle.sh`) and publishes both the GitHub Release and the npm -thin-installer (`scripts/pack-npm.sh`: a shim package + per-platform packages). -Publishing manually is **wrong** now — a plain `npm publish` ships the root -package (non-bundled), which breaks anyone on Node < 22.5. - -After the changelog entry is written and `package.json` is bumped: - -```bash -git add package.json package-lock.json CHANGELOG.md -git commit -m "release: X.Y.Z ()" -git push -``` - -Then trigger **Actions → Release → Run workflow** (on `main`). It reads the -version from `package.json`, builds every platform bundle on one runner, creates -the GitHub Release with notes from the matching `CHANGELOG.md` section, and -publishes to npm. Requires the `NPM_TOKEN` repo secret. - -**Do not run `npm publish`, `git push`, or `git tag` yourself** — these are -publish actions on shared state. Write the files, hand the user the commands. - -## House rules - -- The `0.7.x` line is in active multi-agent rollout. Any change to `src/installer/` (especially `targets/`) needs corresponding test coverage and a CHANGELOG entry — installer regressions break every new install silently. -- When changing what the MCP tools do or how agents should use them, update **all three** of `src/mcp/server-instructions.ts`, `src/installer/instructions-template.ts`, and `.cursor/rules/codegraph.mdc` — they're written to different places but say the same thing. -- CodeGraph provides **code context**, not product requirements. For new features, ask the user about UX, edge cases, and acceptance criteria — the graph won't tell you. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..e362dd24 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2032 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "codegraph" +version = "1.0.0" +dependencies = [ + "anyhow", + "camino", + "clap", + "codegraph-context", + "codegraph-core", + "codegraph-db", + "codegraph-extract", + "codegraph-graph", + "codegraph-installer", + "codegraph-mcp", + "codegraph-resolve", + "console", + "dialoguer", + "dirs", + "notify", + "notify-debouncer-full", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "codegraph-context" +version = "1.0.0" +dependencies = [ + "codegraph-core", + "codegraph-db", + "codegraph-graph", + "serde", + "serde_json", +] + +[[package]] +name = "codegraph-core" +version = "1.0.0" +dependencies = [ + "camino", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "codegraph-db" +version = "1.0.0" +dependencies = [ + "camino", + "codegraph-core", + "parking_lot", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "tracing", +] + +[[package]] +name = "codegraph-extract" +version = "1.0.0" +dependencies = [ + "camino", + "codegraph-core", + "codegraph-db", + "codegraph-resolve", + "crossbeam-channel", + "hex", + "ignore", + "rayon", + "sha2", + "tempfile", + "tracing", + "tree-sitter", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cpp", + "tree-sitter-go", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-lua", + "tree-sitter-php", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-scala", + "tree-sitter-swift", + "tree-sitter-typescript", +] + +[[package]] +name = "codegraph-graph" +version = "1.0.0" +dependencies = [ + "camino", + "codegraph-core", + "codegraph-db", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "codegraph-installer" +version = "1.0.0" +dependencies = [ + "anyhow", + "camino", + "dirs", + "jsonc-parser", + "serde", + "serde_json", + "tempfile", + "toml_edit", + "tracing", + "which", +] + +[[package]] +name = "codegraph-mcp" +version = "1.0.0" +dependencies = [ + "anyhow", + "camino", + "codegraph-context", + "codegraph-core", + "codegraph-db", + "codegraph-graph", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "codegraph-resolve" +version = "1.0.0" +dependencies = [ + "camino", + "codegraph-core", + "codegraph-db", + "globset", + "serde", + "serde_json", + "tempfile", + "tracing", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "file-id" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jsonc-parser" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6d80e6d70e7911a29f3cf3f44f452df85d06f73572b494ca99a2cad3fcf8f4" + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.1", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-debouncer-full" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcf855483228259b2353f89e99df35fc639b2b2510d1166e4858e3f67ec1afb" +dependencies = [ + "file-id", + "log", + "notify", + "notify-types", + "walkdir", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tree-sitter" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afd2b1bf1585dc2ef6d69e87d01db8adb059006649dd5f96f31aa789ee6e9c71" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-lua" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8daaf5f4235188a58603c39760d5fa5d4b920d36a299c934adddae757f32a10c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-php" +version = "0.23.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f066e94e9272cfe4f1dcb07a1c50c66097eca648f2d7233d299c8ae9ed8c130c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-scala" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de5a4a7ff23a55474ce6a741d52aaeca7a82fe9421bb982b86e98c6ac8629397" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-swift" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b98fb6bc8e6a6a10023f401aa6a1858115e849dfaf7de57dd8b8ea0f257bd9" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..9fd617a4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,87 @@ +[workspace] +resolver = "2" +members = [ + "crates/codegraph-core", + "crates/codegraph-db", + "crates/codegraph-extract", + "crates/codegraph-resolve", + "crates/codegraph-graph", + "crates/codegraph-context", + "crates/codegraph-mcp", + "crates/codegraph-installer", + "crates/codegraph", +] + +[workspace.package] +version = "1.0.0" +edition = "2021" +rust-version = "1.80" +license = "MIT" +repository = "https://github.com/cleboost/codegraph" +authors = ["Cleboost "] + +[workspace.dependencies] +# core +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# storage +rusqlite = { version = "0.32", features = ["bundled", "backup"] } + +# tree-sitter core +tree-sitter = "0.24" + +# tree-sitter grammars (one feature per lang on extract crate) +tree-sitter-typescript = "0.23" +tree-sitter-javascript = "0.23" +tree-sitter-python = "0.23" +tree-sitter-rust = "0.23" +tree-sitter-go = "0.23" +tree-sitter-java = "0.23" +tree-sitter-c = "0.23" +tree-sitter-cpp = "0.23" +tree-sitter-c-sharp = "0.23" +tree-sitter-ruby = "0.23" +tree-sitter-php = "0.23" +tree-sitter-scala = "0.26" +tree-sitter-swift = "0.7" +tree-sitter-kotlin = "0.3" +tree-sitter-lua = "0.5" + +# cli / async / fs +clap = { version = "4", features = ["derive", "wrap_help"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "io-util", "fs", "sync", "time"] } +notify = "7" +notify-debouncer-full = "0.4" +ignore = "0.4" +globset = "0.4" +walkdir = "2" + +# misc +camino = { version = "1", features = ["serde1"] } +dashmap = "6" +once_cell = "1" +parking_lot = "0.12" +rayon = "1" +indicatif = "0.17" +dirs = "5" +toml_edit = "0.22" +jsonc-parser = "0.26" +dialoguer = "0.11" +console = "0.15" +which = "7" + +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +strip = "symbols" +panic = "abort" + +[profile.release-small] +inherits = "release" +opt-level = "z" diff --git a/LICENSE b/LICENSE index 31c84c9c..a31fde63 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Colby Mchenry +Copyright (c) 2026 Cleboost Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -17,5 +17,4 @@ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF THE SOFTWARE. diff --git a/README.md b/README.md index 511e2094..f35e8ddf 100644 --- a/README.md +++ b/README.md @@ -1,513 +1,260 @@ -
- # CodeGraph -### Supercharge Claude Code, Cursor, Codex, OpenCode, and Hermes Agent with Semantic Code Intelligence - -**~35% cheaper · ~70% fewer tool calls · 100% local** +[![CI](https://github.com/cleboost/codegraph/actions/workflows/ci.yml/badge.svg)](https://github.com/cleboost/codegraph/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![npm version](https://img.shields.io/npm/v/@colbymchenry/codegraph.svg)](https://www.npmjs.com/package/@colbymchenry/codegraph) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Self-contained](https://img.shields.io/badge/Node.js-bundled%20%C2%B7%20none%20required-brightgreen.svg)](https://nodejs.org/) +> Local-first code intelligence for AI agents. Built in Rust. Single static +> binary, ~5 MB. Tree-sitter knowledge graph in SQLite, served over MCP. -[![Windows](https://img.shields.io/badge/Windows-supported-blue.svg)](#) -[![macOS](https://img.shields.io/badge/macOS-supported-blue.svg)](#) -[![Linux](https://img.shields.io/badge/Linux-supported-blue.svg)](#) +CodeGraph parses your codebase with tree-sitter, stores every symbol, edge, +and file in a local SQLite database (FTS5), and exposes the graph to +AI agents — Claude Code, Cursor, Codex CLI, opencode, Hermes — over the +Model Context Protocol (MCP). -[![Claude Code](https://img.shields.io/badge/Claude_Code-supported-blueviolet.svg)](#) -[![Cursor](https://img.shields.io/badge/Cursor-supported-blueviolet.svg)](#) -[![Codex CLI](https://img.shields.io/badge/Codex_CLI-supported-blueviolet.svg)](#) -[![opencode](https://img.shields.io/badge/opencode-supported-blueviolet.svg)](#) -[![Hermes Agent](https://img.shields.io/badge/Hermes_Agent-supported-blueviolet.svg)](#) +Agents that consult the graph instead of grepping the filesystem make +**fewer tool calls**, **explore faster**, and **stay within context**. -
+## Highlights -## Get Started +- **One binary.** Rust + statically-linked SQLite + native tree-sitter + grammars. No Node runtime, no `.wasm`, no `node_modules`. +- **Small.** ~5 MB stripped (vs ~140 MB for the previous TypeScript build). +- **Fast.** Parses a 139-file TypeScript project in ~190 ms (release, parallel). +- **Local.** Index lives in `.codegraph/db.sqlite` next to your code. Nothing + leaves the machine. +- **Multi-agent.** A single `codegraph install` configures Claude Code, Cursor, + Codex, opencode, Hermes and Antigravity CLI in one go. +- **Live.** Built-in file watcher keeps the index in sync while the MCP server + serves your agent. -**No Node.js required** — one command grabs the right build for your OS: +## Install -```bash -# macOS / Linux -curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh - -# Windows (PowerShell) -irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex -``` +
+Automatic (recommended) -Already have Node? Use npm instead (works on any version): +**Linux / macOS** -```bash -npx @colbymchenry/codegraph # zero-install, or: -npm i -g @colbymchenry/codegraph +```sh +curl -fsSL https://raw.githubusercontent.com/Cleboost/codegraph-rs/main/scripts/install.sh | sh ``` -CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The interactive installer auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent. +Drops `codegraph` into `~/.local/bin`. Override with `CODEGRAPH_INSTALL_DIR`. -### Initialize Projects +**Windows (PowerShell)** -```bash -cd your-project -codegraph init -i +```powershell +irm https://raw.githubusercontent.com/Cleboost/codegraph-rs/main/scripts/install.ps1 | iex ``` -
- -![1_C_VYnhpys0UHrOuOgpgoyw](https://github.com/user-attachments/assets/f168182f-4d9a-44e0-94d7-08d018cc8a3a) - -
- -### Uninstall +Installs to `%LOCALAPPDATA%\codegraph\bin` and adds it to the user PATH. -Changed your mind? One command removes CodeGraph from every agent it configured: +**Arch Linux (AUR)** -```bash -codegraph uninstall +```sh +yay -S codegraph-rs-bin ``` -Reverses the installer — strips CodeGraph's MCP server config, instructions, and permissions from each configured agent. Your project indexes (`.codegraph/`) are left untouched; remove those per-project with `codegraph uninit`. Use `--target` to remove from specific agents, or `--yes` to run non-interactively. - ---- - -## Why CodeGraph? - -When Claude Code explores a codebase, it spawns **Explore agents** that scan files with grep, glob, and Read — consuming tokens on every tool call. - -**CodeGraph gives those agents a pre-indexed knowledge graph** — symbol relationships, call graphs, and code structure. Agents query the graph instantly instead of scanning files. - -### Benchmark Results - -Tested across **7 real-world open-source codebases** spanning 7 languages, comparing an agent (Claude Code, headless) answering one architecture question **with** and **without** CodeGraph. Each cell is the savings at the **median of 4 runs per arm**. - -> **Average: 35% cheaper · 59% fewer tokens · 49% faster · 70% fewer tool calls** - -| Codebase | Language | Cost | Tokens | Time | Tool calls | -|----------|----------|------|--------|------|------------| -| **VS Code** | TypeScript · ~10k files | 35% cheaper | 73% fewer | 41% faster | 72% fewer | -| **Excalidraw** | TypeScript · ~600 | 47% cheaper | 73% fewer | 60% faster | 86% fewer | -| **Django** | Python · ~2.7k | 34% cheaper | 64% fewer | 59% faster | 81% fewer | -| **Tokio** | Rust · ~700 | 52% cheaper | 81% fewer | 63% faster | 89% fewer | -| **OkHttp** | Java · ~640 | 17% cheaper | 41% fewer | 36% faster | 64% fewer | -| **Gin** | Go · ~150 | 22% cheaper | 23% fewer | 34% faster | 19% fewer | -| **Alamofire** | Swift · ~100 | 38% cheaper | 59% fewer | 51% faster | 77% fewer | - -The gains scale with codebase size: on large repos the agent answers from the index in a handful of calls with **zero file reads**, while the no-CodeGraph agent fans out across grep/find/Read (and the sub-agents it spawns). On a small repo like Gin (~150 files) native search is already cheap, so the margin narrows. - -
-Full benchmark details - -**Methodology.** Each arm is `claude -p` (Claude Opus 4.7, Claude Code v2.1.145) run headlessly against the repo with `--strict-mcp-config`: **WITH** = CodeGraph's MCP server enabled, **WITHOUT** = an empty MCP config. Built-in Read/Grep/Bash stay available to both. Same question per repo, **4 runs per arm, median reported**. Cost = the run's `total_cost_usd`; Tokens = total tokens processed (input incl. cached + output); Time = wall-clock; Tool calls = every tool invocation, including those inside any sub-agents the model spawns. Repos cloned at `--depth 1` and indexed by the same CodeGraph build that served them. - -**Queries:** -| Codebase | Query | -|----------|-------| -| VS Code | "How does the extension host communicate with the main process?" | -| Excalidraw | "How does Excalidraw render and update canvas elements?" | -| Django | "How does Django's ORM build and execute a query from a QuerySet?" | -| Tokio | "How does tokio schedule and run async tasks on its runtime?" | -| OkHttp | "How does OkHttp process a request through its interceptor chain?" | -| Gin | "How does gin route requests through its middleware chain?" | -| Alamofire | "How does Alamofire build, send, and validate a request?" | - -**Raw medians — WITH → WITHOUT:** -| Codebase | Cost | Tokens | Time | Tool calls | -|----------|------|--------|------|------------| -| VS Code | $0.42 → $0.64 | 393k → 1.4M | 1m 0s → 1m 43s | 7 → 23 | -| Excalidraw | $0.54 → $1.02 | 851k → 3.2M | 1m 17s → 3m 14s | 12 → 83 | -| Django | $0.41 → $0.62 | 499k → 1.4M | 1m 0s → 2m 25s | 9 → 48 | -| Tokio | $0.50 → $1.04 | 657k → 3.4M | 1m 5s → 2m 56s | 9 → 75 | -| OkHttp | $0.36 → $0.44 | 352k → 596k | 45s → 1m 11s | 5 → 14 | -| Gin | $0.36 → $0.46 | 431k → 562k | 47s → 1m 11s | 7 → 8 | -| Alamofire | $0.61 → $0.99 | 1.1M → 2.6M | 1m 19s → 2m 41s | 15 → 64 | - -**Why CodeGraph wins:** with the index available, the agent answers directly — `codegraph_context` to map the area, then one `codegraph_explore` for the relevant source — and stops, usually with zero file reads. Without it, the agent (and the Explore sub-agents it spawns) spends most of its budget on discovery (find/ls/grep) before reading the right code. CodeGraph only helps when queried *directly*, so its instructions steer agents to answer directly rather than delegate exploration to file-reading sub-agents — otherwise a sub-agent reads files regardless and CodeGraph becomes overhead. -
---- +
+Manual -## Key Features +1. Download the archive for your platform from the [latest release](https://github.com/Cleboost/codegraph-rs/releases/latest): -| | | -|---|---| -| **Smart Context Building** | One tool call returns entry points, related symbols, and code snippets — no expensive exploration agents | -| **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 | -| **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes | -| **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config | -| **19+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi | -| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 14 frameworks | -| **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only | + | Platform | File | + |---|---| + | Linux x86_64 | `codegraph-x86_64-unknown-linux-musl.tar.gz` | + | Linux aarch64 | `codegraph-aarch64-unknown-linux-gnu.tar.gz` | + | macOS x86_64 | `codegraph-x86_64-apple-darwin.tar.gz` | + | macOS arm64 | `codegraph-aarch64-apple-darwin.tar.gz` | + | Windows x86_64 | `codegraph-x86_64-pc-windows-msvc.zip` | ---- +2. Extract and place the `codegraph` binary somewhere on your `PATH`. -## Framework-aware Routes +
-CodeGraph detects web-framework routing files and emits `route` nodes linked by `references` edges to their handler classes or functions. Querying callers of a view/controller now surfaces the URL pattern that binds it. +
+From source -| Framework | Shapes recognized | -|---|---| -| **Django** | `path()`, `re_path()`, `url()`, `include()` in `urls.py` (CBV `.as_view()`, dotted paths) | -| **Flask** | `@app.route('/path', methods=[...])`, blueprint routes | -| **FastAPI** | `@app.get(...)`, `@router.post(...)`, all standard methods | -| **Express** | `app.get(...)`, `router.post(...)` with middleware chains | -| **NestJS** | `@Controller` + `@Get/@Post/...`, GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` | -| **Laravel** | `Route::get()`, `Route::resource()`, `Controller@action`, tuple syntax | -| **Drupal** | `*.routing.yml` routes (`_controller`, `_form`, entity handlers); `hook_*` implementations in `.module`/`.theme`/`.install`/`.inc` | -| **Rails** | `get '/x', to: 'users#index'`, hash-rocket `=>` syntax | -| **Spring** | `@GetMapping`, `@PostMapping`, `@RequestMapping` on methods | -| **Gin / chi / gorilla / mux** | `r.GET(...)`, `router.HandleFunc(...)` | -| **Axum / actix / Rocket** | `.route("/x", get(handler))` | -| **ASP.NET** | `[HttpGet("/x")]` attributes on action methods | -| **Vapor** | `app.get("x", use: handler)` | -| **React Router** / **SvelteKit** | Route component nodes | - ---- - -## Quick Start - -### 1. Run the Installer - -```bash -npx @colbymchenry/codegraph -``` +Requires Rust stable (≥ 1.80). -The installer will: -- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent** -- Prompt to install `codegraph` on your PATH (so agents can launch the MCP server) -- Ask whether configs apply to all your projects or just this one -- Write each chosen agent's MCP server config + an instructions file (e.g. `CLAUDE.md`, `.cursor/rules/codegraph.mdc`, `~/.codex/AGENTS.md`) -- Set up auto-allow permissions when Claude Code is one of the targets -- Initialize your current project (local installs only) - -**Non-interactive (scripting / CI):** - -```bash -codegraph install --yes # auto-detect agents, install global -codegraph install --target=cursor,claude --yes # explicit target list -codegraph install --target=auto --location=local # detected agents, project-local -codegraph install --print-config codex # print snippet, no file writes +```sh +git clone https://github.com/Cleboost/codegraph-rs +cd codegraph-rs +cargo build --release -p codegraph +# binary at target/release/codegraph ``` -| Flag | Values | Default | -|---|---|---| -| `--target` | `auto`, `all`, `none`, or csv (`claude,cursor,...`) | prompt | -| `--location` | `global`, `local` | prompt | -| `--yes` | (boolean) | prompt every step | -| `--no-permissions` | (boolean) skip Claude auto-allow list | permissions on | -| `--print-config ` | dump snippet for one agent and exit | — | - -### 2. Restart Your Agent - -Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent) for the MCP server to load. - -### 3. Initialize Projects +Or via Cargo directly: -```bash -cd your-project -codegraph init -i +```sh +cargo install --git https://github.com/Cleboost/codegraph-rs codegraph ``` -Builds the per-project knowledge graph index. Also wires up any project-local agent surfaces (e.g. Cursor's `.cursor/rules/codegraph.mdc`) so a single global `codegraph install` works in every project you open — no need to re-run the installer per project. - -That's it — your agent will use CodeGraph tools automatically when a `.codegraph/` directory exists. +
-
-Manual Setup (Alternative) +## Quick start -**Install globally:** -```bash -npm install -g @colbymchenry/codegraph -``` +```sh +# 1. Init, index, and configure your agents in one step +cd ~/code/my-project +codegraph init -**Add to `~/.claude.json`:** -```json -{ - "mcpServers": { - "codegraph": { - "type": "stdio", - "command": "codegraph", - "args": ["serve", "--mcp"] - } - } -} +# 2. Use it +codegraph query UserService +codegraph context "auth middleware" ``` -**Add to `~/.claude/settings.json` (optional, for auto-allow):** -```json -{ - "permissions": { - "allow": [ - "mcp__codegraph__codegraph_search", - "mcp__codegraph__codegraph_context", - "mcp__codegraph__codegraph_callers", - "mcp__codegraph__codegraph_callees", - "mcp__codegraph__codegraph_impact", - "mcp__codegraph__codegraph_node", - "mcp__codegraph__codegraph_status", - "mcp__codegraph__codegraph_files" - ] - } -} -``` +Your agent now has tools like `codegraph_search`, `codegraph_callers`, +`codegraph_impact`, `codegraph_context` available over MCP. The file watcher +keeps the index fresh while you edit. -
+## CLI reference -
-Global Instructions Reference +| Command | What it does | +|---|---| +| `codegraph init [--no-index]` | Create `.codegraph/`, index, and configure agents; `--no-index` skips indexing | +| `codegraph uninit` | Remove `.codegraph/` | +| `codegraph index` | Full reindex of the workspace | +| `codegraph sync` | Incremental reindex (sha256-based skip) | +| `codegraph status` | Show counts, size, schema version | +| `codegraph query ` | Full-text search across symbols | +| `codegraph files [path]` | List indexed files under a prefix | +| `codegraph context ` | Build markdown context for a symbol | +| `codegraph serve --mcp` | Run as MCP server over stdio (used by agents) | -The installer automatically adds these instructions to `~/.claude/CLAUDE.md`: +Global flag `--path ` overrides the workspace root. -```markdown -## CodeGraph +## Supported languages -CodeGraph builds a semantic knowledge graph of codebases for faster, smarter code exploration. +15 languages with full tree-sitter extraction: -### If `.codegraph/` exists in the project +TypeScript · TSX · JavaScript · Python · Go · Rust · Java · C · C++ · C# · +Ruby · PHP · Scala · Swift · Lua -**NEVER call `codegraph_explore` or `codegraph_context` directly in the main session.** These tools return large amounts of source code that fills up main session context. Instead, ALWAYS spawn an Explore agent for any exploration question (e.g., "how does X work?", "explain the Y system", "where is Z implemented?"). +Each language emits: +- Declaration nodes (functions, classes, structs, interfaces, traits, enums…) +- `contains` edges (parent → child) +- `calls` edges (resolved by name-matcher post-pass) +- `imports` edges (raw imports captured for further resolution) -**When spawning Explore agents**, include this instruction in the prompt: +Coming back from the TypeScript version: Kotlin (blocked on upstream +tree-sitter grammar upgrade), Dart, Pascal, Luau, and text-based extractors +for Svelte/Vue/Liquid/DFM. -> This project has CodeGraph initialized (.codegraph/ exists). Use `codegraph_explore` as your PRIMARY tool — it returns full source code sections from all relevant files in one call. -> -> **Rules:** -> 1. Follow the explore call budget in the `codegraph_explore` tool description — it scales automatically based on project size. -> 2. Do NOT re-read files that codegraph_explore already returned source code for. The source sections are complete and authoritative. -> 3. Only fall back to grep/glob/read for files listed under "Additional relevant files" if you need more detail, or if codegraph returned no results. +## MCP tools -**The main session may only use these lightweight tools directly** (for targeted lookups before making edits, not for exploration): +Agents see nine tools through the MCP server: -| Tool | Use For | -|------|---------| -| `codegraph_search` | Find symbols by name | -| `codegraph_callers` / `codegraph_callees` | Trace call flow | -| `codegraph_impact` | Check what's affected before editing | -| `codegraph_node` | Get a single symbol's details | +| Tool | Use case | +|---|---| +| `codegraph_search` | Find symbols by name / signature / docstring (FTS5) | +| `codegraph_node` | Look up a symbol by id or exact name | +| `codegraph_callers` | What calls this function? | +| `codegraph_callees` | What does this function call? | +| `codegraph_impact` | Transitive impact radius (callers + references) | +| `codegraph_context` | Composed context for a symbol or topic | +| `codegraph_files` | List indexed files under a path | +| `codegraph_status` | Index health: counts, size, schema | +| `codegraph_explore` | (reserved) Survey an unfamiliar module | -### If `.codegraph/` does NOT exist +Read the [server instructions](crates/codegraph-mcp/src/server-instructions.md) +that ship with the binary — they tell your agent when to reach for which tool. -At the start of a session, ask the user if they'd like to initialize CodeGraph: +## Architecture -"I notice this project doesn't have CodeGraph initialized. Would you like me to run `codegraph init -i` to build a code knowledge graph?" +``` +crates/ + codegraph-core/ NodeKind / EdgeKind / Node / Edge / Error + codegraph-db/ rusqlite (bundled) + FTS5 + migrations + codegraph-extract/ tree-sitter native + per-language extractors + codegraph-resolve/ imports + name-matching + (later) frameworks + codegraph-graph/ callers / callees / impact radius (BFS) + codegraph-context/ markdown + JSON context formatters + codegraph-mcp/ hand-rolled JSON-RPC 2.0 server over stdio + codegraph-installer/ Claude / Cursor / Codex / opencode / Hermes targets + codegraph/ CLI binary (clap) + file watcher (notify) ``` -
- ---- - -## How It Works +Pipeline: ``` -┌─────────────────────────────────────────────────────────────────┐ -│ Claude Code │ -│ │ -│ "Implement user authentication" │ -│ │ │ -│ ▼ │ -│ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ Explore Agent │ ──── │ Explore Agent │ │ -│ └────────┬────────┘ └────────┬────────┘ │ -│ │ │ │ -└───────────┼────────────────────────┼─────────────────────────────┘ - │ │ - ▼ ▼ -┌───────────────────────────────────────────────────────────────────┐ -│ CodeGraph MCP Server │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ Search │ │ Callers │ │ Context │ │ -│ │ "auth" │ │ "login()" │ │ for task │ │ -│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ -│ │ │ │ │ -│ └────────────────┼────────────────┘ │ -│ ▼ │ -│ ┌───────────────────────┐ │ -│ │ SQLite Graph DB │ │ -│ │ • 387 symbols │ │ -│ │ • 1,204 edges │ │ -│ │ • Instant lookups │ │ -│ └───────────────────────┘ │ -└───────────────────────────────────────────────────────────────────┘ +files → ignore::WalkBuilder → rayon parse pool (tree-sitter) + ↓ + batched DB transactions (rusqlite WAL) + ↓ + ReferenceResolver (name-matcher, frameworks) + ↓ + GraphTraverser ← ContextBuilder + ↓ + MCP server / CLI commands ``` -1. **Extraction** — [tree-sitter](https://tree-sitter.github.io/) parses source code into ASTs. Language-specific queries extract nodes (functions, classes, methods) and edges (calls, imports, extends, implements). - -2. **Storage** — Everything goes into a local SQLite database (`.codegraph/codegraph.db`) with FTS5 full-text search. - -3. **Resolution** — After extraction, references are resolved: function calls → definitions, imports → source files, class inheritance, and framework-specific patterns. +Full design in [`docs/PLAN.md`](docs/PLAN.md). One spec per crate in +[`docs/specs/`](docs/specs/). -4. **Auto-Sync** — The MCP server watches your project using native OS file events. Changes are debounced (2-second quiet window), filtered to source files only, and incrementally synced. The graph stays fresh as you code — no configuration needed. - ---- - -## CLI Reference - -```bash -codegraph # Run interactive installer -codegraph install # Run installer (explicit) -codegraph uninstall # Remove CodeGraph from your agents (inverse of install) -codegraph init [path] # Initialize in a project (--index to also index) -codegraph uninit [path] # Remove CodeGraph from a project (--force to skip prompt) -codegraph index [path] # Full index (--force to re-index, --quiet for less output) -codegraph sync [path] # Incremental update -codegraph status [path] # Show statistics -codegraph query # Search symbols (--kind, --limit, --json) -codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json) -codegraph context # Build context for AI (--format, --max-nodes) -codegraph affected [files...] # Find test files affected by changes (see below) -codegraph serve --mcp # Start MCP server -``` - -### `codegraph affected` +## Configuration -Traces import dependencies transitively to find which test files are affected by changed source files. +A `.codegraph/` directory is created next to your project: -```bash -codegraph affected src/utils.ts src/api.ts # Pass files as arguments -git diff --name-only | codegraph affected --stdin # Pipe from git diff -codegraph affected src/auth.ts --filter "e2e/*" # Custom test file pattern ``` - -| Option | Description | Default | -|--------|-------------|---------| -| `--stdin` | Read file list from stdin | `false` | -| `-d, --depth ` | Max dependency traversal depth | `5` | -| `-f, --filter ` | Custom glob to identify test files | auto-detect | -| `-j, --json` | Output as JSON | `false` | -| `-q, --quiet` | Output file paths only | `false` | - -**CI/hook example:** - -```bash -#!/usr/bin/env bash -AFFECTED=$(git diff --name-only HEAD | codegraph affected --stdin --quiet) -if [ -n "$AFFECTED" ]; then - npx vitest run $AFFECTED -fi +.codegraph/ + db.sqlite SQLite v1 (WAL mode, FTS5) + .gitignore Pre-filled so the index is never committed + version Codegraph version that created the directory ``` ---- +Add a `.codegraphignore` file at the workspace root to exclude additional +paths beyond your `.gitignore`. Same syntax. -## MCP Tools +## Why Rust? -When running as an MCP server, CodeGraph exposes these tools to Claude Code: +This project is a from-scratch Rust rewrite of the previous TypeScript +implementation. The old binary embedded a Node.js runtime, 20+ tree-sitter +WASM grammars, and a native SQLite addon — about **140 MB on disk**, with a +multi-second cold start. -| Tool | Purpose | -|------|---------| -| `codegraph_search` | Find symbols by name across the codebase | -| `codegraph_context` | Build relevant code context for a task | -| `codegraph_callers` | Find what calls a function | -| `codegraph_callees` | Find what a function calls | -| `codegraph_impact` | Analyze what code is affected by changing a symbol | -| `codegraph_node` | Get details about a specific symbol (optionally with source code) | -| `codegraph_files` | Get indexed file structure (faster than filesystem scanning) | -| `codegraph_status` | Check index health and statistics | +The Rust port: ---- +- Drops the Node runtime → static binary +- Replaces WASM grammars with statically-linked tree-sitter C libraries +- Bundles SQLite as a static C library (no system dependency) +- Parses in parallel via `rayon` +- Builds with `lto="fat"`, `codegen-units=1`, `strip`, `panic=abort` -## Library Usage +Result: **~5 MB** stripped, **sub-second** startup, **~5× faster** indexing +on the same workspace. -```typescript -import CodeGraph from '@colbymchenry/codegraph'; +# Roadmap: -const cg = await CodeGraph.init('/path/to/project'); -// Or: const cg = await CodeGraph.open('/path/to/project'); +- Framework-aware route extraction (Express, Laravel, Rails, FastAPI, Django, + Spring, Axum, …) +- Additional grammars (Kotlin, Dart, Pascal, Luau, Svelte/Vue/Liquid) +- Eval harness for accuracy regression testing -await cg.indexAll({ - onProgress: (p) => console.log(`${p.phase}: ${p.current}/${p.total}`) -}); +## Development -const results = cg.searchNodes('UserService'); -const callers = cg.getCallers(results[0].node.id); -const context = await cg.buildContext('fix login bug', { maxNodes: 20, includeCode: true, format: 'markdown' }); -const impact = cg.getImpactRadius(results[0].node.id, 2); - -cg.watch(); // auto-sync on file changes -cg.unwatch(); // stop watching -cg.close(); +```sh +cargo build --workspace +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +cargo fmt --all ``` ---- - -## Configuration +Per-crate test runs: -There isn't any — CodeGraph is zero-config. It indexes every file whose -extension maps to a [supported language](#supported-languages) and **respects -your `.gitignore`**: in git repos via git itself, and in non-git projects by -reading `.gitignore` files directly (root and nested, the same way git would). - -What that means in practice: - -- Anything git ignores — `node_modules`, build output, secrets in `.env` — is - never indexed. **To keep something out of the graph, add it to `.gitignore`.** -- There's no config file to write or keep in sync, and nothing to wire up per - language: support is automatic from the file extension. -- Files larger than 1 MB are skipped (generated bundles, minified JS, vendored - blobs) — they cost parse budget for no useful symbols. - -> Committed files that aren't gitignored *are* indexed, even under `vendor/` or a -> committed `dist/`. If you commit a dependency or build directory you don't want -> in the graph, add it to `.gitignore`. - -## Supported Languages - -| Language | Extension | Status | -|----------|-----------|--------| -| TypeScript | `.ts`, `.tsx` | Full support | -| JavaScript | `.js`, `.jsx`, `.mjs` | Full support | -| Python | `.py` | Full support | -| Go | `.go` | Full support | -| Rust | `.rs` | Full support | -| Java | `.java` | Full support | -| C# | `.cs` | Full support | -| PHP | `.php` | Full support | -| Ruby | `.rb` | Full support | -| C | `.c`, `.h` | Full support | -| C++ | `.cpp`, `.hpp`, `.cc` | Full support | -| Swift | `.swift` | Full support | -| Kotlin | `.kt`, `.kts` | Full support | -| Scala | `.scala`, `.sc` | Full support (classes, traits, methods, type aliases, Scala 3 enums) | -| Dart | `.dart` | Full support | -| Svelte | `.svelte` | Full support (script extraction, Svelte 5 runes, SvelteKit routes) | -| Vue | `.vue` | Full support (script + script-setup extraction, Nuxt page/API/middleware routes) | -| Liquid | `.liquid` | Full support | -| Pascal / Delphi | `.pas`, `.dpr`, `.dpk`, `.lpr` | Full support (classes, records, interfaces, enums, DFM/FMX form files) | -| Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) | -| Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) | - -## Troubleshooting - -**"CodeGraph not initialized"** — Run `codegraph init` in your project directory first. - -**Indexing is slow** — Check that `node_modules` and other large directories are excluded. Use `--quiet` to reduce output overhead. - -**MCP hits `database is locked`** — current builds shouldn't: CodeGraph bundles its own Node runtime and uses Node's built-in `node:sqlite` in WAL mode, where concurrent reads never block on a writer. If you still see it: - -- **You're on an old (pre-0.9) install.** Reinstall to get the bundled runtime — `curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh` (macOS/Linux), `irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex` (Windows), or `npm i -g @colbymchenry/codegraph@latest`. -- **`codegraph status` shows `Journal:` other than `wal`** — WAL couldn't be enabled on this filesystem (common on network shares and WSL2 `/mnt`), so reads can block on writes. Move the project (with its `.codegraph/` folder) onto a local disk. - -**MCP server not connecting** — Ensure the project is initialized/indexed, verify the path in your MCP config, and check that `codegraph serve --mcp` works from the command line. - -**Missing symbols** — The MCP server auto-syncs on save (wait a couple seconds). Run `codegraph sync` manually if needed. Check that the file's language is supported and isn't excluded by config patterns. - -## Star History - - - - - - Star History Chart - - +```sh +cargo test -p codegraph-db +cargo test -p codegraph-extract +cargo test -p codegraph-installer +``` ## License -MIT - ---- - -
- -**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, and Hermes Agent** +MIT. See [LICENSE](LICENSE). -[Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues) +## Acknowledgments -
+- The original TypeScript implementation by [@colbymchenry](https://github.com/colbymchenry). +- `tree-sitter` and all language grammar authors. +- `rusqlite`, `notify`, `clap`, `tokio`, `rayon`, `ignore`. diff --git a/__tests__/concurrent-locking.test.ts b/__tests__/concurrent-locking.test.ts deleted file mode 100644 index 5c8ab518..00000000 --- a/__tests__/concurrent-locking.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Issue #238 — "database is locked" on concurrent MCP tool calls. - * - * With node:sqlite (real WAL) as the backend, the fixes that remain relevant: - * 1. busy_timeout is a bounded few-second wait (not a 2-minute hang) and WAL is - * active — so a reader never blocks on a concurrent writer. - * 2. The MCP ToolHandler reuses the default instance when a tool passes a - * projectPath pointing at the default project, instead of opening a SECOND - * connection to the same DB. - */ - -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import CodeGraph from '../src'; -import { ToolHandler } from '../src/mcp/tools'; -import { DatabaseConnection } from '../src/db'; - -/** Normalize a PRAGMA read across return shapes (array | object | scalar). */ -function pragmaValue(raw: unknown, key: string): unknown { - const row = Array.isArray(raw) ? raw[0] : raw; - if (row !== null && typeof row === 'object') return (row as Record)[key]; - return row; -} - -describe('issue #238 — connection PRAGMAs (#1)', () => { - let dir: string; - let conn: DatabaseConnection; - - beforeAll(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-pragma-')); - conn = DatabaseConnection.initialize(path.join(dir, 'codegraph.db')); - }); - - afterAll(() => { - conn.close(); - fs.rmSync(dir, { recursive: true, force: true }); - }); - - it('uses a bounded busy_timeout, not the old 2-minute hang', () => { - const ms = Number(pragmaValue(conn.getDb().pragma('busy_timeout'), 'timeout')); - expect(ms).toBeGreaterThan(0); - expect(ms).toBeLessThanOrEqual(30000); // far below the old 120000 - }); - - it('runs in WAL mode — the mode that lets readers proceed during a write', () => { - const mode = String(pragmaValue(conn.getDb().pragma('journal_mode'), 'journal_mode')).toLowerCase(); - expect(mode).toBe('wal'); - }); - - it('getJournalMode() surfaces the effective mode for status triage', () => { - expect(conn.getJournalMode()).toBe('wal'); - }); -}); - -describe('issue #238 — WAL lets a reader proceed during a writer', () => { - let dir: string; - - beforeAll(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-wal-')); - }); - - afterAll(() => { - fs.rmSync(dir, { recursive: true, force: true }); - }); - - it('a read on a 2nd connection succeeds while a writer holds the lock', () => { - const dbPath = path.join(dir, 'codegraph.db'); - const writer = DatabaseConnection.initialize(dbPath); - // The property only holds under WAL; skip if the filesystem couldn't enable it. - if (writer.getJournalMode() !== 'wal') { - writer.close(); - return; - } - const reader = DatabaseConnection.open(dbPath); - try { - writer.getDb().prepare('BEGIN EXCLUSIVE').run(); // hard write lock, held open - const t0 = Date.now(); - const row = reader.getDb().prepare('SELECT COUNT(*) AS c FROM nodes').get() as { c: number }; - const waited = Date.now() - t0; - expect(row.c).toBe(0); - expect(waited).toBeLessThan(1000); // proceeds immediately, no busy wait - } finally { - try { writer.getDb().prepare('COMMIT').run(); } catch { /* ignore */ } - reader.close(); - writer.close(); - } - }); -}); - -describe('issue #238 — ToolHandler reuses the default instance (#2)', () => { - let dir: string; - let cg: CodeGraph; - let root: string; - let handler: ToolHandler; - - beforeAll(async () => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-tools-')); - fs.writeFileSync(path.join(dir, 'a.ts'), 'export function helper(): number { return 1; }\n'); - fs.writeFileSync( - path.join(dir, 'b.ts'), - "import { helper } from './a';\nexport function main(): number { return helper(); }\n" - ); - cg = await CodeGraph.init(dir, { index: true }); - root = cg.getProjectRoot(); - handler = new ToolHandler(cg); - }); - - afterAll(() => { - cg.close(); - fs.rmSync(dir, { recursive: true, force: true }); - }); - - it('getCodeGraph(defaultRoot) returns the default instance, not a new connection', () => { - const openSpy = vi.spyOn(CodeGraph, 'openSync'); - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resolved = (handler as any).getCodeGraph(root); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const nested = (handler as any).getCodeGraph(path.join(root, 'does', 'not', 'exist')); - expect(resolved).toBe(cg); - expect(nested).toBe(cg); // a sub-path resolves up to the same default project - expect(openSpy).not.toHaveBeenCalled(); // no second connection opened - } finally { - openSpy.mockRestore(); - } - }); - - it('concurrent read tool calls (mixed projectPath) all succeed without "database is locked"', async () => { - const openSpy = vi.spyOn(CodeGraph, 'openSync'); - try { - const calls: Promise<{ content: Array<{ text: string }>; isError?: boolean }>[] = [ - handler.execute('codegraph_search', { query: 'helper' }), - handler.execute('codegraph_search', { query: 'helper', projectPath: root }), - handler.execute('codegraph_callers', { symbol: 'helper', projectPath: root }), - handler.execute('codegraph_callees', { symbol: 'main' }), - handler.execute('codegraph_files', { projectPath: root }), - handler.execute('codegraph_status', { projectPath: root }), - ]; - const results = await Promise.all(calls); - for (const r of results) { - expect(r.isError).not.toBe(true); - expect(r.content[0]?.text ?? '').not.toMatch(/database is locked/i); - } - // Passing the default project's own path must not open a second connection. - expect(openSpy).not.toHaveBeenCalled(); - } finally { - openSpy.mockRestore(); - } - }); -}); diff --git a/__tests__/context.test.ts b/__tests__/context.test.ts deleted file mode 100644 index 52dae1fe..00000000 --- a/__tests__/context.test.ts +++ /dev/null @@ -1,374 +0,0 @@ -/** - * Context Builder Tests - * - * Tests for the context building functionality. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import CodeGraph from '../src/index'; - -describe('Context Builder', () => { - let testDir: string; - let cg: CodeGraph; - - beforeEach(async () => { - testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-context-test-')); - - // Create a sample codebase - const srcDir = path.join(testDir, 'src'); - fs.mkdirSync(srcDir); - - // Create a payment service file - fs.writeFileSync( - path.join(srcDir, 'payment.ts'), - `/** - * Payment Service - * Handles payment processing logic. - */ - -export interface PaymentResult { - success: boolean; - transactionId: string; - amount: number; -} - -export class PaymentService { - private apiKey: string; - - constructor(apiKey: string) { - this.apiKey = apiKey; - } - - /** - * Process a payment for the given amount - */ - async processPayment(amount: number): Promise { - // Validate amount - if (amount <= 0) { - throw new Error('Invalid amount'); - } - - // Process payment - const transactionId = this.generateTransactionId(); - return { - success: true, - transactionId, - amount, - }; - } - - private generateTransactionId(): string { - return 'txn_' + Math.random().toString(36).substring(2); - } -} - -export function createPaymentService(apiKey: string): PaymentService { - return new PaymentService(apiKey); -} -` - ); - - // Create a checkout controller file - fs.writeFileSync( - path.join(srcDir, 'checkout.ts'), - `/** - * Checkout Controller - * Handles the checkout flow. - */ - -import { PaymentService, PaymentResult } from './payment'; - -export interface CartItem { - id: string; - name: string; - price: number; - quantity: number; -} - -export class CheckoutController { - private paymentService: PaymentService; - - constructor(paymentService: PaymentService) { - this.paymentService = paymentService; - } - - /** - * Process checkout for the given cart - */ - async processCheckout(cart: CartItem[]): Promise { - const total = this.calculateTotal(cart); - - if (total === 0) { - throw new Error('Cart is empty'); - } - - return this.paymentService.processPayment(total); - } - - /** - * Calculate the total price of the cart - */ - calculateTotal(cart: CartItem[]): number { - return cart.reduce((sum, item) => sum + item.price * item.quantity, 0); - } -} -` - ); - - // Create a utilities file - fs.writeFileSync( - path.join(srcDir, 'utils.ts'), - `/** - * Utility functions - */ - -export function formatCurrency(amount: number): string { - return '$' + amount.toFixed(2); -} - -export function validateEmail(email: string): boolean { - return email.includes('@'); -} -` - ); - - // Initialize CodeGraph - cg = CodeGraph.initSync(testDir, { - config: { - include: ['**/*.ts'], - exclude: [], - }, - }); - - // Index the codebase - await cg.indexAll(); - }); - - afterEach(() => { - if (cg) { - cg.destroy(); - } - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - }); - - describe('getCode()', () => { - it('should extract code for a node', async () => { - // Find the PaymentService class - const nodes = cg.getNodesByKind('class'); - const paymentService = nodes.find((n) => n.name === 'PaymentService'); - - expect(paymentService).toBeDefined(); - - const code = await cg.getCode(paymentService!.id); - - expect(code).not.toBeNull(); - expect(code).toContain('class PaymentService'); - expect(code).toContain('processPayment'); - }); - - it('should return null for non-existent node', async () => { - const code = await cg.getCode('non-existent-id'); - expect(code).toBeNull(); - }); - }); - - describe('findRelevantContext()', () => { - it('should find relevant nodes for a query', async () => { - // Use simple query that matches symbol names (FTS5 treats spaces as AND) - const result = await cg.findRelevantContext('PaymentService'); - - expect(result.nodes.size).toBeGreaterThan(0); - // Should find payment-related nodes - const nodeNames = Array.from(result.nodes.values()).map((n) => n.name); - expect( - nodeNames.some( - (name) => - name.toLowerCase().includes('payment') || - name.toLowerCase().includes('checkout') - ) - ).toBe(true); - }); - - it('should include edges in the result', async () => { - const result = await cg.findRelevantContext('checkout', { - traversalDepth: 2, - }); - - // Should have some edges from traversal - expect(result.edges).toBeDefined(); - }); - - it('should respect maxNodes option', async () => { - const result = await cg.findRelevantContext('function', { - maxNodes: 5, - }); - - expect(result.nodes.size).toBeLessThanOrEqual(5); - }); - }); - - describe('buildContext()', () => { - it('should build context with markdown format', async () => { - const result = await cg.buildContext('Fix checkout error', { - format: 'markdown', - maxCodeBlocks: 3, - }); - - expect(typeof result).toBe('string'); - const markdown = result as string; - - // Should contain markdown structure - expect(markdown).toContain('## Code Context'); - expect(markdown).toContain('**Query:** Fix checkout error'); - }); - - it('should build context with JSON format', async () => { - const result = await cg.buildContext('payment processing', { - format: 'json', - }); - - expect(typeof result).toBe('string'); - const parsed = JSON.parse(result as string); - - expect(parsed.query).toBe('payment processing'); - expect(parsed.nodes).toBeDefined(); - expect(Array.isArray(parsed.nodes)).toBe(true); - }); - - it('should accept object input with title and description', async () => { - const result = await cg.buildContext( - { - title: 'Checkout bug', - description: 'Cart total calculation is wrong', - }, - { format: 'markdown' } - ); - - expect(typeof result).toBe('string'); - expect(result).toContain('Checkout bug: Cart total calculation is wrong'); - }); - - it('should include code blocks when requested', async () => { - const result = await cg.buildContext('PaymentService', { - format: 'markdown', - includeCode: true, - maxCodeBlocks: 2, - }); - - const markdown = result as string; - - // Should contain code blocks - expect(markdown).toContain('### Code'); - expect(markdown).toContain('```typescript'); - }); - - it('should exclude code blocks when requested', async () => { - const result = await cg.buildContext('payment', { - format: 'markdown', - includeCode: false, - }); - - const markdown = result as string; - - // Should not contain code section - expect(markdown).not.toContain('### Code'); - }); - - it('should include related symbols in compact format', async () => { - const result = await cg.buildContext('checkout', { - format: 'markdown', - maxNodes: 10, - }); - - const markdown = result as string; - - // Compact format uses "Related Symbols" instead of verbose "Related Files" - // and groups symbols by file for compactness - expect(markdown).toContain('### Entry Points'); - }); - - it('should have compact output without verbose stats footer', async () => { - const result = await cg.buildContext('payment', { - format: 'markdown', - }); - - const markdown = result as string; - - // Compact format should NOT have verbose stats footer - expect(markdown).not.toMatch(/\*Context:.*symbols.*relationships.*files/); - // But should still have query - expect(markdown).toContain('**Query:**'); - }); - }); - - describe('Context structure', () => { - it('should find entry points from search', async () => { - const result = await cg.buildContext('PaymentService', { - format: 'json', - }); - - const parsed = JSON.parse(result as string); - - expect(parsed.entryPoints).toBeDefined(); - expect(parsed.entryPoints.length).toBeGreaterThan(0); - }); - - it('should traverse graph from entry points', async () => { - const result = await cg.buildContext('CheckoutController', { - format: 'json', - traversalDepth: 2, - }); - - const parsed = JSON.parse(result as string); - - // Should have found related nodes through traversal - const nodeNames = parsed.nodes.map((n: { name: string }) => n.name); - - // CheckoutController calls PaymentService, so both should be present - expect( - nodeNames.some((name: string) => name.includes('Checkout')) - ).toBe(true); - }); - }); - - describe('Edge cases', () => { - it('should handle empty query', async () => { - const result = await cg.buildContext('', { format: 'markdown' }); - - expect(typeof result).toBe('string'); - }); - - it('should handle query with no matches', async () => { - const result = await cg.buildContext('xyznonexistent123', { - format: 'json', - }); - - const parsed = JSON.parse(result as string); - - // Should return empty or minimal results - expect(parsed.nodes).toBeDefined(); - }); - - it('should truncate long code blocks', async () => { - const result = await cg.buildContext('PaymentService', { - format: 'markdown', - maxCodeBlockSize: 100, - includeCode: true, - }); - - const markdown = result as string; - - // Long code blocks should be truncated - if (markdown.includes('```typescript')) { - // If there's a code block, check for truncation marker if content was long - // This test validates the truncation logic works - expect(typeof markdown).toBe('string'); - } - }); - }); -}); diff --git a/__tests__/drupal.test.ts b/__tests__/drupal.test.ts deleted file mode 100644 index fda5415b..00000000 --- a/__tests__/drupal.test.ts +++ /dev/null @@ -1,518 +0,0 @@ -/** - * Tests for Drupal framework resolver. - * - * Unit tests cover drupalResolver.detect(), extract() (routes + hooks), and resolve(). - * Integration tests use a real CodeGraph instance with a temporary Drupal project layout. - */ - -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { afterEach, beforeAll, describe, expect, it } from 'vitest'; -import { CodeGraph } from '../src'; -import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; -import { drupalResolver } from '../src/resolution/frameworks/drupal'; -import type { ResolutionContext } from '../src/resolution/types'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeContext( - overrides: Partial = {}, -): ResolutionContext { - return { - getNodesInFile: () => [], - getNodesByName: () => [], - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: () => false, - readFile: () => null, - getProjectRoot: () => '/project', - getAllFiles: () => [], - getNodesByLowerName: () => [], - getImportMappings: () => [], - ...overrides, - }; -} - -// --------------------------------------------------------------------------- -// detect() -// --------------------------------------------------------------------------- - -describe('drupalResolver.detect', () => { - it('returns true when composer.json has a drupal/ dependency', () => { - const ctx = makeContext({ - readFile: (f) => - f === 'composer.json' - ? JSON.stringify({ - require: { - 'drupal/core-recommended': '~10.5', - 'drush/drush': '^13', - }, - }) - : null, - }); - expect(drupalResolver.detect(ctx)).toBe(true); - }); - - it('returns true when drupal/ dependency is in require-dev', () => { - const ctx = makeContext({ - readFile: (f) => - f === 'composer.json' - ? JSON.stringify({ 'require-dev': { 'drupal/core': '^10' } }) - : null, - }); - expect(drupalResolver.detect(ctx)).toBe(true); - }); - - it('returns false when composer.json has no drupal/ dependencies', () => { - const ctx = makeContext({ - readFile: (f) => - f === 'composer.json' - ? JSON.stringify({ - require: { 'laravel/framework': '^10', php: '>=8.1' }, - }) - : null, - }); - expect(drupalResolver.detect(ctx)).toBe(false); - }); - - it('returns false when composer.json is absent', () => { - const ctx = makeContext({ readFile: () => null }); - expect(drupalResolver.detect(ctx)).toBe(false); - }); - - it('returns false when composer.json is malformed JSON', () => { - const ctx = makeContext({ readFile: () => '{ bad json' }); - expect(drupalResolver.detect(ctx)).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// extract() — routing.yml -// --------------------------------------------------------------------------- - -describe('drupalResolver.extract — routing.yml', () => { - const routing = ` -mymodule.example: - path: '/mymodule/example' - defaults: - _controller: '\\Drupal\\mymodule\\Controller\\MyController::build' - _title: 'Example page' - requirements: - _permission: 'access content' -`; - - it('emits a route node for each YAML route', () => { - const { nodes } = drupalResolver.extract!( - 'mymodule/mymodule.routing.yml', - routing, - ); - expect(nodes).toHaveLength(1); - expect(nodes[0]!.kind).toBe('route'); - expect(nodes[0]!.name).toBe('/mymodule/example'); - }); - - it('sets qualifiedName to filePath::routeName', () => { - const { nodes } = drupalResolver.extract!( - 'mymodule/mymodule.routing.yml', - routing, - ); - expect(nodes[0]!.qualifiedName).toBe( - 'mymodule/mymodule.routing.yml::mymodule.example', - ); - }); - - it('emits a references edge to the controller FQCN', () => { - const { references } = drupalResolver.extract!( - 'mymodule/mymodule.routing.yml', - routing, - ); - expect(references).toHaveLength(1); - expect(references[0]!.referenceName).toBe( - '\\Drupal\\mymodule\\Controller\\MyController::build', - ); - expect(references[0]!.referenceKind).toBe('references'); - }); - - it('emits a references edge to a _form handler', () => { - const src = ` -mymodule.settings_form: - path: '/admin/config/mymodule' - defaults: - _form: '\\Drupal\\mymodule\\Form\\SettingsForm' - _title: 'MyModule settings' - requirements: - _permission: 'administer site configuration' -`; - const { nodes, references } = drupalResolver.extract!( - 'mymodule/mymodule.routing.yml', - src, - ); - expect(nodes).toHaveLength(1); - expect(references[0]!.referenceName).toBe( - '\\Drupal\\mymodule\\Form\\SettingsForm', - ); - }); - - it('handles multiple routes in one file', () => { - const src = ` -mod.page_one: - path: '/page-one' - defaults: - _controller: '\\Drupal\\mod\\Controller\\PageController::one' - requirements: - _permission: 'access content' - -mod.page_two: - path: '/page-two' - defaults: - _controller: '\\Drupal\\mod\\Controller\\PageController::two' - requirements: - _permission: 'access content' -`; - const { nodes, references } = drupalResolver.extract!( - 'mod/mod.routing.yml', - src, - ); - expect(nodes).toHaveLength(2); - expect(nodes.map((n) => n.name)).toContain('/page-one'); - expect(nodes.map((n) => n.name)).toContain('/page-two'); - expect(references).toHaveLength(2); - }); - - it('skips commented-out lines', () => { - const src = ` -mod.page: - path: '/page' - defaults: - #_controller: '\\Drupal\\mod\\Controller\\Old::build' - _controller: '\\Drupal\\mod\\Controller\\New::build' - requirements: - _permission: 'access content' -`; - const { references } = drupalResolver.extract!('mod/mod.routing.yml', src); - expect(references).toHaveLength(1); - expect(references[0]!.referenceName).toContain('New'); - }); - - it('includes HTTP methods in the route node name when present', () => { - const src = ` -mod.api: - path: '/api/resource' - defaults: - _controller: '\\Drupal\\mod\\Controller\\ApiController::get' - methods: [GET, POST] - requirements: - _permission: 'access content' -`; - const { nodes } = drupalResolver.extract!('mod/mod.routing.yml', src); - expect(nodes[0]!.name).toContain('GET'); - expect(nodes[0]!.name).toContain('POST'); - }); - - it('returns empty result for non-routing-yml files', () => { - const { nodes, references } = drupalResolver.extract!( - 'mymodule.module', - ' { - const { nodes, references } = drupalResolver.extract!( - 'some.routing.yml', - '# empty\n', - ); - expect(nodes).toHaveLength(0); - expect(references).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// extract() — hook detection in .module files -// --------------------------------------------------------------------------- - -describe('drupalResolver.extract — hook detection', () => { - it('detects hook implementation via docblock (Strategy A)', () => { - const src = ` r.referenceName === 'hook_form_alter', - ); - expect(hookRef).toBeDefined(); - expect(hookRef!.referenceKind).toBe('references'); - }); - - it('detects hook implementation via name pattern (Strategy B)', () => { - const src = ` r.referenceName === 'hook_views_data', - ); - expect(hookRef).toBeDefined(); - }); - - it('does not emit a hook ref for non-hook helper functions', () => { - // 'other_module_helper' doesn't start with 'mymodule_', so no hook ref - const src = ` { - const src = ` r.referenceName === 'hook_schema'); - expect(hookRef).toBeDefined(); - }); - - it('detects hooks in .theme files', () => { - const src = ` r.referenceName === 'hook_preprocess_node', - ); - expect(hookRef).toBeDefined(); - }); - - it('does not duplicate refs when both docblock and name pattern match', () => { - // Strategy A matches first and adds to docblockMatched set; - // Strategy B skips already-matched functions. - const src = ` r.referenceName === 'hook_form_alter', - ); - expect(hookRefs).toHaveLength(1); - }); -}); - -// --------------------------------------------------------------------------- -// resolve() -// --------------------------------------------------------------------------- - -describe('drupalResolver.resolve', () => { - it('resolves a _controller FQCN with ::method to the method node', () => { - const methodNode = { - id: 'method:abc123', - kind: 'method' as const, - name: 'build', - qualifiedName: 'MyController::build', - filePath: 'web/modules/custom/mymodule/src/Controller/MyController.php', - language: 'php' as const, - startLine: 10, - endLine: 20, - startColumn: 0, - endColumn: 0, - updatedAt: 0, - }; - const classNode = { - id: 'class:def456', - kind: 'class' as const, - name: 'MyController', - qualifiedName: 'MyController', - filePath: 'web/modules/custom/mymodule/src/Controller/MyController.php', - language: 'php' as const, - startLine: 5, - endLine: 30, - startColumn: 0, - endColumn: 0, - updatedAt: 0, - }; - const ctx = makeContext({ - getNodesByName: (name) => (name === 'MyController' ? [classNode] : []), - getNodesInFile: () => [classNode, methodNode], - }); - const ref = { - fromNodeId: 'route:x', - referenceName: '\\Drupal\\mymodule\\Controller\\MyController::build', - referenceKind: 'references' as const, - line: 1, - column: 0, - filePath: 'mymodule.routing.yml', - language: 'yaml' as const, - }; - const resolved = drupalResolver.resolve(ref, ctx); - expect(resolved).not.toBeNull(); - expect(resolved!.targetNodeId).toBe('method:abc123'); - expect(resolved!.confidence).toBeGreaterThanOrEqual(0.85); - }); - - it('resolves a _form FQCN (no ::method) to the class node', () => { - const classNode = { - id: 'class:form123', - kind: 'class' as const, - name: 'SettingsForm', - qualifiedName: 'SettingsForm', - filePath: 'web/modules/custom/mymodule/src/Form/SettingsForm.php', - language: 'php' as const, - startLine: 1, - endLine: 50, - startColumn: 0, - endColumn: 0, - updatedAt: 0, - }; - const ctx = makeContext({ - getNodesByName: (name) => (name === 'SettingsForm' ? [classNode] : []), - }); - const ref = { - fromNodeId: 'route:x', - referenceName: '\\Drupal\\mymodule\\Form\\SettingsForm', - referenceKind: 'references' as const, - line: 1, - column: 0, - filePath: 'mymodule.routing.yml', - language: 'yaml' as const, - }; - const resolved = drupalResolver.resolve(ref, ctx); - expect(resolved).not.toBeNull(); - expect(resolved!.targetNodeId).toBe('class:form123'); - }); - - it('returns null when the target class cannot be found', () => { - const ctx = makeContext({ getNodesByName: () => [] }); - const ref = { - fromNodeId: 'route:x', - referenceName: '\\Drupal\\mymodule\\Controller\\Missing::method', - referenceKind: 'references' as const, - line: 1, - column: 0, - filePath: 'mymodule.routing.yml', - language: 'yaml' as const, - }; - expect(drupalResolver.resolve(ref, ctx)).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// End-to-end integration test -// --------------------------------------------------------------------------- - -beforeAll(async () => { - await initGrammars(); - await loadAllGrammars(); -}); - -describe('Drupal end-to-end — route node linked to controller method', () => { - let tmpDir: string | undefined; - afterEach(() => { - if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); - tmpDir = undefined; - }); - - it('creates a route→controller edge from routing.yml to PHP class', async () => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-drupal-')); - - // Minimal composer.json to trigger Drupal detection - fs.writeFileSync( - path.join(tmpDir, 'composer.json'), - JSON.stringify({ require: { 'drupal/core-recommended': '~10.5' } }), - ); - - // Module directory structure - const modDir = path.join(tmpDir, 'web', 'modules', 'custom', 'my_module'); - fs.mkdirSync(path.join(modDir, 'src', 'Controller'), { recursive: true }); - - // routing.yml - fs.writeFileSync( - path.join(modDir, 'my_module.routing.yml'), - [ - 'my_module.hello:', - " path: '/hello'", - ' defaults:', - " _controller: '\\Drupal\\my_module\\Controller\\HelloController::build'", - " _title: 'Hello'", - ' requirements:', - " _permission: 'access content'", - ].join('\n') + '\n', - ); - - // PHP controller - fs.writeFileSync( - path.join(modDir, 'src', 'Controller', 'HelloController.php'), - [ - ' 'Hello'];", - ' }', - '}', - ].join('\n') + '\n', - ); - - const cg = CodeGraph.initSync(tmpDir); - await cg.indexAll(); - - // Route node must exist - const routes = cg.getNodesByKind('route'); - expect(routes.length).toBeGreaterThan(0); - const route = routes.find((n) => n.name.includes('/hello')); - expect(route).toBeDefined(); - - // Controller method must be indexed - const methods = cg.getNodesByKind('method'); - const buildMethod = methods.find((n) => n.name === 'build'); - expect(buildMethod).toBeDefined(); - - // Edge: route → build method (or class fallback) - const edges = cg.getOutgoingEdges(route!.id); - expect(edges.length).toBeGreaterThan(0); - - cg.close(); - }); -}); diff --git a/__tests__/evaluation/runner.ts b/__tests__/evaluation/runner.ts deleted file mode 100644 index 7ff04359..00000000 --- a/__tests__/evaluation/runner.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import { CodeGraph } from '../../src/index.js'; -import { scoreSearchNodes, scoreFindRelevantContext } from './scoring.js'; -import { testCases } from './test-cases.js'; -import type { EvalReport, EvalResult } from './types.js'; - -const codebasePath = process.env.EVAL_CODEBASE || process.argv[2]; -if (!codebasePath) { - console.error('Usage: EVAL_CODEBASE=/path/to/codebase npx tsx __tests__/evaluation/runner.ts'); - console.error(' or: npx tsx __tests__/evaluation/runner.ts /path/to/codebase'); - process.exit(1); -} - -const resolvedPath = path.resolve(codebasePath); -if (!fs.existsSync(path.join(resolvedPath, '.codegraph', 'codegraph.db'))) { - console.error(`No .codegraph/codegraph.db found at ${resolvedPath}`); - process.exit(1); -} - -let codegraphSha = 'unknown'; -try { - codegraphSha = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim(); -} catch {} - -console.log(`\nCodeGraph Eval — ${path.basename(resolvedPath)}`); -console.log(`Codebase: ${resolvedPath}`); -console.log(`Commit: ${codegraphSha}`); -console.log(`Cases: ${testCases.length}`); -console.log(''); - -async function run() { - const cg = CodeGraph.openSync(resolvedPath); - const results: EvalResult[] = []; - - for (const tc of testCases) { - const start = performance.now(); - - if (tc.api === 'searchNodes') { - const searchResults = cg.searchNodes(tc.query, { - limit: 10, - kinds: tc.kinds, - ...(tc.options as Record), - }); - const latency = performance.now() - start; - const result = scoreSearchNodes(tc.id, tc.expectedSymbols, searchResults, latency); - results.push(result); - } else { - const subgraph = await cg.findRelevantContext(tc.query, { - searchLimit: 8, - traversalDepth: 3, - maxNodes: 80, - minScore: 0.2, - ...(tc.options as Record), - }); - const latency = performance.now() - start; - const result = scoreFindRelevantContext(tc.id, tc.expectedSymbols, subgraph, latency); - results.push(result); - } - } - - cg.close(); - - // Print results table - const maxIdLen = Math.max(...results.map((r) => r.caseId.length)); - - for (const r of results) { - const status = r.pass ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; - const id = r.caseId.padEnd(maxIdLen); - const recall = `recall=${r.recall.toFixed(2)}`; - const extra = - r.edgeDensity !== undefined - ? `density=${r.edgeDensity.toFixed(2)}` - : `mrr=${r.mrr.toFixed(2)}`; - const latency = `${Math.round(r.latencyMs)}ms`; - - console.log(` ${id} ${status} ${recall} ${extra} ${latency}`); - - if (r.missedSymbols.length > 0) { - console.log(` ${' '.repeat(maxIdLen)} missed: ${r.missedSymbols.join(', ')}`); - } - } - - // Summary - const passed = results.filter((r) => r.pass).length; - const failed = results.length - passed; - const meanRecall = results.reduce((s, r) => s + r.recall, 0) / results.length; - const mrrResults = results.filter((r) => r.mrr > 0 || r.caseId.startsWith('search-')); - const meanMRR = - mrrResults.length > 0 ? mrrResults.reduce((s, r) => s + r.mrr, 0) / mrrResults.length : 0; - - console.log(''); - const summaryColor = failed === 0 ? '\x1b[32m' : '\x1b[33m'; - console.log( - `${summaryColor}SUMMARY: ${passed}/${results.length} passed | recall=${meanRecall.toFixed(2)} | mrr=${meanMRR.toFixed(2)}\x1b[0m` - ); - - // Save JSON report - const report: EvalReport = { - timestamp: new Date().toISOString(), - codebasePath: resolvedPath, - codegraphSha, - summary: { total: results.length, passed, failed, meanRecall, meanMRR }, - results, - }; - - const resultsDir = path.join(__dirname, 'results'); - fs.mkdirSync(resultsDir, { recursive: true }); - const reportFile = path.join( - resultsDir, - `${new Date().toISOString().replace(/[:.]/g, '-')}.json` - ); - fs.writeFileSync(reportFile, JSON.stringify(report, null, 2)); - console.log(`\nReport saved: ${reportFile}`); - - process.exit(failed > 0 ? 1 : 0); -} - -run().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/__tests__/evaluation/scoring.ts b/__tests__/evaluation/scoring.ts deleted file mode 100644 index b20f604c..00000000 --- a/__tests__/evaluation/scoring.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { EvalResult } from './types.js'; - -export const PASS_THRESHOLD = 0.5; - -export function scoreSearchNodes( - caseId: string, - expectedSymbols: string[], - results: Array<{ node: { name: string }; score: number }>, - latencyMs: number -): EvalResult { - const expectedLower = expectedSymbols.map((s) => s.toLowerCase()); - const resultNames = results.map((r) => r.node.name.toLowerCase()); - - const found: string[] = []; - const missed: string[] = []; - let firstRank = 0; - - for (let i = 0; i < expectedLower.length; i++) { - const idx = resultNames.indexOf(expectedLower[i]); - if (idx !== -1) { - found.push(expectedSymbols[i]); - if (firstRank === 0) firstRank = idx + 1; - } else { - missed.push(expectedSymbols[i]); - } - } - - const recall = expectedSymbols.length > 0 ? found.length / expectedSymbols.length : 0; - const mrr = firstRank > 0 ? 1 / firstRank : 0; - - return { - caseId, - pass: recall >= PASS_THRESHOLD, - recall, - mrr, - foundSymbols: found, - missedSymbols: missed, - latencyMs, - }; -} - -export function scoreFindRelevantContext( - caseId: string, - expectedSymbols: string[], - subgraph: { nodes: Map; edges: unknown[]; roots: string[] }, - latencyMs: number -): EvalResult { - const expectedLower = new Set(expectedSymbols.map((s) => s.toLowerCase())); - const nodeNames = new Set(); - for (const node of subgraph.nodes.values()) { - nodeNames.add(node.name.toLowerCase()); - } - - const found: string[] = []; - const missed: string[] = []; - - for (const sym of expectedSymbols) { - if (nodeNames.has(sym.toLowerCase())) { - found.push(sym); - } else { - missed.push(sym); - } - } - - const recall = expectedSymbols.length > 0 ? found.length / expectedSymbols.length : 0; - const nodeCount = subgraph.nodes.size; - const edgeCount = subgraph.edges.length; - const edgeDensity = nodeCount > 0 ? edgeCount / nodeCount : 0; - - return { - caseId, - pass: recall >= PASS_THRESHOLD, - recall, - mrr: 0, - foundSymbols: found, - missedSymbols: missed, - nodeCount, - edgeCount, - edgeDensity, - latencyMs, - }; -} diff --git a/__tests__/evaluation/test-cases.ts b/__tests__/evaluation/test-cases.ts deleted file mode 100644 index b9db233f..00000000 --- a/__tests__/evaluation/test-cases.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { EvalTestCase } from './types.js'; - -export const testCases: EvalTestCase[] = [ - // === searchNodes: Symbol Lookup Precision === - - { - id: 'search-class-exact', - query: 'TransportService', - api: 'searchNodes', - expectedSymbols: ['TransportService'], - kinds: ['class'], - }, - { - id: 'search-method-qualified', - query: 'TransportService sendRequest', - api: 'searchNodes', - expectedSymbols: ['sendRequest'], - kinds: ['method'], - }, - { - id: 'search-interface', - query: 'ActionListener', - api: 'searchNodes', - expectedSymbols: ['ActionListener'], - kinds: ['interface'], - }, - { - id: 'search-enum', - query: 'RestStatus', - api: 'searchNodes', - expectedSymbols: ['RestStatus'], - kinds: ['enum'], - }, - { - id: 'search-exception', - query: 'SearchPhaseExecutionException', - api: 'searchNodes', - expectedSymbols: ['SearchPhaseExecutionException'], - kinds: ['class'], - }, - { - id: 'search-nested-class', - query: 'Engine Index', - api: 'searchNodes', - expectedSymbols: ['Index'], - kinds: ['class'], - }, - - // === findRelevantContext: Exploration Quality === - - { - id: 'explore-rest-layer', - query: 'How does the REST layer handle HTTP requests?', - api: 'findRelevantContext', - expectedSymbols: ['RestController', 'RestHandler', 'BaseRestHandler', 'RestRequest'], - options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 }, - }, - { - id: 'explore-search-execution', - query: 'How does search execution work from request to shard?', - api: 'findRelevantContext', - expectedSymbols: ['ShardSearchRequest', 'SearchShardsRequest', 'SearchShardsGroup'], - options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 }, - }, - { - id: 'explore-bulk-indexing', - query: 'How does bulk indexing work?', - api: 'findRelevantContext', - expectedSymbols: ['TransportBulkAction', 'BulkRequest', 'BulkResponse'], - options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 }, - }, - { - id: 'explore-shard-allocation', - query: 'How does shard rebalancing and allocation work?', - api: 'findRelevantContext', - expectedSymbols: ['AllocationService', 'BalancedShardsAllocator'], - options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 }, - }, - { - id: 'explore-transport-search', - query: 'How does TransportService connect to SearchTransportService?', - api: 'findRelevantContext', - expectedSymbols: ['TransportService', 'SearchTransportService'], - options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 }, - }, - { - id: 'explore-engine-implementations', - query: 'What are the Engine implementations for indexing?', - api: 'findRelevantContext', - expectedSymbols: ['InternalEngine', 'ReadOnlyEngine', 'Engine'], - options: { searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2 }, - }, -]; diff --git a/__tests__/evaluation/types.ts b/__tests__/evaluation/types.ts deleted file mode 100644 index 64a24270..00000000 --- a/__tests__/evaluation/types.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { NodeKind } from '../../src/types.js'; - -export interface EvalTestCase { - id: string; - query: string; - api: 'searchNodes' | 'findRelevantContext'; - expectedSymbols: string[]; - kinds?: NodeKind[]; - options?: Record; -} - -export interface EvalResult { - caseId: string; - pass: boolean; - recall: number; - mrr: number; - foundSymbols: string[]; - missedSymbols: string[]; - nodeCount?: number; - edgeCount?: number; - edgeDensity?: number; - latencyMs: number; -} - -export interface EvalReport { - timestamp: string; - codebasePath: string; - codegraphSha: string; - summary: { - total: number; - passed: number; - failed: number; - meanRecall: number; - meanMRR: number; - }; - results: EvalResult[]; -} diff --git a/__tests__/explore-output-budget.test.ts b/__tests__/explore-output-budget.test.ts deleted file mode 100644 index 65ddc648..00000000 --- a/__tests__/explore-output-budget.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Adaptive output budget for codegraph_explore (#185). - * - * The explore tool used to apply a fixed 35KB output cap regardless of - * project size, which on small codebases was a net loss vs. native - * grep+Read. These tests pin the per-tier budget shape so future tuning - * doesn't silently drift the small-project case back into bloat. - */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { getExploreOutputBudget, getExploreBudget, ToolHandler } from '../src/mcp/tools'; -import CodeGraph from '../src/index'; - -describe('getExploreOutputBudget', () => { - it('returns a strictly smaller total cap for small projects than for huge ones', () => { - const small = getExploreOutputBudget(100); - const huge = getExploreOutputBudget(30000); - expect(small.maxOutputChars).toBeLessThan(huge.maxOutputChars); - expect(small.defaultMaxFiles).toBeLessThan(huge.defaultMaxFiles); - expect(small.maxCharsPerFile).toBeLessThan(huge.maxCharsPerFile); - }); - - it('caps total output well under 8000 tokens (~32k chars) on small projects', () => { - const small = getExploreOutputBudget(100); - expect(small.maxOutputChars).toBeLessThanOrEqual(20000); - }); - - it('keeps the historical 35k+ ceiling for medium-large projects so existing benchmarks do not regress', () => { - const large = getExploreOutputBudget(10000); - expect(large.maxOutputChars).toBeGreaterThanOrEqual(35000); - }); - - it('uses tier breakpoints matching getExploreBudget so call-count and output-budget agree on a project', () => { - // Anything in the same tier should pick the same total-output cap. - const tier1a = getExploreOutputBudget(50); - const tier1b = getExploreOutputBudget(499); - expect(tier1a.maxOutputChars).toBe(tier1b.maxOutputChars); - expect(getExploreBudget(50)).toBe(getExploreBudget(499)); - - const tier2a = getExploreOutputBudget(500); - const tier2b = getExploreOutputBudget(4999); - expect(tier2a.maxOutputChars).toBe(tier2b.maxOutputChars); - expect(getExploreBudget(500)).toBe(getExploreBudget(4999)); - - const tier3a = getExploreOutputBudget(5000); - const tier3b = getExploreOutputBudget(14999); - expect(tier3a.maxOutputChars).toBe(tier3b.maxOutputChars); - - // And crossing a breakpoint changes the cap. - expect(tier1a.maxOutputChars).not.toBe(tier2a.maxOutputChars); - expect(tier2a.maxOutputChars).not.toBe(tier3a.maxOutputChars); - }); - - it('gates off "Additional relevant files", completeness signal, and budget note on small projects', () => { - const small = getExploreOutputBudget(100); - expect(small.includeAdditionalFiles).toBe(false); - expect(small.includeCompletenessSignal).toBe(false); - expect(small.includeBudgetNote).toBe(false); - }); - - it('keeps all meta-text on for projects that earn the breadth signal (>=500 files)', () => { - const medium = getExploreOutputBudget(1000); - expect(medium.includeAdditionalFiles).toBe(true); - expect(medium.includeCompletenessSignal).toBe(true); - expect(medium.includeBudgetNote).toBe(true); - }); - - it('keeps the Relationships section on for every tier — it is the cheapest structural signal', () => { - expect(getExploreOutputBudget(50).includeRelationships).toBe(true); - expect(getExploreOutputBudget(1000).includeRelationships).toBe(true); - expect(getExploreOutputBudget(10000).includeRelationships).toBe(true); - expect(getExploreOutputBudget(30000).includeRelationships).toBe(true); - }); - - it('caps the per-file header symbol list more tightly on small projects', () => { - // Without this cap, a file like Alamofire's Session.swift produced - // a 3.4KB symbol list in the `#### path — sym, sym, ...` header, - // dwarfing the per-file body cap. - const small = getExploreOutputBudget(100); - const huge = getExploreOutputBudget(30000); - expect(small.maxSymbolsInFileHeader).toBeLessThan(huge.maxSymbolsInFileHeader); - expect(small.maxSymbolsInFileHeader).toBeGreaterThan(0); - }); - - it('uses a tighter clustering gap threshold on small projects to break runaway single clusters', () => { - const small = getExploreOutputBudget(100); - const huge = getExploreOutputBudget(30000); - expect(small.gapThreshold).toBeLessThanOrEqual(huge.gapThreshold); - }); - - it('handles the boundary file counts exactly (off-by-one regression guard)', () => { - // 499 -> small tier, 500 -> medium tier - expect(getExploreOutputBudget(499).maxOutputChars).toBe(getExploreOutputBudget(100).maxOutputChars); - expect(getExploreOutputBudget(500).maxOutputChars).toBe(getExploreOutputBudget(1000).maxOutputChars); - // 4999 -> medium, 5000 -> large - expect(getExploreOutputBudget(4999).maxOutputChars).toBe(getExploreOutputBudget(1000).maxOutputChars); - expect(getExploreOutputBudget(5000).maxOutputChars).toBe(getExploreOutputBudget(10000).maxOutputChars); - // 14999 -> large, 15000 -> xlarge - expect(getExploreOutputBudget(14999).maxOutputChars).toBe(getExploreOutputBudget(10000).maxOutputChars); - expect(getExploreOutputBudget(15000).maxOutputChars).toBe(getExploreOutputBudget(30000).maxOutputChars); - }); -}); - -/** - * End-to-end check that the budget is actually applied by handleExplore. - * - * Builds a tiny synthetic project (<500 files, so the small tier), indexes - * it, and confirms the output: - * - stays under the small-tier maxOutputChars cap - * - omits the meta-text the small tier gates off (completeness signal, - * budget note, "Additional relevant files") - * - * Regression guard for #185 — protects against future edits to handleExplore - * silently re-introducing the fixed 35KB cap on small projects. - */ -describe('codegraph_explore output respects the adaptive budget', () => { - let testDir: string; - let cg: CodeGraph; - let handler: ToolHandler; - - beforeAll(async () => { - testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-explore-budget-')); - const srcDir = path.join(testDir, 'src'); - fs.mkdirSync(srcDir); - - // A handful of files with one fat target file. The fat file mimics the - // Alamofire Session.swift case: many methods stacked on top of each other, - // which collapsed into one giant cluster pre-#185. - const fatLines: string[] = ['export class Session {']; - for (let i = 0; i < 30; i++) { - fatLines.push(` method${i}(arg: string): string {`); - fatLines.push(` return this.helper${i}(arg) + "${i}";`); - fatLines.push(` }`); - fatLines.push(` private helper${i}(arg: string): string {`); - fatLines.push(` return arg.repeat(${i + 1});`); - fatLines.push(` }`); - } - fatLines.push('}'); - fs.writeFileSync(path.join(srcDir, 'session.ts'), fatLines.join('\n')); - - // A few small supporting files so the project has >1 indexed file. - for (let i = 0; i < 5; i++) { - fs.writeFileSync( - path.join(srcDir, `support${i}.ts`), - `import { Session } from './session';\nexport function callSession${i}(s: Session) { return s.method${i}('hi'); }\n` - ); - } - - cg = CodeGraph.initSync(testDir, { - config: { include: ['**/*.ts'], exclude: [] }, - }); - await cg.indexAll(); - handler = new ToolHandler(cg); - }); - - afterAll(() => { - if (cg) cg.destroy(); - if (testDir && fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - }); - - it('keeps total output under the small-project cap', async () => { - const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); - const text = result.content?.[0]?.text ?? ''; - const smallBudget = getExploreOutputBudget(100); - // Allow a small overshoot for the trailing markers — the cap is enforced - // per-file rather than as an absolute output ceiling. - expect(text.length).toBeLessThan(smallBudget.maxOutputChars + 500); - }); - - it('omits the meta-text gated off for small projects', async () => { - const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); - const text = result.content?.[0]?.text ?? ''; - expect(text).not.toContain('### Additional relevant files'); - expect(text).not.toContain('Complete source code is included above'); - expect(text).not.toContain('Explore budget:'); - }); - - it('still includes the Relationships section — it is the cheapest structural signal', async () => { - const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); - const text = result.content?.[0]?.text ?? ''; - // Either there are relationships, or no edges were significant — both are fine. - // We just want to confirm we did not accidentally gate it off. - const hasRelationships = text.includes('### Relationships'); - const sourceFollowsHeader = text.indexOf('### Source Code') > 0; - expect(hasRelationships || sourceFollowsHeader).toBe(true); - }); - - it('prefixes source lines with line numbers by default (cat -n style)', async () => { - delete process.env.CODEGRAPH_EXPLORE_LINENUMS; - const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); - const text = result.content?.[0]?.text ?? ''; - // At least one fenced source line should look like `\t`. - expect(/\n\d+\t/.test(text)).toBe(true); - }); - - it('omits line numbers when CODEGRAPH_EXPLORE_LINENUMS=0', async () => { - process.env.CODEGRAPH_EXPLORE_LINENUMS = '0'; - try { - const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); - const text = result.content?.[0]?.text ?? ''; - // The synthetic source has no tab-prefixed numeric lines of its own, - // so none should appear when the toggle is off. - expect(/\n\d+\t(?:export| )/.test(text)).toBe(false); - } finally { - delete process.env.CODEGRAPH_EXPLORE_LINENUMS; - } - }); - - it('uses language-neutral omission markers (no C-style // in the output)', async () => { - // The gap/trimmed separators must not assume `//` is a comment — that's - // wrong in Python, Ruby, etc. They render inside fenced source blocks. - const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); - const text = result.content?.[0]?.text ?? ''; - expect(text).not.toContain('// ... (gap)'); - expect(text).not.toContain('// ... trimmed'); - }); - - it('does not collapse a whole-file class into just its header (envelope filter)', async () => { - // The synthetic `Session` class spans the entire file. Without the - // envelope filter it would form one giant cluster that tail-trims to - // the class declaration, hiding the methods. Confirm real method bodies - // make it into the output. Regression guard for the #185 follow-up. - const result = await handler.execute('codegraph_explore', { query: 'Session method helper' }); - const text = result.content?.[0]?.text ?? ''; - // A method body line (`methodN(arg: string)`) should appear, not just - // the `export class Session {` opener. - const hasMethodBody = /method\d+\(arg: string\)/.test(text); - expect(hasMethodBody).toBe(true); - }); -}); diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts deleted file mode 100644 index 92717759..00000000 --- a/__tests__/extraction.test.ts +++ /dev/null @@ -1,3897 +0,0 @@ -/** - * Extraction Tests - * - * Tests for the tree-sitter extraction system. - */ - -import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { CodeGraph } from '../src'; -import { extractFromSource, scanDirectory } from '../src/extraction'; -import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars } from '../src/extraction/grammars'; -import { normalizePath } from '../src/utils'; - -beforeAll(async () => { - await initGrammars(); - await loadAllGrammars(); -}); - -// Create a temporary directory for each test -function createTempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-')); -} - -// Clean up temporary directory -function cleanupTempDir(dir: string): void { - if (fs.existsSync(dir)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - -describe('Language Detection', () => { - it('should detect TypeScript files', () => { - expect(detectLanguage('src/index.ts')).toBe('typescript'); - expect(detectLanguage('components/Button.tsx')).toBe('tsx'); - }); - - it('should detect JavaScript files', () => { - expect(detectLanguage('index.js')).toBe('javascript'); - expect(detectLanguage('App.jsx')).toBe('jsx'); - expect(detectLanguage('config.mjs')).toBe('javascript'); - }); - - it('should detect Python files', () => { - expect(detectLanguage('main.py')).toBe('python'); - }); - - it('should detect Go files', () => { - expect(detectLanguage('main.go')).toBe('go'); - }); - - it('should detect Rust files', () => { - expect(detectLanguage('lib.rs')).toBe('rust'); - }); - - it('should detect Java files', () => { - expect(detectLanguage('Main.java')).toBe('java'); - }); - - it('should detect C files', () => { - expect(detectLanguage('main.c')).toBe('c'); - expect(detectLanguage('utils.h')).toBe('c'); - }); - - it('should detect C++ files', () => { - expect(detectLanguage('main.cpp')).toBe('cpp'); - expect(detectLanguage('class.hpp')).toBe('cpp'); - }); - - it('should detect C# files', () => { - expect(detectLanguage('Program.cs')).toBe('csharp'); - }); - - it('should detect PHP files', () => { - expect(detectLanguage('index.php')).toBe('php'); - }); - - it('should detect Ruby files', () => { - expect(detectLanguage('app.rb')).toBe('ruby'); - }); - - it('should detect Swift files', () => { - expect(detectLanguage('ViewController.swift')).toBe('swift'); - }); - - it('should detect Kotlin files', () => { - expect(detectLanguage('MainActivity.kt')).toBe('kotlin'); - expect(detectLanguage('build.gradle.kts')).toBe('kotlin'); - }); - - it('should detect Dart files', () => { - expect(detectLanguage('main.dart')).toBe('dart'); - }); - - it('should return unknown for unsupported extensions', () => { - expect(detectLanguage('styles.css')).toBe('unknown'); - expect(detectLanguage('data.json')).toBe('unknown'); - }); -}); - -describe('Language Support', () => { - it('should report supported languages', () => { - expect(isLanguageSupported('typescript')).toBe(true); - expect(isLanguageSupported('python')).toBe(true); - expect(isLanguageSupported('go')).toBe(true); - expect(isLanguageSupported('unknown')).toBe(false); - }); - - it('should list all supported languages', () => { - const languages = getSupportedLanguages(); - expect(languages).toContain('typescript'); - expect(languages).toContain('javascript'); - expect(languages).toContain('python'); - expect(languages).toContain('go'); - expect(languages).toContain('rust'); - expect(languages).toContain('java'); - expect(languages).toContain('csharp'); - expect(languages).toContain('php'); - expect(languages).toContain('ruby'); - expect(languages).toContain('swift'); - expect(languages).toContain('kotlin'); - expect(languages).toContain('dart'); - }); -}); - -describe('TypeScript Extraction', () => { - it('should extract function declarations', () => { - const code = ` -export function processPayment(amount: number): Promise { - return stripe.charge(amount); -} -`; - const result = extractFromSource('payment.ts', code); - - // File node + function node - const fileNode = result.nodes.find((n) => n.kind === 'file'); - expect(fileNode).toBeDefined(); - expect(fileNode?.name).toBe('payment.ts'); - - const funcNode = result.nodes.find((n) => n.kind === 'function'); - expect(funcNode).toMatchObject({ - kind: 'function', - name: 'processPayment', - language: 'typescript', - isExported: true, - }); - expect(funcNode?.signature).toContain('amount: number'); - }); - - it('should extract class declarations', () => { - const code = ` -export class PaymentService { - private stripe: StripeClient; - - constructor(apiKey: string) { - this.stripe = new StripeClient(apiKey); - } - - async charge(amount: number): Promise { - return this.stripe.charge(amount); - } -} -`; - const result = extractFromSource('service.ts', code); - - const classNode = result.nodes.find((n) => n.kind === 'class'); - const methodNodes = result.nodes.filter((n) => n.kind === 'method'); - - expect(classNode).toBeDefined(); - expect(classNode?.name).toBe('PaymentService'); - expect(classNode?.isExported).toBe(true); - - expect(methodNodes.length).toBeGreaterThanOrEqual(1); - const chargeMethod = methodNodes.find((m) => m.name === 'charge'); - expect(chargeMethod).toBeDefined(); - }); - - it('should extract interfaces', () => { - const code = ` -export interface User { - id: string; - name: string; - email: string; -} -`; - const result = extractFromSource('types.ts', code); - - const fileNode = result.nodes.find((n) => n.kind === 'file'); - expect(fileNode).toBeDefined(); - - const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); - expect(ifaceNode).toMatchObject({ - kind: 'interface', - name: 'User', - isExported: true, - }); - }); - - it('should track function calls', () => { - const code = ` -function main() { - const result = processData(); - console.log(result); -} -`; - const result = extractFromSource('main.ts', code); - - expect(result.unresolvedReferences.length).toBeGreaterThan(0); - const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); - expect(calls.some((c) => c.referenceName === 'processData')).toBe(true); - }); -}); - -describe('Arrow Function Export Extraction', () => { - it('should extract exported arrow functions assigned to const', () => { - const code = ` -export const useAuth = (): AuthContextValue => { - return useContext(AuthContext); -}; -`; - const result = extractFromSource('hooks.ts', code); - - const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'useAuth'); - expect(funcNode).toBeDefined(); - expect(funcNode).toMatchObject({ - kind: 'function', - name: 'useAuth', - isExported: true, - }); - }); - - it('should extract exported function expressions assigned to const', () => { - const code = ` -export const processData = function(input: string): string { - return input.trim(); -}; -`; - const result = extractFromSource('utils.ts', code); - - const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'processData'); - expect(funcNode).toBeDefined(); - expect(funcNode).toMatchObject({ - kind: 'function', - name: 'processData', - isExported: true, - }); - }); - - it('should not extract non-exported arrow functions as exported', () => { - const code = ` -const internalHelper = () => { - return 42; -}; -`; - const result = extractFromSource('internal.ts', code); - - const helperNode = result.nodes.find((n) => n.name === 'internalHelper'); - expect(helperNode).toBeDefined(); - expect(helperNode?.isExported).toBeFalsy(); - }); - - it('should still skip truly anonymous arrow functions', () => { - const code = ` -const items = [1, 2, 3].map((x) => x * 2); -`; - const result = extractFromSource('anon.ts', code); - - // The inline arrow function passed to .map() has no variable_declarator parent - // and should remain anonymous (skipped) - const anonFunctions = result.nodes.filter( - (n) => n.kind === 'function' && n.name === '' - ); - expect(anonFunctions).toHaveLength(0); - }); - - it('should extract multiple exported arrow functions from the same file', () => { - const code = ` -export const add = (a: number, b: number): number => a + b; - -export const subtract = (a: number, b: number): number => a - b; - -const internal = () => 'not exported'; -`; - const result = extractFromSource('math.ts', code); - - const exported = result.nodes.filter((n) => n.kind === 'function' && n.isExported); - expect(exported).toHaveLength(2); - expect(exported.map((n) => n.name).sort()).toEqual(['add', 'subtract']); - - const internalNode = result.nodes.find((n) => n.name === 'internal'); - expect(internalNode).toBeDefined(); - expect(internalNode?.isExported).toBeFalsy(); - }); - - it('should extract arrow functions in JavaScript files', () => { - const code = ` -export const fetchData = async () => { - const response = await fetch('/api/data'); - return response.json(); -}; -`; - const result = extractFromSource('api.js', code); - - const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'fetchData'); - expect(funcNode).toBeDefined(); - expect(funcNode).toMatchObject({ - kind: 'function', - name: 'fetchData', - isExported: true, - }); - }); -}); - -describe('Type Alias Extraction', () => { - it('should extract exported type aliases in TypeScript', () => { - const code = ` -export type AuthContextValue = { - user: User | null; - login: () => void; - logout: () => void; -}; -`; - const result = extractFromSource('types.ts', code); - - const typeNode = result.nodes.find((n) => n.kind === 'type_alias'); - expect(typeNode).toMatchObject({ - kind: 'type_alias', - name: 'AuthContextValue', - isExported: true, - }); - }); - - it('should extract non-exported type aliases', () => { - const code = ` -type InternalState = { - loading: boolean; - error: string | null; -}; -`; - const result = extractFromSource('internal.ts', code); - - const typeNode = result.nodes.find((n) => n.kind === 'type_alias'); - expect(typeNode).toMatchObject({ - kind: 'type_alias', - name: 'InternalState', - isExported: false, - }); - }); - - it('should extract multiple type aliases from the same file', () => { - const code = ` -export type UnitSystem = 'metric' | 'imperial'; -export type DateFormat = 'ISO' | 'US' | 'EU'; -type Internal = string; -`; - const result = extractFromSource('config.ts', code); - - const typeAliases = result.nodes.filter((n) => n.kind === 'type_alias'); - expect(typeAliases).toHaveLength(3); - - const exported = typeAliases.filter((n) => n.isExported); - expect(exported).toHaveLength(2); - expect(exported.map((n) => n.name).sort()).toEqual(['DateFormat', 'UnitSystem']); - }); -}); - -describe('Exported Variable Extraction', () => { - it('should extract exported const with call expression (Zustand store)', () => { - const code = ` -export const useUIStore = create((set) => ({ - isOpen: false, - toggle: () => set((s) => ({ isOpen: !s.isOpen })), -})); -`; - const result = extractFromSource('store.ts', code); - - const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'useUIStore'); - expect(varNode).toBeDefined(); - expect(varNode?.isExported).toBe(true); - }); - - it('should extract exported const with object literal', () => { - const code = ` -export const config = { - apiUrl: 'https://api.example.com', - timeout: 5000, -}; -`; - const result = extractFromSource('config.ts', code); - - const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'config'); - expect(varNode).toBeDefined(); - expect(varNode?.isExported).toBe(true); - }); - - it('should extract exported const with array literal', () => { - const code = ` -export const SCREEN_NAMES = ['home', 'settings', 'profile'] as const; -`; - const result = extractFromSource('constants.ts', code); - - const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'SCREEN_NAMES'); - expect(varNode).toBeDefined(); - expect(varNode?.isExported).toBe(true); - }); - - it('should extract exported const with primitive value', () => { - const code = ` -export const MAX_RETRIES = 3; -export const API_VERSION = "v2"; -`; - const result = extractFromSource('constants.ts', code); - - const variables = result.nodes.filter((n) => n.kind === 'constant'); - expect(variables).toHaveLength(2); - expect(variables.map((n) => n.name).sort()).toEqual(['API_VERSION', 'MAX_RETRIES']); - }); - - it('should NOT duplicate arrow functions as both function and variable', () => { - const code = ` -export const useAuth = () => { - return useContext(AuthContext); -}; -`; - const result = extractFromSource('hooks.ts', code); - - // Should be extracted as function (from arrow function handler), NOT as variable - const funcNodes = result.nodes.filter((n) => n.kind === 'function' && n.name === 'useAuth'); - const varNodes = result.nodes.filter((n) => n.kind === 'variable' && n.name === 'useAuth'); - expect(funcNodes).toHaveLength(1); - expect(varNodes).toHaveLength(0); - }); - - it('should extract non-exported const as non-exported variable', () => { - const code = ` -const internalConfig = { - debug: true, -}; -`; - const result = extractFromSource('internal.ts', code); - - // Non-exported const at file level should be extracted as a constant (not exported) - const varNodes = result.nodes.filter((n) => (n.kind === 'variable' || n.kind === 'constant') && n.name === 'internalConfig'); - expect(varNodes).toHaveLength(1); - expect(varNodes[0]?.isExported).toBeFalsy(); - }); - - it('should extract Zod schema exports', () => { - const code = ` -export const userSchema = z.object({ - id: z.string(), - name: z.string(), - email: z.string().email(), -}); -`; - const result = extractFromSource('schemas.ts', code); - - const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'userSchema'); - expect(varNode).toBeDefined(); - expect(varNode?.isExported).toBe(true); - }); - - it('should extract XState machine exports', () => { - const code = ` -export const authMachine = createMachine({ - id: "auth", - initial: "idle", - states: { - idle: {}, - authenticated: {}, - }, -}); -`; - const result = extractFromSource('machine.ts', code); - - const varNode = result.nodes.find((n) => n.kind === 'constant' && n.name === 'authMachine'); - expect(varNode).toBeDefined(); - expect(varNode?.isExported).toBe(true); - }); -}); - -describe('File Node Extraction', () => { - it('should create a file-kind node for each parsed file', () => { - const code = ` -export function greet(name: string): string { - return "Hello " + name; -} -`; - const result = extractFromSource('greeter.ts', code); - - const fileNode = result.nodes.find((n) => n.kind === 'file'); - expect(fileNode).toBeDefined(); - expect(fileNode?.name).toBe('greeter.ts'); - expect(fileNode?.filePath).toBe('greeter.ts'); - expect(fileNode?.language).toBe('typescript'); - expect(fileNode?.startLine).toBe(1); - }); - - it('should create file nodes for Python files', () => { - const code = ` -def main(): - pass -`; - const result = extractFromSource('main.py', code); - - const fileNode = result.nodes.find((n) => n.kind === 'file'); - expect(fileNode).toBeDefined(); - expect(fileNode?.name).toBe('main.py'); - expect(fileNode?.language).toBe('python'); - }); - - it('should create containment edges from file node to top-level declarations', () => { - const code = ` -export function foo() {} -export function bar() {} -`; - const result = extractFromSource('fns.ts', code); - - const fileNode = result.nodes.find((n) => n.kind === 'file'); - expect(fileNode).toBeDefined(); - - // There should be contains edges from the file node to each function - const containsEdges = result.edges.filter( - (e) => e.source === fileNode?.id && e.kind === 'contains' - ); - expect(containsEdges.length).toBeGreaterThanOrEqual(2); - }); -}); - -describe('Python Extraction', () => { - it('should extract function definitions', () => { - const code = ` -def calculate_total(items: list, tax_rate: float) -> float: - """Calculate total with tax.""" - subtotal = sum(item.price for item in items) - return subtotal * (1 + tax_rate) -`; - const result = extractFromSource('calc.py', code); - - const fileNode = result.nodes.find((n) => n.kind === 'file'); - expect(fileNode).toBeDefined(); - - const funcNode = result.nodes.find((n) => n.kind === 'function'); - expect(funcNode).toMatchObject({ - kind: 'function', - name: 'calculate_total', - language: 'python', - }); - }); - - it('should extract class definitions', () => { - const code = ` -class UserService: - """Service for managing users.""" - - def __init__(self, db): - self.db = db - - def get_user(self, user_id: str) -> User: - return self.db.find_user(user_id) -`; - const result = extractFromSource('service.py', code); - - const classNode = result.nodes.find((n) => n.kind === 'class'); - expect(classNode).toBeDefined(); - expect(classNode?.name).toBe('UserService'); - }); -}); - -describe('Go Extraction', () => { - it('should extract function declarations', () => { - const code = ` -package main - -func ProcessOrder(order Order) (Receipt, error) { - // Process the order - return Receipt{}, nil -} -`; - const result = extractFromSource('main.go', code); - - const funcNode = result.nodes.find((n) => n.kind === 'function'); - expect(funcNode).toBeDefined(); - expect(funcNode?.name).toBe('ProcessOrder'); - }); - - it('should extract method declarations', () => { - const code = ` -package main - -type Service struct { - db *Database -} - -func (s *Service) GetUser(id string) (*User, error) { - return s.db.FindUser(id) -} -`; - const result = extractFromSource('service.go', code); - - const methodNode = result.nodes.find((n) => n.kind === 'method'); - expect(methodNode).toBeDefined(); - expect(methodNode?.name).toBe('GetUser'); - }); -}); - -describe('Rust Extraction', () => { - it('should extract function declarations', () => { - const code = ` -pub fn process_data(input: &str) -> Result { - // Process data - Ok(Output::new()) -} -`; - const result = extractFromSource('lib.rs', code); - - const funcNode = result.nodes.find((n) => n.kind === 'function'); - expect(funcNode).toBeDefined(); - expect(funcNode?.name).toBe('process_data'); - expect(funcNode?.visibility).toBe('public'); - }); - - it('should extract struct declarations', () => { - const code = ` -pub struct User { - pub id: String, - pub name: String, - email: String, -} -`; - const result = extractFromSource('models.rs', code); - - const structNode = result.nodes.find((n) => n.kind === 'struct'); - expect(structNode).toBeDefined(); - expect(structNode?.name).toBe('User'); - }); - - it('should extract trait declarations', () => { - const code = ` -pub trait Repository { - fn find(&self, id: &str) -> Option; - fn save(&mut self, entity: Entity) -> Result<(), Error>; -} -`; - const result = extractFromSource('traits.rs', code); - - const traitNode = result.nodes.find((n) => n.kind === 'trait'); - expect(traitNode).toBeDefined(); - expect(traitNode?.name).toBe('Repository'); - }); - - it('should extract impl Trait for Type as implements edges', () => { - const code = ` -pub struct MyCache {} - -pub trait Cache { - fn get(&self, key: &str) -> Option; -} - -impl Cache for MyCache { - fn get(&self, key: &str) -> Option { - None - } -} -`; - const result = extractFromSource('cache.rs', code); - - // Should have an unresolved reference for implements - const implRef = result.unresolvedReferences.find( - (r) => r.referenceKind === 'implements' && r.referenceName === 'Cache' - ); - expect(implRef).toBeDefined(); - - // The struct MyCache should be the source - const myCacheNode = result.nodes.find((n) => n.name === 'MyCache' && n.kind === 'struct'); - expect(myCacheNode).toBeDefined(); - expect(implRef?.fromNodeId).toBe(myCacheNode?.id); - }); - - it('should extract trait supertraits as extends references', () => { - const code = ` -pub trait Display {} - -pub trait Error: Display { - fn description(&self) -> &str; -} -`; - const result = extractFromSource('error.rs', code); - - const extendsRef = result.unresolvedReferences.find( - (r) => r.referenceKind === 'extends' && r.referenceName === 'Display' - ); - expect(extendsRef).toBeDefined(); - - const errorTrait = result.nodes.find((n) => n.name === 'Error' && n.kind === 'trait'); - expect(errorTrait).toBeDefined(); - expect(extendsRef?.fromNodeId).toBe(errorTrait?.id); - }); - - it('should not create implements edges for plain impl blocks', () => { - const code = ` -pub struct Counter { - count: u32, -} - -impl Counter { - pub fn new() -> Counter { - Counter { count: 0 } - } - pub fn increment(&mut self) { - self.count += 1; - } -} -`; - const result = extractFromSource('counter.rs', code); - - // Should have no implements references (no trait involved) - const implRefs = result.unresolvedReferences.filter( - (r) => r.referenceKind === 'implements' - ); - expect(implRefs).toHaveLength(0); - }); -}); - -describe('Java Extraction', () => { - it('should extract class declarations', () => { - const code = ` -public class UserService { - private final UserRepository repository; - - public UserService(UserRepository repository) { - this.repository = repository; - } - - public User getUser(String id) { - return repository.findById(id); - } -} -`; - const result = extractFromSource('UserService.java', code); - - const classNode = result.nodes.find((n) => n.kind === 'class'); - expect(classNode).toBeDefined(); - expect(classNode?.name).toBe('UserService'); - expect(classNode?.visibility).toBe('public'); - }); - - it('should extract method declarations', () => { - const code = ` -public class Calculator { - public static int add(int a, int b) { - return a + b; - } -} -`; - const result = extractFromSource('Calculator.java', code); - - const methodNode = result.nodes.find((n) => n.kind === 'method' && n.name === 'add'); - expect(methodNode).toBeDefined(); - expect(methodNode?.isStatic).toBe(true); - }); -}); - -describe('C# Extraction', () => { - it('should extract class declarations', () => { - const code = ` -public class OrderService -{ - private readonly IOrderRepository _repository; - - public OrderService(IOrderRepository repository) - { - _repository = repository; - } - - public async Task GetOrderAsync(string id) - { - return await _repository.FindByIdAsync(id); - } -} -`; - const result = extractFromSource('OrderService.cs', code); - - const classNode = result.nodes.find((n) => n.kind === 'class'); - expect(classNode).toBeDefined(); - expect(classNode?.name).toBe('OrderService'); - expect(classNode?.visibility).toBe('public'); - }); -}); - -describe('PHP Extraction', () => { - it('should extract class declarations', () => { - const code = `userService = $userService; - } - - public function show(string $id): User - { - return $this->userService->find($id); - } -} -`; - const result = extractFromSource('UserController.php', code); - - const classNode = result.nodes.find((n) => n.kind === 'class'); - expect(classNode).toBeDefined(); - expect(classNode?.name).toBe('UserController'); - }); - - it('should extract class inheritance (extends) and interface implementation', () => { - const code = ` n.kind === 'class'); - expect(classNode).toBeDefined(); - expect(classNode?.name).toBe('ChildController'); - - const extendsRef = result.unresolvedReferences.find( - (r) => r.referenceKind === 'extends' - ); - expect(extendsRef).toBeDefined(); - expect(extendsRef?.referenceName).toBe('BaseController'); - - const implementsRefs = result.unresolvedReferences.filter( - (r) => r.referenceKind === 'implements' - ); - expect(implementsRefs.length).toBe(2); - expect(implementsRefs.map((r) => r.referenceName)).toContain('Serializable'); - expect(implementsRefs.map((r) => r.referenceName)).toContain('JsonSerializable'); - }); -}); - -describe('Swift Extraction', () => { - it('should extract class declarations', () => { - const code = ` -public class NetworkManager { - private let session: URLSession - - public init(session: URLSession = .shared) { - self.session = session - } - - public func fetchData(from url: URL) async throws -> Data { - let (data, _) = try await session.data(from: url) - return data - } -} -`; - const result = extractFromSource('NetworkManager.swift', code); - - const classNode = result.nodes.find((n) => n.kind === 'class'); - expect(classNode).toBeDefined(); - expect(classNode?.name).toBe('NetworkManager'); - }); - - it('should extract function declarations', () => { - const code = ` -func calculateSum(_ numbers: [Int]) -> Int { - return numbers.reduce(0, +) -} - -public func formatCurrency(amount: Double) -> String { - return String(format: "$%.2f", amount) -} -`; - const result = extractFromSource('utils.swift', code); - - const functions = result.nodes.filter((n) => n.kind === 'function'); - expect(functions.length).toBeGreaterThanOrEqual(1); - }); - - it('should extract struct declarations', () => { - const code = ` -public struct User { - let id: UUID - var name: String - var email: String - - func displayName() -> String { - return name - } -} -`; - const result = extractFromSource('User.swift', code); - - const structNode = result.nodes.find((n) => n.kind === 'struct'); - expect(structNode).toBeDefined(); - expect(structNode?.name).toBe('User'); - }); - - it('should extract protocol declarations', () => { - const code = ` -public protocol Repository { - associatedtype Entity - - func find(id: String) async throws -> Entity? - func save(_ entity: Entity) async throws -} -`; - const result = extractFromSource('Repository.swift', code); - - const protocolNode = result.nodes.find((n) => n.kind === 'interface'); - expect(protocolNode).toBeDefined(); - expect(protocolNode?.name).toBe('Repository'); - }); - - it('should extract class inheritance and protocol conformance', () => { - const code = ` -class DataRequest: Request { - func validate() {} -} - -class UploadRequest: DataRequest, Sendable { - func upload() {} -} - -enum AFError: Error { - case invalidURL -} - -struct HTTPMethod: RawRepresentable { - let rawValue: String -} - -protocol UploadConvertible: URLRequestConvertible { - func asURLRequest() throws -> URLRequest -} -`; - const result = extractFromSource('Inheritance.swift', code); - - const extendsRefs = result.unresolvedReferences.filter( - (r) => r.referenceKind === 'extends' - ); - - // DataRequest extends Request - expect(extendsRefs.find((r) => r.referenceName === 'Request')).toBeDefined(); - // UploadRequest extends DataRequest and Sendable - expect(extendsRefs.find((r) => r.referenceName === 'DataRequest')).toBeDefined(); - expect(extendsRefs.find((r) => r.referenceName === 'Sendable')).toBeDefined(); - // AFError extends Error - expect(extendsRefs.find((r) => r.referenceName === 'Error')).toBeDefined(); - // HTTPMethod extends RawRepresentable - expect(extendsRefs.find((r) => r.referenceName === 'RawRepresentable')).toBeDefined(); - // UploadConvertible extends URLRequestConvertible - expect(extendsRefs.find((r) => r.referenceName === 'URLRequestConvertible')).toBeDefined(); - }); -}); - -describe('Kotlin Extraction', () => { - it('should extract class declarations', () => { - const code = ` -class UserRepository(private val database: Database) { - fun findById(id: String): User? { - return database.query("SELECT * FROM users WHERE id = ?", id) - } - - suspend fun save(user: User) { - database.insert(user) - } -} -`; - const result = extractFromSource('UserRepository.kt', code); - - const classNode = result.nodes.find((n) => n.kind === 'class'); - expect(classNode).toBeDefined(); - expect(classNode?.name).toBe('UserRepository'); - }); - - it('should extract function declarations', () => { - const code = ` -fun calculateTotal(items: List): Double { - return items.sumOf { it.price } -} - -suspend fun fetchUserData(userId: String): User { - return api.getUser(userId) -} -`; - const result = extractFromSource('utils.kt', code); - - const functions = result.nodes.filter((n) => n.kind === 'function'); - expect(functions.length).toBeGreaterThanOrEqual(1); - }); - - it('should detect suspend functions as async', () => { - const code = ` -suspend fun loadData(): List { - delay(1000) - return listOf("a", "b", "c") -} -`; - const result = extractFromSource('loader.kt', code); - - const funcNode = result.nodes.find((n) => n.kind === 'function'); - expect(funcNode).toBeDefined(); - expect(funcNode?.isAsync).toBe(true); - }); - - it('should extract fun interface declarations', () => { - const code = ` -fun interface OnObjectRetainedListener { - fun onObjectRetained() -} -`; - const result = extractFromSource('listener.kt', code); - - const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); - expect(ifaceNode).toBeDefined(); - expect(ifaceNode?.name).toBe('OnObjectRetainedListener'); - - const methodNode = result.nodes.find((n) => n.kind === 'method'); - expect(methodNode).toBeDefined(); - expect(methodNode?.name).toBe('onObjectRetained'); - expect(methodNode?.qualifiedName).toBe('OnObjectRetainedListener::onObjectRetained'); - }); - - it('should extract complex fun interface with nested classes', () => { - const code = ` -fun interface EventListener { - fun onEvent(event: Event) - - sealed class Event { - class DumpingHeap : Event() - } -} -`; - const result = extractFromSource('events.kt', code); - - const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); - expect(ifaceNode).toBeDefined(); - expect(ifaceNode?.name).toBe('EventListener'); - - // Nested sealed class should still be extracted (as sibling due to grammar limitations) - const eventClass = result.nodes.find((n) => n.kind === 'class' && n.name === 'Event'); - expect(eventClass).toBeDefined(); - - const dumpingHeap = result.nodes.find((n) => n.kind === 'class' && n.name === 'DumpingHeap'); - expect(dumpingHeap).toBeDefined(); - }); - - it('should not affect regular function declarations', () => { - const code = ` -fun interface MyCallback { - fun invoke(value: Int) -} - -fun regularFunction(): String { - return "hello" -} -`; - const result = extractFromSource('mixed.kt', code); - - const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); - expect(ifaceNode).toBeDefined(); - expect(ifaceNode?.name).toBe('MyCallback'); - - const funcNode = result.nodes.find((n) => n.kind === 'function'); - expect(funcNode).toBeDefined(); - expect(funcNode?.name).toBe('regularFunction'); - }); - - it('should extract fun interface with annotation on method (Pattern 2b)', () => { - // When the SAM method has annotations like @Throws, tree-sitter produces a different - // misparse: function_declaration > ERROR("interface Name {") instead of - // function_declaration > user_type("interface"). This is the OkHttp Interceptor pattern. - const code = ` -import java.io.IOException - -fun interface Interceptor { - @Throws(IOException::class) - fun intercept(chain: Chain): Response -} -`; - const result = extractFromSource('interceptor.kt', code); - - const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); - expect(ifaceNode).toBeDefined(); - expect(ifaceNode?.name).toBe('Interceptor'); - }); - - it('should extract methods from interface with nested fun interface', () => { - // When an interface contains a nested `fun interface`, tree-sitter misparsed - // the parent body as ERROR. Methods inside should still be extracted. - const code = ` -interface WebSocket { - fun request(): Request - fun send(text: String): Boolean - fun cancel() - fun interface Factory { - fun newWebSocket(request: Request): WebSocket - } -} -`; - const result = extractFromSource('websocket.kt', code); - - const wsIface = result.nodes.find((n) => n.kind === 'interface' && n.name === 'WebSocket'); - expect(wsIface).toBeDefined(); - - const methods = result.nodes.filter((n) => n.kind === 'method' && n.qualifiedName?.startsWith('WebSocket::')); - const methodNames = methods.map((m) => m.name); - expect(methodNames).toContain('request'); - expect(methodNames).toContain('send'); - expect(methodNames).toContain('cancel'); - }); -}); - -describe('Dart Extraction', () => { - it('should extract class declarations', () => { - const code = ` -class UserService { - final Database _db; - - Future findById(String id) async { - return await _db.query(id); - } - - void _privateMethod() {} -} -`; - const result = extractFromSource('service.dart', code); - - const classNode = result.nodes.find((n) => n.kind === 'class'); - expect(classNode).toBeDefined(); - expect(classNode?.name).toBe('UserService'); - expect(classNode?.visibility).toBe('public'); - - const methodNodes = result.nodes.filter((n) => n.kind === 'method'); - expect(methodNodes.length).toBeGreaterThanOrEqual(2); - - const findById = methodNodes.find((m) => m.name === 'findById'); - expect(findById).toBeDefined(); - expect(findById?.isAsync).toBe(true); - - const privateMethod = methodNodes.find((m) => m.name === '_privateMethod'); - expect(privateMethod).toBeDefined(); - expect(privateMethod?.visibility).toBe('private'); - }); - - it('should extract top-level function declarations', () => { - const code = ` -void topLevelFunction(String name) { - print(name); -} -`; - const result = extractFromSource('utils.dart', code); - - const funcNode = result.nodes.find((n) => n.kind === 'function'); - expect(funcNode).toBeDefined(); - expect(funcNode?.name).toBe('topLevelFunction'); - expect(funcNode?.language).toBe('dart'); - }); - - it('should extract enum declarations', () => { - const code = ` -enum Status { active, inactive, pending } -`; - const result = extractFromSource('models.dart', code); - - const enumNode = result.nodes.find((n) => n.kind === 'enum'); - expect(enumNode).toBeDefined(); - expect(enumNode?.name).toBe('Status'); - }); - - it('should extract mixin declarations', () => { - const code = ` -mixin LoggerMixin { - void log(String message) {} -} -`; - const result = extractFromSource('mixins.dart', code); - - const classNode = result.nodes.find((n) => n.kind === 'class'); - expect(classNode).toBeDefined(); - expect(classNode?.name).toBe('LoggerMixin'); - - const methodNode = result.nodes.find((n) => n.kind === 'method'); - expect(methodNode).toBeDefined(); - expect(methodNode?.name).toBe('log'); - }); - - it('should extract extension declarations', () => { - const code = ` -extension StringExt on String { - bool get isBlank => trim().isEmpty; -} -`; - const result = extractFromSource('extensions.dart', code); - - const classNode = result.nodes.find((n) => n.kind === 'class'); - expect(classNode).toBeDefined(); - expect(classNode?.name).toBe('StringExt'); - }); - - it('should detect static methods', () => { - const code = ` -class Utils { - static void doWork() {} -} -`; - const result = extractFromSource('utils.dart', code); - - const methodNode = result.nodes.find((n) => n.kind === 'method'); - expect(methodNode).toBeDefined(); - expect(methodNode?.name).toBe('doWork'); - expect(methodNode?.isStatic).toBe(true); - }); - - it('should detect async functions', () => { - const code = ` -Future fetchData() async { - return await http.get('/data'); -} -`; - const result = extractFromSource('api.dart', code); - - const funcNode = result.nodes.find((n) => n.kind === 'function'); - expect(funcNode).toBeDefined(); - expect(funcNode?.name).toBe('fetchData'); - expect(funcNode?.isAsync).toBe(true); - }); - - it('should detect private visibility via underscore convention', () => { - const code = ` -void _privateHelper() {} - -void publicFunction() {} -`; - const result = extractFromSource('helpers.dart', code); - - const functions = result.nodes.filter((n) => n.kind === 'function'); - const privateFunc = functions.find((f) => f.name === '_privateHelper'); - const publicFunc = functions.find((f) => f.name === 'publicFunction'); - - expect(privateFunc?.visibility).toBe('private'); - expect(publicFunc?.visibility).toBe('public'); - }); -}); - -describe('Import Extraction', () => { - describe('TypeScript/JavaScript imports', () => { - it('should extract default imports', () => { - const code = `import React from 'react';`; - const result = extractFromSource('app.tsx', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('react'); - expect(importNode?.signature).toBe("import React from 'react';"); - }); - - it('should extract named imports', () => { - const code = `import { Bug, Database } from '@phosphor-icons/react';`; - const result = extractFromSource('icons.tsx', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('@phosphor-icons/react'); - expect(importNode?.signature).toContain('Bug'); - expect(importNode?.signature).toContain('Database'); - }); - - it('should extract namespace imports', () => { - const code = `import * as Icons from '@phosphor-icons/react';`; - const result = extractFromSource('icons.tsx', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('@phosphor-icons/react'); - expect(importNode?.signature).toContain('* as Icons'); - }); - - it('should extract side-effect imports', () => { - const code = `import './styles.css';`; - const result = extractFromSource('app.tsx', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('./styles.css'); - }); - - it('should extract mixed imports (default + named)', () => { - const code = `import React, { useState, useEffect } from 'react';`; - const result = extractFromSource('app.tsx', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('react'); - expect(importNode?.signature).toContain('React'); - expect(importNode?.signature).toContain('useState'); - expect(importNode?.signature).toContain('useEffect'); - }); - - it('should extract multiple import statements', () => { - const code = ` -import React from 'react'; -import { Button } from './components'; -import './styles.css'; -`; - const result = extractFromSource('app.tsx', code); - - const importNodes = result.nodes.filter((n) => n.kind === 'import'); - expect(importNodes.length).toBe(3); - - const names = importNodes.map((n) => n.name); - expect(names).toContain('react'); - expect(names).toContain('./components'); - expect(names).toContain('./styles.css'); - }); - - it('should extract type imports', () => { - const code = `import type { FC, ReactNode } from 'react';`; - const result = extractFromSource('types.ts', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('react'); - expect(importNode?.signature).toContain('type'); - expect(importNode?.signature).toContain('FC'); - }); - - it('should extract aliased named imports', () => { - const code = `import { useState as useStateAlias } from 'react';`; - const result = extractFromSource('hooks.ts', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('react'); - expect(importNode?.signature).toContain('useState'); - expect(importNode?.signature).toContain('useStateAlias'); - }); - - it('should extract relative path imports', () => { - const code = `import { helper } from '../utils/helper';`; - const result = extractFromSource('components/Button.tsx', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('../utils/helper'); - expect(importNode?.signature).toContain('helper'); - }); - }); - - describe('Python imports', () => { - it('should extract simple import statement', () => { - const code = `import json`; - const result = extractFromSource('utils.py', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('json'); - }); - - it('should extract from import statement', () => { - const code = `from os import path`; - const result = extractFromSource('utils.py', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('os'); - expect(importNode?.signature).toContain('path'); - }); - - it('should extract multiple imports from same module', () => { - const code = `from typing import List, Dict, Optional`; - const result = extractFromSource('types.py', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('typing'); - expect(importNode?.signature).toContain('List'); - expect(importNode?.signature).toContain('Dict'); - }); - - it('should extract multiple import statements', () => { - const code = ` -import os -import sys -`; - const result = extractFromSource('main.py', code); - - const importNodes = result.nodes.filter((n) => n.kind === 'import'); - expect(importNodes.length).toBe(2); - - const names = importNodes.map((n) => n.name); - expect(names).toContain('os'); - expect(names).toContain('sys'); - }); - - it('should extract aliased import', () => { - const code = `import numpy as np`; - const result = extractFromSource('data.py', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('numpy'); - expect(importNode?.signature).toContain('as np'); - }); - - it('should extract relative import', () => { - const code = `from .utils import helper`; - const result = extractFromSource('module.py', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('.utils'); - expect(importNode?.signature).toContain('helper'); - }); - - it('should extract wildcard import', () => { - const code = `from typing import *`; - const result = extractFromSource('types.py', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('typing'); - expect(importNode?.signature).toContain('*'); - }); - }); - - describe('Rust imports', () => { - it('should extract simple use declaration', () => { - const code = `use std::io;`; - const result = extractFromSource('main.rs', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('std'); - expect(importNode?.signature).toBe('use std::io;'); - }); - - it('should extract scoped use list', () => { - const code = `use std::{ffi::OsStr, io, path::Path};`; - const result = extractFromSource('main.rs', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('std'); - expect(importNode?.signature).toContain('ffi::OsStr'); - expect(importNode?.signature).toContain('path::Path'); - }); - - it('should extract crate imports', () => { - const code = `use crate::error::Error;`; - const result = extractFromSource('lib.rs', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('crate'); - }); - - it('should extract super imports', () => { - const code = `use super::utils;`; - const result = extractFromSource('submod.rs', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('super'); - }); - - it('should extract external crate imports', () => { - const code = `use serde::{Serialize, Deserialize};`; - const result = extractFromSource('types.rs', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('serde'); - expect(importNode?.signature).toContain('Serialize'); - expect(importNode?.signature).toContain('Deserialize'); - }); - }); - - describe('Go imports', () => { - it('should extract single import', () => { - const code = ` -package main - -import "fmt" -`; - const result = extractFromSource('main.go', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('fmt'); - }); - - it('should extract grouped imports', () => { - const code = ` -package main - -import ( - "fmt" - "os" - "encoding/json" -) -`; - const result = extractFromSource('main.go', code); - - const importNodes = result.nodes.filter((n) => n.kind === 'import'); - expect(importNodes.length).toBe(3); - - const names = importNodes.map((n) => n.name); - expect(names).toContain('fmt'); - expect(names).toContain('os'); - expect(names).toContain('encoding/json'); - }); - - it('should extract aliased import', () => { - const code = ` -package main - -import f "fmt" -`; - const result = extractFromSource('main.go', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('fmt'); - expect(importNode?.signature).toContain('f'); - }); - - it('should extract dot import', () => { - const code = ` -package main - -import . "math" -`; - const result = extractFromSource('main.go', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('math'); - expect(importNode?.signature).toContain('.'); - }); - - it('should extract blank import', () => { - const code = ` -package main - -import _ "github.com/go-sql-driver/mysql" -`; - const result = extractFromSource('main.go', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('github.com/go-sql-driver/mysql'); - expect(importNode?.signature).toContain('_'); - }); - }); - - describe('Swift imports', () => { - it('should extract simple import', () => { - const code = `import Foundation`; - const result = extractFromSource('main.swift', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('Foundation'); - expect(importNode?.signature).toBe('import Foundation'); - }); - - it('should extract @testable import', () => { - const code = `@testable import Alamofire`; - const result = extractFromSource('Tests.swift', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('Alamofire'); - expect(importNode?.signature).toContain('@testable'); - }); - - it('should extract @preconcurrency import', () => { - const code = `@preconcurrency import Security`; - const result = extractFromSource('Auth.swift', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('Security'); - }); - - it('should extract multiple imports', () => { - const code = ` -import Foundation -import UIKit -import Alamofire -`; - const result = extractFromSource('App.swift', code); - - const importNodes = result.nodes.filter((n) => n.kind === 'import'); - expect(importNodes.length).toBe(3); - - const names = importNodes.map((n) => n.name); - expect(names).toContain('Foundation'); - expect(names).toContain('UIKit'); - expect(names).toContain('Alamofire'); - }); - }); - - describe('Kotlin imports', () => { - it('should extract simple import', () => { - const code = `import java.io.IOException`; - const result = extractFromSource('Main.kt', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('java.io.IOException'); - expect(importNode?.signature).toBe('import java.io.IOException'); - }); - - it('should extract aliased import', () => { - const code = `import okhttp3.Request.Builder as RequestBuilder`; - const result = extractFromSource('Utils.kt', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('okhttp3.Request.Builder'); - expect(importNode?.signature).toContain('as RequestBuilder'); - }); - - it('should extract wildcard import', () => { - const code = `import java.util.concurrent.TimeUnit.*`; - const result = extractFromSource('Time.kt', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('java.util.concurrent.TimeUnit'); - expect(importNode?.signature).toContain('.*'); - }); - - it('should extract multiple imports', () => { - const code = ` -import java.io.IOException -import kotlin.test.assertFailsWith -import okhttp3.OkHttpClient -`; - const result = extractFromSource('Test.kt', code); - - const importNodes = result.nodes.filter((n) => n.kind === 'import'); - expect(importNodes.length).toBe(3); - - const names = importNodes.map((n) => n.name); - expect(names).toContain('java.io.IOException'); - expect(names).toContain('kotlin.test.assertFailsWith'); - expect(names).toContain('okhttp3.OkHttpClient'); - }); - }); - - describe('Java imports', () => { - it('should extract simple import', () => { - const code = `import java.util.List;`; - const result = extractFromSource('Main.java', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('java.util.List'); - expect(importNode?.signature).toBe('import java.util.List;'); - }); - - it('should extract static import', () => { - const code = `import static java.util.Collections.emptyList;`; - const result = extractFromSource('Utils.java', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('java.util.Collections.emptyList'); - expect(importNode?.signature).toContain('static'); - }); - - it('should extract wildcard import', () => { - const code = `import java.util.*;`; - const result = extractFromSource('App.java', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('java.util'); - expect(importNode?.signature).toContain('.*'); - }); - - it('should extract nested class import', () => { - const code = `import java.util.Map.Entry;`; - const result = extractFromSource('MapUtil.java', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('java.util.Map.Entry'); - }); - - it('should extract multiple imports', () => { - const code = ` -import java.util.List; -import java.util.Map; -import java.io.IOException; -`; - const result = extractFromSource('Service.java', code); - - const importNodes = result.nodes.filter((n) => n.kind === 'import'); - expect(importNodes.length).toBe(3); - - const names = importNodes.map((n) => n.name); - expect(names).toContain('java.util.List'); - expect(names).toContain('java.util.Map'); - expect(names).toContain('java.io.IOException'); - }); - }); - - describe('C# imports', () => { - it('should extract simple using', () => { - const code = `using System;`; - const result = extractFromSource('Program.cs', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('System'); - expect(importNode?.signature).toBe('using System;'); - }); - - it('should extract qualified using', () => { - const code = `using System.Collections.Generic;`; - const result = extractFromSource('Utils.cs', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('System.Collections.Generic'); - }); - - it('should extract static using', () => { - const code = `using static System.Console;`; - const result = extractFromSource('App.cs', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('System.Console'); - expect(importNode?.signature).toContain('static'); - }); - - it('should extract alias using', () => { - const code = `using MyList = System.Collections.Generic.List;`; - const result = extractFromSource('Types.cs', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('System.Collections.Generic.List'); - expect(importNode?.signature).toContain('MyList ='); - }); - - it('should extract multiple usings', () => { - const code = ` -using System; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -`; - const result = extractFromSource('Service.cs', code); - - const importNodes = result.nodes.filter((n) => n.kind === 'import'); - expect(importNodes.length).toBe(3); - - const names = importNodes.map((n) => n.name); - expect(names).toContain('System'); - expect(names).toContain('System.Threading.Tasks'); - expect(names).toContain('Microsoft.Extensions.DependencyInjection'); - }); - }); - - describe('PHP imports', () => { - it('should extract simple use', () => { - const code = ` n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('PHPUnit\\Framework\\TestCase'); - }); - - it('should extract aliased use', () => { - const code = ` n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('Mockery'); - expect(importNode?.signature).toContain('as m'); - }); - - it('should extract function use', () => { - const code = ` n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('Illuminate\\Support\\env'); - expect(importNode?.signature).toContain('function'); - }); - - it('should extract grouped use', () => { - const code = ` n.kind === 'import'); - expect(importNodes.length).toBe(2); - - const names = importNodes.map((n) => n.name); - expect(names).toContain('Illuminate\\Database\\Model'); - expect(names).toContain('Illuminate\\Database\\Builder'); - }); - - it('should extract multiple uses', () => { - const code = ` n.kind === 'import'); - expect(importNodes.length).toBe(3); - - const names = importNodes.map((n) => n.name); - expect(names).toContain('Illuminate\\Support\\Collection'); - expect(names).toContain('Illuminate\\Support\\Str'); - expect(names).toContain('Closure'); - }); - }); - - describe('Ruby imports', () => { - it('should extract require', () => { - const code = `require 'json'`; - const result = extractFromSource('app.rb', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('json'); - expect(importNode?.signature).toBe("require 'json'"); - }); - - it('should extract require with path', () => { - const code = `require 'active_support/core_ext/string'`; - const result = extractFromSource('config.rb', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('active_support/core_ext/string'); - }); - - it('should extract require_relative', () => { - const code = `require_relative '../test_helper'`; - const result = extractFromSource('test/my_test.rb', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('../test_helper'); - expect(importNode?.signature).toContain('require_relative'); - }); - - it('should not extract non-require calls', () => { - const code = `puts 'hello'`; - const result = extractFromSource('app.rb', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeUndefined(); - }); - - it('should extract multiple requires', () => { - const code = ` -require 'json' -require 'yaml' -require_relative 'helper' -`; - const result = extractFromSource('lib.rb', code); - - const importNodes = result.nodes.filter((n) => n.kind === 'import'); - expect(importNodes.length).toBe(3); - - const names = importNodes.map((n) => n.name); - expect(names).toContain('json'); - expect(names).toContain('yaml'); - expect(names).toContain('helper'); - }); - }); - - describe('Ruby modules', () => { - it('should extract module as module node with containment', () => { - const code = ` -module CachedCounting - def self.disable - @enabled = false - end - - def perform_increment!(key, count) - write_cache!(key, count) - end -end -`; - const result = extractFromSource('concerns/cached_counting.rb', code); - - const moduleNode = result.nodes.find((n) => n.kind === 'module' && n.name === 'CachedCounting'); - expect(moduleNode).toBeDefined(); - expect(moduleNode?.qualifiedName).toBe('CachedCounting'); - - // Methods inside module should have module-qualified names - const disableMethod = result.nodes.find((n) => n.name === 'disable' && n.kind === 'method'); - expect(disableMethod).toBeDefined(); - expect(disableMethod?.qualifiedName).toBe('CachedCounting::disable'); - - const incrementMethod = result.nodes.find((n) => n.name === 'perform_increment!' && n.kind === 'method'); - expect(incrementMethod).toBeDefined(); - expect(incrementMethod?.qualifiedName).toBe('CachedCounting::perform_increment!'); - - // Containment edge from module to methods - const containsEdges = result.edges.filter((e) => e.source === moduleNode?.id && e.kind === 'contains'); - expect(containsEdges.length).toBeGreaterThanOrEqual(2); - }); - - it('should handle nested modules with classes', () => { - const code = ` -module Discourse - module Auth - class AuthProvider - def authenticate(params) - validate(params) - end - end - end -end -`; - const result = extractFromSource('lib/auth.rb', code); - - const discourseModule = result.nodes.find((n) => n.kind === 'module' && n.name === 'Discourse'); - expect(discourseModule).toBeDefined(); - - const authModule = result.nodes.find((n) => n.kind === 'module' && n.name === 'Auth'); - expect(authModule).toBeDefined(); - expect(authModule?.qualifiedName).toBe('Discourse::Auth'); - - const authProvider = result.nodes.find((n) => n.kind === 'class' && n.name === 'AuthProvider'); - expect(authProvider).toBeDefined(); - expect(authProvider?.qualifiedName).toBe('Discourse::Auth::AuthProvider'); - - const authMethod = result.nodes.find((n) => n.name === 'authenticate'); - expect(authMethod).toBeDefined(); - expect(authMethod?.qualifiedName).toBe('Discourse::Auth::AuthProvider::authenticate'); - }); - }); - - describe('C/C++ imports', () => { - it('should extract system include', () => { - const code = `#include `; - const result = extractFromSource('main.cpp', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('iostream'); - expect(importNode?.signature).toBe('#include '); - }); - - it('should extract system include with path', () => { - const code = `#include `; - const result = extractFromSource('app.cpp', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('nlohmann/json.hpp'); - }); - - it('should extract local include', () => { - const code = `#include "myheader.h"`; - const result = extractFromSource('main.cpp', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('myheader.h'); - }); - - it('should extract C header', () => { - const code = `#include `; - const result = extractFromSource('main.c', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('stdio.h'); - }); - - it('should extract multiple includes', () => { - const code = ` -#include -#include -#include "config.h" -`; - const result = extractFromSource('app.cpp', code); - - const importNodes = result.nodes.filter((n) => n.kind === 'import'); - expect(importNodes.length).toBe(3); - - const names = importNodes.map((n) => n.name); - expect(names).toContain('iostream'); - expect(names).toContain('vector'); - expect(names).toContain('config.h'); - }); - }); - - describe('Dart imports', () => { - it('should extract dart: import', () => { - const code = `import 'dart:async';`; - const result = extractFromSource('main.dart', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('dart:async'); - expect(importNode?.signature).toBe("import 'dart:async';"); - }); - - it('should extract package import', () => { - const code = `import 'package:flutter/material.dart';`; - const result = extractFromSource('app.dart', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('package:flutter/material.dart'); - }); - - it('should extract aliased import', () => { - const code = `import 'package:http/http.dart' as http;`; - const result = extractFromSource('api.dart', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('package:http/http.dart'); - expect(importNode?.signature).toContain('as http'); - }); - - it('should extract multiple imports', () => { - const code = ` -import 'dart:async'; -import 'dart:convert'; -import 'package:flutter/material.dart'; -`; - const result = extractFromSource('main.dart', code); - - const importNodes = result.nodes.filter((n) => n.kind === 'import'); - expect(importNodes.length).toBe(3); - - const names = importNodes.map((n) => n.name); - expect(names).toContain('dart:async'); - expect(names).toContain('dart:convert'); - expect(names).toContain('package:flutter/material.dart'); - }); - - it('should extract relative import', () => { - const code = `import '../utils/helpers.dart';`; - const result = extractFromSource('lib/main.dart', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('../utils/helpers.dart'); - }); - }); - - describe('Liquid imports', () => { - it('should extract render tag', () => { - const code = `{% render 'loading-spinner' %}`; - const result = extractFromSource('template.liquid', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('loading-spinner'); - expect(importNode?.signature).toContain('render'); - }); - - it('should extract section tag', () => { - const code = `{% section 'header' %}`; - const result = extractFromSource('layout/theme.liquid', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('header'); - expect(importNode?.signature).toContain('section'); - }); - - it('should extract include tag', () => { - const code = `{% include 'icon-cart' %}`; - const result = extractFromSource('snippets/header.liquid', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('icon-cart'); - expect(importNode?.signature).toContain('include'); - }); - - it('should extract render with whitespace control', () => { - const code = `{%- render 'price' -%}`; - const result = extractFromSource('snippets/product.liquid', code); - - const importNode = result.nodes.find((n) => n.kind === 'import'); - expect(importNode).toBeDefined(); - expect(importNode?.name).toBe('price'); - }); - - it('should extract multiple imports', () => { - const code = ` -{% section 'header' %} -{% render 'loading-spinner' %} -{% render 'cart-drawer' %} -`; - const result = extractFromSource('layout/theme.liquid', code); - - const importNodes = result.nodes.filter((n) => n.kind === 'import'); - expect(importNodes.length).toBe(3); - - const names = importNodes.map((n) => n.name); - expect(names).toContain('header'); - expect(names).toContain('loading-spinner'); - expect(names).toContain('cart-drawer'); - }); - }); -}); - -// ============================================================================= -// Pascal / Delphi Extraction -// ============================================================================= - -describe('Pascal / Delphi Extraction', () => { - describe('Language detection', () => { - it('should detect Pascal files', () => { - expect(detectLanguage('UAuth.pas')).toBe('pascal'); - expect(detectLanguage('App.dpr')).toBe('pascal'); - expect(detectLanguage('Package.dpk')).toBe('pascal'); - expect(detectLanguage('App.lpr')).toBe('pascal'); - expect(detectLanguage('MainForm.dfm')).toBe('pascal'); - expect(detectLanguage('MainForm.fmx')).toBe('pascal'); - }); - - it('should report Pascal as supported', () => { - expect(isLanguageSupported('pascal')).toBe(true); - expect(getSupportedLanguages()).toContain('pascal'); - }); - }); - - describe('Unit extraction', () => { - it('should extract unit as module', () => { - const code = `unit MyUnit;\ninterface\nimplementation\nend.`; - const result = extractFromSource('MyUnit.pas', code); - - const moduleNode = result.nodes.find((n) => n.kind === 'module'); - expect(moduleNode).toBeDefined(); - expect(moduleNode?.name).toBe('MyUnit'); - expect(moduleNode?.language).toBe('pascal'); - }); - - it('should extract program as module', () => { - const code = `program MyApp;\nbegin\nend.`; - const result = extractFromSource('MyApp.dpr', code); - - const moduleNode = result.nodes.find((n) => n.kind === 'module'); - expect(moduleNode).toBeDefined(); - expect(moduleNode?.name).toBe('MyApp'); - }); - - it('should fallback to filename when module name is empty', () => { - // Some .dpr templates use "program;" without a name - const code = `program;\nuses SysUtils;\nbegin\nend.`; - const result = extractFromSource('Console.dpr', code); - - const moduleNode = result.nodes.find((n) => n.kind === 'module'); - expect(moduleNode).toBeDefined(); - expect(moduleNode?.name).toBe('Console'); - }); - }); - - describe('Uses clause (imports)', () => { - it('should extract uses as individual imports', () => { - const code = `unit Test;\ninterface\nuses\n System.SysUtils,\n System.Classes;\nimplementation\nend.`; - const result = extractFromSource('Test.pas', code); - - const imports = result.nodes.filter((n) => n.kind === 'import'); - expect(imports.length).toBe(2); - expect(imports.map((n) => n.name)).toContain('System.SysUtils'); - expect(imports.map((n) => n.name)).toContain('System.Classes'); - }); - - it('should create unresolved references for imports', () => { - const code = `unit Test;\ninterface\nuses\n UAuth;\nimplementation\nend.`; - const result = extractFromSource('Test.pas', code); - - const importRef = result.unresolvedReferences.find( - (r) => r.referenceKind === 'imports' - ); - expect(importRef).toBeDefined(); - expect(importRef?.referenceName).toBe('UAuth'); - }); - }); - - describe('Class extraction', () => { - it('should extract class declarations', () => { - const code = `unit Test;\ninterface\ntype\n TMyClass = class\n public\n procedure DoSomething;\n end;\nimplementation\nend.`; - const result = extractFromSource('Test.pas', code); - - const classNode = result.nodes.find((n) => n.kind === 'class'); - expect(classNode).toBeDefined(); - expect(classNode?.name).toBe('TMyClass'); - }); - - it('should extract class with inheritance', () => { - const code = `unit Test;\ninterface\ntype\n TChild = class(TParent)\n end;\nimplementation\nend.`; - const result = extractFromSource('Test.pas', code); - - const extendsRef = result.unresolvedReferences.find( - (r) => r.referenceKind === 'extends' - ); - expect(extendsRef).toBeDefined(); - expect(extendsRef?.referenceName).toBe('TParent'); - }); - - it('should extract class with interface implementation', () => { - const code = `unit Test;\ninterface\ntype\n TService = class(TInterfacedObject, ILogger)\n end;\nimplementation\nend.`; - const result = extractFromSource('Test.pas', code); - - const extendsRef = result.unresolvedReferences.find( - (r) => r.referenceKind === 'extends' - ); - const implementsRef = result.unresolvedReferences.find( - (r) => r.referenceKind === 'implements' - ); - expect(extendsRef?.referenceName).toBe('TInterfacedObject'); - expect(implementsRef?.referenceName).toBe('ILogger'); - }); - }); - - describe('Record extraction', () => { - it('should extract records as class nodes', () => { - const code = `unit Test;\ninterface\ntype\n TPoint = record\n X: Double;\n Y: Double;\n end;\nimplementation\nend.`; - const result = extractFromSource('Test.pas', code); - - const classNode = result.nodes.find((n) => n.kind === 'class'); - expect(classNode).toBeDefined(); - expect(classNode?.name).toBe('TPoint'); - - const fields = result.nodes.filter((n) => n.kind === 'field'); - expect(fields.length).toBe(2); - expect(fields.map((f) => f.name)).toContain('X'); - expect(fields.map((f) => f.name)).toContain('Y'); - }); - }); - - describe('Interface extraction', () => { - it('should extract interface declarations', () => { - const code = `unit Test;\ninterface\ntype\n ILogger = interface\n procedure Log(const AMsg: string);\n end;\nimplementation\nend.`; - const result = extractFromSource('Test.pas', code); - - const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); - expect(ifaceNode).toBeDefined(); - expect(ifaceNode?.name).toBe('ILogger'); - }); - }); - - describe('Method extraction', () => { - it('should extract methods with visibility', () => { - const code = `unit Test;\ninterface\ntype\n TMyClass = class\n private\n FValue: Integer;\n public\n constructor Create;\n function GetValue: Integer;\n end;\nimplementation\nend.`; - const result = extractFromSource('Test.pas', code); - - const methods = result.nodes.filter((n) => n.kind === 'method'); - expect(methods.length).toBe(2); - - const createMethod = methods.find((m) => m.name === 'Create'); - expect(createMethod?.visibility).toBe('public'); - - const getValue = methods.find((m) => m.name === 'GetValue'); - expect(getValue?.visibility).toBe('public'); - - const fields = result.nodes.filter((n) => n.kind === 'field'); - const fValue = fields.find((f) => f.name === 'FValue'); - expect(fValue?.visibility).toBe('private'); - }); - - it('should detect static methods (class methods)', () => { - const code = `unit Test;\ninterface\ntype\n THelper = class\n public\n class function Create: THelper; static;\n end;\nimplementation\nend.`; - const result = extractFromSource('Test.pas', code); - - const methods = result.nodes.filter((n) => n.kind === 'method'); - const staticMethod = methods.find((m) => m.name === 'Create'); - expect(staticMethod?.isStatic).toBe(true); - }); - }); - - describe('Enum extraction', () => { - it('should extract enums with members', () => { - const code = `unit Test;\ninterface\ntype\n TColor = (clRed, clGreen, clBlue);\nimplementation\nend.`; - const result = extractFromSource('Test.pas', code); - - const enumNode = result.nodes.find((n) => n.kind === 'enum'); - expect(enumNode).toBeDefined(); - expect(enumNode?.name).toBe('TColor'); - - const members = result.nodes.filter((n) => n.kind === 'enum_member'); - expect(members.length).toBe(3); - expect(members.map((m) => m.name)).toEqual(['clRed', 'clGreen', 'clBlue']); - }); - }); - - describe('Property extraction', () => { - it('should extract properties', () => { - const code = `unit Test;\ninterface\ntype\n TObj = class\n public\n property Name: string read FName write FName;\n end;\nimplementation\nend.`; - const result = extractFromSource('Test.pas', code); - - const propNode = result.nodes.find((n) => n.kind === 'property'); - expect(propNode).toBeDefined(); - expect(propNode?.name).toBe('Name'); - expect(propNode?.visibility).toBe('public'); - }); - }); - - describe('Constant extraction', () => { - it('should extract constants', () => { - const code = `unit Test;\ninterface\nconst\n MAX_RETRIES = 3;\n APP_NAME = 'MyApp';\nimplementation\nend.`; - const result = extractFromSource('Test.pas', code); - - const constants = result.nodes.filter((n) => n.kind === 'constant'); - expect(constants.length).toBe(2); - expect(constants.map((c) => c.name)).toContain('MAX_RETRIES'); - expect(constants.map((c) => c.name)).toContain('APP_NAME'); - }); - }); - - describe('Type alias extraction', () => { - it('should extract type aliases', () => { - const code = `unit Test;\ninterface\ntype\n TUserName = string;\nimplementation\nend.`; - const result = extractFromSource('Test.pas', code); - - const aliasNode = result.nodes.find((n) => n.kind === 'type_alias'); - expect(aliasNode).toBeDefined(); - expect(aliasNode?.name).toBe('TUserName'); - }); - }); - - describe('Call extraction', () => { - it('should extract calls from implementation bodies', () => { - const code = `unit Test;\ninterface\ntype\n TObj = class\n public\n procedure DoWork;\n end;\nimplementation\nprocedure TObj.DoWork;\nbegin\n WriteLn('hello');\nend;\nend.`; - const result = extractFromSource('Test.pas', code); - - const callRef = result.unresolvedReferences.find( - (r) => r.referenceKind === 'calls' - ); - expect(callRef).toBeDefined(); - expect(callRef?.referenceName).toBe('WriteLn'); - }); - }); - - describe('Containment edges', () => { - it('should create contains edges for class members', () => { - const code = `unit Test;\ninterface\ntype\n TObj = class\n public\n procedure Foo;\n end;\nimplementation\nend.`; - const result = extractFromSource('Test.pas', code); - - const classNode = result.nodes.find((n) => n.kind === 'class'); - const methodNode = result.nodes.find((n) => n.kind === 'method'); - expect(classNode).toBeDefined(); - expect(methodNode).toBeDefined(); - - const containsEdge = result.edges.find( - (e) => e.source === classNode?.id && e.target === methodNode?.id && e.kind === 'contains' - ); - expect(containsEdge).toBeDefined(); - }); - }); - - describe('Full fixture: UAuth.pas', () => { - const code = `unit UAuth; - -interface - -uses - System.SysUtils, - System.Classes; - -type - ITokenValidator = interface - ['{11111111-1111-1111-1111-111111111111}'] - function Validate(const AToken: string): Boolean; - end; - - TAuthService = class(TInterfacedObject, ITokenValidator) - private - FToken: string; - FLoginCount: Integer; - procedure IncLoginCount; - protected - function GetToken: string; - public - constructor Create; - destructor Destroy; override; - function Validate(const AToken: string): Boolean; - function Login(const AUser, APass: string): string; - property Token: string read GetToken; - property LoginCount: Integer read FLoginCount; - end; - -implementation - -constructor TAuthService.Create; -begin - inherited Create; - FToken := ''; - FLoginCount := 0; -end; - -destructor TAuthService.Destroy; -begin - FToken := ''; - inherited Destroy; -end; - -procedure TAuthService.IncLoginCount; -begin - Inc(FLoginCount); -end; - -function TAuthService.GetToken: string; -begin - Result := FToken; -end; - -function TAuthService.Validate(const AToken: string): Boolean; -begin - Result := AToken <> ''; -end; - -function TAuthService.Login(const AUser, APass: string): string; -begin - IncLoginCount; - if Validate(AUser + ':' + APass) then - begin - FToken := AUser; - Result := 'ok'; - end - else - Result := ''; -end; - -end.`; - - it('should extract all expected nodes', () => { - const result = extractFromSource('UAuth.pas', code); - - expect(result.errors).toHaveLength(0); - - // Module - const moduleNode = result.nodes.find((n) => n.kind === 'module'); - expect(moduleNode?.name).toBe('UAuth'); - - // Imports - const imports = result.nodes.filter((n) => n.kind === 'import'); - expect(imports.length).toBe(2); - - // Interface - const ifaceNode = result.nodes.find((n) => n.kind === 'interface'); - expect(ifaceNode?.name).toBe('ITokenValidator'); - - // Class - const classNode = result.nodes.find((n) => n.kind === 'class'); - expect(classNode?.name).toBe('TAuthService'); - - // Methods - const methods = result.nodes.filter((n) => n.kind === 'method'); - expect(methods.length).toBeGreaterThanOrEqual(6); - expect(methods.map((m) => m.name)).toContain('Create'); - expect(methods.map((m) => m.name)).toContain('Destroy'); - expect(methods.map((m) => m.name)).toContain('Login'); - - // Fields - const fields = result.nodes.filter((n) => n.kind === 'field'); - expect(fields.length).toBe(2); - expect(fields.every((f) => f.visibility === 'private')).toBe(true); - - // Properties - const props = result.nodes.filter((n) => n.kind === 'property'); - expect(props.length).toBe(2); - expect(props.map((p) => p.name)).toContain('Token'); - expect(props.map((p) => p.name)).toContain('LoginCount'); - }); - - it('should extract inheritance and interface implementation', () => { - const result = extractFromSource('UAuth.pas', code); - - const extendsRef = result.unresolvedReferences.find( - (r) => r.referenceKind === 'extends' - ); - expect(extendsRef?.referenceName).toBe('TInterfacedObject'); - - const implementsRef = result.unresolvedReferences.find( - (r) => r.referenceKind === 'implements' - ); - expect(implementsRef?.referenceName).toBe('ITokenValidator'); - }); - - it('should extract calls from implementation', () => { - const result = extractFromSource('UAuth.pas', code); - - const callRefs = result.unresolvedReferences.filter( - (r) => r.referenceKind === 'calls' - ); - expect(callRefs.map((r) => r.referenceName)).toContain('Inc'); - expect(callRefs.map((r) => r.referenceName)).toContain('Validate'); - }); - }); - - describe('Full fixture: UTypes.pas', () => { - const code = `unit UTypes; - -interface - -uses - System.SysUtils; - -const - C_MAX_RETRIES = 3; - C_DEFAULT_NAME = 'Guest'; - -type - TUserRole = (urAdmin, urEditor, urViewer); - - TPoint2D = record - X: Double; - Y: Double; - end; - - TUserName = string; - - TUserInfo = class - public - type - TAddress = record - Street: string; - City: string; - Zip: string; - end; - private - FName: TUserName; - FRole: TUserRole; - FAddress: TAddress; - public - constructor Create(const AName: TUserName; ARole: TUserRole); - function GetDisplayName: string; - class function CreateAdmin(const AName: TUserName): TUserInfo; static; - property Name: TUserName read FName write FName; - property Role: TUserRole read FRole; - property Address: TAddress read FAddress write FAddress; - end; - -implementation - -constructor TUserInfo.Create(const AName: TUserName; ARole: TUserRole); -begin - FName := AName; - FRole := ARole; -end; - -function TUserInfo.GetDisplayName: string; -begin - if FRole = urAdmin then - Result := '[Admin] ' + FName - else - Result := FName; -end; - -class function TUserInfo.CreateAdmin(const AName: TUserName): TUserInfo; -begin - Result := TUserInfo.Create(AName, urAdmin); -end; - -end.`; - - it('should extract enums with members', () => { - const result = extractFromSource('UTypes.pas', code); - - const enumNode = result.nodes.find((n) => n.kind === 'enum'); - expect(enumNode?.name).toBe('TUserRole'); - - const members = result.nodes.filter((n) => n.kind === 'enum_member'); - expect(members.length).toBe(3); - expect(members.map((m) => m.name)).toEqual(['urAdmin', 'urEditor', 'urViewer']); - }); - - it('should extract constants', () => { - const result = extractFromSource('UTypes.pas', code); - - const constants = result.nodes.filter((n) => n.kind === 'constant'); - expect(constants.length).toBe(2); - expect(constants.map((c) => c.name)).toContain('C_MAX_RETRIES'); - expect(constants.map((c) => c.name)).toContain('C_DEFAULT_NAME'); - }); - - it('should extract type aliases', () => { - const result = extractFromSource('UTypes.pas', code); - - const aliases = result.nodes.filter((n) => n.kind === 'type_alias'); - expect(aliases.map((a) => a.name)).toContain('TUserName'); - }); - - it('should extract records as classes with fields', () => { - const result = extractFromSource('UTypes.pas', code); - - const classes = result.nodes.filter((n) => n.kind === 'class'); - expect(classes.map((c) => c.name)).toContain('TPoint2D'); - - // TPoint2D fields - const fields = result.nodes.filter((n) => n.kind === 'field'); - expect(fields.map((f) => f.name)).toContain('X'); - expect(fields.map((f) => f.name)).toContain('Y'); - }); - - it('should extract static class methods', () => { - const result = extractFromSource('UTypes.pas', code); - - const methods = result.nodes.filter((n) => n.kind === 'method'); - const staticMethod = methods.find((m) => m.name === 'CreateAdmin'); - expect(staticMethod).toBeDefined(); - expect(staticMethod?.isStatic).toBe(true); - }); - - it('should extract nested types', () => { - const result = extractFromSource('UTypes.pas', code); - - const classes = result.nodes.filter((n) => n.kind === 'class'); - expect(classes.map((c) => c.name)).toContain('TAddress'); - }); - }); -}); - -// ============================================================================= -// DFM/FMX Extraction -// ============================================================================= - -describe('DFM/FMX Extraction', () => { - it('should extract components from DFM', () => { - const code = `object Form1: TForm1 - Left = 0 - Top = 0 - Caption = 'My Form' - object Button1: TButton - Left = 10 - Top = 10 - Caption = 'Click Me' - end -end`; - const result = extractFromSource('Form1.dfm', code); - - const components = result.nodes.filter((n) => n.kind === 'component'); - expect(components.length).toBe(2); - expect(components.map((c) => c.name)).toContain('Form1'); - expect(components.map((c) => c.name)).toContain('Button1'); - - const button = components.find((c) => c.name === 'Button1'); - expect(button?.signature).toBe('TButton'); - }); - - it('should extract nested component hierarchy', () => { - const code = `object Form1: TForm1 - object Panel1: TPanel - object Label1: TLabel - Caption = 'Hello' - end - end -end`; - const result = extractFromSource('Form1.dfm', code); - - const components = result.nodes.filter((n) => n.kind === 'component'); - expect(components.length).toBe(3); - - // Check nesting: Panel1 contains Label1 - const panel = components.find((c) => c.name === 'Panel1'); - const label = components.find((c) => c.name === 'Label1'); - const containsEdge = result.edges.find( - (e) => e.source === panel?.id && e.target === label?.id && e.kind === 'contains' - ); - expect(containsEdge).toBeDefined(); - }); - - it('should extract event handler references', () => { - const code = `object Form1: TForm1 - OnCreate = FormCreate - OnDestroy = FormDestroy - object Button1: TButton - OnClick = Button1Click - end -end`; - const result = extractFromSource('Form1.dfm', code); - - const refs = result.unresolvedReferences; - expect(refs.length).toBe(3); - expect(refs.map((r) => r.referenceName)).toContain('FormCreate'); - expect(refs.map((r) => r.referenceName)).toContain('FormDestroy'); - expect(refs.map((r) => r.referenceName)).toContain('Button1Click'); - expect(refs.every((r) => r.referenceKind === 'references')).toBe(true); - }); - - it('should handle multi-line properties', () => { - const code = `object Form1: TForm1 - SQL.Strings = ( - 'SELECT * FROM users' - 'WHERE active = 1') - object Button1: TButton - OnClick = Button1Click - end -end`; - const result = extractFromSource('Form1.dfm', code); - - const components = result.nodes.filter((n) => n.kind === 'component'); - expect(components.length).toBe(2); - - const refs = result.unresolvedReferences; - expect(refs.length).toBe(1); - expect(refs[0]?.referenceName).toBe('Button1Click'); - }); - - it('should handle inherited keyword', () => { - const code = `inherited Form1: TForm1 - Caption = 'Inherited Form' - object Button1: TButton - OnClick = Button1Click - end -end`; - const result = extractFromSource('Form1.dfm', code); - - const components = result.nodes.filter((n) => n.kind === 'component'); - expect(components.length).toBe(2); - expect(components.map((c) => c.name)).toContain('Form1'); - }); - - it('should handle item collection properties', () => { - const code = `object Form1: TForm1 - object StatusBar1: TStatusBar - Panels = < - item - Width = 200 - end - item - Width = 200 - end> - end -end`; - const result = extractFromSource('Form1.dfm', code); - - const components = result.nodes.filter((n) => n.kind === 'component'); - expect(components.length).toBe(2); - }); - - describe('Full fixture: MainForm.dfm', () => { - const code = `object frmMain: TfrmMain - Left = 0 - Top = 0 - Caption = 'CodeGraph DFM Fixture' - ClientHeight = 480 - ClientWidth = 640 - OnCreate = FormCreate - OnDestroy = FormDestroy - object pnlTop: TPanel - Left = 0 - Top = 0 - Width = 640 - Height = 50 - object lblTitle: TLabel - Left = 16 - Top = 16 - Caption = 'Authentication Service' - end - object btnLogin: TButton - Left = 540 - Top = 12 - OnClick = btnLoginClick - end - end - object pnlContent: TPanel - Left = 0 - Top = 50 - object edtUsername: TEdit - Left = 16 - Top = 16 - OnChange = edtUsernameChange - end - object edtPassword: TEdit - Left = 16 - Top = 48 - OnKeyPress = edtPasswordKeyPress - end - object mmoLog: TMemo - Left = 16 - Top = 88 - end - end - object pnlStatus: TStatusBar - Left = 0 - Top = 440 - Panels = < - item - Width = 200 - end - item - Width = 200 - end> - end -end`; - - it('should extract all components', () => { - const result = extractFromSource('MainForm.dfm', code); - - const components = result.nodes.filter((n) => n.kind === 'component'); - expect(components.length).toBe(9); - expect(components.map((c) => c.name)).toEqual( - expect.arrayContaining([ - 'frmMain', 'pnlTop', 'lblTitle', 'btnLogin', - 'pnlContent', 'edtUsername', 'edtPassword', 'mmoLog', 'pnlStatus', - ]) - ); - }); - - it('should extract all event handlers', () => { - const result = extractFromSource('MainForm.dfm', code); - - const refs = result.unresolvedReferences; - expect(refs.length).toBe(5); - expect(refs.map((r) => r.referenceName)).toEqual( - expect.arrayContaining([ - 'FormCreate', 'FormDestroy', 'btnLoginClick', - 'edtUsernameChange', 'edtPasswordKeyPress', - ]) - ); - }); - }); -}); - -describe('Full Indexing', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - cleanupTempDir(tempDir); - }); - - it('should index a TypeScript file', async () => { - // Create test file - const srcDir = path.join(tempDir, 'src'); - fs.mkdirSync(srcDir); - fs.writeFileSync( - path.join(srcDir, 'utils.ts'), - ` -export function add(a: number, b: number): number { - return a + b; -} - -export function multiply(a: number, b: number): number { - return a * b; -} -` - ); - - // Initialize and index - const cg = CodeGraph.initSync(tempDir); - const result = await cg.indexAll(); - - expect(result.success).toBe(true); - expect(result.filesIndexed).toBe(1); - expect(result.nodesCreated).toBeGreaterThanOrEqual(2); - - // Check nodes were stored - const nodes = cg.getNodesInFile('src/utils.ts'); - expect(nodes.length).toBeGreaterThanOrEqual(2); - - const addFunc = nodes.find((n) => n.name === 'add'); - expect(addFunc).toBeDefined(); - expect(addFunc?.kind).toBe('function'); - - cg.close(); - }); - - it('should index multiple files', async () => { - // Create test files - const srcDir = path.join(tempDir, 'src'); - fs.mkdirSync(srcDir); - - fs.writeFileSync( - path.join(srcDir, 'math.ts'), - `export function add(a: number, b: number) { return a + b; }` - ); - - fs.writeFileSync( - path.join(srcDir, 'string.ts'), - `export function capitalize(s: string) { return s.toUpperCase(); }` - ); - - // Initialize and index - const cg = CodeGraph.initSync(tempDir); - const result = await cg.indexAll(); - - expect(result.success).toBe(true); - expect(result.filesIndexed).toBe(2); - - const files = cg.getFiles(); - expect(files.length).toBe(2); - - cg.close(); - }); - - it('should track file hashes for incremental updates', async () => { - // Create initial file - const srcDir = path.join(tempDir, 'src'); - fs.mkdirSync(srcDir); - fs.writeFileSync(path.join(srcDir, 'main.ts'), `export const x = 1;`); - - // Initialize and index - const cg = CodeGraph.initSync(tempDir); - await cg.indexAll(); - - // Check file is tracked - const file = cg.getFile('src/main.ts'); - expect(file).toBeDefined(); - expect(file?.contentHash).toBeDefined(); - - // Modify file - fs.writeFileSync(path.join(srcDir, 'main.ts'), `export const x = 2;`); - - // Check for changes - const changes = cg.getChangedFiles(); - expect(changes.modified).toContain('src/main.ts'); - - cg.close(); - }); - - it('should sync and detect changes', async () => { - // Create initial file - const srcDir = path.join(tempDir, 'src'); - fs.mkdirSync(srcDir); - fs.writeFileSync( - path.join(srcDir, 'main.ts'), - `export function original() { return 1; }` - ); - - // Initialize and index - const cg = CodeGraph.initSync(tempDir); - await cg.indexAll(); - - const initialNodes = cg.getNodesInFile('src/main.ts'); - expect(initialNodes.some((n) => n.name === 'original')).toBe(true); - - // Modify file - fs.writeFileSync( - path.join(srcDir, 'main.ts'), - `export function updated() { return 2; }` - ); - - // Sync - const syncResult = await cg.sync(); - expect(syncResult.filesModified).toBe(1); - - // Check nodes were updated - const updatedNodes = cg.getNodesInFile('src/main.ts'); - expect(updatedNodes.some((n) => n.name === 'updated')).toBe(true); - expect(updatedNodes.some((n) => n.name === 'original')).toBe(false); - - cg.close(); - }); -}); - -describe('Path Normalization', () => { - it('should convert backslashes to forward slashes', () => { - expect(normalizePath('gui\\node_modules\\foo')).toBe('gui/node_modules/foo'); - expect(normalizePath('src\\components\\Button.tsx')).toBe('src/components/Button.tsx'); - }); - - it('should leave forward-slash paths unchanged', () => { - expect(normalizePath('src/components/Button.tsx')).toBe('src/components/Button.tsx'); - }); - - it('should handle empty string', () => { - expect(normalizePath('')).toBe(''); - }); -}); - -describe('Directory Exclusion', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - cleanupTempDir(tempDir); - }); - - it('should exclude directories listed in .gitignore', () => { - // Create structure: src/index.ts + node_modules/pkg/index.js, gitignore node_modules - const srcDir = path.join(tempDir, 'src'); - const nmDir = path.join(tempDir, 'node_modules', 'pkg'); - fs.mkdirSync(srcDir, { recursive: true }); - fs.mkdirSync(nmDir, { recursive: true }); - fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;'); - fs.writeFileSync(path.join(nmDir, 'index.js'), 'module.exports = {};'); - fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules/\n'); - - const files = scanDirectory(tempDir); - - expect(files).toContain('src/index.ts'); - expect(files.every((f) => !f.includes('node_modules'))).toBe(true); - }); - - it('should exclude nested node_modules via a root .gitignore', () => { - // A trailing-slash pattern with no leading slash matches at any depth. - const srcDir = path.join(tempDir, 'packages', 'app', 'src'); - const nmDir = path.join(tempDir, 'packages', 'app', 'node_modules', 'pkg'); - fs.mkdirSync(srcDir, { recursive: true }); - fs.mkdirSync(nmDir, { recursive: true }); - fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;'); - fs.writeFileSync(path.join(nmDir, 'index.js'), 'module.exports = {};'); - fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules/\n'); - - const files = scanDirectory(tempDir); - - expect(files).toContain('packages/app/src/index.ts'); - expect(files.every((f) => !f.includes('node_modules'))).toBe(true); - }); - - it('should apply a nested .gitignore only to its own subtree', () => { - const appSrc = path.join(tempDir, 'app', 'src'); - fs.mkdirSync(appSrc, { recursive: true }); - fs.writeFileSync(path.join(appSrc, 'keep.ts'), 'export const a = 1;'); - fs.writeFileSync(path.join(appSrc, 'skip.ts'), 'export const b = 2;'); - fs.writeFileSync(path.join(tempDir, 'app', '.gitignore'), 'src/skip.ts\n'); - // A sibling with the same name outside app/ must NOT be ignored. - const otherDir = path.join(tempDir, 'other', 'src'); - fs.mkdirSync(otherDir, { recursive: true }); - fs.writeFileSync(path.join(otherDir, 'skip.ts'), 'export const c = 3;'); - - const files = scanDirectory(tempDir); - - expect(files).toContain('app/src/keep.ts'); - expect(files).not.toContain('app/src/skip.ts'); - expect(files).toContain('other/src/skip.ts'); - }); - - it('should always skip .git directories', () => { - const srcDir = path.join(tempDir, 'src'); - const gitDir = path.join(tempDir, '.git', 'objects'); - fs.mkdirSync(srcDir, { recursive: true }); - fs.mkdirSync(gitDir, { recursive: true }); - fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;'); - fs.writeFileSync(path.join(gitDir, 'pack.ts'), 'export const y = 2;'); - - const files = scanDirectory(tempDir); - - expect(files).toContain('src/index.ts'); - expect(files.every((f) => !f.includes('.git'))).toBe(true); - }); - - it('should return forward-slash paths on all platforms', () => { - const srcDir = path.join(tempDir, 'src', 'components'); - fs.mkdirSync(srcDir, { recursive: true }); - fs.writeFileSync(path.join(srcDir, 'Button.tsx'), 'export function Button() {}'); - - const files = scanDirectory(tempDir); - - expect(files.length).toBe(1); - expect(files[0]).toBe('src/components/Button.tsx'); - expect(files[0]).not.toContain('\\'); - }); -}); - -describe('Git Submodules', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - cleanupTempDir(tempDir); - }); - - it('should index files inside git submodules (issue #147)', async () => { - const { execFileSync } = await import('child_process'); - const git = (cwd: string, ...args: string[]) => - execFileSync('git', args, { cwd, stdio: 'pipe' }); - - // Build a separate "library" repo to use as a submodule source. - const libDir = path.join(tempDir, '_lib'); - fs.mkdirSync(libDir, { recursive: true }); - git(libDir, 'init', '-q'); - git(libDir, 'config', 'user.email', 'test@test.com'); - git(libDir, 'config', 'user.name', 'Test'); - fs.writeFileSync(path.join(libDir, 'lib.ts'), 'export const fromSubmodule = 1;'); - git(libDir, 'add', '-A'); - git(libDir, 'commit', '-q', '-m', 'lib init'); - - // Build the main repo and add the lib repo as a submodule. - const mainDir = path.join(tempDir, 'main'); - fs.mkdirSync(mainDir, { recursive: true }); - git(mainDir, 'init', '-q'); - git(mainDir, 'config', 'user.email', 'test@test.com'); - git(mainDir, 'config', 'user.name', 'Test'); - fs.writeFileSync(path.join(mainDir, 'app.ts'), 'export const app = 1;'); - git(mainDir, 'add', '-A'); - git(mainDir, 'commit', '-q', '-m', 'app init'); - // protocol.file.allow=always is required to add a local-path submodule on - // recent git versions (CVE-2022-39253 mitigation). - execFileSync( - 'git', - ['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', libDir, 'libs/lib'], - { cwd: mainDir, stdio: 'pipe' } - ); - git(mainDir, 'commit', '-q', '-m', 'add submodule'); - - const files = scanDirectory(mainDir); - - expect(files).toContain('app.ts'); - expect(files).toContain('libs/lib/lib.ts'); - }); -}); - -describe('Nested non-submodule git repos', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - cleanupTempDir(tempDir); - }); - - it('should index files in embedded git repos run from a git super-repo (issue #193)', async () => { - const { execFileSync } = await import('child_process'); - const git = (cwd: string, ...args: string[]) => - execFileSync('git', args, { cwd, stdio: 'pipe' }); - - // Top-level workspace is itself a git repo, holding no source directly — - // the CMake "super-repo" layout from the issue. - const root = path.join(tempDir, 'root'); - fs.mkdirSync(path.join(root, 'coding'), { recursive: true }); - git(root, 'init', '-q'); - git(root, 'config', 'user.email', 'test@test.com'); - git(root, 'config', 'user.name', 'Test'); - fs.writeFileSync(path.join(root, 'CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.10)\n'); - - // Two independent clones living inside the workspace (NOT submodules): - // one with committed source, one with only untracked source. - const sub1 = path.join(root, 'sub_repo1', 'src'); - fs.mkdirSync(sub1, { recursive: true }); - git(path.join(root, 'sub_repo1'), 'init', '-q'); - git(path.join(root, 'sub_repo1'), 'config', 'user.email', 'test@test.com'); - git(path.join(root, 'sub_repo1'), 'config', 'user.name', 'Test'); - fs.writeFileSync(path.join(sub1, 'one.ts'), 'export const one = 1;'); - git(path.join(root, 'sub_repo1'), 'add', '-A'); - git(path.join(root, 'sub_repo1'), 'commit', '-q', '-m', 'sub1 init'); - - const sub2 = path.join(root, 'sub_repo2', 'src'); - fs.mkdirSync(sub2, { recursive: true }); - git(path.join(root, 'sub_repo2'), 'init', '-q'); - fs.writeFileSync(path.join(sub2, 'two.ts'), 'export const two = 2;'); - - const files = scanDirectory(root); - - // Both committed and untracked source from the nested repos must be found. - expect(files).toContain('sub_repo1/src/one.ts'); - expect(files).toContain('sub_repo2/src/two.ts'); - }); - - it('should respect each embedded repo\'s own .gitignore', async () => { - const { execFileSync } = await import('child_process'); - const git = (cwd: string, ...args: string[]) => - execFileSync('git', args, { cwd, stdio: 'pipe' }); - - const root = path.join(tempDir, 'root'); - fs.mkdirSync(root, { recursive: true }); - git(root, 'init', '-q'); - - const sub = path.join(root, 'sub_repo', 'src'); - fs.mkdirSync(sub, { recursive: true }); - git(path.join(root, 'sub_repo'), 'init', '-q'); - fs.writeFileSync(path.join(root, 'sub_repo', '.gitignore'), 'src/generated.ts\n'); - fs.writeFileSync(path.join(sub, 'real.ts'), 'export const real = 1;'); - fs.writeFileSync(path.join(sub, 'generated.ts'), 'export const generated = 1;'); - - const files = scanDirectory(root); - - expect(files).toContain('sub_repo/src/real.ts'); - expect(files).not.toContain('sub_repo/src/generated.ts'); - }); -}); - -// ============================================================================= -// Scala -// ============================================================================= - -describe('Scala Extraction', () => { - describe('Language detection', () => { - it('should detect Scala files', () => { - expect(detectLanguage('Main.scala')).toBe('scala'); - expect(detectLanguage('script.sc')).toBe('scala'); - expect(detectLanguage('src/UserService.scala')).toBe('scala'); - }); - - it('should report Scala as supported', () => { - expect(isLanguageSupported('scala')).toBe(true); - expect(getSupportedLanguages()).toContain('scala'); - }); - }); - - describe('Class extraction', () => { - it('should extract class definitions', () => { - const code = ` -class UserService(private val repo: UserRepository) { - def findUser(id: String): Option[String] = Some(id) -} -`; - const result = extractFromSource('UserService.scala', code); - const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'UserService'); - expect(cls).toBeDefined(); - expect(cls?.language).toBe('scala'); - }); - - it('should extract object definitions as class kind', () => { - const code = ` -object DatabaseConfig { - val url = "jdbc:postgresql://localhost/mydb" -} -`; - const result = extractFromSource('Config.scala', code); - const obj = result.nodes.find((n) => n.kind === 'class' && n.name === 'DatabaseConfig'); - expect(obj).toBeDefined(); - }); - - it('should extract trait definitions as trait kind', () => { - const code = ` -trait Repository[A] { - def findById(id: String): Option[A] - def save(entity: A): Unit -} -`; - const result = extractFromSource('Repository.scala', code); - const trait_ = result.nodes.find((n) => n.kind === 'trait' && n.name === 'Repository'); - expect(trait_).toBeDefined(); - }); - }); - - describe('Method and function extraction', () => { - it('should extract method definitions inside a class', () => { - const code = ` -class Calculator { - def add(a: Int, b: Int): Int = a + b - def divide(a: Double, b: Double): Double = a / b -} -`; - const result = extractFromSource('Calculator.scala', code); - const methods = result.nodes.filter((n) => n.kind === 'method'); - expect(methods.find((m) => m.name === 'add')).toBeDefined(); - expect(methods.find((m) => m.name === 'divide')).toBeDefined(); - }); - - it('should extract method signatures', () => { - const code = ` -class Greeter { - def greet(name: String): String = s"Hello, \${name}!" -} -`; - const result = extractFromSource('Greeter.scala', code); - const method = result.nodes.find((n) => n.name === 'greet'); - expect(method?.signature).toContain('name: String'); - expect(method?.signature).toContain('String'); - }); - - it('should extract top-level function definitions as functions', () => { - const code = ` -def factorial(n: Int): Int = if (n <= 1) 1 else n * factorial(n - 1) -def greet(name: String): String = s"Hello, \${name}!" -`; - const result = extractFromSource('utils.scala', code); - const fns = result.nodes.filter((n) => n.kind === 'function'); - expect(fns.find((f) => f.name === 'factorial')).toBeDefined(); - expect(fns.find((f) => f.name === 'greet')).toBeDefined(); - }); - }); - - describe('Val and var extraction', () => { - it('should extract val inside a class as field', () => { - const code = ` -class Config { - val timeout: Int = 30 - val host: String = "localhost" -} -`; - const result = extractFromSource('Config.scala', code); - const fields = result.nodes.filter((n) => n.kind === 'field'); - expect(fields.find((f) => f.name === 'timeout')).toBeDefined(); - expect(fields.find((f) => f.name === 'host')).toBeDefined(); - }); - - it('should extract var inside a class as field', () => { - const code = ` -class Counter { - var count: Int = 0 -} -`; - const result = extractFromSource('Counter.scala', code); - const field = result.nodes.find((n) => n.kind === 'field' && n.name === 'count'); - expect(field).toBeDefined(); - }); - - it('should extract top-level val as constant', () => { - const code = ` -val MaxConnections: Int = 100 -val DefaultTimeout = 30 -`; - const result = extractFromSource('constants.scala', code); - const consts = result.nodes.filter((n) => n.kind === 'constant'); - expect(consts.find((c) => c.name === 'MaxConnections')).toBeDefined(); - }); - - it('should extract top-level var as variable', () => { - const code = ` -var retries: Int = 3 -`; - const result = extractFromSource('state.scala', code); - const v = result.nodes.find((n) => n.kind === 'variable' && n.name === 'retries'); - expect(v).toBeDefined(); - }); - - it('should include type in val/var signature', () => { - const code = ` -class Service { - val timeout: Int = 30 -} -`; - const result = extractFromSource('Service.scala', code); - const field = result.nodes.find((n) => n.name === 'timeout'); - expect(field?.signature).toContain('timeout'); - expect(field?.signature).toContain('Int'); - }); - }); - - describe('Enum extraction', () => { - it('should extract enum definitions', () => { - const code = ` -enum Color: - case Red - case Green - case Blue -`; - const result = extractFromSource('Color.scala', code); - const enumNode = result.nodes.find((n) => n.kind === 'enum' && n.name === 'Color'); - expect(enumNode).toBeDefined(); - }); - - it('should extract enum cases as enum_member', () => { - const code = ` -enum Direction: - case North - case South - case East - case West -`; - const result = extractFromSource('Direction.scala', code); - const members = result.nodes.filter((n) => n.kind === 'enum_member'); - expect(members.find((m) => m.name === 'North')).toBeDefined(); - expect(members.find((m) => m.name === 'South')).toBeDefined(); - expect(members.length).toBeGreaterThanOrEqual(4); - }); - }); - - describe('Type alias extraction', () => { - it('should extract type aliases', () => { - const code = ` -type UserId = String -type UserMap = Map[String, String] -`; - const result = extractFromSource('types.scala', code); - const aliases = result.nodes.filter((n) => n.kind === 'type_alias'); - expect(aliases.find((a) => a.name === 'UserId')).toBeDefined(); - expect(aliases.find((a) => a.name === 'UserMap')).toBeDefined(); - }); - }); - - describe('Import extraction', () => { - it('should extract import declarations', () => { - const code = ` -import scala.collection.mutable.ListBuffer -import scala.concurrent.Future -`; - const result = extractFromSource('imports.scala', code); - const imports = result.nodes.filter((n) => n.kind === 'import'); - expect(imports.length).toBeGreaterThanOrEqual(2); - }); - }); - - describe('Visibility modifiers', () => { - it('should extract private visibility', () => { - const code = ` -class Service { - private val secret: String = "abc" - private def helper(): Unit = {} -} -`; - const result = extractFromSource('Service.scala', code); - const secretField = result.nodes.find((n) => n.name === 'secret'); - expect(secretField?.visibility).toBe('private'); - const helperMethod = result.nodes.find((n) => n.name === 'helper'); - expect(helperMethod?.visibility).toBe('private'); - }); - - it('should extract protected visibility', () => { - const code = ` -class Base { - protected def helperMethod(): Unit = {} -} -`; - const result = extractFromSource('Base.scala', code); - const method = result.nodes.find((n) => n.name === 'helperMethod'); - expect(method?.visibility).toBe('protected'); - }); - - it('should default to public visibility', () => { - const code = ` -class Greeter { - def hello(): Unit = {} -} -`; - const result = extractFromSource('Greeter.scala', code); - const method = result.nodes.find((n) => n.name === 'hello'); - expect(method?.visibility).toBe('public'); - }); - }); - - describe('Inheritance', () => { - it('should extract extends relationships', () => { - const code = ` -class AdminUser extends User { - def adminAction(): Unit = {} -} -`; - const result = extractFromSource('AdminUser.scala', code); - const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends'); - expect(extendsRefs.find((r) => r.referenceName === 'User')).toBeDefined(); - }); - }); - - describe('Call extraction', () => { - it('should extract function call expressions', () => { - const code = ` -def processData(): Unit = { - val result = computeResult() - println(result) -} -`; - const result = extractFromSource('processor.scala', code); - const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); - expect(calls.length).toBeGreaterThan(0); - }); - }); -}); - -describe('Vue Extraction', () => { - it('should detect Vue files', () => { - expect(detectLanguage('App.vue')).toBe('vue'); - expect(detectLanguage('components/Button.vue')).toBe('vue'); - expect(isLanguageSupported('vue')).toBe(true); - }); - - it('should extract component node from a Vue SFC', () => { - const code = ` - - -`; - const result = extractFromSource('HelloWorld.vue', code); - - const componentNode = result.nodes.find((n) => n.kind === 'component'); - expect(componentNode).toBeDefined(); - expect(componentNode?.name).toBe('HelloWorld'); - expect(componentNode?.language).toBe('vue'); - expect(componentNode?.isExported).toBe(true); - }); - - it('should extract functions from -`; - const result = extractFromSource('Button.vue', code); - - const componentNode = result.nodes.find((n) => n.kind === 'component'); - expect(componentNode).toBeDefined(); - expect(componentNode?.name).toBe('Button'); - - const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'handleClick'); - expect(funcNode).toBeDefined(); - expect(funcNode?.language).toBe('vue'); - }); - - it('should extract from -`; - const result = extractFromSource('Counter.vue', code); - - const componentNode = result.nodes.find((n) => n.kind === 'component'); - expect(componentNode).toBeDefined(); - expect(componentNode?.name).toBe('Counter'); - - const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'increment'); - expect(funcNode).toBeDefined(); - expect(funcNode?.language).toBe('vue'); - - // All nodes should be marked as vue language - for (const node of result.nodes) { - expect(node.language).toBe('vue'); - } - }); - - it('should extract from both - - -`; - const result = extractFromSource('DualScript.vue', code); - - const componentNode = result.nodes.find((n) => n.kind === 'component'); - expect(componentNode).toBeDefined(); - - const greetFunc = result.nodes.find((n) => n.kind === 'function' && n.name === 'greet'); - expect(greetFunc).toBeDefined(); - }); - - it('should create component node for template-only Vue file', () => { - const code = ` -`; - const result = extractFromSource('Static.vue', code); - - const componentNode = result.nodes.find((n) => n.kind === 'component'); - expect(componentNode).toBeDefined(); - expect(componentNode?.name).toBe('Static'); - expect(componentNode?.language).toBe('vue'); - - // Only the component node should exist (no script nodes) - expect(result.nodes.length).toBe(1); - }); - - it('should create containment edges from component to script nodes', () => { - const code = ` - - -`; - const result = extractFromSource('Contained.vue', code); - - const componentNode = result.nodes.find((n) => n.kind === 'component'); - expect(componentNode).toBeDefined(); - - // Should have containment edges from component to child nodes - const containEdges = result.edges.filter( - (e) => e.source === componentNode!.id && e.kind === 'contains' - ); - expect(containEdges.length).toBeGreaterThan(0); - }); -}); - -describe('Instantiates + Decorates edge extraction', () => { - it('emits an instantiates ref for `new Foo()`', () => { - const code = ` -class Foo {} -function bootstrap() { return new Foo(); } -`; - const result = extractFromSource('app.ts', code); - const ref = result.unresolvedReferences.find( - (r) => r.referenceKind === 'instantiates' && r.referenceName === 'Foo' - ); - expect(ref).toBeDefined(); - }); - - it('strips type-argument suffix from generic constructors', () => { - const code = ` -class Container { constructor(_: T) {} } -function go() { return new Container('x'); } -`; - const result = extractFromSource('app.ts', code); - const ref = result.unresolvedReferences.find( - (r) => r.referenceKind === 'instantiates' - ); - expect(ref).toBeDefined(); - // Container must be normalised to "Container" — otherwise - // resolution can never match the class node. - expect(ref!.referenceName).toBe('Container'); - }); - - it('keeps trailing identifier from qualified `new ns.Foo()`', () => { - const code = ` -const ns = { Foo: class {} }; -function go() { return new ns.Foo(); } -`; - const result = extractFromSource('app.ts', code); - const ref = result.unresolvedReferences.find( - (r) => r.referenceKind === 'instantiates' - ); - // We can't always resolve which Foo, but the name should be the - // simple identifier so name-matching has a chance. - expect(ref?.referenceName).toBe('Foo'); - }); - - it('emits a decorates ref for `@Foo class X {}`', () => { - const code = ` -function Foo(_arg: string) { return (cls: any) => cls; } -@Foo('x') -class X {} -`; - const result = extractFromSource('app.ts', code); - const decorClass = result.unresolvedReferences.find( - (r) => r.referenceKind === 'decorates' && r.referenceName === 'Foo' - ); - expect(decorClass).toBeDefined(); - }); - - it('does NOT attribute a prior class\'s decorator to the next class', () => { - // Regression: the sibling-walk must stop at the first non- - // decorator separator. `@A class Foo {} @B class Bar {}` must - // produce `decorates(Foo, A)` and `decorates(Bar, B)` — never - // `decorates(Bar, A)`. - const code = ` -function A(cls: any) { return cls; } -function B(cls: any) { return cls; } -@A -class Foo {} -@B -class Bar {} -`; - const result = extractFromSource('app.ts', code); - const decoratesEdges = result.unresolvedReferences.filter( - (r) => r.referenceKind === 'decorates' - ); - // Exactly one decorates ref per decorated class, no cross-attribution. - const fromBar = decoratesEdges.filter((r) => - result.nodes.find((n) => n.id === r.fromNodeId && n.name === 'Bar') - ); - expect(fromBar.length).toBe(1); - expect(fromBar[0]!.referenceName).toBe('B'); - }); - - it('emits a decorates ref for `@Foo method() {}`', () => { - const code = ` -function Get(p: string) { return (t: any, k: string) => t; } -class Svc { - @Get('/x') method() { return 1; } -} -`; - const result = extractFromSource('app.ts', code); - const decorMethod = result.unresolvedReferences.find( - (r) => r.referenceKind === 'decorates' && r.referenceName === 'Get' - ); - expect(decorMethod).toBeDefined(); - // The decorated symbol must be `method`, not the constructor or class. - const decoratedNode = result.nodes.find((n) => n.id === decorMethod!.fromNodeId); - expect(decoratedNode?.name).toBe('method'); - }); -}); - -// ============================================================================= -// Lua -// ============================================================================= - -describe('Lua Extraction', () => { - describe('Language detection', () => { - it('should detect Lua files', () => { - expect(detectLanguage('init.lua')).toBe('lua'); - expect(detectLanguage('src/util.lua')).toBe('lua'); - }); - - it('should report Lua as supported', () => { - expect(isLanguageSupported('lua')).toBe(true); - expect(getSupportedLanguages()).toContain('lua'); - }); - }); - - describe('Function extraction', () => { - it('should extract global and local functions', () => { - const code = ` -function configure(opts) return opts end -local function helper(x) return x * 2 end -`; - const result = extractFromSource('init.lua', code); - const funcs = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name); - expect(funcs).toContain('configure'); - expect(funcs).toContain('helper'); - const configure = result.nodes.find((n) => n.name === 'configure'); - expect(configure?.language).toBe('lua'); - expect(configure?.signature).toBe('(opts)'); - }); - - it('should split table/method functions into a receiver and method name', () => { - const code = ` -function M.connect(host, port) return host end -function M:send(data) return self end -`; - const result = extractFromSource('init.lua', code); - const methods = result.nodes.filter((n) => n.kind === 'method'); - const connect = methods.find((m) => m.name === 'connect'); - expect(connect?.qualifiedName).toBe('M::connect'); - const send = methods.find((m) => m.name === 'send'); - expect(send?.qualifiedName).toBe('M::send'); - }); - }); - - describe('Variable extraction', () => { - it('should extract local variable declarations', () => { - const code = ` -local M = {} -local count = 0 -`; - const result = extractFromSource('mod.lua', code); - const vars = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name); - expect(vars).toContain('M'); - expect(vars).toContain('count'); - }); - }); - - describe('Import extraction (require)', () => { - it('should extract require() in local declarations and bare calls', () => { - const code = ` -local socket = require("socket") -local http = require "resty.http" -require("side.effect") -`; - const result = extractFromSource('net.lua', code); - const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name); - expect(imports).toContain('socket'); - expect(imports).toContain('resty.http'); - expect(imports).toContain('side.effect'); - - const ref = result.unresolvedReferences.find( - (r) => r.referenceKind === 'imports' && r.referenceName === 'socket' - ); - expect(ref).toBeDefined(); - }); - - // Regression: the tree-sitter-wasms Lua grammar (ABI 13) corrupts the shared - // WASM heap under web-tree-sitter 0.25, dropping nested calls/imports on every - // parse after the first. We vendor the ABI-15 grammar instead — this guards it - // by extracting several sources in sequence and asserting the LAST still works. - it('should keep extracting require across many sequential parses', () => { - let last; - for (let i = 0; i < 8; i++) { - last = extractFromSource(`f${i}.lua`, `local m = require("module.${i}")\nreturn m\n`); - } - const imports = last!.nodes.filter((n) => n.kind === 'import').map((n) => n.name); - expect(imports).toContain('module.7'); - }); - }); - - describe('Call extraction', () => { - it('should record intra-file calls as resolvable references', () => { - const code = ` -local function helper(x) return x end -local function run(y) return helper(y) end -`; - const result = extractFromSource('calls.lua', code); - const call = result.unresolvedReferences.find( - (r) => r.referenceKind === 'calls' && r.referenceName === 'helper' - ); - expect(call).toBeDefined(); - }); - }); -}); - -// ============================================================================= -// Luau (typed superset of Lua — https://luau.org) -// ============================================================================= - -describe('Luau Extraction', () => { - describe('Language detection', () => { - it('should detect Luau files', () => { - expect(detectLanguage('init.luau')).toBe('luau'); - expect(detectLanguage('src/Client.luau')).toBe('luau'); - }); - - it('should report Luau as supported', () => { - expect(isLanguageSupported('luau')).toBe(true); - expect(getSupportedLanguages()).toContain('luau'); - }); - }); - - describe('Type aliases', () => { - it('should extract `type` and `export type` definitions', () => { - const code = ` -export type Vector = { x: number, y: number } -type Handler = (msg: string) -> boolean -`; - const result = extractFromSource('types.luau', code); - const aliases = result.nodes.filter((n) => n.kind === 'type_alias'); - const vector = aliases.find((a) => a.name === 'Vector'); - expect(vector).toBeDefined(); - expect(vector?.isExported).toBe(true); - const handler = aliases.find((a) => a.name === 'Handler'); - expect(handler).toBeDefined(); - expect(handler?.isExported).toBe(false); - }); - }); - - describe('Typed functions and methods', () => { - it('should capture typed signatures and split methods by receiver', () => { - const code = ` -function configure(opts: { debug: boolean }): boolean - return opts.debug -end -function Client:fetch(path: string): Response - return path -end -`; - const result = extractFromSource('client.luau', code); - const configure = result.nodes.find((n) => n.kind === 'function' && n.name === 'configure'); - expect(configure?.language).toBe('luau'); - expect(configure?.signature).toBe('(opts: { debug: boolean }): boolean'); - const fetch = result.nodes.find((n) => n.kind === 'method' && n.name === 'fetch'); - expect(fetch?.qualifiedName).toBe('Client::fetch'); - }); - }); - - describe('Imports and variables', () => { - it('should extract string and Roblox instance-path require imports', () => { - const code = ` -local http = require("http") -local Signal = require(script.Parent.Signal) -local count = 0 -`; - const result = extractFromSource('mod.luau', code); - const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name); - expect(imports).toContain('http'); // string require - expect(imports).toContain('Signal'); // Roblox instance-path require - const vars = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name); - expect(vars).toContain('count'); - }); - }); -}); diff --git a/__tests__/foundation.test.ts b/__tests__/foundation.test.ts deleted file mode 100644 index 78ebfce4..00000000 --- a/__tests__/foundation.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * Foundation Tests - * - * Tests for the CodeGraph foundation layer. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { CodeGraph } from '../src'; -import { Node, Edge } from '../src/types'; -import { isInitialized, getCodeGraphDir, validateDirectory } from '../src/directory'; -import { DatabaseConnection, getDatabasePath } from '../src/db'; - -// Create a temporary directory for each test -function createTempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-')); -} - -// Clean up temporary directory -function cleanupTempDir(dir: string): void { - if (fs.existsSync(dir)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - -describe('CodeGraph Foundation', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - cleanupTempDir(tempDir); - }); - - describe('Initialization', () => { - it('should initialize a new project', () => { - const cg = CodeGraph.initSync(tempDir); - - expect(CodeGraph.isInitialized(tempDir)).toBe(true); - expect(fs.existsSync(getCodeGraphDir(tempDir))).toBe(true); - expect(fs.existsSync(getDatabasePath(tempDir))).toBe(true); - - cg.close(); - }); - - it('should create .gitignore in .CodeGraph directory', () => { - const cg = CodeGraph.initSync(tempDir); - - const gitignorePath = path.join(getCodeGraphDir(tempDir), '.gitignore'); - expect(fs.existsSync(gitignorePath)).toBe(true); - - const content = fs.readFileSync(gitignorePath, 'utf-8'); - expect(content).toContain('*.db'); - - cg.close(); - }); - - it('should throw if already initialized', () => { - const cg = CodeGraph.initSync(tempDir); - cg.close(); - - expect(() => CodeGraph.initSync(tempDir)).toThrow(/already initialized/i); - }); - }); - - describe('Opening Projects', () => { - it('should open an existing project', () => { - // First initialize - const cg1 = CodeGraph.initSync(tempDir); - cg1.close(); - - // Then open - const cg2 = CodeGraph.openSync(tempDir); - expect(cg2.getProjectRoot()).toBe(path.resolve(tempDir)); - cg2.close(); - }); - - it('should throw if not initialized', () => { - expect(() => CodeGraph.openSync(tempDir)).toThrow(/not initialized/i); - }); - }); - - describe('Static Methods', () => { - it('isInitialized should return false for new directory', () => { - expect(CodeGraph.isInitialized(tempDir)).toBe(false); - }); - - it('isInitialized should return true after init', () => { - const cg = CodeGraph.initSync(tempDir); - expect(CodeGraph.isInitialized(tempDir)).toBe(true); - cg.close(); - }); - }); - - describe('Database', () => { - it('should create database with correct schema', () => { - const cg = CodeGraph.initSync(tempDir); - - // Check that we can get stats (requires tables to exist) - const stats = cg.getStats(); - expect(stats.nodeCount).toBe(0); - expect(stats.edgeCount).toBe(0); - expect(stats.fileCount).toBe(0); - - cg.close(); - }); - - it('should return correct database size', () => { - const cg = CodeGraph.initSync(tempDir); - const stats = cg.getStats(); - - // Database should have some size (at least the schema) - expect(stats.dbSizeBytes).toBeGreaterThan(0); - - cg.close(); - }); - - it('should support optimize operation', () => { - const cg = CodeGraph.initSync(tempDir); - - // Should not throw - expect(() => cg.optimize()).not.toThrow(); - - cg.close(); - }); - - it('should support clear operation', () => { - const cg = CodeGraph.initSync(tempDir); - - // Should not throw - expect(() => cg.clear()).not.toThrow(); - - const stats = cg.getStats(); - expect(stats.nodeCount).toBe(0); - - cg.close(); - }); - }); - - describe('Directory Management', () => { - it('should validate directory structure', () => { - const cg = CodeGraph.initSync(tempDir); - cg.close(); - - const validation = validateDirectory(tempDir); - expect(validation.valid).toBe(true); - expect(validation.errors).toHaveLength(0); - }); - - it('should detect invalid directory', () => { - const validation = validateDirectory(tempDir); - expect(validation.valid).toBe(false); - expect(validation.errors.length).toBeGreaterThan(0); - }); - }); - - describe('Uninitialize', () => { - it('should remove .CodeGraph directory', () => { - const cg = CodeGraph.initSync(tempDir); - - cg.uninitialize(); - - expect(fs.existsSync(getCodeGraphDir(tempDir))).toBe(false); - expect(CodeGraph.isInitialized(tempDir)).toBe(false); - }); - }); - - describe('Close/Destroy', () => { - it('should close database but keep .CodeGraph directory', () => { - const cg = CodeGraph.initSync(tempDir); - - cg.destroy(); // destroy is alias for close - - expect(fs.existsSync(getCodeGraphDir(tempDir))).toBe(true); - expect(CodeGraph.isInitialized(tempDir)).toBe(true); - }); - }); - - describe('Graph Query Methods', () => { - it('should throw "Node not found" for non-existent nodes', () => { - const cg = CodeGraph.initSync(tempDir); - - // getContext throws for non-existent nodes - expect(() => cg.getContext('non-existent')).toThrow(/not found/i); - - cg.close(); - }); - - it('should return empty results for non-existent nodes', () => { - const cg = CodeGraph.initSync(tempDir); - - // These methods return empty results instead of throwing - const traverseResult = cg.traverse('non-existent'); - expect(traverseResult.nodes.size).toBe(0); - - const callGraph = cg.getCallGraph('non-existent'); - expect(callGraph.nodes.size).toBe(0); - - const typeHierarchy = cg.getTypeHierarchy('non-existent'); - expect(typeHierarchy.nodes.size).toBe(0); - - const usages = cg.findUsages('non-existent'); - expect(usages.length).toBe(0); - - cg.close(); - }); - - }); -}); - -describe('Database Connection', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - cleanupTempDir(tempDir); - }); - - it('should initialize new database', () => { - const dbPath = path.join(tempDir, 'test.db'); - const db = DatabaseConnection.initialize(dbPath); - - expect(db.isOpen()).toBe(true); - expect(fs.existsSync(dbPath)).toBe(true); - - db.close(); - }); - - it('should get schema version', () => { - const dbPath = path.join(tempDir, 'test.db'); - const db = DatabaseConnection.initialize(dbPath); - - const version = db.getSchemaVersion(); - expect(version).not.toBeNull(); - expect(version?.version).toBe(4); - - db.close(); - }); - - it('should support transactions', () => { - const dbPath = path.join(tempDir, 'test.db'); - const db = DatabaseConnection.initialize(dbPath); - - const result = db.transaction(() => { - return 42; - }); - - expect(result).toBe(42); - - db.close(); - }); - - it('should throw when opening non-existent database', () => { - const dbPath = path.join(tempDir, 'nonexistent.db'); - - expect(() => DatabaseConnection.open(dbPath)).toThrow(/not found/i); - }); -}); - -describe('Query Builder', () => { - let tempDir: string; - let cg: CodeGraph; - - beforeEach(() => { - tempDir = createTempDir(); - cg = CodeGraph.initSync(tempDir); - }); - - afterEach(() => { - cg.close(); - cleanupTempDir(tempDir); - }); - - it('should return null for non-existent node', () => { - const node = cg.getNode('nonexistent'); - expect(node).toBeNull(); - }); - - it('should return empty array for nodes in non-existent file', () => { - const nodes = cg.getNodesInFile('nonexistent.ts'); - expect(nodes).toEqual([]); - }); - - it('should return empty array for edges from non-existent node', () => { - const edges = cg.getOutgoingEdges('nonexistent'); - expect(edges).toEqual([]); - }); - - it('should return null for non-existent file', () => { - const file = cg.getFile('nonexistent.ts'); - expect(file).toBeNull(); - }); - - it('should return empty array for files when none tracked', () => { - const files = cg.getFiles(); - expect(files).toEqual([]); - }); -}); diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts deleted file mode 100644 index b64e8c66..00000000 --- a/__tests__/frameworks-integration.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, it, expect, beforeAll, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { CodeGraph } from '../src'; -import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; - -beforeAll(async () => { - await initGrammars(); - await loadAllGrammars(); -}); - -describe('Django end-to-end framework extraction', () => { - let tmpDir: string | undefined; - afterEach(() => { - if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); - tmpDir = undefined; - }); - - it('creates a route->view edge from urls.py to view class', async () => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-django-')); - fs.writeFileSync(path.join(tmpDir, 'manage.py'), '# marker\n'); - fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'django==4.2\n'); - fs.mkdirSync(path.join(tmpDir, 'users')); - fs.writeFileSync(path.join(tmpDir, 'users/__init__.py'), ''); - fs.writeFileSync( - path.join(tmpDir, 'users/views.py'), - 'class UserListView:\n def get(self, request): pass\n' - ); - fs.writeFileSync( - path.join(tmpDir, 'users/urls.py'), - 'from django.urls import path\n' + - 'from users.views import UserListView\n' + - 'urlpatterns = [path("users/", UserListView.as_view(), name="user-list")]\n' - ); - - const cg = CodeGraph.initSync(tmpDir); - await cg.indexAll(); - - // Route node exists - const routes = cg.getNodesByKind('route'); - expect(routes.length).toBeGreaterThan(0); - const route = routes.find((n) => n.name === 'users/'); - expect(route).toBeDefined(); - - // View class exists - const classNodes = cg.getNodesByKind('class'); - const view = classNodes.find((n) => n.name === 'UserListView'); - expect(view).toBeDefined(); - - // Edge route -> view exists - const edges = cg.getOutgoingEdges(route!.id); - const toView = edges.find((e) => e.target === view!.id); - expect(toView).toBeDefined(); - expect(toView!.kind).toBe('references'); - - cg.close(); - }); -}); diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts deleted file mode 100644 index a5e5c56b..00000000 --- a/__tests__/frameworks.test.ts +++ /dev/null @@ -1,1069 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import type { FrameworkResolver, UnresolvedRef } from '../src/resolution/types'; -import type { Node } from '../src/types'; - -describe('FrameworkResolver.extract interface', () => { - it('extract() returns { nodes, references }', () => { - const resolver: FrameworkResolver = { - name: 'fake', - detect: () => true, - resolve: () => null, - languages: ['python'], - extract: (_filePath: string, _content: string) => ({ - nodes: [] as Node[], - references: [] as UnresolvedRef[], - }), - }; - const result = resolver.extract!('foo.py', ''); - expect(result).toEqual({ nodes: [], references: [] }); - }); -}); - -import { getApplicableFrameworks } from '../src/resolution/frameworks'; -import type { FrameworkResolver } from '../src/resolution/types'; - -describe('getApplicableFrameworks', () => { - const pyFw: FrameworkResolver = { name: 'py', languages: ['python'], detect: () => true, resolve: () => null }; - const jsFw: FrameworkResolver = { name: 'js', languages: ['javascript', 'typescript'], detect: () => true, resolve: () => null }; - const anyFw: FrameworkResolver = { name: 'any', detect: () => true, resolve: () => null }; - - it('filters by language', () => { - const result = getApplicableFrameworks([pyFw, jsFw, anyFw], 'python'); - expect(result.map(r => r.name)).toEqual(['py', 'any']); - }); - - it('returns anyFw-only when language has no matches', () => { - const result = getApplicableFrameworks([pyFw, jsFw, anyFw], 'rust'); - expect(result.map(r => r.name)).toEqual(['any']); - }); -}); - -import { djangoResolver } from '../src/resolution/frameworks/python'; - -describe('djangoResolver.extract', () => { - it('extracts route node and reference for path() with CBV.as_view()', () => { - const src = ` -from django.urls import path -from users.views import UserListView - -urlpatterns = [ - path('users/', UserListView.as_view(), name='user-list'), -] -`; - const { nodes, references } = djangoResolver.extract!('users/urls.py', src); - expect(nodes).toHaveLength(1); - expect(nodes[0].kind).toBe('route'); - expect(nodes[0].name).toBe('users/'); - expect(references).toHaveLength(1); - expect(references[0].referenceName).toBe('UserListView'); - expect(references[0].referenceKind).toBe('references'); - expect(references[0].fromNodeId).toBe(nodes[0].id); - }); - - it('extracts route for path() with dotted module.Class.as_view()', () => { - const src = `from django.urls import path\nfrom api.v1 import views as api_v1_views\nurlpatterns = [path('api/', api_v1_views.UserListView.as_view())]\n`; - const { nodes, references } = djangoResolver.extract!('api/urls.py', src); - expect(nodes).toHaveLength(1); - expect(references[0].referenceName).toBe('UserListView'); - }); - - it('extracts route for path() with bare function view', () => { - const src = `from django.urls import path\nurlpatterns = [path('home/', home_view, name='home')]\n`; - const { nodes, references } = djangoResolver.extract!('home/urls.py', src); - expect(references[0].referenceName).toBe('home_view'); - }); - - it('extracts route for path() with include()', () => { - const src = `from django.urls import path, include\nurlpatterns = [path('api/', include('api.urls'))]\n`; - const { nodes, references } = djangoResolver.extract!('root/urls.py', src); - expect(nodes).toHaveLength(1); - expect(nodes[0].kind).toBe('route'); - expect(references[0].referenceName).toBe('api.urls'); - expect(references[0].referenceKind).toBe('imports'); - }); - - it('extracts routes for re_path and url', () => { - const src = `from django.urls import re_path, url\nurlpatterns = [re_path(r'^users/$', UserView), url(r'^old/$', OldView)]\n`; - const { nodes } = djangoResolver.extract!('legacy/urls.py', src); - expect(nodes).toHaveLength(2); - expect(nodes.map(n => n.name)).toEqual(['^users/$', '^old/$']); - }); - - it('returns empty result for a non-urls.py python file', () => { - const src = `def foo(): return 1\n`; - const { nodes, references } = djangoResolver.extract!('views.py', src); - expect(nodes).toEqual([]); - expect(references).toEqual([]); - }); -}); - -import { flaskResolver, fastapiResolver } from '../src/resolution/frameworks/python'; - -describe('flaskResolver.extract', () => { - it('extracts route and reference from @app.route', () => { - const src = ` -@app.route('/users') -def list_users(): - return [] -`; - const { nodes, references } = flaskResolver.extract!('app.py', src); - expect(nodes).toHaveLength(1); - expect(nodes[0].kind).toBe('route'); - expect(nodes[0].name).toBe('GET /users'); - expect(references[0].referenceName).toBe('list_users'); - }); - - it('extracts blueprint routes', () => { - const src = ` -@users_bp.route('/', methods=['POST']) -def create_user(id): - pass -`; - const { nodes, references } = flaskResolver.extract!('routes.py', src); - expect(nodes[0].name).toBe('POST /'); - expect(references[0].referenceName).toBe('create_user'); - }); -}); - -describe('fastapiResolver.extract', () => { - it('extracts route and reference from @app.get', () => { - const src = ` -@app.get('/users') -async def list_users(): - return [] -`; - const { nodes, references } = fastapiResolver.extract!('main.py', src); - expect(nodes[0].name).toBe('GET /users'); - expect(references[0].referenceName).toBe('list_users'); - }); - - it('extracts route from router.post', () => { - const src = ` -@router.post('/items') -def create_item(item: Item): - pass -`; - const { nodes, references } = fastapiResolver.extract!('items.py', src); - expect(nodes[0].name).toBe('POST /items'); - expect(references[0].referenceName).toBe('create_item'); - }); -}); - -import { expressResolver } from '../src/resolution/frameworks/express'; - -describe('expressResolver.extract', () => { - it('extracts route with inline handler reference', () => { - const src = `app.get('/users', listUsers);\n`; - const { nodes, references } = expressResolver.extract!('routes.ts', src); - expect(nodes).toHaveLength(1); - expect(nodes[0].name).toBe('GET /users'); - expect(references[0].referenceName).toBe('listUsers'); - }); - - it('extracts route with router.post and middleware chain', () => { - const src = `router.post('/items', auth, createItem);\n`; - const { nodes, references } = expressResolver.extract!('items.ts', src); - expect(nodes[0].name).toBe('POST /items'); - // Multiple handlers: prefer the LAST one (convention: middleware first, handler last) - expect(references[0].referenceName).toBe('createItem'); - }); - - it('extracts route with controller method reference', () => { - const src = `app.get('/x', userController.list);\n`; - const { nodes, references } = expressResolver.extract!('routes.ts', src); - expect(references[0].referenceName).toBe('list'); - }); -}); - -import { nestjsResolver } from '../src/resolution/frameworks/nestjs'; - -describe('nestjsResolver.extract — HTTP', () => { - it('joins @Controller prefix with @Get and links the handler', () => { - const src = ` -@Controller('users') -export class UsersController { - @Get() - findAll() { return []; } -} -`; - const { nodes, references } = nestjsResolver.extract!('users.controller.ts', src); - expect(nodes).toHaveLength(1); - expect(nodes[0].kind).toBe('route'); - expect(nodes[0].name).toBe('GET /users'); - expect(references[0].referenceName).toBe('findAll'); - expect(references[0].referenceKind).toBe('references'); - expect(references[0].fromNodeId).toBe(nodes[0].id); - }); - - it('joins controller prefix with a method-level path param', () => { - const src = ` -@Controller('cats') -export class CatsController { - @Get(':id') - findOne(@Param('id') id: string) { return id; } -} -`; - const { nodes, references } = nestjsResolver.extract!('cats.controller.ts', src); - expect(nodes[0].name).toBe('GET /cats/:id'); - expect(references[0].referenceName).toBe('findOne'); - }); - - it('handles an empty @Controller() and empty @Post()', () => { - const src = ` -@Controller() -export class AppController { - @Post() - create() {} -} -`; - const { nodes, references } = nestjsResolver.extract!('app.controller.ts', src); - expect(nodes[0].name).toBe('POST /'); - expect(references[0].referenceName).toBe('create'); - }); - - it('covers HTTP verbs and skips intervening method decorators', () => { - const src = ` -@Controller('todos') -export class TodosController { - @Put(':id') - @UseGuards(AuthGuard) - update(@Param('id') id: string) {} - - @Delete(':id') - async remove(@Param('id') id: string) {} -} -`; - const { nodes, references } = nestjsResolver.extract!('todos.controller.ts', src); - expect(nodes.map((n) => n.name)).toEqual(['PUT /todos/:id', 'DELETE /todos/:id']); - expect(references.map((r) => r.referenceName)).toEqual(['update', 'remove']); - }); - - it('attributes methods to the right controller when a file has two', () => { - const src = ` -@Controller('a') -export class AController { - @Get('x') - ax() {} -} - -@Controller('b') -export class BController { - @Get('y') - by() {} -} -`; - const { nodes } = nestjsResolver.extract!('multi.controller.ts', src); - expect(nodes.map((n) => n.name)).toEqual(['GET /a/x', 'GET /b/y']); - }); -}); - -describe('nestjsResolver.extract — GraphQL', () => { - it('emits QUERY/MUTATION nodes from a resolver, defaulting to the method name', () => { - const src = ` -@Resolver(() => User) -export class UsersResolver { - @Query(() => [User]) - users() { return []; } - - @Mutation(() => User) - createUser(@Args('input') input: CreateUserInput) {} -} -`; - const { nodes, references } = nestjsResolver.extract!('users.resolver.ts', src); - expect(nodes.map((n) => n.name)).toEqual(['QUERY users', 'MUTATION createUser']); - expect(references.map((r) => r.referenceName)).toEqual(['users', 'createUser']); - }); - - it('uses an explicit operation name when given', () => { - const src = ` -@Resolver() -export class CatsResolver { - @Query(() => Cat, { name: 'cat' }) - getCat() {} -} -`; - const { nodes } = nestjsResolver.extract!('cats.resolver.ts', src); - expect(nodes[0].name).toBe('QUERY cat'); - }); - - it('does NOT treat the REST @Query() parameter decorator as a GraphQL op', () => { - const src = ` -@Controller('search') -export class SearchController { - @Get() - search(@Query() query: SearchDto) { return query; } -} -`; - const { nodes } = nestjsResolver.extract!('search.controller.ts', src); - // Only the HTTP route — the @Query() param decorator must be ignored. - expect(nodes.map((n) => n.name)).toEqual(['GET /search']); - }); -}); - -describe('nestjsResolver.extract — microservices & websockets', () => { - it('extracts @MessagePattern and @EventPattern handlers', () => { - const src = ` -@Controller() -export class MathController { - @MessagePattern({ cmd: 'sum' }) - accumulate(data: number[]) {} - - @EventPattern('user.created') - handleUserCreated(data: any) {} -} -`; - const { nodes, references } = nestjsResolver.extract!('math.controller.ts', src); - expect(nodes.map((n) => n.name)).toEqual(['MESSAGE sum', 'EVENT user.created']); - expect(references.map((r) => r.referenceName)).toEqual(['accumulate', 'handleUserCreated']); - }); - - it('extracts @SubscribeMessage handlers with the gateway namespace', () => { - const src = ` -@WebSocketGateway({ namespace: 'chat' }) -export class ChatGateway { - @SubscribeMessage('message') - handleMessage(@MessageBody() data: string) {} -} -`; - const { nodes, references } = nestjsResolver.extract!('chat.gateway.ts', src); - expect(nodes[0].name).toBe('WS chat:message'); - expect(references[0].referenceName).toBe('handleMessage'); - }); - - it('extracts @SubscribeMessage without a namespace', () => { - const src = ` -@WebSocketGateway() -export class EventsGateway { - @SubscribeMessage('events') - onEvent() {} -} -`; - const { nodes } = nestjsResolver.extract!('events.gateway.ts', src); - expect(nodes[0].name).toBe('WS events'); - }); - - it('returns empty for a non-JS/TS file', () => { - const { nodes, references } = nestjsResolver.extract!('thing.py', '@Controller("x")'); - expect(nodes).toEqual([]); - expect(references).toEqual([]); - }); -}); - -describe('nestjsResolver.detect', () => { - const baseContext = { - getNodesInFile: () => [], - getNodesByName: () => [], - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: () => false, - getProjectRoot: () => '/test', - getAllFiles: () => [], - getNodesByLowerName: () => [], - getImportMappings: () => [], - }; - - it('detects @nestjs/* in package.json', () => { - const context = { - ...baseContext, - readFile: (p: string) => - p === 'package.json' - ? JSON.stringify({ dependencies: { '@nestjs/common': '^10.0.0' } }) - : null, - }; - expect(nestjsResolver.detect(context as any)).toBe(true); - }); - - it('detects @Controller in a *.controller.ts file when package.json is absent', () => { - const context = { - ...baseContext, - getAllFiles: () => ['src/users.controller.ts'], - readFile: (p: string) => - p === 'src/users.controller.ts' - ? `@Controller('users')\nexport class UsersController {}` - : null, - }; - expect(nestjsResolver.detect(context as any)).toBe(true); - }); - - it('returns false for a non-Nest project', () => { - const context = { - ...baseContext, - readFile: (p: string) => - p === 'package.json' ? JSON.stringify({ dependencies: { express: '^4' } }) : null, - }; - expect(nestjsResolver.detect(context as any)).toBe(false); - }); -}); - -describe('nestjsResolver.resolve', () => { - const baseContext = { - getNodesInFile: () => [], - getNodesByName: () => [], - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: () => false, - readFile: () => null, - getProjectRoot: () => '/test', - getAllFiles: () => [], - getNodesByLowerName: () => [], - getImportMappings: () => [], - }; - - it('resolves an injected *Service reference to the class in a *.service.ts file', () => { - const svcNode: Node = { - id: 'class:src/users/users.service.ts:UsersService:3', - kind: 'class', - name: 'UsersService', - qualifiedName: 'src/users/users.service.ts::UsersService', - filePath: 'src/users/users.service.ts', - language: 'typescript', - startLine: 3, - endLine: 3, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }; - const context = { - ...baseContext, - getNodesByName: (n: string) => (n === 'UsersService' ? [svcNode] : []), - }; - const ref = { - fromNodeId: 'class:src/users/users.controller.ts:UsersController:5', - referenceName: 'UsersService', - referenceKind: 'references' as const, - line: 6, - column: 4, - filePath: 'src/users/users.controller.ts', - language: 'typescript' as const, - }; - const result = nestjsResolver.resolve(ref, context as any); - expect(result?.targetNodeId).toBe(svcNode.id); - expect(result?.resolvedBy).toBe('framework'); - expect(result?.confidence).toBeGreaterThanOrEqual(0.85); - }); - - it('returns null for a name without a provider suffix', () => { - const ref = { - fromNodeId: 'x', - referenceName: 'doThing', - referenceKind: 'references' as const, - line: 1, - column: 1, - filePath: 'a.ts', - language: 'typescript' as const, - }; - expect(nestjsResolver.resolve(ref, baseContext as any)).toBeNull(); - }); -}); - -import { laravelResolver } from '../src/resolution/frameworks/laravel'; - -describe('laravelResolver.extract', () => { - it('extracts route with controller tuple syntax', () => { - const src = `Route::get('/users', [UserController::class, 'index']);\n`; - const { nodes, references } = laravelResolver.extract!('routes/web.php', src); - expect(nodes[0].name).toBe('GET /users'); - expect(references[0].referenceName).toBe('index'); - }); - - it('extracts route with Controller@action syntax', () => { - const src = `Route::post('/users', 'UserController@store');\n`; - const { nodes, references } = laravelResolver.extract!('routes/web.php', src); - expect(references[0].referenceName).toBe('store'); - }); - - it('extracts resource route', () => { - const src = `Route::resource('users', UserController::class);\n`; - const { nodes, references } = laravelResolver.extract!('routes/web.php', src); - expect(nodes[0].kind).toBe('route'); - expect(references[0].referenceName).toBe('UserController'); - }); -}); - -import { railsResolver } from '../src/resolution/frameworks/ruby'; - -describe('railsResolver.extract', () => { - it('extracts route with controller#action syntax', () => { - const src = `get '/users', to: 'users#index'\n`; - const { nodes, references } = railsResolver.extract!('config/routes.rb', src); - expect(nodes[0].name).toBe('GET /users'); - expect(references[0].referenceName).toBe('index'); - }); - - it('extracts route without to: keyword', () => { - const src = `post '/items' => 'items#create'\n`; - const { nodes, references } = railsResolver.extract!('config/routes.rb', src); - expect(references[0].referenceName).toBe('create'); - }); -}); - -import { springResolver } from '../src/resolution/frameworks/java'; - -describe('springResolver.extract', () => { - it('extracts route with @GetMapping and next method', () => { - const src = ` -@GetMapping("/users") -public List listUsers() { - return users; -} -`; - const { nodes, references } = springResolver.extract!('UserController.java', src); - expect(nodes[0].name).toBe('GET /users'); - expect(references[0].referenceName).toBe('listUsers'); - }); -}); - -import { goResolver } from '../src/resolution/frameworks/go'; - -describe('goResolver.extract', () => { - it('extracts route from r.GET', () => { - const src = `r.GET("/users", listUsers)\n`; - const { nodes, references } = goResolver.extract!('main.go', src); - expect(nodes[0].name).toBe('GET /users'); - expect(references[0].referenceName).toBe('listUsers'); - }); - - it('extracts route from router.HandleFunc', () => { - const src = `router.HandleFunc("/items", createItem)\n`; - const { nodes, references } = goResolver.extract!('main.go', src); - expect(references[0].referenceName).toBe('createItem'); - }); -}); - -import { rustResolver } from '../src/resolution/frameworks/rust'; - -describe('rustResolver.extract', () => { - it('extracts route from axum .route with get()', () => { - const src = `let app = Router::new().route("/users", get(list_users));\n`; - const { nodes, references } = rustResolver.extract!('main.rs', src); - expect(nodes[0].name).toBe('GET /users'); - expect(references[0].referenceName).toBe('list_users'); - }); -}); - -describe('rustResolver.resolve cargo workspace crates', () => { - it('resolves crate name from workspace member lib.rs', () => { - const workspaceCargo = ` -[workspace] -members = ["crates/mytool-core", "crates/mytool-fetcher"] -`; - const coreCargo = ` -[package] -name = "mytool-core" -version = "0.1.0" -`; - const libNode: Node = { - id: 'module:crates/mytool-core/src/lib.rs:mytool_core:1', - kind: 'module', - name: 'mytool_core', - qualifiedName: 'crates/mytool-core/src/lib.rs::mytool_core', - filePath: 'crates/mytool-core/src/lib.rs', - language: 'rust', - startLine: 1, - endLine: 1, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }; - - const context = { - getNodesInFile: (fp: string) => (fp === 'crates/mytool-core/src/lib.rs' ? [libNode] : []), - getNodesByName: () => [], - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: (p: string) => ( - p === 'Cargo.toml' || - p === 'crates/mytool-core/Cargo.toml' || - p === 'crates/mytool-core/src/lib.rs' - ), - readFile: (p: string) => { - if (p === 'Cargo.toml') return workspaceCargo; - if (p === 'crates/mytool-core/Cargo.toml') return coreCargo; - return null; - }, - getProjectRoot: () => '/test', - getAllFiles: () => [ - 'Cargo.toml', - 'crates/mytool-core/Cargo.toml', - 'crates/mytool-core/src/lib.rs', - ], - getNodesByLowerName: () => [], - getImportMappings: () => [], - }; - - const ref = { - fromNodeId: 'fn:crates/mytool-fetcher/src/main.rs:main:1', - referenceName: 'mytool_core', - referenceKind: 'references' as const, - line: 1, - column: 1, - filePath: 'crates/mytool-fetcher/src/main.rs', - language: 'rust' as const, - }; - - const result = rustResolver.resolve(ref, context); - expect(result?.targetNodeId).toBe(libNode.id); - expect(result?.resolvedBy).toBe('framework'); - // Workspace-manifest hits are unambiguous and must beat name-matcher's - // self-file matches (0.7) so cross-crate `imports` edges materialize. - expect(result?.confidence).toBeGreaterThanOrEqual(0.9); - }); - - it('resolves crate name from workspace member main.rs when lib.rs is absent', () => { - const workspaceCargo = ` -[workspace] -members = [ - "crates/mytool-runner", -] -`; - const runnerCargo = ` -[package] -name = "mytool-runner" -version = "0.1.0" -`; - const mainNode: Node = { - id: 'module:crates/mytool-runner/src/main.rs:mytool_runner:1', - kind: 'module', - name: 'mytool_runner', - qualifiedName: 'crates/mytool-runner/src/main.rs::mytool_runner', - filePath: 'crates/mytool-runner/src/main.rs', - language: 'rust', - startLine: 1, - endLine: 1, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }; - - const context = { - getNodesInFile: (fp: string) => (fp === 'crates/mytool-runner/src/main.rs' ? [mainNode] : []), - getNodesByName: () => [], - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: (p: string) => ( - p === 'Cargo.toml' || - p === 'crates/mytool-runner/Cargo.toml' || - p === 'crates/mytool-runner/src/main.rs' - ), - readFile: (p: string) => { - if (p === 'Cargo.toml') return workspaceCargo; - if (p === 'crates/mytool-runner/Cargo.toml') return runnerCargo; - return null; - }, - getProjectRoot: () => '/test', - getAllFiles: () => [ - 'Cargo.toml', - 'crates/mytool-runner/Cargo.toml', - 'crates/mytool-runner/src/main.rs', - ], - getNodesByLowerName: () => [], - getImportMappings: () => [], - }; - - const ref = { - fromNodeId: 'fn:crates/mytool-runner/src/main.rs:main:1', - referenceName: 'mytool_runner', - referenceKind: 'references' as const, - line: 1, - column: 1, - filePath: 'crates/mytool-runner/src/main.rs', - language: 'rust' as const, - }; - - const result = rustResolver.resolve(ref, context); - expect(result?.targetNodeId).toBe(mainNode.id); - expect(result?.resolvedBy).toBe('framework'); - }); - - it('resolves crate name when members uses a glob (crates/*)', () => { - const workspaceCargo = ` -[workspace] -members = ["crates/*"] -`; - const fooCargo = ` -[package] -name = "mytool-foo" -version = "0.1.0" -`; - const barCargo = ` -[package] -name = "mytool-bar" -version = "0.1.0" -`; - const fooLib: Node = { - id: 'module:crates/mytool-foo/src/lib.rs:mytool_foo:1', - kind: 'module', - name: 'mytool_foo', - qualifiedName: 'crates/mytool-foo/src/lib.rs::mytool_foo', - filePath: 'crates/mytool-foo/src/lib.rs', - language: 'rust', - startLine: 1, - endLine: 1, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }; - const barLib: Node = { - id: 'module:crates/mytool-bar/src/lib.rs:mytool_bar:1', - kind: 'module', - name: 'mytool_bar', - qualifiedName: 'crates/mytool-bar/src/lib.rs::mytool_bar', - filePath: 'crates/mytool-bar/src/lib.rs', - language: 'rust', - startLine: 1, - endLine: 1, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }; - - const filesByPath: Record = { - 'Cargo.toml': workspaceCargo, - 'crates/mytool-foo/Cargo.toml': fooCargo, - 'crates/mytool-bar/Cargo.toml': barCargo, - }; - const nodesByFile: Record = { - 'crates/mytool-foo/src/lib.rs': [fooLib], - 'crates/mytool-bar/src/lib.rs': [barLib], - }; - const dirsByPath: Record = { - '.': ['crates'], - crates: ['mytool-foo', 'mytool-bar'], - 'crates/mytool-foo': ['src'], - 'crates/mytool-bar': ['src'], - }; - - const context = { - getNodesInFile: (fp: string) => nodesByFile[fp] ?? [], - getNodesByName: () => [], - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: (p: string) => ( - Object.prototype.hasOwnProperty.call(filesByPath, p) || - Object.prototype.hasOwnProperty.call(nodesByFile, p) - ), - readFile: (p: string) => filesByPath[p] ?? null, - getProjectRoot: () => '/test', - getAllFiles: () => [ - 'Cargo.toml', - ...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'), - ...Object.keys(nodesByFile), - ], - getNodesByLowerName: () => [], - getImportMappings: () => [], - listDirectories: (rel: string) => dirsByPath[rel] ?? [], - }; - - const fooRef = { - fromNodeId: 'fn:crates/mytool-bar/src/lib.rs:other:1', - referenceName: 'mytool_foo', - referenceKind: 'references' as const, - line: 1, - column: 1, - filePath: 'crates/mytool-bar/src/lib.rs', - language: 'rust' as const, - }; - const barRef = { - fromNodeId: 'fn:crates/mytool-foo/src/lib.rs:other:1', - referenceName: 'mytool_bar', - referenceKind: 'references' as const, - line: 1, - column: 1, - filePath: 'crates/mytool-foo/src/lib.rs', - language: 'rust' as const, - }; - - expect(rustResolver.resolve(fooRef, context)?.targetNodeId).toBe(fooLib.id); - expect(rustResolver.resolve(barRef, context)?.targetNodeId).toBe(barLib.id); - }); - - it('resolves crate name when members uses a name glob at root (helix-*)', () => { - const workspaceCargo = ` -[workspace] -members = ["helix-*"] -`; - const coreCargo = ` -[package] -name = "helix-core" -version = "0.1.0" -`; - const coreLib: Node = { - id: 'module:helix-core/src/lib.rs:helix_core:1', - kind: 'module', - name: 'helix_core', - qualifiedName: 'helix-core/src/lib.rs::helix_core', - filePath: 'helix-core/src/lib.rs', - language: 'rust', - startLine: 1, - endLine: 1, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }; - - const filesByPath: Record = { - 'Cargo.toml': workspaceCargo, - 'helix-core/Cargo.toml': coreCargo, - }; - const nodesByFile: Record = { - 'helix-core/src/lib.rs': [coreLib], - }; - const dirsByPath: Record = { - '.': ['helix-core', 'docs', 'target'], - 'helix-core': ['src'], - }; - - const context = { - getNodesInFile: (fp: string) => nodesByFile[fp] ?? [], - getNodesByName: () => [], - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: (p: string) => ( - Object.prototype.hasOwnProperty.call(filesByPath, p) || - Object.prototype.hasOwnProperty.call(nodesByFile, p) - ), - readFile: (p: string) => filesByPath[p] ?? null, - getProjectRoot: () => '/test', - getAllFiles: () => [ - 'Cargo.toml', - ...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'), - ...Object.keys(nodesByFile), - ], - getNodesByLowerName: () => [], - getImportMappings: () => [], - listDirectories: (rel: string) => dirsByPath[rel] ?? [], - }; - - const ref = { - fromNodeId: 'fn:helix-core/src/lib.rs:other:1', - referenceName: 'helix_core', - referenceKind: 'references' as const, - line: 1, - column: 1, - filePath: 'helix-core/src/lib.rs', - language: 'rust' as const, - }; - - expect(rustResolver.resolve(ref, context)?.targetNodeId).toBe(coreLib.id); - }); -}); - -import { aspnetResolver } from '../src/resolution/frameworks/csharp'; - -describe('aspnetResolver.extract', () => { - it('extracts route from [HttpGet] attribute', () => { - const src = ` -[HttpGet("/users")] -public IActionResult ListUsers() -{ - return Ok(); -} -`; - const { nodes, references } = aspnetResolver.extract!('UserController.cs', src); - expect(nodes[0].name).toBe('GET /users'); - expect(references[0].referenceName).toBe('ListUsers'); - }); -}); - -import { vaporResolver } from '../src/resolution/frameworks/swift'; - -describe('vaporResolver.extract', () => { - it('extracts route from app.get with use:', () => { - const src = `app.get("users", use: listUsers)\n`; - const { nodes, references } = vaporResolver.extract!('routes.swift', src); - expect(nodes[0].name).toBe('GET users'); - expect(references[0].referenceName).toBe('listUsers'); - }); -}); - -import { reactResolver } from '../src/resolution/frameworks/react'; -import { svelteResolver } from '../src/resolution/frameworks/svelte'; - -describe('reactResolver.extract (smoke)', () => { - it('returns { nodes, references } shape', () => { - const src = `}/>`; - const result = reactResolver.extract!('App.tsx', src); - expect(result).toHaveProperty('nodes'); - expect(result).toHaveProperty('references'); - expect(Array.isArray(result.nodes)).toBe(true); - expect(Array.isArray(result.references)).toBe(true); - }); -}); - -describe('svelteResolver.extract (smoke)', () => { - it('returns { nodes, references } shape', () => { - const result = svelteResolver.extract!('+page.svelte', ''); - expect(result).toHaveProperty('nodes'); - expect(result).toHaveProperty('references'); - }); -}); - -// Regression tests: commented-out and docstring route examples must NOT -// surface as phantom route nodes. These would have failed before the -// strip-comments wiring (the regex would happily scan comments/docstrings). -describe('framework extractors ignore commented-out routes', () => { - it('django: skips line-comment and docstring routes', () => { - const src = ` -# urls.py example: -# path('/admin/', AdminPanel.as_view()) -""" -Other routing example: - path('/users/', UserListView.as_view()) -""" -urlpatterns = [path('/real/', RealView.as_view())] -`; - const result = djangoResolver.extract!('app/urls.py', src); - const urls = result.nodes.map((n) => n.name); - expect(urls).toEqual(['/real/']); - }); - - it('flask: skips commented-out @app.route', () => { - const src = ` -# @app.route('/fake') -# def fake_view(): -# return '' - -@app.route('/real') -def real_view(): - return '' -`; - const { nodes, references } = flaskResolver.extract!('app.py', src); - expect(nodes.map((n) => n.name)).toEqual(['GET /real']); - expect(references.map((r) => r.referenceName)).toEqual(['real_view']); - }); - - it('fastapi: skips docstring example routes', () => { - const src = ` -""" -Example: - @app.get('/in-docstring') - async def doc(): - pass -""" -@app.get('/real') -async def real_handler(): - return {} -`; - const { nodes, references } = fastapiResolver.extract!('main.py', src); - expect(nodes.map((n) => n.name)).toEqual(['GET /real']); - expect(references.map((r) => r.referenceName)).toEqual(['real_handler']); - }); - - it('express: skips // and /* */ commented routes', () => { - const src = ` -// app.get('/fake', fakeHandler); -/* router.post('/also-fake', otherHandler); */ -app.get('/real', realHandler); -`; - const { nodes, references } = expressResolver.extract!('routes.ts', src); - expect(nodes.map((n) => n.name)).toEqual(['GET /real']); - expect(references.map((r) => r.referenceName)).toEqual(['realHandler']); - }); - - it('laravel: skips // # and /* */ commented Route::* calls', () => { - const src = ` n.name)).toEqual(['GET /real']); - expect(references.map((r) => r.referenceName)).toEqual(['index']); - }); - - it('rails: skips =begin/=end and # commented routes', () => { - const src = ` -# get '/fake', to: 'fake#index' -=begin -get '/also-fake', to: 'fake#show' -=end -get '/real', to: 'real#index' -`; - const { nodes, references } = railsResolver.extract!('config/routes.rb', src); - expect(nodes.map((n) => n.name)).toEqual(['GET /real']); - expect(references.map((r) => r.referenceName)).toEqual(['index']); - }); - - it('spring: skips // and /* */ commented @GetMapping', () => { - const src = ` -// @GetMapping("/fake") -// public List fake() { return null; } - -/* @PostMapping("/also-fake") - public void alsoFake() {} */ - -@GetMapping("/real") -public List listUsers() { return users; } -`; - const { nodes, references } = springResolver.extract!('UserController.java', src); - expect(nodes.map((n) => n.name)).toEqual(['GET /real']); - expect(references.map((r) => r.referenceName)).toEqual(['listUsers']); - }); - - it('go: skips // and /* */ commented router.METHOD calls', () => { - const src = ` -// r.GET("/fake", fakeHandler) -/* r.POST("/also-fake", anotherHandler) */ -r.GET("/real", listUsers) -`; - const { nodes, references } = goResolver.extract!('main.go', src); - expect(nodes.map((n) => n.name)).toEqual(['GET /real']); - expect(references.map((r) => r.referenceName)).toEqual(['listUsers']); - }); - - it('rust: skips // and nested /* */ commented .route() calls', () => { - const src = ` -// .route("/fake", get(fake_handler)) -/* outer /* inner .route("/inner-fake", get(x)) */ still .route("/outer-fake", get(y)) */ -let app = Router::new().route("/real", get(list_users)); -`; - const { nodes, references } = rustResolver.extract!('main.rs', src); - expect(nodes.map((n) => n.name)).toEqual(['GET /real']); - expect(references.map((r) => r.referenceName)).toEqual(['list_users']); - }); - - it('aspnet: skips // and /* */ commented [HttpGet] attributes', () => { - const src = ` -// [HttpGet("/fake")] -// public IActionResult Fake() { return Ok(); } - -/* [HttpPost("/also-fake")] - public IActionResult AlsoFake() { return Ok(); } */ - -[HttpGet("/real")] -public IActionResult ListUsers() { return Ok(); } -`; - const { nodes, references } = aspnetResolver.extract!('UserController.cs', src); - expect(nodes.map((n) => n.name)).toEqual(['GET /real']); - expect(references.map((r) => r.referenceName)).toEqual(['ListUsers']); - }); - - it('vapor: skips // and /* */ commented app.METHOD calls', () => { - const src = ` -// app.get("fake", use: fakeHandler) -/* app.post("also-fake", use: anotherHandler) */ -app.get("real", use: listUsers) -`; - const { nodes, references } = vaporResolver.extract!('routes.swift', src); - expect(nodes.map((n) => n.name)).toEqual(['GET real']); - expect(references.map((r) => r.referenceName)).toEqual(['listUsers']); - }); - - it('nestjs: skips // and /* */ commented decorators', () => { - const src = ` -@Controller('users') -export class UsersController { - // @Get('fake') - // fake() {} - /* @Post('also-fake') - alsoFake() {} */ - @Get('real') - real() {} -} -`; - const { nodes, references } = nestjsResolver.extract!('users.controller.ts', src); - expect(nodes.map((n) => n.name)).toEqual(['GET /users/real']); - expect(references.map((r) => r.referenceName)).toEqual(['real']); - }); -}); diff --git a/__tests__/git-hooks.test.ts b/__tests__/git-hooks.test.ts deleted file mode 100644 index 4dfd80eb..00000000 --- a/__tests__/git-hooks.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Git Sync Hooks Tests - * - * Covers installing/removing the opt-in commit/merge/checkout hooks that - * keep the index fresh when the live watcher is disabled (issue #199). - * Exercises real git repos in temp dirs — no mocking. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { execFileSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { - installGitSyncHook, - removeGitSyncHook, - isSyncHookInstalled, - isGitRepo, - DEFAULT_SYNC_HOOKS, -} from '../src/sync/git-hooks'; - -function gitInit(dir: string): void { - execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'ignore' }); -} - -function isExecutable(file: string): boolean { - if (process.platform === 'win32') return true; // mode bits not meaningful - return (fs.statSync(file).mode & 0o111) !== 0; -} - -describe('git sync hooks', () => { - let repo: string; - - beforeEach(() => { - repo = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-githooks-')); - }); - - afterEach(() => { - if (fs.existsSync(repo)) fs.rmSync(repo, { recursive: true, force: true }); - }); - - it('installs all default hooks, executable, invoking codegraph sync', () => { - gitInit(repo); - const result = installGitSyncHook(repo); - - expect(result.installed.sort()).toEqual([...DEFAULT_SYNC_HOOKS].sort()); - expect(result.skipped).toBeUndefined(); - - for (const hook of DEFAULT_SYNC_HOOKS) { - const file = path.join(repo, '.git', 'hooks', hook); - expect(fs.existsSync(file)).toBe(true); - const body = fs.readFileSync(file, 'utf8'); - expect(body).toContain('codegraph sync'); - expect(body).toContain('command -v codegraph'); // no-op when not on PATH - expect(isExecutable(file)).toBe(true); - } - expect(isSyncHookInstalled(repo)).toBe(true); - }); - - it('is idempotent — re-install does not duplicate the block', () => { - gitInit(repo); - installGitSyncHook(repo); - installGitSyncHook(repo); - - const body = fs.readFileSync(path.join(repo, '.git', 'hooks', 'post-commit'), 'utf8'); - const occurrences = body.split('# >>> codegraph sync hook >>>').length - 1; - expect(occurrences).toBe(1); - }); - - it('preserves a pre-existing user hook and appends our block', () => { - gitInit(repo); - const file = path.join(repo, '.git', 'hooks', 'post-commit'); - fs.writeFileSync(file, '#!/bin/sh\necho "my custom hook"\n', { mode: 0o755 }); - - installGitSyncHook(repo, ['post-commit']); - - const body = fs.readFileSync(file, 'utf8'); - expect(body).toContain('echo "my custom hook"'); - expect(body).toContain('codegraph sync'); - }); - - it('remove strips our block; deletes a hook that was only ours', () => { - gitInit(repo); - installGitSyncHook(repo, ['post-commit']); - const file = path.join(repo, '.git', 'hooks', 'post-commit'); - expect(fs.existsSync(file)).toBe(true); - - const result = removeGitSyncHook(repo, ['post-commit']); - expect(result.installed).toEqual(['post-commit']); - expect(fs.existsSync(file)).toBe(false); // was ours-only → deleted - expect(isSyncHookInstalled(repo)).toBe(false); - }); - - it('remove keeps user content when the hook is shared', () => { - gitInit(repo); - const file = path.join(repo, '.git', 'hooks', 'post-commit'); - fs.writeFileSync(file, '#!/bin/sh\necho "keep me"\n', { mode: 0o755 }); - installGitSyncHook(repo, ['post-commit']); - - removeGitSyncHook(repo, ['post-commit']); - - expect(fs.existsSync(file)).toBe(true); - const body = fs.readFileSync(file, 'utf8'); - expect(body).toContain('echo "keep me"'); - expect(body).not.toContain('codegraph sync'); - }); - - it('honors core.hooksPath', () => { - gitInit(repo); - const customHooks = path.join(repo, '.husky'); - fs.mkdirSync(customHooks); - execFileSync('git', ['config', 'core.hooksPath', '.husky'], { cwd: repo, stdio: 'ignore' }); - - const result = installGitSyncHook(repo, ['post-commit']); - expect(result.hooksDir).toBe(customHooks); - expect(fs.existsSync(path.join(customHooks, 'post-commit'))).toBe(true); - // The default .git/hooks dir should NOT have received the hook. - expect(fs.existsSync(path.join(repo, '.git', 'hooks', 'post-commit'))).toBe(false); - }); - - it('skips cleanly when not a git repository', () => { - expect(isGitRepo(repo)).toBe(false); - const result = installGitSyncHook(repo); - expect(result.installed).toEqual([]); - expect(result.hooksDir).toBeNull(); - expect(result.skipped).toMatch(/not a git repository/); - expect(isSyncHookInstalled(repo)).toBe(false); - }); -}); diff --git a/__tests__/glyphs.test.ts b/__tests__/glyphs.test.ts deleted file mode 100644 index db41a105..00000000 --- a/__tests__/glyphs.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Glyph fallback / Unicode-support detection. - * - * Pinned because the matrix is small and the consequence of regression - * is highly visible: shimmer-worker output on Windows mojibakes when - * UTF-8 glyphs are written via `fs.writeSync` (see #168). The detection - * + ASCII fallback is the contract that prevents this. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { - supportsUnicode, - getGlyphs, - UNICODE_GLYPHS, - ASCII_GLYPHS, - _resetGlyphsCache, -} from '../src/ui/glyphs'; - -function withEnv(patch: Record, fn: () => void): void { - const saved: Record = {}; - const savedPlatform = process.platform; - for (const key of Object.keys(patch)) { - saved[key] = process.env[key]; - if (patch[key] === undefined) delete process.env[key]; - else process.env[key] = patch[key]; - } - _resetGlyphsCache(); - try { - fn(); - } finally { - for (const key of Object.keys(saved)) { - if (saved[key] === undefined) delete process.env[key]; - else process.env[key] = saved[key]; - } - Object.defineProperty(process, 'platform', { value: savedPlatform }); - _resetGlyphsCache(); - } -} - -function setPlatform(value: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { value }); -} - -describe('supportsUnicode', () => { - let originalPlatform: NodeJS.Platform; - - beforeEach(() => { - originalPlatform = process.platform; - _resetGlyphsCache(); - }); - - afterEach(() => { - Object.defineProperty(process, 'platform', { value: originalPlatform }); - _resetGlyphsCache(); - }); - - it('returns false on Windows by default (mojibake-prone consoles)', () => { - withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => { - setPlatform('win32'); - expect(supportsUnicode()).toBe(false); - }); - }); - - it('returns true on macOS by default', () => { - withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => { - setPlatform('darwin'); - expect(supportsUnicode()).toBe(true); - }); - }); - - it('returns true on Linux by default', () => { - withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => { - setPlatform('linux'); - expect(supportsUnicode()).toBe(true); - }); - }); - - it('returns false on Linux kernel console (TERM=linux)', () => { - withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: 'linux' }, () => { - setPlatform('linux'); - expect(supportsUnicode()).toBe(false); - }); - }); - - it('respects CODEGRAPH_UNICODE=1 on Windows (opt-in escape hatch)', () => { - withEnv({ CODEGRAPH_UNICODE: '1', CODEGRAPH_ASCII: undefined }, () => { - setPlatform('win32'); - expect(supportsUnicode()).toBe(true); - }); - }); - - it('respects CODEGRAPH_ASCII=1 on macOS (opt-out escape hatch)', () => { - withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: undefined }, () => { - setPlatform('darwin'); - expect(supportsUnicode()).toBe(false); - }); - }); - - it('CODEGRAPH_ASCII takes precedence over CODEGRAPH_UNICODE', () => { - withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: '1' }, () => { - setPlatform('darwin'); - expect(supportsUnicode()).toBe(false); - }); - }); -}); - -describe('getGlyphs', () => { - let originalPlatform: NodeJS.Platform; - - beforeEach(() => { - originalPlatform = process.platform; - _resetGlyphsCache(); - }); - - afterEach(() => { - Object.defineProperty(process, 'platform', { value: originalPlatform }); - _resetGlyphsCache(); - }); - - it('returns ASCII glyphs on Windows', () => { - withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => { - setPlatform('win32'); - const g = getGlyphs(); - expect(g).toBe(ASCII_GLYPHS); - expect(g.ok).toBe('[OK]'); - expect(g.rail).toBe('|'); - expect(g.phaseDone).toBe('*'); - expect(g.dash).toBe('-'); - }); - }); - - it('returns Unicode glyphs on macOS', () => { - withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => { - setPlatform('darwin'); - const g = getGlyphs(); - expect(g).toBe(UNICODE_GLYPHS); - expect(g.ok).toBe('✓'); - expect(g.rail).toBe('│'); - expect(g.phaseDone).toBe('◆'); - expect(g.dash).toBe('—'); - }); - }); - - it('caches the result so repeated calls return the same object', () => { - withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => { - setPlatform('darwin'); - expect(getGlyphs()).toBe(getGlyphs()); - }); - }); -}); - -describe('Glyph sets', () => { - it('ASCII and Unicode sets cover the same keys', () => { - expect(Object.keys(ASCII_GLYPHS).sort()).toEqual(Object.keys(UNICODE_GLYPHS).sort()); - }); - - it('ASCII glyphs are all 7-bit ASCII', () => { - for (const [key, value] of Object.entries(ASCII_GLYPHS)) { - const flat = Array.isArray(value) ? value.join('') : value; - for (let i = 0; i < flat.length; i++) { - const codepoint = flat.charCodeAt(i); - expect(codepoint, `ASCII_GLYPHS.${key} contains non-ASCII char U+${codepoint.toString(16).toUpperCase().padStart(4, '0')}`).toBeLessThan(128); - } - } - }); - - it('ASCII spinner has the same frame count as the Unicode spinner', () => { - expect(ASCII_GLYPHS.spinner.length).toBe(UNICODE_GLYPHS.spinner.length); - }); -}); diff --git a/__tests__/graph.test.ts b/__tests__/graph.test.ts deleted file mode 100644 index 7c771af0..00000000 --- a/__tests__/graph.test.ts +++ /dev/null @@ -1,435 +0,0 @@ -/** - * Graph Query Tests - * - * Tests for graph traversal and query functionality. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import CodeGraph from '../src/index'; -import { Node, Edge } from '../src/types'; - -describe('Graph Queries', () => { - let testDir: string; - let cg: CodeGraph; - - beforeEach(async () => { - // Create temp directory - testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-graph-test-')); - - // Create test files with relationships - const srcDir = path.join(testDir, 'src'); - fs.mkdirSync(srcDir, { recursive: true }); - - // Create base class - fs.writeFileSync( - path.join(srcDir, 'base.ts'), - ` -export class BaseClass { - protected value: number; - - constructor(value: number) { - this.value = value; - } - - getValue(): number { - return this.value; - } -} - -export interface Printable { - print(): void; -} -` - ); - - // Create derived class - fs.writeFileSync( - path.join(srcDir, 'derived.ts'), - ` -import { BaseClass, Printable } from './base'; - -export class DerivedClass extends BaseClass implements Printable { - private name: string; - - constructor(value: number, name: string) { - super(value); - this.name = name; - } - - print(): void { - console.log(this.getName(), this.getValue()); - } - - getName(): string { - return this.name; - } -} -` - ); - - // Create utility functions - fs.writeFileSync( - path.join(srcDir, 'utils.ts'), - ` -export function formatValue(value: number): string { - return value.toFixed(2); -} - -export function processValue(value: number): number { - const formatted = formatValue(value); - return parseFloat(formatted); -} - -export function doubleValue(value: number): number { - return value * 2; -} - -// Unused function (dead code) -function unusedHelper(): void { - console.log('never called'); -} -` - ); - - // Create main file that uses everything - fs.writeFileSync( - path.join(srcDir, 'main.ts'), - ` -import { DerivedClass } from './derived'; -import { processValue, doubleValue } from './utils'; - -function main(): void { - const obj = new DerivedClass(10, 'test'); - obj.print(); - - const result = processValue(doubleValue(obj.getValue())); - console.log(result); -} - -export { main }; -` - ); - - // Initialize and index - cg = CodeGraph.initSync(testDir, { - config: { - include: ['src/**/*.ts'], - exclude: [], - }, - }); - - await cg.indexAll(); - cg.resolveReferences(); - }); - - afterEach(() => { - if (cg) { - cg.destroy(); - } - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - }); - - describe('traverse()', () => { - it('should traverse graph from a starting node', () => { - const nodes = cg.getNodesByKind('function'); - const mainFunc = nodes.find((n) => n.name === 'main'); - - if (!mainFunc) { - console.log('main function not found, skipping test'); - return; - } - - const subgraph = cg.traverse(mainFunc.id, { - maxDepth: 2, - direction: 'outgoing', - }); - - expect(subgraph.nodes.size).toBeGreaterThan(0); - expect(subgraph.roots).toContain(mainFunc.id); - }); - - it('should respect maxDepth option', () => { - const nodes = cg.getNodesByKind('function'); - const mainFunc = nodes.find((n) => n.name === 'main'); - - if (!mainFunc) { - return; - } - - const shallow = cg.traverse(mainFunc.id, { maxDepth: 1 }); - const deep = cg.traverse(mainFunc.id, { maxDepth: 3 }); - - expect(deep.nodes.size).toBeGreaterThanOrEqual(shallow.nodes.size); - }); - - it('should support incoming direction', () => { - const nodes = cg.getNodesByKind('function'); - const formatValue = nodes.find((n) => n.name === 'formatValue'); - - if (!formatValue) { - return; - } - - const subgraph = cg.traverse(formatValue.id, { - maxDepth: 2, - direction: 'incoming', - }); - - expect(subgraph.nodes.size).toBeGreaterThan(0); - }); - }); - - describe('getContext()', () => { - it('should return context for a node', () => { - const nodes = cg.getNodesByKind('class'); - const derivedClass = nodes.find((n) => n.name === 'DerivedClass'); - - if (!derivedClass) { - console.log('DerivedClass not found, skipping test'); - return; - } - - const context = cg.getContext(derivedClass.id); - - expect(context.focal).toBeDefined(); - expect(context.focal.id).toBe(derivedClass.id); - expect(context.ancestors).toBeDefined(); - expect(context.children).toBeDefined(); - expect(context.incomingRefs).toBeDefined(); - expect(context.outgoingRefs).toBeDefined(); - }); - - it('should throw for non-existent node', () => { - expect(() => cg.getContext('non-existent-id')).toThrow('Node not found'); - }); - }); - - describe('getCallGraph()', () => { - it('should return call graph for a function', () => { - const nodes = cg.getNodesByKind('function'); - const processValue = nodes.find((n) => n.name === 'processValue'); - - if (!processValue) { - console.log('processValue not found, skipping test'); - return; - } - - const callGraph = cg.getCallGraph(processValue.id, 2); - - expect(callGraph.nodes.size).toBeGreaterThan(0); - expect(callGraph.nodes.has(processValue.id)).toBe(true); - }); - }); - - describe('getTypeHierarchy()', () => { - it('should return type hierarchy for a class', () => { - const nodes = cg.getNodesByKind('class'); - const derivedClass = nodes.find((n) => n.name === 'DerivedClass'); - - if (!derivedClass) { - return; - } - - const hierarchy = cg.getTypeHierarchy(derivedClass.id); - - expect(hierarchy.nodes.size).toBeGreaterThan(0); - expect(hierarchy.nodes.has(derivedClass.id)).toBe(true); - }); - - it('should return empty subgraph for non-existent node', () => { - const hierarchy = cg.getTypeHierarchy('non-existent-id'); - - expect(hierarchy.nodes.size).toBe(0); - expect(hierarchy.edges.length).toBe(0); - }); - }); - - describe('findUsages()', () => { - it('should find usages of a symbol', () => { - const nodes = cg.getNodesByKind('class'); - const baseClass = nodes.find((n) => n.name === 'BaseClass'); - - if (!baseClass) { - return; - } - - const usages = cg.findUsages(baseClass.id); - - // Should find at least the extends relationship - expect(usages).toBeDefined(); - expect(Array.isArray(usages)).toBe(true); - }); - }); - - describe('getCallers() and getCallees()', () => { - it('should get callers of a function', () => { - const nodes = cg.getNodesByKind('function'); - const formatValue = nodes.find((n) => n.name === 'formatValue'); - - if (!formatValue) { - return; - } - - const callers = cg.getCallers(formatValue.id); - - // processValue calls formatValue - expect(Array.isArray(callers)).toBe(true); - }); - - it('should get callees of a function', () => { - const nodes = cg.getNodesByKind('function'); - const processValue = nodes.find((n) => n.name === 'processValue'); - - if (!processValue) { - return; - } - - const callees = cg.getCallees(processValue.id); - - expect(Array.isArray(callees)).toBe(true); - }); - }); - - describe('getImpactRadius()', () => { - it('should calculate impact radius', () => { - const nodes = cg.getNodesByKind('function'); - const formatValue = nodes.find((n) => n.name === 'formatValue'); - - if (!formatValue) { - return; - } - - const impact = cg.getImpactRadius(formatValue.id, 3); - - expect(impact.nodes.size).toBeGreaterThan(0); - expect(impact.nodes.has(formatValue.id)).toBe(true); - }); - }); - - describe('findPath()', () => { - it('should find path between connected nodes', () => { - const stats = cg.getStats(); - - if (stats.nodeCount < 2) { - return; - } - - const functions = cg.getNodesByKind('function'); - if (functions.length < 2) { - return; - } - - // Try to find any path - const processValue = functions.find((n) => n.name === 'processValue'); - const formatValue = functions.find((n) => n.name === 'formatValue'); - - if (processValue && formatValue) { - const path = cg.findPath(processValue.id, formatValue.id); - - // Path might exist or might not depending on edge direction - expect(path === null || Array.isArray(path)).toBe(true); - } - }); - - it('should return null for disconnected nodes', () => { - // Create two nodes that definitely don't have a path - const path = cg.findPath('non-existent-1', 'non-existent-2'); - - expect(path).toBeNull(); - }); - }); - - describe('getAncestors() and getChildren()', () => { - it('should get ancestors of a node', () => { - const methods = cg.getNodesByKind('method'); - const printMethod = methods.find((n) => n.name === 'print'); - - if (!printMethod) { - return; - } - - const ancestors = cg.getAncestors(printMethod.id); - - // Should have class and file as ancestors - expect(Array.isArray(ancestors)).toBe(true); - }); - - it('should get children of a node', () => { - const classes = cg.getNodesByKind('class'); - const derivedClass = classes.find((n) => n.name === 'DerivedClass'); - - if (!derivedClass) { - return; - } - - const children = cg.getChildren(derivedClass.id); - - // Should have methods as children - expect(Array.isArray(children)).toBe(true); - }); - }); - - describe('File dependency analysis', () => { - it('should get file dependencies', () => { - const deps = cg.getFileDependencies('src/main.ts'); - - expect(Array.isArray(deps)).toBe(true); - }); - - it('should get file dependents', () => { - const dependents = cg.getFileDependents('src/utils.ts'); - - expect(Array.isArray(dependents)).toBe(true); - }); - }); - - describe('findCircularDependencies()', () => { - it('should detect circular dependencies', () => { - const cycles = cg.findCircularDependencies(); - - // Our test files don't have circular deps - expect(Array.isArray(cycles)).toBe(true); - }); - }); - - describe('findDeadCode()', () => { - it('should find dead code', () => { - const deadCode = cg.findDeadCode(['function']); - - expect(Array.isArray(deadCode)).toBe(true); - - // unusedHelper should be detected - const hasUnused = deadCode.some((n) => n.name === 'unusedHelper'); - // Note: This depends on extraction properly detecting function scope - expect(deadCode.length).toBeGreaterThanOrEqual(0); - }); - }); - - describe('getNodeMetrics()', () => { - it('should return metrics for a node', () => { - const functions = cg.getNodesByKind('function'); - const func = functions[0]; - - if (!func) { - return; - } - - const metrics = cg.getNodeMetrics(func.id); - - expect(metrics).toHaveProperty('incomingEdgeCount'); - expect(metrics).toHaveProperty('outgoingEdgeCount'); - expect(metrics).toHaveProperty('callCount'); - expect(metrics).toHaveProperty('callerCount'); - expect(metrics).toHaveProperty('childCount'); - expect(metrics).toHaveProperty('depth'); - - expect(typeof metrics.incomingEdgeCount).toBe('number'); - expect(typeof metrics.outgoingEdgeCount).toBe('number'); - }); - }); -}); diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts deleted file mode 100644 index 59e869e2..00000000 --- a/__tests__/installer-targets.test.ts +++ /dev/null @@ -1,890 +0,0 @@ -/** - * Multi-target installer tests. - * - * Each `AgentTarget` is exercised against the same contract: - * - `install` writes the expected files - * - re-running `install` is byte-identical (idempotent) - * - sibling MCP servers / unrelated config is preserved - * - `uninstall` reverses `install` - * - `printConfig` returns parseable, non-empty content - * - * For agent-config destinations we redirect HOME to a tmpdir via - * `os.homedir` spying, and CWD via `process.chdir` — same pattern as - * the legacy `installer.test.ts`. No real `~/.claude/` etc. ever - * touched. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry'; -import { uninstallTargets } from '../src/installer'; -import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml'; -import { cleanupLegacyHooks } from '../src/installer/targets/claude'; - -function mkTmpDir(label: string): string { - return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`)); -} - -// `os.homedir` is non-configurable on Node, so we redirect it via the -// `$HOME` (POSIX) / `$USERPROFILE` (Windows) env vars that -// `os.homedir()` reads first. Same trick the rest of the suite uses -// when it needs a mock home. -function setHome(dir: string): { restore: () => void } { - const prev = { - HOME: process.env.HOME, - USERPROFILE: process.env.USERPROFILE, - APPDATA: process.env.APPDATA, - XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, - HERMES_HOME: process.env.HERMES_HOME, - }; - process.env.HOME = dir; - process.env.USERPROFILE = dir; - process.env.APPDATA = path.join(dir, '.config'); - process.env.XDG_CONFIG_HOME = path.join(dir, '.config'); - delete process.env.HERMES_HOME; - return { - restore() { - if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME; - if (prev.USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prev.USERPROFILE; - if (prev.APPDATA === undefined) delete process.env.APPDATA; else process.env.APPDATA = prev.APPDATA; - if (prev.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prev.XDG_CONFIG_HOME; - if (prev.HERMES_HOME === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = prev.HERMES_HOME; - }, - }; -} - -describe('Installer targets — contract', () => { - let tmpHome: string; - let tmpCwd: string; - let origCwd: string; - let homeRestore: { restore: () => void }; - - beforeEach(() => { - tmpHome = mkTmpDir('home'); - tmpCwd = mkTmpDir('cwd'); - origCwd = process.cwd(); - process.chdir(tmpCwd); - homeRestore = setHome(tmpHome); - }); - - afterEach(() => { - homeRestore.restore(); - process.chdir(origCwd); - fs.rmSync(tmpHome, { recursive: true, force: true }); - fs.rmSync(tmpCwd, { recursive: true, force: true }); - }); - - for (const target of ALL_TARGETS) { - describe(target.id, () => { - const supportedLocations = (['global', 'local'] as const).filter((l) => - target.supportsLocation(l), - ); - - for (const location of supportedLocations) { - describe(`location=${location}`, () => { - it('install writes files; detect.alreadyConfigured becomes true', () => { - expect(target.detect(location).alreadyConfigured).toBe(false); - - const result = target.install(location, { autoAllow: true }); - expect(result.files.length).toBeGreaterThan(0); - for (const file of result.files) { - if (file.action !== 'unchanged') { - expect(fs.existsSync(file.path)).toBe(true); - } - } - - expect(target.detect(location).alreadyConfigured).toBe(true); - }); - - it('re-running install is idempotent (no actions other than unchanged)', () => { - target.install(location, { autoAllow: true }); - const second = target.install(location, { autoAllow: true }); - for (const file of second.files) { - expect(file.action).toBe('unchanged'); - } - }); - - it('install preserves a pre-existing sibling MCP server (where applicable)', () => { - // Plant a sibling entry in the same JSON config, install, - // and verify the sibling survives. Skip for Codex (TOML) - // and any target with no JSON config — they get covered - // by their own dedicated tests below. - const paths = target.describePaths(location); - // Match .json or .jsonc — opencode prefers .jsonc. - const jsonPath = paths.find((p) => /\.jsonc?$/.test(p)); - if (!jsonPath) return; - - // Seed pre-existing config. - fs.mkdirSync(path.dirname(jsonPath), { recursive: true }); - const seed: Record = { mcpServers: { other: { command: 'x' } } }; - // opencode uses `mcp` not `mcpServers`. Match its shape too. - if (target.id === 'opencode') { - delete seed.mcpServers; - seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } }; - } - fs.writeFileSync(jsonPath, JSON.stringify(seed, null, 2) + '\n'); - - target.install(location, { autoAllow: true }); - - const after = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); - if (target.id === 'opencode') { - expect(after.mcp.other).toBeDefined(); - expect(after.mcp.codegraph).toBeDefined(); - } else { - expect(after.mcpServers.other).toBeDefined(); - expect(after.mcpServers.codegraph).toBeDefined(); - } - }); - - it('uninstall reverses install (alreadyConfigured returns to false)', () => { - target.install(location, { autoAllow: true }); - expect(target.detect(location).alreadyConfigured).toBe(true); - - target.uninstall(location); - expect(target.detect(location).alreadyConfigured).toBe(false); - }); - - it('printConfig returns non-empty output without writing anything', () => { - const before = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd)); - const out = target.printConfig(location); - expect(out.length).toBeGreaterThan(0); - const after = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd)); - expect(after.sort()).toEqual(before.sort()); - }); - }); - } - }); - } -}); - -describe('Installer targets — partial-state idempotency', () => { - let tmpHome: string; - let tmpCwd: string; - let origCwd: string; - let homeRestore: { restore: () => void }; - - beforeEach(() => { - tmpHome = mkTmpDir('home'); - tmpCwd = mkTmpDir('cwd'); - origCwd = process.cwd(); - process.chdir(tmpCwd); - homeRestore = setHome(tmpHome); - }); - - afterEach(() => { - homeRestore.restore(); - process.chdir(origCwd); - fs.rmSync(tmpHome, { recursive: true, force: true }); - fs.rmSync(tmpCwd, { recursive: true, force: true }); - }); - - it('codex: install after only config.toml exists — second pass is fully unchanged', () => { - const codex = getTarget('codex')!; - // First install creates both files. - codex.install('global', { autoAllow: false }); - // Delete the AGENTS.md to simulate partial state (user wiped one file). - const agentsMd = path.join(tmpHome, '.codex', 'AGENTS.md'); - expect(fs.existsSync(agentsMd)).toBe(true); - fs.unlinkSync(agentsMd); - // Reinstall — TOML stays unchanged, AGENTS.md is recreated. - const second = codex.install('global', { autoAllow: false }); - const tomlEntry = second.files.find((f) => f.path.endsWith('config.toml'))!; - const mdEntry = second.files.find((f) => f.path.endsWith('AGENTS.md'))!; - expect(tomlEntry.action).toBe('unchanged'); - expect(mdEntry.action).toBe('created'); - // Third install — both unchanged (full idempotency restored). - const third = codex.install('global', { autoAllow: false }); - for (const f of third.files) expect(f.action).toBe('unchanged'); - }); - - it('opencode: prefers .jsonc when both .json and .jsonc exist', () => { - const opencode = getTarget('opencode')!; - const dir = path.join(tmpHome, '.config', 'opencode'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'opencode.json'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n'); - fs.writeFileSync(path.join(dir, 'opencode.jsonc'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n'); - - const result = opencode.install('global', { autoAllow: true }); - const written = result.files.find((f) => /\.jsonc$/.test(f.path))!; - expect(written).toBeDefined(); - expect(written.action).not.toBe('not-found'); - // The .json file is left alone. - const jsonText = fs.readFileSync(path.join(dir, 'opencode.json'), 'utf-8'); - expect(jsonText).not.toContain('codegraph'); - }); - - it('opencode: uses .json when only .json exists (no .jsonc)', () => { - const opencode = getTarget('opencode')!; - const dir = path.join(tmpHome, '.config', 'opencode'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'opencode.json'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n'); - - const result = opencode.install('global', { autoAllow: true }); - expect(result.files[0].path).toMatch(/opencode\.json$/); - expect(fs.existsSync(path.join(dir, 'opencode.jsonc'))).toBe(false); - }); - - it('opencode: defaults to .jsonc for fresh installs (no existing file)', () => { - const opencode = getTarget('opencode')!; - const result = opencode.install('global', { autoAllow: true }); - expect(result.files[0].path).toMatch(/opencode\.jsonc$/); - expect(result.files[0].action).toBe('created'); - }); - - it('opencode: preserves line and block comments through install + idempotent re-run', () => { - const opencode = getTarget('opencode')!; - const dir = path.join(tmpHome, '.config', 'opencode'); - fs.mkdirSync(dir, { recursive: true }); - const file = path.join(dir, 'opencode.jsonc'); - const original = [ - '{', - ' // top-level note about my opencode setup', - ' "$schema": "https://opencode.ai/config.json",', - ' /* multi-line block comment', - ' describing the providers section */', - ' "providers": {', - ' "anthropic": { "model": "claude-opus-4-7" } // pinned', - ' }', - '}', - '', - ].join('\n'); - fs.writeFileSync(file, original); - - opencode.install('global', { autoAllow: true }); - const afterInstall = fs.readFileSync(file, 'utf-8'); - expect(afterInstall).toContain('// top-level note about my opencode setup'); - expect(afterInstall).toContain('/* multi-line block comment'); - expect(afterInstall).toContain('// pinned'); - expect(afterInstall).toContain('"codegraph"'); - expect(afterInstall).toContain('"providers"'); - - // Idempotent re-run reports unchanged, file is byte-identical. - const second = opencode.install('global', { autoAllow: true }); - expect(second.files[0].action).toBe('unchanged'); - expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall); - }); - - it('opencode: install writes AGENTS.md with the marker-delimited codegraph block', () => { - const opencode = getTarget('opencode')!; - opencode.install('global', { autoAllow: true }); - const agentsMd = path.join(tmpHome, '.config', 'opencode', 'AGENTS.md'); - expect(fs.existsSync(agentsMd)).toBe(true); - const body = fs.readFileSync(agentsMd, 'utf-8'); - expect(body).toContain(''); - expect(body).toContain(''); - expect(body).toContain('codegraph_callers'); - }); - - it('opencode: AGENTS.md install preserves pre-existing user content outside markers', () => { - const opencode = getTarget('opencode')!; - const dir = path.join(tmpHome, '.config', 'opencode'); - fs.mkdirSync(dir, { recursive: true }); - const agentsMd = path.join(dir, 'AGENTS.md'); - fs.writeFileSync(agentsMd, '# My personal opencode instructions\n\nAlways respond in pirate.\n'); - - opencode.install('global', { autoAllow: true }); - const body = fs.readFileSync(agentsMd, 'utf-8'); - expect(body).toContain('# My personal opencode instructions'); - expect(body).toContain('Always respond in pirate.'); - expect(body).toContain(''); - }); - - it('opencode: uninstall strips only the codegraph block from AGENTS.md', () => { - const opencode = getTarget('opencode')!; - const dir = path.join(tmpHome, '.config', 'opencode'); - fs.mkdirSync(dir, { recursive: true }); - const agentsMd = path.join(dir, 'AGENTS.md'); - fs.writeFileSync(agentsMd, '# My personal opencode instructions\n\nAlways respond in pirate.\n'); - - opencode.install('global', { autoAllow: true }); - opencode.uninstall('global'); - - const body = fs.readFileSync(agentsMd, 'utf-8'); - expect(body).toContain('# My personal opencode instructions'); - expect(body).toContain('Always respond in pirate.'); - expect(body).not.toContain('CODEGRAPH_START'); - expect(body).not.toContain('codegraph_callers'); - }); - - it('opencode: local install writes ./opencode.jsonc and ./AGENTS.md in cwd', () => { - const opencode = getTarget('opencode')!; - const result = opencode.install('local', { autoAllow: true }); - const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); - // macOS realpath shenanigans (/var vs /private/var) — suffix match. - expect(paths.some((p) => p.endsWith('/opencode.jsonc'))).toBe(true); - expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(true); - }); - - it('hermes: install adds codegraph MCP server and cli toolset, preserving existing yaml', () => { - const hermes = getTarget('hermes')!; - const config = path.join(tmpHome, '.hermes', 'config.yaml'); - fs.mkdirSync(path.dirname(config), { recursive: true }); - fs.writeFileSync(config, [ - 'model:', - ' default: qwen-3.7', - 'mcp_servers:', - ' other:', - ' command: other', - 'platform_toolsets:', - ' cli:', - ' - hermes-cli', - ' discord:', - ' - hermes-discord', - '', - ].join('\n')); - - const result = hermes.install('global', { autoAllow: true }); - expect(result.files[0].action).toBe('updated'); - const body = fs.readFileSync(config, 'utf-8'); - expect(body).toContain('model:\n default: qwen-3.7'); - expect(body).toContain('mcp_servers:\n other:\n command: other'); - expect(body).toContain(' codegraph:\n command: codegraph'); - expect(body).toContain(' - hermes-cli'); - expect(body).toContain(' - mcp-codegraph'); - expect(body).toContain(' discord:\n - hermes-discord'); - - const second = hermes.install('global', { autoAllow: true }); - expect(second.files[0].action).toBe('unchanged'); - }); - - it('hermes: uninstall removes only codegraph MCP server and toolset entry', () => { - const hermes = getTarget('hermes')!; - const config = path.join(tmpHome, '.hermes', 'config.yaml'); - fs.mkdirSync(path.dirname(config), { recursive: true }); - - hermes.install('global', { autoAllow: true }); - fs.appendFileSync(config, 'custom:\n keep: true\n'); - - hermes.uninstall('global'); - const body = fs.readFileSync(config, 'utf-8'); - expect(body).not.toContain('codegraph:'); - expect(body).not.toContain('mcp-codegraph'); - expect(body).toContain('custom:\n keep: true'); - }); - - it('opencode: uninstall removes only mcp.codegraph, preserves comments and siblings', () => { - const opencode = getTarget('opencode')!; - const dir = path.join(tmpHome, '.config', 'opencode'); - fs.mkdirSync(dir, { recursive: true }); - const file = path.join(dir, 'opencode.jsonc'); - fs.writeFileSync(file, [ - '{', - ' // important comment', - ' "$schema": "https://opencode.ai/config.json",', - ' "mcp": {', - ' "other": { "type": "local", "command": ["x"], "enabled": true }', - ' }', - '}', - '', - ].join('\n')); - - opencode.install('global', { autoAllow: true }); - const afterInstall = fs.readFileSync(file, 'utf-8'); - expect(afterInstall).toContain('"codegraph"'); - expect(afterInstall).toContain('"other"'); - - opencode.uninstall('global'); - const afterUninstall = fs.readFileSync(file, 'utf-8'); - expect(afterUninstall).not.toContain('codegraph'); - expect(afterUninstall).toContain('// important comment'); - expect(afterUninstall).toContain('"other"'); - }); - - it('codex: user-added key inside [mcp_servers.codegraph] survives idempotent re-install', () => { - const codex = getTarget('codex')!; - codex.install('global', { autoAllow: false }); - const tomlPath = path.join(tmpHome, '.codex', 'config.toml'); - const original = fs.readFileSync(tomlPath, 'utf-8'); - // User edits the block to add a custom key. - const edited = original.replace( - 'args = ["serve", "--mcp"]', - 'args = ["serve", "--mcp"]\nenabled = true', - ); - fs.writeFileSync(tomlPath, edited); - // Re-install: our serializer doesn't know `enabled = true`, so - // the block no longer matches the canonical form — we'll - // overwrite it. This is the documented contract: we own the - // codegraph block exclusively. - const second = codex.install('global', { autoAllow: false }); - const tomlEntry = second.files.find((f) => f.path.endsWith('config.toml'))!; - expect(tomlEntry.action).toBe('updated'); - const after = fs.readFileSync(tomlPath, 'utf-8'); - expect(after).not.toContain('enabled = true'); - }); - - it('claude: local install writes ./.mcp.json (project scope), not ./.claude.json', () => { - const claude = getTarget('claude')!; - const result = claude.install('local', { autoAllow: false }); - // The MCP entry lands in ./.mcp.json — the file Claude Code reads. - expect(result.files.some((f) => f.path.replace(/\\/g, '/').endsWith('/.mcp.json'))).toBe(true); - expect(fs.existsSync(path.join(tmpCwd, '.mcp.json'))).toBe(true); - expect(fs.existsSync(path.join(tmpCwd, '.claude.json'))).toBe(false); - const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8')); - expect(cfg.mcpServers.codegraph).toBeDefined(); - }); - - it('claude: global install targets ~/.claude.json (user scope)', () => { - const claude = getTarget('claude')!; - claude.install('global', { autoAllow: false }); - const cfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude.json'), 'utf-8')); - expect(cfg.mcpServers.codegraph).toBeDefined(); - }); - - it('claude: local install migrates a legacy ./.claude.json codegraph entry into ./.mcp.json', () => { - const claude = getTarget('claude')!; - const legacy = path.join(tmpCwd, '.claude.json'); - fs.writeFileSync( - legacy, - JSON.stringify({ mcpServers: { codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] } } }, null, 2), - ); - - claude.install('local', { autoAllow: false }); - - // codegraph now lives in .mcp.json; the legacy file (which held only - // codegraph) is gone. - const mcp = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8')); - expect(mcp.mcpServers.codegraph).toBeDefined(); - expect(fs.existsSync(legacy)).toBe(false); - }); - - it('claude: legacy ./.claude.json migration preserves sibling servers and unrelated keys', () => { - const claude = getTarget('claude')!; - const legacy = path.join(tmpCwd, '.claude.json'); - fs.writeFileSync( - legacy, - JSON.stringify({ - mcpServers: { - codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] }, - other: { command: 'x' }, - }, - somethingElse: true, - }, null, 2), - ); - - claude.install('local', { autoAllow: false }); - - // Only codegraph is stripped from the legacy file; siblings survive. - const after = JSON.parse(fs.readFileSync(legacy, 'utf-8')); - expect(after.mcpServers.codegraph).toBeUndefined(); - expect(after.mcpServers.other).toBeDefined(); - expect(after.somethingElse).toBe(true); - const mcp = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8')); - expect(mcp.mcpServers.codegraph).toBeDefined(); - }); - - it('claude: uninstall strips codegraph from ./.mcp.json and a legacy ./.claude.json', () => { - const claude = getTarget('claude')!; - // A user left with both the working .mcp.json and a stale .claude.json. - fs.writeFileSync( - path.join(tmpCwd, '.mcp.json'), - JSON.stringify({ mcpServers: { codegraph: { command: 'codegraph' } } }, null, 2), - ); - fs.writeFileSync( - path.join(tmpCwd, '.claude.json'), - JSON.stringify({ mcpServers: { codegraph: { command: 'codegraph' }, other: { command: 'x' } } }, null, 2), - ); - - claude.uninstall('local'); - - const mcp = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8')); - expect(mcp.mcpServers).toBeUndefined(); - const legacy = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.claude.json'), 'utf-8')); - expect(legacy.mcpServers.codegraph).toBeUndefined(); - expect(legacy.mcpServers.other).toBeDefined(); - }); - - // ---- Legacy auto-sync hook cleanup ---- - // Pre-0.8 installs wrote `codegraph mark-dirty` / `sync-if-dirty` - // hooks to settings.json. Both subcommands were removed from the CLI, - // so the Stop hook fails every turn ("unknown command - // 'sync-if-dirty'"). The installer must strip them on upgrade and - // uninstall — without touching the user's unrelated hooks. - - function seedSettings(loc: 'global' | 'local', settings: Record): string { - const dir = path.join(loc === 'global' ? tmpHome : tmpCwd, '.claude'); - fs.mkdirSync(dir, { recursive: true }); - const file = path.join(dir, 'settings.json'); - fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n'); - return file; - } - - // Realistic pre-0.8 settings.json: our two auto-sync hooks plus an - // unrelated GitKraken Stop hook the user added (matches the report). - function legacyHookSettings(): Record { - return { - hooks: { - PostToolUse: [ - { matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'codegraph mark-dirty', async: true }] }, - ], - Stop: [ - { hooks: [{ type: 'command', command: 'codegraph sync-if-dirty' }] }, - { hooks: [{ type: 'command', command: '"/Users/me/gk" ai hook run --host claude-code' }] }, - ], - }, - }; - } - - it('claude: install strips stale codegraph auto-sync hooks but keeps the user\'s GitKraken hook', () => { - const claude = getTarget('claude')!; - const file = seedSettings('global', legacyHookSettings()); - - claude.install('global', { autoAllow: true }); - - const after = JSON.parse(fs.readFileSync(file, 'utf-8')); - // The only PostToolUse group held mark-dirty → the event is gone. - expect(after.hooks?.PostToolUse).toBeUndefined(); - const stopCommands = (after.hooks?.Stop ?? []).flatMap((g: any) => - (g.hooks ?? []).map((h: any) => h.command), - ); - expect(stopCommands).not.toContain('codegraph sync-if-dirty'); - // The unrelated GitKraken hook survives untouched. - expect(stopCommands.some((c: string) => c.includes('gk') && c.includes('ai hook run'))).toBe(true); - // Permissions still written as normal alongside the cleanup. - expect(after.permissions?.allow).toContain('mcp__codegraph__codegraph_search'); - }); - - it('claude: cleanupLegacyHooks preserves a sibling hook sharing our matcher group', () => { - const file = seedSettings('global', { - hooks: { - Stop: [ - { - hooks: [ - { type: 'command', command: 'codegraph sync-if-dirty' }, - { type: 'command', command: 'gk ai hook run --host claude-code' }, - ], - }, - ], - }, - }); - - expect(cleanupLegacyHooks('global').action).toBe('removed'); - - const after = JSON.parse(fs.readFileSync(file, 'utf-8')); - expect(after.hooks.Stop[0].hooks.map((h: any) => h.command)).toEqual([ - 'gk ai hook run --host claude-code', - ]); - }); - - it('claude: cleanupLegacyHooks is a byte-for-byte no-op without codegraph hooks', () => { - const original = - JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'gk ai hook run' }] }] } }, null, 2) + '\n'; - const file = seedSettings('global', JSON.parse(original)); - - expect(cleanupLegacyHooks('global').action).toBe('unchanged'); - expect(fs.readFileSync(file, 'utf-8')).toBe(original); - }); - - it('claude: cleanupLegacyHooks reports not-found when settings.json is absent', () => { - expect(cleanupLegacyHooks('global').action).toBe('not-found'); - }); - - it('claude: re-running install after a legacy cleanup leaves settings.json unchanged', () => { - const claude = getTarget('claude')!; - const file = seedSettings('global', legacyHookSettings()); - claude.install('global', { autoAllow: true }); - const firstPass = fs.readFileSync(file, 'utf-8'); - claude.install('global', { autoAllow: true }); - expect(fs.readFileSync(file, 'utf-8')).toBe(firstPass); - }); - - it('claude: uninstall strips stale hooks written in the npx form (local)', () => { - const claude = getTarget('claude')!; - const file = seedSettings('local', { - hooks: { - PostToolUse: [ - { matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'npx @colbymchenry/codegraph mark-dirty', async: true }] }, - ], - Stop: [ - { hooks: [{ type: 'command', command: 'npx @colbymchenry/codegraph sync-if-dirty' }] }, - ], - }, - }); - - claude.uninstall('local'); - - const after = JSON.parse(fs.readFileSync(file, 'utf-8')); - // Both events emptied → the whole `hooks` object is removed. - expect(after.hooks).toBeUndefined(); - }); -}); - -describe('Installer targets — registry', () => { - it('getTarget returns the right target for each id', () => { - expect(getTarget('claude')?.id).toBe('claude'); - expect(getTarget('cursor')?.id).toBe('cursor'); - expect(getTarget('codex')?.id).toBe('codex'); - expect(getTarget('opencode')?.id).toBe('opencode'); - expect(getTarget('hermes')?.id).toBe('hermes'); - expect(getTarget('not-a-real-target')).toBeUndefined(); - }); - - it('resolveTargetFlag handles auto/all/none/csv', () => { - expect(resolveTargetFlag('none', 'global')).toEqual([]); - expect(resolveTargetFlag('all', 'global').length).toBe(ALL_TARGETS.length); - const csv = resolveTargetFlag('claude,cursor', 'global'); - expect(csv.map((t) => t.id)).toEqual(['claude', 'cursor']); - }); - - it('resolveTargetFlag throws on unknown id', () => { - expect(() => resolveTargetFlag('claude,bogus', 'global')).toThrow(/Unknown --target/); - }); -}); - -describe('Installer targets — TOML serializer (Codex backbone)', () => { - it('builds a [mcp_servers.codegraph] block with command + args', () => { - const block = buildTomlTable('mcp_servers.codegraph', { - command: 'codegraph', - args: ['serve', '--mcp'], - }); - expect(block).toContain('[mcp_servers.codegraph]'); - expect(block).toContain('command = "codegraph"'); - expect(block).toContain('args = ["serve", "--mcp"]'); - }); - - it('upsert inserts into empty content', () => { - const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] }); - const { content, action } = upsertTomlTable('', 'mcp_servers.codegraph', block); - expect(action).toBe('inserted'); - expect(content.startsWith('[mcp_servers.codegraph]')).toBe(true); - }); - - it('upsert is idempotent — second call returns unchanged', () => { - const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] }); - const first = upsertTomlTable('', 'mcp_servers.codegraph', block); - const second = upsertTomlTable(first.content, 'mcp_servers.codegraph', block); - expect(second.action).toBe('unchanged'); - expect(second.content).toBe(first.content); - }); - - it('upsert replaces an existing block in place, preserving sibling tables', () => { - const existing = [ - '[other_table]', - 'foo = "bar"', - '', - '[mcp_servers.codegraph]', - 'command = "old-codegraph"', - 'args = ["old"]', - '', - '[zzz]', - 'baz = "qux"', - '', - ].join('\n'); - const newBlock = buildTomlTable('mcp_servers.codegraph', { - command: 'codegraph', - args: ['serve', '--mcp'], - }); - const { content, action } = upsertTomlTable(existing, 'mcp_servers.codegraph', newBlock); - expect(action).toBe('replaced'); - expect(content).toContain('[other_table]'); - expect(content).toContain('foo = "bar"'); - expect(content).toContain('[zzz]'); - expect(content).toContain('baz = "qux"'); - expect(content).toContain('command = "codegraph"'); - expect(content).not.toContain('old-codegraph'); - }); - - it('removeTomlTable strips the block and preserves siblings', () => { - const existing = [ - '[other_table]', - 'foo = "bar"', - '', - '[mcp_servers.codegraph]', - 'command = "codegraph"', - 'args = ["serve"]', - ].join('\n'); - const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph'); - expect(action).toBe('removed'); - expect(content).toContain('[other_table]'); - expect(content).toContain('foo = "bar"'); - expect(content).not.toContain('mcp_servers.codegraph'); - }); - - it('removeTomlTable on missing table returns not-found, no content change', () => { - const existing = '[other]\nfoo = "bar"\n'; - const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph'); - expect(action).toBe('not-found'); - expect(content).toBe(existing); - }); - - it('upsert preserves an array-of-tables sibling [[foo]]', () => { - const existing = [ - '[[foo]]', - 'name = "a"', - '', - '[[foo]]', - 'name = "b"', - '', - ].join('\n'); - const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] }); - const { content } = upsertTomlTable(existing, 'mcp_servers.codegraph', block); - expect(content.match(/\[\[foo\]\]/g)?.length).toBe(2); - expect(content).toContain('[mcp_servers.codegraph]'); - }); -}); - -describe('Installer — uninstallTargets sweep (codegraph uninstall)', () => { - let tmpHome: string; - let tmpCwd: string; - let origCwd: string; - let homeRestore: { restore: () => void }; - - beforeEach(() => { - tmpHome = mkTmpDir('un-home'); - tmpCwd = mkTmpDir('un-cwd'); - origCwd = process.cwd(); - process.chdir(tmpCwd); - homeRestore = setHome(tmpHome); - }); - - afterEach(() => { - homeRestore.restore(); - process.chdir(origCwd); - fs.rmSync(tmpHome, { recursive: true, force: true }); - fs.rmSync(tmpCwd, { recursive: true, force: true }); - }); - - it('sweeps every agent it was installed on and reports removed for each (global)', () => { - for (const t of ALL_TARGETS) { - if (t.supportsLocation('global')) t.install('global', { autoAllow: true }); - } - - const reports = uninstallTargets(ALL_TARGETS, 'global'); - - for (const t of ALL_TARGETS) { - const r = reports.find((x) => x.id === t.id)!; - expect(r.status).toBe('removed'); - expect(r.removedPaths.length).toBeGreaterThan(0); - // The actual config is gone afterward. - expect(t.detect('global').alreadyConfigured).toBe(false); - } - }); - - it('is safe on a clean slate — every agent reports not-configured, nothing removed', () => { - const reports = uninstallTargets(ALL_TARGETS, 'global'); - for (const r of reports) { - expect(r.status).toBe('not-configured'); - expect(r.removedPaths).toEqual([]); - } - }); - - it('reports removed only for agents that were actually configured', () => { - // Install on Claude only; the rest stay untouched. - getTarget('claude')!.install('global', { autoAllow: true }); - - const reports = uninstallTargets(ALL_TARGETS, 'global'); - - const claude = reports.find((r) => r.id === 'claude')!; - expect(claude.status).toBe('removed'); - expect(claude.displayName).toBe(getTarget('claude')!.displayName); - - for (const r of reports.filter((x) => x.id !== 'claude')) { - expect(r.status).toBe('not-configured'); - } - }); - - it('marks global-only agents as unsupported for a local sweep (and never touches them)', () => { - const reports = uninstallTargets(ALL_TARGETS, 'local'); - for (const t of ALL_TARGETS) { - const r = reports.find((x) => x.id === t.id)!; - if (t.supportsLocation('local')) { - expect(r.status).toBe('not-configured'); - } else { - expect(r.status).toBe('unsupported'); - expect(r.removedPaths).toEqual([]); - expect(r.notes[0]).toMatch(/global-only/); - } - } - }); - - it('is idempotent — a second sweep finds nothing left to remove', () => { - for (const t of ALL_TARGETS) { - if (t.supportsLocation('global')) t.install('global', { autoAllow: true }); - } - const first = uninstallTargets(ALL_TARGETS, 'global'); - expect(first.some((r) => r.status === 'removed')).toBe(true); - - const second = uninstallTargets(ALL_TARGETS, 'global'); - for (const r of second) { - expect(r.status).toBe('not-configured'); - expect(r.removedPaths).toEqual([]); - } - }); - - it('a --target subset removes only the chosen agents, leaving siblings configured', () => { - getTarget('claude')!.install('global', { autoAllow: true }); - getTarget('cursor')!.install('global', { autoAllow: true }); - - const reports = uninstallTargets(resolveTargetFlag('claude', 'global'), 'global'); - - expect(reports.map((r) => r.id)).toEqual(['claude']); - expect(reports[0].status).toBe('removed'); - // Cursor was not in the subset — still configured. - expect(getTarget('cursor')!.detect('global').alreadyConfigured).toBe(true); - expect(getTarget('claude')!.detect('global').alreadyConfigured).toBe(false); - }); -}); - -describe('Installer — Cursor rules file cleanup on uninstall', () => { - let tmpHome: string; - let tmpCwd: string; - let origCwd: string; - let homeRestore: { restore: () => void }; - const cursor = getTarget('cursor')!; - - beforeEach(() => { - tmpHome = mkTmpDir('cur-home'); - tmpCwd = mkTmpDir('cur-cwd'); - origCwd = process.cwd(); - process.chdir(tmpCwd); - homeRestore = setHome(tmpHome); - }); - - afterEach(() => { - homeRestore.restore(); - process.chdir(origCwd); - fs.rmSync(tmpHome, { recursive: true, force: true }); - fs.rmSync(tmpCwd, { recursive: true, force: true }); - }); - - const rulesFile = () => path.join(process.cwd(), '.cursor', 'rules', 'codegraph.mdc'); - - it('deletes the dedicated codegraph.mdc entirely (no orphaned frontmatter left behind)', () => { - cursor.install('local', { autoAllow: true }); - expect(fs.existsSync(rulesFile())).toBe(true); - - cursor.uninstall('local'); - - // The whole file — frontmatter included — is gone, not just the block. - expect(fs.existsSync(rulesFile())).toBe(false); - expect(cursor.detect('local').alreadyConfigured).toBe(false); - }); - - it('preserves user content added outside the codegraph markers (strips only our block)', () => { - cursor.install('local', { autoAllow: true }); - const withUserContent = - fs.readFileSync(rulesFile(), 'utf-8') + '\n## My own rule\nkeep me\n'; - fs.writeFileSync(rulesFile(), withUserContent); - - cursor.uninstall('local'); - - expect(fs.existsSync(rulesFile())).toBe(true); - const after = fs.readFileSync(rulesFile(), 'utf-8'); - expect(after).toContain('keep me'); - // Our tool-usage block is gone. - expect(after).not.toContain('codegraph_search'); - expect(after).not.toContain('CODEGRAPH_START'); - }); -}); - -function listAllFiles(dir: string): string[] { - if (!fs.existsSync(dir)) return []; - const out: string[] = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) out.push(...listAllFiles(full)); - else out.push(full); - } - return out; -} diff --git a/__tests__/installer.test.ts b/__tests__/installer.test.ts deleted file mode 100644 index 728ed7c3..00000000 --- a/__tests__/installer.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * Installer Tests - * - * Tests for installer config-writer fixes: - * - readJsonFile error handling - * - writeClaudeMd section replacement - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; - -// We test the exported functions from config-writer -import { - writeMcpConfig, - writePermissions, - writeClaudeMd, - hasMcpConfig, - hasPermissions, - hasClaudeMdSection, -} from '../src/installer/config-writer'; - -function createTempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-installer-test-')); -} - -function cleanupTempDir(dir: string): void { - if (fs.existsSync(dir)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - -describe('Installer Config Writer', () => { - let origCwd: string; - let tempDir: string; - - beforeEach(() => { - tempDir = createTempDir(); - origCwd = process.cwd(); - process.chdir(tempDir); - }); - - afterEach(() => { - process.chdir(origCwd); - cleanupTempDir(tempDir); - }); - - describe('readJsonFile error handling', () => { - it('should return empty object for non-existent file', () => { - // writeMcpConfig reads .mcp.json - if it doesn't exist, it should create it - writeMcpConfig('local'); - - const mcpJson = path.join(tempDir, '.mcp.json'); - expect(fs.existsSync(mcpJson)).toBe(true); - - const content = JSON.parse(fs.readFileSync(mcpJson, 'utf-8')); - expect(content.mcpServers).toBeDefined(); - expect(content.mcpServers.codegraph).toBeDefined(); - }); - - it('should handle corrupted JSON by creating backup', () => { - // Create a corrupted .mcp.json - const mcpJson = path.join(tempDir, '.mcp.json'); - fs.writeFileSync(mcpJson, '{ this is not valid json !!!'); - - // Suppress console.warn during test - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - // Should not throw - gracefully handles corruption - writeMcpConfig('local'); - - // Should have warned - expect(warnSpy).toHaveBeenCalled(); - const warnMsg = warnSpy.mock.calls[0][0]; - expect(warnMsg).toContain('Warning'); - - // Backup should exist - expect(fs.existsSync(mcpJson + '.backup')).toBe(true); - // Original backup content should be the corrupted content - const backup = fs.readFileSync(mcpJson + '.backup', 'utf-8'); - expect(backup).toContain('this is not valid json'); - - // New file should be valid JSON with codegraph config - const content = JSON.parse(fs.readFileSync(mcpJson, 'utf-8')); - expect(content.mcpServers.codegraph).toBeDefined(); - - warnSpy.mockRestore(); - }); - - it('should preserve existing valid config when adding codegraph', () => { - const mcpJson = path.join(tempDir, '.mcp.json'); - fs.writeFileSync(mcpJson, JSON.stringify({ - mcpServers: { other: { command: 'other-tool' } }, - customField: 'preserved', - }, null, 2)); - - writeMcpConfig('local'); - - const content = JSON.parse(fs.readFileSync(mcpJson, 'utf-8')); - expect(content.mcpServers.codegraph).toBeDefined(); - expect(content.mcpServers.other).toBeDefined(); - expect(content.customField).toBe('preserved'); - }); - }); - - describe('writeClaudeMd section replacement', () => { - it('should create new CLAUDE.md with markers', () => { - const result = writeClaudeMd('local'); - - expect(result.created).toBe(true); - const content = fs.readFileSync(path.join(tempDir, '.claude', 'CLAUDE.md'), 'utf-8'); - expect(content).toContain(''); - expect(content).toContain(''); - expect(content).toContain('## CodeGraph'); - }); - - it('should replace marked section on update', () => { - // First write - writeClaudeMd('local'); - - // Modify file to add custom content before and after - const claudeMdPath = path.join(tempDir, '.claude', 'CLAUDE.md'); - const original = fs.readFileSync(claudeMdPath, 'utf-8'); - const modified = '## My Custom Section\n\nCustom content\n\n' + original + '\n\n## Another Section\n\nMore content\n'; - fs.writeFileSync(claudeMdPath, modified); - - // Second write should leave the marked block as-is (byte-identical - // body, so result is `created:false, updated:false` — both flags - // are off but the surrounding custom content must survive). - writeClaudeMd('local'); - - const final = fs.readFileSync(claudeMdPath, 'utf-8'); - expect(final).toContain('## My Custom Section'); - expect(final).toContain('Custom content'); - expect(final).toContain('## Another Section'); - expect(final).toContain('More content'); - expect(final).toContain('## CodeGraph'); - }); - - it('should use atomic writes (no temp files left behind)', () => { - writeClaudeMd('local'); - - const claudeDir = path.join(tempDir, '.claude'); - const files = fs.readdirSync(claudeDir); - const tmpFiles = files.filter(f => f.includes('.tmp.')); - expect(tmpFiles).toHaveLength(0); - }); - - it('should not overwrite content after unmarked section with ### subsections', () => { - // Create a CLAUDE.md with an unmarked CodeGraph section that has ### subsections - // followed by another ## section - const claudeDir = path.join(tempDir, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - const claudeMdPath = path.join(claudeDir, 'CLAUDE.md'); - fs.writeFileSync(claudeMdPath, [ - '## Pre-existing Section', - '', - 'Some content', - '', - '## CodeGraph', - '', - '### Subsection A', - '', - 'Old codegraph content', - '', - '### Subsection B', - '', - 'More old content', - '', - '## Important Section After', - '', - 'This content must not be overwritten!', - '', - ].join('\n')); - - const result = writeClaudeMd('local'); - expect(result.updated).toBe(true); - - const final = fs.readFileSync(claudeMdPath, 'utf-8'); - // The section after CodeGraph must be preserved - expect(final).toContain('## Important Section After'); - expect(final).toContain('This content must not be overwritten!'); - // Pre-existing section should also be preserved - expect(final).toContain('## Pre-existing Section'); - // New CodeGraph content should be present with markers - expect(final).toContain(''); - expect(final).toContain(''); - }); - - it('should replace unmarked section without subsections', () => { - const claudeDir = path.join(tempDir, '.claude'); - fs.mkdirSync(claudeDir, { recursive: true }); - const claudeMdPath = path.join(claudeDir, 'CLAUDE.md'); - // Note: regex needs \n before ## CodeGraph, so prefix with another section - fs.writeFileSync(claudeMdPath, [ - '## Intro', - '', - 'Preamble', - '', - '## CodeGraph', - '', - 'Old simple content', - '', - '## Next Section', - '', - 'Must be preserved', - '', - ].join('\n')); - - writeClaudeMd('local'); - - const final = fs.readFileSync(claudeMdPath, 'utf-8'); - expect(final).toContain(''); - expect(final).toContain('## Next Section'); - expect(final).toContain('Must be preserved'); - expect(final).not.toContain('Old simple content'); - }); - }); -}); diff --git a/__tests__/is-test-file.test.ts b/__tests__/is-test-file.test.ts deleted file mode 100644 index e3fc6d03..00000000 --- a/__tests__/is-test-file.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * isTestFile heuristic — test-file detection used to deprioritize test code in - * search/explore ranking. - * - * Regression coverage for the cold-query fix: the heuristic previously only - * knew Java/JS/Python conventions, so Kotlin (`*Test.kt`, `jvmTest/`), Swift - * (`*Tests.swift`), and camelCase test source-set dirs slipped through — which - * let OkHttp's tests flood `codegraph_explore` results on a plain-language - * query. The false-positive guards matter just as much: `latest.kt` / - * `manifest.kt` / a `RealCall.kt` production file must NOT be flagged. - */ -import { describe, it, expect } from 'vitest'; -import { isTestFile } from '../src/search/query-utils'; - -describe('isTestFile', () => { - it('flags Kotlin test files and source sets', () => { - expect(isTestFile('okhttp/src/jvmTest/kotlin/okhttp3/CallTest.kt')).toBe(true); - expect(isTestFile('okhttp/src/commonTest/kotlin/okhttp3/CompressionInterceptorTest.kt')).toBe(true); - expect(isTestFile('app/src/androidTest/java/com/example/FooTest.kt')).toBe(true); - expect(isTestFile('module/src/integrationTest/kotlin/BarSpec.kt')).toBe(true); - }); - - it('flags Swift test files', () => { - expect(isTestFile('Tests/SessionTests.swift')).toBe(true); - expect(isTestFile('Sources/FooTest.swift')).toBe(true); - }); - - it('still flags the previously-supported conventions', () => { - expect(isTestFile('foo/test_bar.py')).toBe(true); - expect(isTestFile('pkg/bar_test.go')).toBe(true); - expect(isTestFile('src/foo.test.ts')).toBe(true); - expect(isTestFile('src/foo.spec.ts')).toBe(true); - expect(isTestFile('com/example/FooTest.java')).toBe(true); - expect(isTestFile('com/example/FooTestCase.java')).toBe(true); - expect(isTestFile('project/__tests__/foo.ts')).toBe(true); - expect(isTestFile('project/tests/foo.rb')).toBe(true); - }); - - it('does NOT flag production files that merely contain "test" lowercase', () => { - // The fix is capital-led so camelCase boundaries distinguish these. - expect(isTestFile('src/latest/loader.kt')).toBe(false); - expect(isTestFile('lib/manifest.kt')).toBe(false); - expect(isTestFile('okhttp/src/jvmMain/kotlin/okhttp3/internal/connection/RealCall.kt')).toBe(false); - expect(isTestFile('src/contestEntry.ts')).toBe(false); - expect(isTestFile('pkg/greatest.go')).toBe(false); - }); - - it('does NOT flag ordinary production source', () => { - expect(isTestFile('src/flask/app.py')).toBe(false); - expect(isTestFile('src/vs/workbench/api/common/extensionHostMain.ts')).toBe(false); - expect(isTestFile('okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt')).toBe(false); - }); -}); diff --git a/__tests__/mcp-initialize.test.ts b/__tests__/mcp-initialize.test.ts deleted file mode 100644 index 4a57ebae..00000000 --- a/__tests__/mcp-initialize.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * MCP `initialize` handshake regression tests. - * - * Issue #172: on slow filesystems (Docker Desktop VirtioFS on macOS, WSL2), - * the MCP server was blocking the initialize response on CodeGraph.open() and - * Parser.init() (web-tree-sitter WASM bootstrap), which could take longer than - * Claude Code's ~30s handshake timeout. The child process stayed alive and - * had received the request, but never sent a response, so tools never - * appeared in the client. The fix sends the initialize response before - * kicking off the heavy init in the background. These tests guard the - * contract that initialize is fast regardless of how much work init does. - */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { CodeGraph } from '../src'; - -const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); - -function spawnServer(cwd: string): ChildProcessWithoutNullStreams { - return spawn(process.execPath, [BIN, 'serve', '--mcp'], { - cwd, - stdio: ['pipe', 'pipe', 'pipe'], - }) as ChildProcessWithoutNullStreams; -} - -function sendInitialize(child: ChildProcessWithoutNullStreams, projectPath: string) { - const msg = JSON.stringify({ - jsonrpc: '2.0', - id: 0, - method: 'initialize', - params: { - protocolVersion: '2025-11-25', - capabilities: {}, - clientInfo: { name: 'test', version: '0.0.0' }, - rootUri: `file://${projectPath}`, - }, - }); - child.stdin.write(msg + '\n'); -} - -/** - * Collect stdout lines and stderr text from the child, tagging each piece - * with a monotonic sequence number. Lets us assert ordering between the - * JSON-RPC response (stdout) and side-effect logs (stderr). - */ -function tagStreams(child: ChildProcessWithoutNullStreams) { - const events: Array<{ seq: number; stream: 'stdout' | 'stderr'; text: string }> = []; - let seq = 0; - let stdoutBuf = ''; - let stderrBuf = ''; - child.stdout.on('data', (chunk) => { - stdoutBuf += chunk.toString('utf8'); - let idx; - while ((idx = stdoutBuf.indexOf('\n')) !== -1) { - const line = stdoutBuf.slice(0, idx); - stdoutBuf = stdoutBuf.slice(idx + 1); - events.push({ seq: seq++, stream: 'stdout', text: line }); - } - }); - child.stderr.on('data', (chunk) => { - stderrBuf += chunk.toString('utf8'); - let idx; - while ((idx = stderrBuf.indexOf('\n')) !== -1) { - const line = stderrBuf.slice(0, idx); - stderrBuf = stderrBuf.slice(idx + 1); - events.push({ seq: seq++, stream: 'stderr', text: line }); - } - }); - return events; -} - -function waitFor( - events: ReadonlyArray<{ seq: number; stream: string; text: string }>, - predicate: (e: { seq: number; stream: string; text: string }) => boolean, - timeoutMs: number, -): Promise<{ seq: number; stream: string; text: string }> { - return new Promise((resolve, reject) => { - const started = Date.now(); - const tick = () => { - const hit = events.find(predicate); - if (hit) return resolve(hit); - if (Date.now() - started > timeoutMs) { - return reject(new Error(`Timed out waiting for predicate. Events: ${JSON.stringify(events)}`)); - } - setTimeout(tick, 20); - }; - tick(); - }); -} - -describe('MCP initialize handshake (issue #172)', () => { - let tempDir: string; - let child: ChildProcessWithoutNullStreams | null = null; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-init-')); - }); - - afterEach(() => { - if (child && !child.killed) { - child.kill('SIGKILL'); - child = null; - } - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - it('responds to initialize quickly when no .codegraph exists in cwd', async () => { - child = spawnServer(tempDir); - const events = tagStreams(child); - sendInitialize(child, tempDir); - const response = await waitFor(events, (e) => e.stream === 'stdout', 5000); - const json = JSON.parse(response.text); - expect(json.jsonrpc).toBe('2.0'); - expect(json.id).toBe(0); - expect(json.result.protocolVersion).toBeDefined(); - expect(json.result.capabilities.tools).toBeDefined(); - }, 10000); - - it('sends initialize response BEFORE tryInitializeDefault finishes', async () => { - // Seed a real .codegraph so the server's tryInitializeDefault path runs - // its full body: CodeGraph.open() (which awaits initGrammars()) and then - // startWatching() (which logs "File watcher active" to stderr). On any - // platform, that stderr log is observable evidence that tryInitializeDefault - // has completed. The contract we're protecting: the JSON-RPC response on - // stdout must arrive BEFORE that stderr log. If a future change re-awaits - // tryInitializeDefault before sendResult, this ordering inverts and the - // test fails — regardless of how fast the local filesystem is. - const cg = await CodeGraph.init(tempDir); - cg.close(); - - child = spawnServer(tempDir); - const events = tagStreams(child); - sendInitialize(child, tempDir); - - const response = await waitFor(events, (e) => e.stream === 'stdout', 10000); - const watcherLog = await waitFor( - events, - (e) => e.stream === 'stderr' && e.text.includes('File watcher active'), - 10000, - ); - expect(response.seq).toBeLessThan(watcherLog.seq); - const json = JSON.parse(response.text); - expect(json.id).toBe(0); - expect(json.result.serverInfo.name).toBe('codegraph'); - }, 20000); -}); diff --git a/__tests__/mcp-roots.test.ts b/__tests__/mcp-roots.test.ts deleted file mode 100644 index 8e1d4520..00000000 --- a/__tests__/mcp-roots.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * MCP project-resolution regression tests (issue #196). - * - * When an MCP client launches the server outside the project directory AND - * doesn't pass a `rootUri`/`workspaceFolders` in `initialize`, the server used - * to fall straight back to `process.cwd()` — which for many IDE clients is the - * wrong directory. Every tool call without an explicit `projectPath` then - * failed with a misleading "CodeGraph not initialized. Run 'codegraph init'." - * - * The fix: when no explicit path is provided, the server asks the client for - * its workspace root via the spec-blessed `roots/list` request (if the client - * advertised the `roots` capability), and only falls back to cwd otherwise. - * When it still can't resolve, the error now says exactly how to fix it. - * - * These tests drive the real stdio transport via a spawned subprocess — no - * mocking — so they also exercise the new bidirectional request/response path. - */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { CodeGraph } from '../src'; - -const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); - -function spawnServer(cwd: string): ChildProcessWithoutNullStreams { - // --no-watch keeps the test deterministic and avoids watcher startup noise. - return spawn(process.execPath, [BIN, 'serve', '--mcp', '--no-watch'], { - cwd, - stdio: ['pipe', 'pipe', 'pipe'], - }) as ChildProcessWithoutNullStreams; -} - -/** Parse every JSON-RPC message the server writes to stdout into an array. */ -function collectMessages(child: ChildProcessWithoutNullStreams): Array> { - const messages: Array> = []; - let buf = ''; - child.stdout.on('data', (chunk) => { - buf += chunk.toString('utf8'); - let idx; - while ((idx = buf.indexOf('\n')) !== -1) { - const line = buf.slice(0, idx).trim(); - buf = buf.slice(idx + 1); - if (!line) continue; - try { messages.push(JSON.parse(line)); } catch { /* ignore non-JSON */ } - } - }); - return messages; -} - -function waitForMessage( - messages: ReadonlyArray>, - predicate: (m: Record) => boolean, - timeoutMs: number, -): Promise> { - return new Promise((resolve, reject) => { - const started = Date.now(); - const tick = () => { - const hit = messages.find(predicate); - if (hit) return resolve(hit); - if (Date.now() - started > timeoutMs) { - return reject(new Error(`Timed out. Messages so far: ${JSON.stringify(messages)}`)); - } - setTimeout(tick, 20); - }; - tick(); - }); -} - -function send(child: ChildProcessWithoutNullStreams, msg: object): void { - child.stdin.write(JSON.stringify(msg) + '\n'); -} - -const CLIENT_INFO = { name: 'test', version: '0.0.0' }; - -describe('MCP project resolution via roots/list (issue #196)', () => { - let cwdDir: string; // where the server is launched — has NO .codegraph - let projectDir: string; // the real indexed project the client reports - let child: ChildProcessWithoutNullStreams | null = null; - - beforeEach(() => { - cwdDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-cwd-')); - projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-proj-')); - }); - - afterEach(() => { - if (child && !child.killed) { - child.kill('SIGKILL'); - child = null; - } - fs.rmSync(cwdDir, { recursive: true, force: true }); - fs.rmSync(projectDir, { recursive: true, force: true }); - }); - - it('resolves the project from the client roots/list when no rootUri is sent', async () => { - const cg = await CodeGraph.init(projectDir); - cg.close(); - - child = spawnServer(cwdDir); - const messages = collectMessages(child); - - // Advertise the roots capability but pass NO rootUri/workspaceFolders. - send(child, { - jsonrpc: '2.0', id: 0, method: 'initialize', - params: { protocolVersion: '2025-11-25', capabilities: { roots: {} }, clientInfo: CLIENT_INFO }, - }); - await waitForMessage(messages, (m) => m.id === 0 && !!m.result, 5000); - send(child, { jsonrpc: '2.0', method: 'notifications/initialized' }); - - // First tool call (no projectPath) drives the server to ask us for roots. - send(child, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } }); - - const rootsReq = await waitForMessage(messages, (m) => m.method === 'roots/list', 5000); - expect(typeof rootsReq.id).toBe('string'); // server-initiated id - send(child, { - jsonrpc: '2.0', id: rootsReq.id, - result: { roots: [{ uri: `file://${projectDir}`, name: 'proj' }] }, - }); - - // The status call now succeeds against the resolved project. - const resp = await waitForMessage(messages, (m) => m.id === 1, 8000); - const text = resp.result.content[0].text as string; - expect(text).toContain('CodeGraph Status'); - expect(text).not.toContain('No CodeGraph project is loaded'); - }, 20000); - - it('returns an actionable error when there is no rootUri and no roots capability', async () => { - child = spawnServer(cwdDir); - const messages = collectMessages(child); - - send(child, { - jsonrpc: '2.0', id: 0, method: 'initialize', - params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: CLIENT_INFO }, - }); - await waitForMessage(messages, (m) => m.id === 0 && !!m.result, 5000); - send(child, { jsonrpc: '2.0', method: 'notifications/initialized' }); - - send(child, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } }); - const resp = await waitForMessage(messages, (m) => m.id === 1, 8000); - const text = resp.result.content[0].text as string; - - expect(text).toContain('No CodeGraph project is loaded'); - expect(text).toContain('projectPath'); - expect(text).toContain('--path'); - // Names the directory it actually searched (the wrong cwd) so the user can - // see why detection missed. basename survives any symlink realpath-ing. - expect(text).toContain(path.basename(cwdDir)); - // It must not have hung waiting on roots/list — the client never offered it. - expect(messages.some((m) => m.method === 'roots/list')).toBe(false); - }, 20000); - - it('honors an explicit rootUri without asking the client for roots', async () => { - const cg = await CodeGraph.init(projectDir); - cg.close(); - - child = spawnServer(cwdDir); - const messages = collectMessages(child); - - send(child, { - jsonrpc: '2.0', id: 0, method: 'initialize', - params: { - protocolVersion: '2025-11-25', - capabilities: { roots: {} }, - clientInfo: CLIENT_INFO, - rootUri: `file://${projectDir}`, - }, - }); - await waitForMessage(messages, (m) => m.id === 0 && !!m.result, 5000); - send(child, { jsonrpc: '2.0', method: 'notifications/initialized' }); - - send(child, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } }); - const resp = await waitForMessage(messages, (m) => m.id === 1, 8000); - const text = resp.result.content[0].text as string; - - expect(text).toContain('CodeGraph Status'); - // rootUri is a stronger signal than roots — we never needed to ask. - expect(messages.some((m) => m.method === 'roots/list')).toBe(false); - }, 20000); -}); diff --git a/__tests__/node-sqlite-backend.test.ts b/__tests__/node-sqlite-backend.test.ts deleted file mode 100644 index d1e630f6..00000000 --- a/__tests__/node-sqlite-backend.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * node:sqlite backend (issue #238 follow-up). - * - * node:sqlite (Node's built-in real SQLite) is now the sole backend. This drives - * a real index + queries through it, so WAL, FTS5 search, and @named-param - * writes are all exercised end-to-end. - * - * Skipped on Node < 22.5 where node:sqlite doesn't exist. - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import CodeGraph from '../src'; - -let nodeSqliteAvailable = false; -try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - require('node:sqlite'); - nodeSqliteAvailable = true; -} catch { - nodeSqliteAvailable = false; -} - -describe.skipIf(!nodeSqliteAvailable)('node:sqlite backend — real index + queries', () => { - let dir: string; - let cg: CodeGraph; - - beforeAll(async () => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nodesqlite-')); - fs.writeFileSync(path.join(dir, 'a.ts'), 'export function helper(): number { return 1; }\n'); - fs.writeFileSync( - path.join(dir, 'b.ts'), - "import { helper } from './a';\nexport function main(): number { return helper(); }\n" - ); - cg = await CodeGraph.init(dir, { index: true }); - }); - - afterAll(() => { - cg?.close(); - fs.rmSync(dir, { recursive: true, force: true }); - }); - - it('uses the node:sqlite backend', () => { - expect(cg.getBackend()).toBe('node-sqlite'); - }); - - it('runs in WAL mode — the whole reason it beats the wasm fallback', () => { - expect(cg.getJournalMode()).toBe('wal'); - }); - - it('indexed the project (write path: @named-param INSERTs via node:sqlite)', () => { - const stats = cg.getStats(); - expect(stats.fileCount).toBe(2); - expect(stats.nodeCount).toBeGreaterThan(0); - }); - - it('FTS5 search returns the indexed symbol (read path)', () => { - const results = cg.searchNodes('helper'); - const names = results.map(r => r.node.name); - expect(names).toContain('helper'); - }); - - it('graph traversal resolves the cross-file caller', () => { - const helper = cg.searchNodes('helper').find(r => r.node.name === 'helper'); - expect(helper).toBeTruthy(); - const callers = cg.getCallers(helper!.node.id); - expect(callers.map(c => c.node.name)).toContain('main'); - }); -}); diff --git a/__tests__/node-version-check.test.ts b/__tests__/node-version-check.test.ts deleted file mode 100644 index fc455eb8..00000000 --- a/__tests__/node-version-check.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Pin the Node-25 block banner content. The banner replaced a soft - * `console.warn` because the warning was scrolling off-screen before - * the OOM crash 30 seconds later, generating duplicate bug reports - * (#54, #81, #140). The recipe and override env var below are - * load-bearing — if any of them get edited away, this test catches it. - */ - -import { describe, it, expect } from 'vitest'; -import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from '../src/bin/node-version-check'; - -describe('buildNode25BlockBanner', () => { - it('embeds the reported Node version in the header', () => { - expect(buildNode25BlockBanner('25.9.0')).toContain( - 'Unsupported Node.js version: 25.9.0' - ); - }); - - it('names the V8 turboshaft WASM root cause and the OOM symptom', () => { - const banner = buildNode25BlockBanner('25.7.0'); - expect(banner).toContain('V8 WASM JIT'); - expect(banner).toContain('turboshaft'); - expect(banner).toContain('Fatal process out of memory: Zone'); - }); - - it('points users to Node 22 LTS via nvm and Homebrew', () => { - const banner = buildNode25BlockBanner('25.7.0'); - expect(banner).toContain('Node.js 22 LTS'); - expect(banner).toContain('nvm install 22'); - expect(banner).toContain('brew install node@22'); - }); - - it('documents the CODEGRAPH_ALLOW_UNSAFE_NODE override', () => { - const banner = buildNode25BlockBanner('25.7.0'); - expect(banner).toContain('CODEGRAPH_ALLOW_UNSAFE_NODE=1'); - }); - - it('links to issue #81 for the root-cause writeup', () => { - expect(buildNode25BlockBanner('25.7.0')).toContain( - 'github.com/colbymchenry/codegraph/issues/81' - ); - }); -}); - -describe('buildNodeTooOldBanner', () => { - it('embeds the reported Node version in the header', () => { - expect(buildNodeTooOldBanner('18.20.0')).toContain( - 'Unsupported Node.js version: 18.20.0' - ); - }); - - it('states the supported floor matching MIN_NODE_MAJOR', () => { - expect(MIN_NODE_MAJOR).toBe(20); - expect(buildNodeTooOldBanner('18.0.0')).toContain( - `requires Node.js ${MIN_NODE_MAJOR} or newer` - ); - }); - - it('points users to Node 22 LTS via nvm and Homebrew', () => { - const banner = buildNodeTooOldBanner('16.0.0'); - expect(banner).toContain('Node.js 22 LTS'); - expect(banner).toContain('nvm install 22'); - expect(banner).toContain('brew install node@22'); - }); - - it('documents the CODEGRAPH_ALLOW_UNSAFE_NODE override', () => { - expect(buildNodeTooOldBanner('18.0.0')).toContain('CODEGRAPH_ALLOW_UNSAFE_NODE=1'); - }); -}); diff --git a/__tests__/npm-shim.test.ts b/__tests__/npm-shim.test.ts deleted file mode 100644 index 16e70506..00000000 --- a/__tests__/npm-shim.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * npm thin-installer launcher (`scripts/npm-shim.js`) tests. - * - * The shim runs on the user's own Node, locates the per-platform optionalDependency - * bundle, and — when a registry mirror failed to deliver it (issue #303) — falls - * back to downloading the bundle from GitHub Releases. These tests exercise that - * shim as a real subprocess from a temp "main package" dir (its own package.json - * + node_modules), so resolution and version lookup behave hermetically. - * - * The download/checksum paths run against a local self-signed HTTPS server via - * CODEGRAPH_DOWNLOAD_BASE — no real network, no published release needed. The - * shim is launched with async `spawn` (not spawnSync), so the test's event loop - * stays free to serve those requests. - * - * POSIX only: the fake bundle launcher is a shell script and extraction uses the - * system `tar`. Skipped on Windows (where the shim's exec path differs anyway). - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { spawn, execSync } from 'child_process'; -import * as https from 'https'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import * as crypto from 'crypto'; -import type { AddressInfo } from 'net'; - -const SHIM_SRC = path.join(__dirname, '..', 'scripts', 'npm-shim.js'); -const target = `${process.platform}-${process.arch}`; -const asset = `codegraph-${target}.tar.gz`; -const isWindows = process.platform === 'win32'; - -function hasOpenssl(): boolean { - try { execSync('openssl version', { stdio: 'ignore' }); return true; } catch { return false; } -} -const CAN_NET = !isWindows && hasOpenssl(); - -function mkTmp(label: string): string { - return fs.mkdtempSync(path.join(os.tmpdir(), `cg-shim-${label}-`)); -} - -// A temp dir standing in for the installed @colbymchenry/codegraph main package. -function makePkg(version = '9.9.9-test'): string { - const dir = mkTmp('pkg'); - fs.copyFileSync(SHIM_SRC, path.join(dir, 'npm-shim.js')); - fs.writeFileSync(path.join(dir, 'package.json'), - JSON.stringify({ name: '@colbymchenry/codegraph', version }) + '\n'); - return dir; -} - -// A fake bundle launcher that prints a marker + its args, so we can prove the -// shim found and exec'd it (and passed args through). -function writeLauncher(binDir: string): void { - fs.mkdirSync(binDir, { recursive: true }); - const p = path.join(binDir, 'codegraph'); - fs.writeFileSync(p, '#!/bin/sh\necho "FAKE_BUNDLE_RAN args:$*"\n'); - fs.chmodSync(p, 0o755); -} - -// Launch the shim with async spawn so the in-process HTTPS server can respond -// while it runs (spawnSync would block this event loop and deadlock). -function runShim(pkgDir: string, args: string[], env: Record) { - return new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => { - const child = spawn(process.execPath, [path.join(pkgDir, 'npm-shim.js'), ...args], { - env: { ...process.env, ...env }, - }); - let stdout = '', stderr = ''; - child.stdout.on('data', (d) => { stdout += d.toString(); }); - child.stderr.on('data', (d) => { stderr += d.toString(); }); - child.on('close', (status) => resolve({ status, stdout, stderr })); - }); -} - -describe.skipIf(isWindows)('npm-shim launcher', () => { - it('runs the installed optional-dependency bundle without any download', async () => { - const pkg = makePkg(); - const platformPkg = path.join(pkg, 'node_modules', '@colbymchenry', `codegraph-${target}`); - writeLauncher(path.join(platformPkg, 'bin')); - fs.writeFileSync(path.join(platformPkg, 'package.json'), - JSON.stringify({ name: `@colbymchenry/codegraph-${target}`, version: '9.9.9-test' }) + '\n'); - const cache = mkTmp('cache'); - const r = await runShim(pkg, ['--probe-abc'], { CODEGRAPH_INSTALL_DIR: cache }); - - expect(r.status).toBe(0); - expect(r.stdout).toContain('FAKE_BUNDLE_RAN'); - expect(r.stdout).toContain('--probe-abc'); // args passed through - expect(r.stderr).not.toContain('downloading'); // never reached the fallback - expect(fs.existsSync(path.join(cache, 'bundles'))).toBe(false); - }); - - it('uses an already-cached bundle even when downloads are disabled', async () => { - const pkg = makePkg('1.2.3-cached'); - const cache = mkTmp('cache'); - writeLauncher(path.join(cache, 'bundles', `${target}-1.2.3-cached`, 'bin')); - const r = await runShim(pkg, ['--probe-xyz'], { - CODEGRAPH_INSTALL_DIR: cache, - CODEGRAPH_NO_DOWNLOAD: '1', - }); - - expect(r.status).toBe(0); - expect(r.stdout).toContain('FAKE_BUNDLE_RAN'); - expect(r.stdout).toContain('--probe-xyz'); - expect(r.stderr).toBe(''); - }); - - it('prints actionable guidance and exits 1 when disabled with no bundle', async () => { - const pkg = makePkg(); - const r = await runShim(pkg, ['--version'], { - CODEGRAPH_INSTALL_DIR: mkTmp('cache'), - CODEGRAPH_NO_DOWNLOAD: '1', - }); - - expect(r.status).toBe(1); - expect(r.stderr).toContain(`no prebuilt bundle for ${target}`); - expect(r.stderr).toContain(`@colbymchenry/codegraph-${target}`); - expect(r.stderr).toContain('--registry=https://registry.npmjs.org'); - expect(r.stderr).toContain('install.sh'); - }); -}); - -describe.skipIf(!CAN_NET)('npm-shim download fallback (local HTTPS)', () => { - let server: https.Server; - let port = 0; - let fixtureBytes: Buffer; - let fixtureSha: string; - let sumsBody: string | null = null; // per-test: SHA256SUMS contents, or null for 404 - - beforeAll(async () => { - // Self-signed cert for the mock release host. - const cdir = mkTmp('tls'); - const keyP = path.join(cdir, 'key.pem'); - const certP = path.join(cdir, 'cert.pem'); - execSync( - `openssl req -x509 -newkey rsa:2048 -nodes -keyout ${keyP} -out ${certP} -days 1 -subj "/CN=localhost"`, - { stdio: 'ignore' }, - ); - - // Build a fake bundle archive (codegraph-/bin/codegraph), like a real release asset. - const work = mkTmp('fixture'); - writeLauncher(path.join(work, `codegraph-${target}`, 'bin')); - const archive = path.join(work, asset); - execSync(`tar -czf ${JSON.stringify(archive)} -C ${JSON.stringify(work)} codegraph-${target}`); - fixtureBytes = fs.readFileSync(archive); - fixtureSha = crypto.createHash('sha256').update(fixtureBytes).digest('hex'); - - server = https.createServer({ key: fs.readFileSync(keyP), cert: fs.readFileSync(certP) }, (req, res) => { - const url = req.url || ''; - if (url.endsWith(`/${asset}`)) { - res.writeHead(200); res.end(fixtureBytes); - } else if (url.endsWith('/SHA256SUMS')) { - if (sumsBody === null) { res.writeHead(404); res.end('not found'); } - else { res.writeHead(200); res.end(sumsBody); } - } else { - res.writeHead(404); res.end('not found'); - } - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - port = (server.address() as AddressInfo).port; - }, 30000); - - afterAll(() => { server?.close(); }); - - function netEnv(cache: string): Record { - return { - CODEGRAPH_INSTALL_DIR: cache, - CODEGRAPH_DOWNLOAD_BASE: `https://127.0.0.1:${port}`, - NODE_TLS_REJECT_UNAUTHORIZED: '0', - }; - } - - it('downloads, verifies the checksum, extracts, and execs the bundle', async () => { - sumsBody = `${fixtureSha} ${asset}\n`; - const pkg = makePkg('5.0.0-net'); - const cache = mkTmp('cache'); - const r = await runShim(pkg, ['--probe-net'], netEnv(cache)); - - expect(r.stderr).toContain('downloading'); - expect(r.stderr).toContain('checksum verified'); - expect(r.status).toBe(0); - expect(r.stdout).toContain('FAKE_BUNDLE_RAN'); - expect(r.stdout).toContain('--probe-net'); - expect(fs.existsSync(path.join(cache, 'bundles', `${target}-5.0.0-net`, 'bin', 'codegraph'))).toBe(true); - }, 20000); - - it('aborts (exit 1) on a checksum mismatch and caches nothing', async () => { - sumsBody = `${'0'.repeat(64)} ${asset}\n`; - const pkg = makePkg('5.0.0-bad'); - const cache = mkTmp('cache'); - const r = await runShim(pkg, ['--version'], netEnv(cache)); - - expect(r.status).toBe(1); - expect(r.stderr).toContain('checksum mismatch'); - expect(r.stdout).not.toContain('FAKE_BUNDLE_RAN'); // never exec'd a tampered bundle - expect(fs.existsSync(path.join(cache, 'bundles', `${target}-5.0.0-bad`))).toBe(false); - }, 20000); - - it('proceeds when no SHA256SUMS is published (older releases)', async () => { - sumsBody = null; // 404 - const pkg = makePkg('5.0.0-nosums'); - const cache = mkTmp('cache'); - const r = await runShim(pkg, ['--version'], netEnv(cache)); - - expect(r.status).toBe(0); - expect(r.stderr).toContain('downloading'); - expect(r.stderr).not.toContain('checksum verified'); // skipped, not failed - expect(r.stdout).toContain('FAKE_BUNDLE_RAN'); - }, 20000); -}); diff --git a/__tests__/pr19-improvements.test.ts b/__tests__/pr19-improvements.test.ts deleted file mode 100644 index 6741e905..00000000 --- a/__tests__/pr19-improvements.test.ts +++ /dev/null @@ -1,719 +0,0 @@ -/** - * PR #19 Improvement Tests - * - * Tests for changes ported from PR #15 and #16: - * - Lazy grammar loading - * - Arrow function extraction (body traversal) - * - Graph traversal 'both' direction fix - * - Best-candidate resolution picking - * - Schema v2 migration (filePath/language on unresolved_refs) - * - Batch insert for unresolved refs - * - SQLite performance pragmas - * - MCP symbol disambiguation and output truncation - * - CLI uninit command - */ - -import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { extractFromSource } from '../src/extraction'; -import { - getParser, - isLanguageSupported, - getSupportedLanguages, - clearParserCache, - getUnavailableGrammarErrors, - initGrammars, - loadAllGrammars, -} from '../src/extraction/grammars'; - -beforeAll(async () => { - await initGrammars(); - await loadAllGrammars(); -}); - -// Create a temporary directory for each test -function createTempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-pr19-test-')); -} - -// Clean up temporary directory -function cleanupTempDir(dir: string): void { - if (fs.existsSync(dir)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - -// Check if the node:sqlite backend is available (Node >= 22.5) -function hasSqliteBindings(): boolean { - try { - const { DatabaseSync } = require('node:sqlite'); - const db = new DatabaseSync(':memory:'); - db.close(); - return true; - } catch { - return false; - } -} - -const HAS_SQLITE = hasSqliteBindings(); - -// ============================================================================= -// Lazy Grammar Loading -// ============================================================================= - -describe('Lazy Grammar Loading', () => { - afterEach(() => { - clearParserCache(); - }); - - it('should load grammars lazily on first use', () => { - // Clear cache to force fresh load - clearParserCache(); - - // TypeScript should be loadable - const parser = getParser('typescript'); - expect(parser).not.toBeNull(); - }); - - it('should cache loaded grammars', () => { - clearParserCache(); - - const parser1 = getParser('typescript'); - const parser2 = getParser('typescript'); - - // Same reference from cache - expect(parser1).toBe(parser2); - }); - - it('should return null for unknown language', () => { - const parser = getParser('unknown'); - expect(parser).toBeNull(); - }); - - it('should handle unavailable grammars gracefully', () => { - // 'unknown' is not a valid grammar, should not crash - expect(isLanguageSupported('unknown')).toBe(false); - }); - - it('should report liquid as supported (custom extractor)', () => { - expect(isLanguageSupported('liquid')).toBe(true); - }); - - it('should include liquid in supported languages', () => { - const supported = getSupportedLanguages(); - expect(supported).toContain('liquid'); - }); - - it('should return unavailable grammar errors as a record', () => { - clearParserCache(); - const errors = getUnavailableGrammarErrors(); - // Should be a plain object (may or may not have entries depending on platform) - expect(typeof errors).toBe('object'); - }); - - it('should support multiple languages independently', () => { - clearParserCache(); - - // Load two different languages - one failing shouldn't affect the other - const tsParser = getParser('typescript'); - const pyParser = getParser('python'); - - expect(tsParser).not.toBeNull(); - expect(pyParser).not.toBeNull(); - expect(tsParser).not.toBe(pyParser); - }); - - it('should clear all caches on clearParserCache', () => { - // Load a grammar - getParser('typescript'); - - // Clear - clearParserCache(); - - // Errors should be cleared too - const errors = getUnavailableGrammarErrors(); - expect(Object.keys(errors)).toHaveLength(0); - }); -}); - -// ============================================================================= -// Arrow Function Extraction - Body Traversal -// ============================================================================= - -describe('Arrow Function Body Traversal', () => { - it('should extract unresolved references from arrow function bodies', () => { - const code = ` -export const useAuth = () => { - const user = getUser(); - const token = generateToken(user); - return { user, token }; -}; -`; - const result = extractFromSource('hooks.ts', code); - - // The arrow function should be extracted - const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'useAuth'); - expect(funcNode).toBeDefined(); - - // Calls inside the body should be captured as unresolved references - const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); - const callNames = calls.map((c) => c.referenceName); - expect(callNames).toContain('getUser'); - expect(callNames).toContain('generateToken'); - }); - - it('should extract unresolved references from function expression bodies', () => { - const code = ` -export const processData = function(input: string): string { - const cleaned = sanitize(input); - return transform(cleaned); -}; -`; - const result = extractFromSource('utils.ts', code); - - const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'processData'); - expect(funcNode).toBeDefined(); - - const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); - const callNames = calls.map((c) => c.referenceName); - expect(callNames).toContain('sanitize'); - expect(callNames).toContain('transform'); - }); - - it('should not create duplicate nodes for arrow functions', () => { - const code = ` -export const handler = () => { - doSomething(); -}; -`; - const result = extractFromSource('handler.ts', code); - - // Should be exactly 1 function node, 0 variable nodes for 'handler' - const funcNodes = result.nodes.filter((n) => n.name === 'handler' && n.kind === 'function'); - const varNodes = result.nodes.filter((n) => n.name === 'handler' && n.kind === 'variable'); - expect(funcNodes).toHaveLength(1); - expect(varNodes).toHaveLength(0); - }); - - it('should extract nested calls in arrow functions in JavaScript', () => { - const code = ` -export const fetchData = async () => { - const response = await fetchAPI('/data'); - return parseResponse(response); -}; -`; - const result = extractFromSource('api.js', code); - - const funcNode = result.nodes.find((n) => n.name === 'fetchData'); - expect(funcNode).toBeDefined(); - expect(funcNode?.kind).toBe('function'); - - const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); - const callNames = calls.map((c) => c.referenceName); - expect(callNames).toContain('fetchAPI'); - expect(callNames).toContain('parseResponse'); - }); -}); - -// ============================================================================= -// Graph Traversal 'both' Direction Fix -// (requires better-sqlite3 - will use CodeGraph integration) -// ============================================================================= - -describe('Graph Traversal Both Direction', () => { - let testDir: string; - - beforeEach(() => { - testDir = createTempDir(); - }); - - afterEach(() => { - cleanupTempDir(testDir); - }); - - it.skipIf(!HAS_SQLITE)('should traverse both directions from a node', async () => { - const CodeGraph = (await import('../src/index')).default; - - const srcDir = path.join(testDir, 'src'); - fs.mkdirSync(srcDir, { recursive: true }); - - // A -> B -> C (A calls B, B calls C) - fs.writeFileSync(path.join(srcDir, 'a.ts'), ` -import { funcB } from './b'; -export function funcA(): void { funcB(); } -`); - fs.writeFileSync(path.join(srcDir, 'b.ts'), ` -import { funcC } from './c'; -export function funcB(): void { funcC(); } -`); - fs.writeFileSync(path.join(srcDir, 'c.ts'), ` -export function funcC(): void { console.log('c'); } -`); - - const cg = CodeGraph.initSync(testDir, { - config: { include: ['src/**/*.ts'], exclude: [] }, - }); - - await cg.indexAll(); - cg.resolveReferences(); - - const functions = cg.getNodesByKind('function'); - const funcB = functions.find((n) => n.name === 'funcB'); - - if (!funcB) { - cg.destroy(); - return; - } - - // Traverse 'both' from B - should find A (incoming caller) and C (outgoing callee) - const subgraph = cg.traverse(funcB.id, { - maxDepth: 1, - direction: 'both', - }); - - // B itself + at least one neighbor in each direction - expect(subgraph.nodes.size).toBeGreaterThanOrEqual(2); - expect(subgraph.nodes.has(funcB.id)).toBe(true); - - cg.destroy(); - }); -}); - -// ============================================================================= -// Best-Candidate Resolution -// ============================================================================= - -describe('Best-Candidate Resolution', () => { - it.skipIf(!HAS_SQLITE)('should be testable via the resolution module types', async () => { - const { ReferenceResolver } = await import('../src/resolution'); - expect(typeof ReferenceResolver.prototype.resolveOne).toBe('function'); - }); -}); - -// ============================================================================= -// Schema v2 Migration -// ============================================================================= - -describe('Schema v2 Migration', () => { - it.skipIf(!HAS_SQLITE)('should have correct current schema version', async () => { - const { CURRENT_SCHEMA_VERSION } = await import('../src/db/migrations'); - expect(CURRENT_SCHEMA_VERSION).toBe(4); - }); - - it.skipIf(!HAS_SQLITE)('should have migration for version 2', async () => { - const { getPendingMigrations } = await import('../src/db/migrations'); - expect(typeof getPendingMigrations).toBe('function'); - }); -}); - -// ============================================================================= -// Database Layer: Batch Insert, getAllNodes, Pragmas -// ============================================================================= - -describe('Database Layer Improvements', () => { - let testDir: string; - - beforeEach(() => { - testDir = createTempDir(); - }); - - afterEach(() => { - cleanupTempDir(testDir); - }); - - it.skipIf(!HAS_SQLITE)('should support batch insert of unresolved refs', async () => { - const { DatabaseConnection } = await import('../src/db'); - const { QueryBuilder } = await import('../src/db/queries'); - - const dbPath = path.join(testDir, 'codegraph.db'); - const db = DatabaseConnection.initialize(dbPath); - const queries = new QueryBuilder(db.getDb()); - - // Insert a node first (needed as foreign key) - queries.insertNode({ - id: 'func:test:1', - kind: 'function', - name: 'testFunc', - qualifiedName: 'test::testFunc', - filePath: 'test.ts', - language: 'typescript', - startLine: 1, - endLine: 5, - startColumn: 0, - endColumn: 1, - updatedAt: Date.now(), - }); - - // Batch insert unresolved refs with filePath and language - queries.insertUnresolvedRefsBatch([ - { - fromNodeId: 'func:test:1', - referenceName: 'helperA', - referenceKind: 'calls', - line: 2, - column: 4, - filePath: 'test.ts', - language: 'typescript', - }, - { - fromNodeId: 'func:test:1', - referenceName: 'helperB', - referenceKind: 'calls', - line: 3, - column: 4, - filePath: 'test.ts', - language: 'typescript', - }, - ]); - - const refs = queries.getUnresolvedReferences(); - expect(refs).toHaveLength(2); - expect(refs.map((r) => r.referenceName).sort()).toEqual(['helperA', 'helperB']); - - // Verify filePath and language are persisted - expect(refs[0]?.filePath).toBe('test.ts'); - expect(refs[0]?.language).toBe('typescript'); - - db.close(); - }); - - it.skipIf(!HAS_SQLITE)('should support getAllNodes', async () => { - const { DatabaseConnection } = await import('../src/db'); - const { QueryBuilder } = await import('../src/db/queries'); - - const dbPath = path.join(testDir, 'codegraph.db'); - const db = DatabaseConnection.initialize(dbPath); - const queries = new QueryBuilder(db.getDb()); - - // Insert some nodes - for (let i = 0; i < 3; i++) { - queries.insertNode({ - id: `func:test:${i}`, - kind: 'function', - name: `func${i}`, - qualifiedName: `test::func${i}`, - filePath: 'test.ts', - language: 'typescript', - startLine: i * 10 + 1, - endLine: i * 10 + 5, - startColumn: 0, - endColumn: 1, - updatedAt: Date.now(), - }); - } - - const allNodes = queries.getAllNodes(); - expect(allNodes).toHaveLength(3); - expect(allNodes.map((n) => n.name).sort()).toEqual(['func0', 'func1', 'func2']); - - db.close(); - }); - - it.skipIf(!HAS_SQLITE)('should set performance pragmas on initialization', async () => { - const { DatabaseConnection } = await import('../src/db'); - - const dbPath = path.join(testDir, 'codegraph.db'); - const db = DatabaseConnection.initialize(dbPath); - const rawDb = db.getDb(); - - // Check pragmas were set - const synchronous = rawDb.pragma('synchronous', { simple: true }); - expect(synchronous).toBe(1); // NORMAL = 1 - - const cacheSize = rawDb.pragma('cache_size', { simple: true }) as number; - expect(cacheSize).toBe(-64000); - - const tempStore = rawDb.pragma('temp_store', { simple: true }); - expect(tempStore).toBe(2); // MEMORY = 2 - - const mmapSize = rawDb.pragma('mmap_size', { simple: true }) as number; - expect(mmapSize).toBe(268435456); // 256 MB - - db.close(); - }); - - it.skipIf(!HAS_SQLITE)('should handle empty batch insert gracefully', async () => { - const { DatabaseConnection } = await import('../src/db'); - const { QueryBuilder } = await import('../src/db/queries'); - - const dbPath = path.join(testDir, 'codegraph.db'); - const db = DatabaseConnection.initialize(dbPath); - const queries = new QueryBuilder(db.getDb()); - - // Should not throw on empty array - expect(() => queries.insertUnresolvedRefsBatch([])).not.toThrow(); - - db.close(); - }); -}); - -// ============================================================================= -// Resolution Warm Caches -// ============================================================================= - -describe('Resolution Warm Caches', () => { - let testDir: string; - - beforeEach(() => { - testDir = createTempDir(); - }); - - afterEach(() => { - cleanupTempDir(testDir); - }); - - it.skipIf(!HAS_SQLITE)('should warm caches and use them for lookups', async () => { - const CodeGraph = (await import('../src/index')).default; - - const srcDir = path.join(testDir, 'src'); - fs.mkdirSync(srcDir, { recursive: true }); - - fs.writeFileSync(path.join(srcDir, 'a.ts'), ` -export function myFunc(): void {} -export function otherFunc(): void { myFunc(); } -`); - - const cg = CodeGraph.initSync(testDir, { - config: { include: ['src/**/*.ts'], exclude: [] }, - }); - - await cg.indexAll(); - - // resolveReferences internally calls warmCaches - const result = cg.resolveReferences(); - - // Should complete without error - expect(result.stats.total).toBeGreaterThanOrEqual(0); - - cg.destroy(); - }); -}); - -// ============================================================================= -// MCP Tool Improvements -// ============================================================================= - -describe('MCP Tool Improvements', () => { - it.skipIf(!HAS_SQLITE)('should export ToolHandler class', async () => { - const { ToolHandler } = await import('../src/mcp/tools'); - expect(typeof ToolHandler).toBe('function'); - }); - - it.skipIf(!HAS_SQLITE)('should have findSymbol and truncateOutput as private methods', async () => { - const { ToolHandler } = await import('../src/mcp/tools'); - const proto = ToolHandler.prototype; - expect(typeof (proto as any).findSymbol).toBe('function'); - expect(typeof (proto as any).truncateOutput).toBe('function'); - }); - - it.skipIf(!HAS_SQLITE)('should truncate output exceeding MAX_OUTPUT_LENGTH', async () => { - const { ToolHandler } = await import('../src/mcp/tools'); - - // Access private method for testing - const handler = Object.create(ToolHandler.prototype); - const truncate = (handler as any).truncateOutput.bind(handler); - - // Short text should not be truncated - const short = 'Hello world'; - expect(truncate(short)).toBe(short); - - // Long text should be truncated - const long = 'x'.repeat(20000); - const result = truncate(long); - expect(result.length).toBeLessThan(long.length); - expect(result).toContain('... (output truncated)'); - }); - - it.skipIf(!HAS_SQLITE)('should truncate at a clean line boundary', async () => { - const { ToolHandler } = await import('../src/mcp/tools'); - - const handler = Object.create(ToolHandler.prototype); - const truncate = (handler as any).truncateOutput.bind(handler); - - // Build text with newlines exceeding the limit - const lines: string[] = []; - for (let i = 0; i < 500; i++) { - lines.push(`Line ${i}: ${'a'.repeat(50)}`); - } - const text = lines.join('\n'); - - const result = truncate(text); - // Should end with truncation notice after a newline boundary - expect(result).toContain('... (output truncated)'); - // Should not cut mid-line (the char before truncation notice should be \n) - const beforeTruncation = result.split('\n\n... (output truncated)')[0]!; - expect(beforeTruncation.endsWith('\n') || !beforeTruncation.includes('\0')).toBe(true); - }); - - describe('findSymbol disambiguation', () => { - it.skipIf(!HAS_SQLITE)('should prefer exact name matches', async () => { - const { ToolHandler } = await import('../src/mcp/tools'); - const CodeGraph = (await import('../src/index')).default; - - const tmpDir = createTempDir(); - const srcDir = path.join(tmpDir, 'src'); - fs.mkdirSync(srcDir, { recursive: true }); - - fs.writeFileSync(path.join(srcDir, 'a.ts'), ` -export function getValue(): number { return 1; } -export function getValueFromCache(): number { return 2; } -`); - - const cg = CodeGraph.initSync(tmpDir, { - config: { include: ['src/**/*.ts'], exclude: [] }, - }); - await cg.indexAll(); - - const handler = new ToolHandler(cg); - const findSymbol = (handler as any).findSymbol.bind(handler); - - const match = findSymbol(cg, 'getValue'); - expect(match).not.toBeNull(); - expect(match.node.name).toBe('getValue'); - // Should not have a disambiguation note for single exact match - expect(match.note).toBe(''); - - handler.closeAll(); - cg.destroy(); - cleanupTempDir(tmpDir); - }); - - it.skipIf(!HAS_SQLITE)('should note when multiple symbols share the same name', async () => { - const { ToolHandler } = await import('../src/mcp/tools'); - const CodeGraph = (await import('../src/index')).default; - - const tmpDir = createTempDir(); - const srcDir = path.join(tmpDir, 'src'); - fs.mkdirSync(srcDir, { recursive: true }); - - // Two files with the same function name - fs.writeFileSync(path.join(srcDir, 'a.ts'), ` -export function handle(): void {} -`); - fs.writeFileSync(path.join(srcDir, 'b.ts'), ` -export function handle(): void {} -`); - - const cg = CodeGraph.initSync(tmpDir, { - config: { include: ['src/**/*.ts'], exclude: [] }, - }); - await cg.indexAll(); - - const handler = new ToolHandler(cg); - const findSymbol = (handler as any).findSymbol.bind(handler); - - const match = findSymbol(cg, 'handle'); - expect(match).not.toBeNull(); - expect(match.node.name).toBe('handle'); - // Should have a disambiguation note - expect(match.note).toContain('2 symbols named "handle"'); - - handler.closeAll(); - cg.destroy(); - cleanupTempDir(tmpDir); - }); - - it.skipIf(!HAS_SQLITE)('should return null when symbol is not found', async () => { - const { ToolHandler } = await import('../src/mcp/tools'); - const CodeGraph = (await import('../src/index')).default; - - const tmpDir = createTempDir(); - const srcDir = path.join(tmpDir, 'src'); - fs.mkdirSync(srcDir, { recursive: true }); - fs.writeFileSync(path.join(srcDir, 'a.ts'), `export function foo(): void {}`); - - const cg = CodeGraph.initSync(tmpDir, { - config: { include: ['src/**/*.ts'], exclude: [] }, - }); - await cg.indexAll(); - - const handler = new ToolHandler(cg); - const findSymbol = (handler as any).findSymbol.bind(handler); - - const match = findSymbol(cg, 'nonExistentSymbol'); - expect(match).toBeNull(); - - handler.closeAll(); - cg.destroy(); - cleanupTempDir(tmpDir); - }); - }); -}); - -// ============================================================================= -// CLI uninit Command -// ============================================================================= - -describe('CLI uninit', () => { - let testDir: string; - - beforeEach(() => { - testDir = createTempDir(); - }); - - afterEach(() => { - cleanupTempDir(testDir); - }); - - it.skipIf(!HAS_SQLITE)('should uninitialize a project via CodeGraph.uninitialize()', async () => { - const CodeGraph = (await import('../src/index')).default; - - // Initialize - const cg = CodeGraph.initSync(testDir); - expect(CodeGraph.isInitialized(testDir)).toBe(true); - - // Uninitialize - cg.uninitialize(); - - // .codegraph directory should be removed - expect(CodeGraph.isInitialized(testDir)).toBe(false); - }); -}); - -// ============================================================================= -// Tree-sitter Version Pinning -// ============================================================================= - -describe('Tree-sitter WASM Setup', () => { - it('should use web-tree-sitter and tree-sitter-wasms in dependencies', () => { - const pkgPath = path.join(__dirname, '..', 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); - - expect(pkg.dependencies['web-tree-sitter']).toBeDefined(); - expect(pkg.dependencies['tree-sitter-wasms']).toBeDefined(); - }); - - it('should not have native tree-sitter in dependencies', () => { - const pkgPath = path.join(__dirname, '..', 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); - - expect(pkg.dependencies['tree-sitter']).toBeUndefined(); - expect(pkg.overrides).toBeUndefined(); - }); -}); - -// ============================================================================= -// Embedder Float32Array Fix -// ============================================================================= - -describe('Float32Array Fix', () => { - it('should correctly convert typed arrays (regression check)', () => { - // Simulates the fix: Float32Array.from(Array.from(arr)) vs new Float32Array(arr.length) - const source = new Float64Array([1.5, 2.5, 3.5, 4.5]); - - // The OLD buggy approach: - const buggy = new Float32Array(source.length); - // buggy is all zeros! - expect(buggy[0]).toBe(0); - expect(buggy[1]).toBe(0); - - // The NEW fixed approach: - const fixed = Float32Array.from(Array.from(source)); - expect(fixed[0]).toBeCloseTo(1.5); - expect(fixed[1]).toBeCloseTo(2.5); - expect(fixed[2]).toBeCloseTo(3.5); - expect(fixed[3]).toBeCloseTo(4.5); - }); -}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts deleted file mode 100644 index 1ca3a3f8..00000000 --- a/__tests__/resolution.test.ts +++ /dev/null @@ -1,849 +0,0 @@ -/** - * Resolution Module Tests - * - * Tests for Phase 3: Reference Resolution - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { CodeGraph } from '../src'; -import { Node, UnresolvedReference } from '../src/types'; -import { ReferenceResolver, createResolver, ResolutionContext } from '../src/resolution'; -import { matchReference } from '../src/resolution/name-matcher'; -import { resolveImportPath, extractImportMappings } from '../src/resolution/import-resolver'; -import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks'; -import { QueryBuilder } from '../src/db/queries'; -import { DatabaseConnection } from '../src/db'; - -describe('Resolution Module', () => { - let tempDir: string; - let cg: CodeGraph; - - beforeEach(() => { - // Create temp directory - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-resolution-test-')); - }); - - afterEach(() => { - // Clean up - if (cg) { - cg.destroy(); - } else if (fs.existsSync(tempDir)) { - fs.rmSync(tempDir, { recursive: true }); - } - }); - - describe('Name Matcher', () => { - it('should match exact name references', () => { - // Create a mock context - const mockNodes: Node[] = [ - { - id: 'func:test.ts:myFunction:10', - kind: 'function', - name: 'myFunction', - qualifiedName: 'test.ts::myFunction', - filePath: 'test.ts', - language: 'typescript', - startLine: 10, - endLine: 20, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }, - ]; - - const context: ResolutionContext = { - getNodesInFile: () => mockNodes, - getNodesByName: (name) => mockNodes.filter((n) => n.name === name), - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: () => true, - readFile: () => null, - getProjectRoot: () => '/test', - getAllFiles: () => ['test.ts'], - }; - - const ref = { - fromNodeId: 'caller:main.ts:caller:5', - referenceName: 'myFunction', - referenceKind: 'calls' as const, - line: 5, - column: 10, - filePath: 'main.ts', - language: 'typescript' as const, - }; - - const result = matchReference(ref, context); - - expect(result).not.toBeNull(); - expect(result?.targetNodeId).toBe('func:test.ts:myFunction:10'); - expect(result?.resolvedBy).toBe('exact-match'); - }); - - it('should prefer same-module candidates over cross-module matches', () => { - // Simulates a Python monorepo where multiple apps define navigate() - const candidateA: Node = { - id: 'func:apps/app_a/src/server.py:navigate:10', - kind: 'function', - name: 'navigate', - qualifiedName: 'apps/app_a/src/server.py::navigate', - filePath: 'apps/app_a/src/server.py', - language: 'python', - startLine: 10, - endLine: 20, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }; - - const candidateB: Node = { - id: 'func:apps/app_b/src/server.py:navigate:15', - kind: 'function', - name: 'navigate', - qualifiedName: 'apps/app_b/src/server.py::navigate', - filePath: 'apps/app_b/src/server.py', - language: 'python', - startLine: 15, - endLine: 25, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }; - - const context: ResolutionContext = { - getNodesInFile: () => [], - getNodesByName: (name) => name === 'navigate' ? [candidateA, candidateB] : [], - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: () => true, - readFile: () => null, - getProjectRoot: () => '/test', - getAllFiles: () => [], - getNodesByLowerName: () => [], - getImportMappings: () => [], - }; - - // Reference from app_a should resolve to app_a's navigate, not app_b's - const ref = { - fromNodeId: 'func:apps/app_a/src/handler.py:handler:5', - referenceName: 'navigate', - referenceKind: 'calls' as const, - line: 5, - column: 10, - filePath: 'apps/app_a/src/handler.py', - language: 'python' as const, - }; - - const result = matchReference(ref, context); - - expect(result).not.toBeNull(); - expect(result?.targetNodeId).toBe('func:apps/app_a/src/server.py:navigate:10'); - expect(result?.resolvedBy).toBe('exact-match'); - }); - - it('should lower confidence for cross-module exact matches', () => { - // Only one candidate but in a completely different module - const candidates: Node[] = [ - { - id: 'func:apps/app_b/src/server.py:navigate:10', - kind: 'function', - name: 'navigate', - qualifiedName: 'apps/app_b/src/server.py::navigate', - filePath: 'apps/app_b/src/server.py', - language: 'python', - startLine: 10, - endLine: 20, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }, - { - id: 'func:apps/app_c/src/server.py:navigate:10', - kind: 'function', - name: 'navigate', - qualifiedName: 'apps/app_c/src/server.py::navigate', - filePath: 'apps/app_c/src/server.py', - language: 'python', - startLine: 10, - endLine: 20, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }, - ]; - - const context: ResolutionContext = { - getNodesInFile: () => [], - getNodesByName: (name) => name === 'navigate' ? candidates : [], - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: () => true, - readFile: () => null, - getProjectRoot: () => '/test', - getAllFiles: () => [], - getNodesByLowerName: () => [], - getImportMappings: () => [], - }; - - // Reference from app_a — neither candidate is in the same module - const ref = { - fromNodeId: 'func:apps/app_a/src/handler.py:handler:5', - referenceName: 'navigate', - referenceKind: 'calls' as const, - line: 5, - column: 10, - filePath: 'apps/app_a/src/handler.py', - language: 'python' as const, - }; - - const result = matchReference(ref, context); - - // Should still resolve but with low confidence - expect(result).not.toBeNull(); - expect(result?.confidence).toBeLessThanOrEqual(0.4); - }); - - it('should match qualified name references', () => { - const mockClassNode: Node = { - id: 'class:user.ts:User:5', - kind: 'class', - name: 'User', - qualifiedName: 'user.ts::User', - filePath: 'user.ts', - language: 'typescript', - startLine: 5, - endLine: 30, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }; - - const mockMethodNode: Node = { - id: 'method:user.ts:User.save:15', - kind: 'method', - name: 'save', - qualifiedName: 'user.ts::User::save', - filePath: 'user.ts', - language: 'typescript', - startLine: 15, - endLine: 25, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }; - - const context: ResolutionContext = { - getNodesInFile: (fp) => fp === 'user.ts' ? [mockClassNode, mockMethodNode] : [], - getNodesByName: (name) => { - if (name === 'User') return [mockClassNode]; - if (name === 'save') return [mockMethodNode]; - return []; - }, - getNodesByQualifiedName: (qn) => { - if (qn === 'user.ts::User::save') return [mockMethodNode]; - return []; - }, - getNodesByKind: () => [], - fileExists: () => true, - readFile: () => null, - getProjectRoot: () => '/test', - getAllFiles: () => ['user.ts'], - }; - - const ref = { - fromNodeId: 'caller:main.ts:main:5', - referenceName: 'User.save', - referenceKind: 'calls' as const, - line: 5, - column: 10, - filePath: 'main.ts', - language: 'typescript' as const, - }; - - const result = matchReference(ref, context); - - expect(result).not.toBeNull(); - expect(result?.targetNodeId).toBe('method:user.ts:User.save:15'); - }); - }); - - describe('Import Resolver', () => { - it('should resolve relative import paths', () => { - const context: ResolutionContext = { - getNodesInFile: () => [], - getNodesByName: () => [], - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: (p) => p === 'src/components/utils.ts' || p === 'src/components/utils/index.ts', - readFile: () => null, - getProjectRoot: () => '', - getAllFiles: () => ['src/components/utils.ts', 'src/components/utils/index.ts'], - }; - - const result = resolveImportPath( - './utils', - 'src/components/Button.ts', - 'typescript', - context - ); - - expect(result).toBe('src/components/utils.ts'); - }); - - it('should resolve parent directory imports', () => { - const context: ResolutionContext = { - getNodesInFile: () => [], - getNodesByName: () => [], - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: (p) => p === 'src/helpers.ts' || p === 'src/helpers/index.ts', - readFile: () => null, - getProjectRoot: () => '', - getAllFiles: () => ['src/helpers.ts', 'src/helpers/index.ts'], - }; - - const result = resolveImportPath( - '../helpers', - 'src/components/Button.ts', - 'typescript', - context - ); - - expect(result).toBe('src/helpers.ts'); - }); - - it('should extract JS/TS import mappings', () => { - const content = ` -import { foo } from './foo'; -import bar from '../bar'; -import * as utils from './utils'; -import { baz, qux } from './baz'; -`; - - const mappings = extractImportMappings( - 'src/index.ts', - content, - 'typescript' - ); - - expect(mappings.length).toBeGreaterThan(0); - expect(mappings.some((m) => m.localName === 'foo')).toBe(true); - expect(mappings.some((m) => m.localName === 'bar')).toBe(true); - }); - - it('should extract Python import mappings', () => { - const content = ` -from utils import helper -from .models import User -import os -from ..services import auth_service -`; - - const mappings = extractImportMappings( - 'src/main.py', - content, - 'python' - ); - - expect(mappings.length).toBeGreaterThan(0); - expect(mappings.some((m) => m.localName === 'helper')).toBe(true); - expect(mappings.some((m) => m.localName === 'User')).toBe(true); - }); - }); - - describe('Framework Detection', () => { - it('should detect React framework', () => { - const context: ResolutionContext = { - getNodesInFile: () => [], - getNodesByName: () => [], - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: () => false, - readFile: (p) => { - if (p === 'package.json') { - return JSON.stringify({ - dependencies: { react: '^18.0.0' }, - }); - } - return null; - }, - getProjectRoot: () => '/test', - getAllFiles: () => ['package.json', 'src/App.tsx'], - }; - - const frameworks = detectFrameworks(context); - expect(frameworks.some((f) => f.name === 'react')).toBe(true); - }); - - it('should detect Express framework', () => { - const context: ResolutionContext = { - getNodesInFile: () => [], - getNodesByName: () => [], - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: () => false, - readFile: (p) => { - if (p === 'package.json') { - return JSON.stringify({ - dependencies: { express: '^4.18.0' }, - }); - } - return null; - }, - getProjectRoot: () => '/test', - getAllFiles: () => ['package.json', 'src/app.js'], - }; - - const frameworks = detectFrameworks(context); - expect(frameworks.some((f) => f.name === 'express')).toBe(true); - }); - - it('should detect Laravel framework', () => { - const context: ResolutionContext = { - getNodesInFile: () => [], - getNodesByName: () => [], - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: (p) => p === 'artisan', - readFile: () => null, - getProjectRoot: () => '/test', - getAllFiles: () => ['artisan', 'app/Http/Kernel.php'], - }; - - const frameworks = detectFrameworks(context); - expect(frameworks.some((f) => f.name === 'laravel')).toBe(true); - }); - - it('should return all framework resolvers', () => { - const resolvers = getAllFrameworkResolvers(); - expect(resolvers.length).toBeGreaterThan(0); - expect(resolvers.some((r) => r.name === 'react')).toBe(true); - expect(resolvers.some((r) => r.name === 'express')).toBe(true); - expect(resolvers.some((r) => r.name === 'laravel')).toBe(true); - }); - }); - - describe('React Framework Resolver', () => { - it('should resolve React component references', () => { - const mockNodes: Node[] = [ - { - id: 'component:src/Button.tsx:Button:5', - kind: 'component', - name: 'Button', - qualifiedName: 'src/Button.tsx::Button', - filePath: 'src/Button.tsx', - language: 'tsx', - startLine: 5, - endLine: 20, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }, - ]; - - const context: ResolutionContext = { - getNodesInFile: (fp) => (fp === 'src/Button.tsx' ? mockNodes : []), - getNodesByName: () => mockNodes, - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: () => false, - readFile: (p) => { - if (p === 'package.json') { - return JSON.stringify({ dependencies: { react: '^18.0.0' } }); - } - return null; - }, - getProjectRoot: () => '/test', - getAllFiles: () => ['package.json', 'src/Button.tsx', 'src/App.tsx'], - }; - - const frameworks = detectFrameworks(context); - const reactResolver = frameworks.find((f) => f.name === 'react'); - expect(reactResolver).toBeDefined(); - - const ref = { - fromNodeId: 'component:src/App.tsx:App:1', - referenceName: 'Button', - referenceKind: 'renders' as const, - line: 10, - column: 5, - filePath: 'src/App.tsx', - language: 'typescript' as const, - }; - - const result = reactResolver!.resolve(ref, context); - expect(result).not.toBeNull(); - expect(result?.targetNodeId).toBe('component:src/Button.tsx:Button:5'); - }); - - it('should resolve custom hook references', () => { - const mockNodes: Node[] = [ - { - id: 'hook:src/hooks/useAuth.ts:useAuth:1', - kind: 'function', - name: 'useAuth', - qualifiedName: 'src/hooks/useAuth.ts::useAuth', - filePath: 'src/hooks/useAuth.ts', - language: 'typescript', - startLine: 1, - endLine: 20, - startColumn: 0, - endColumn: 0, - updatedAt: Date.now(), - }, - ]; - - const context: ResolutionContext = { - getNodesInFile: (fp) => (fp.includes('useAuth') ? mockNodes : []), - getNodesByName: () => mockNodes, - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: () => false, - readFile: (p) => { - if (p === 'package.json') { - return JSON.stringify({ dependencies: { react: '^18.0.0' } }); - } - return null; - }, - getProjectRoot: () => '/test', - getAllFiles: () => ['package.json', 'src/hooks/useAuth.ts'], - }; - - const frameworks = detectFrameworks(context); - const reactResolver = frameworks.find((f) => f.name === 'react'); - - const ref = { - fromNodeId: 'component:src/App.tsx:App:1', - referenceName: 'useAuth', - referenceKind: 'calls' as const, - line: 5, - column: 10, - filePath: 'src/App.tsx', - language: 'typescript' as const, - }; - - const result = reactResolver!.resolve(ref, context); - expect(result).not.toBeNull(); - expect(result?.targetNodeId).toBe('hook:src/hooks/useAuth.ts:useAuth:1'); - }); - }); - - describe('Integration Tests', () => { - it('should create resolver from CodeGraph instance', async () => { - // Create a simple TypeScript project - fs.writeFileSync( - path.join(tempDir, 'package.json'), - JSON.stringify({ name: 'test', dependencies: { react: '^18.0.0' } }) - ); - - const srcDir = path.join(tempDir, 'src'); - fs.mkdirSync(srcDir); - - // Create utility file - fs.writeFileSync( - path.join(srcDir, 'utils.ts'), - `export function formatDate(date: Date): string { - return date.toISOString(); -} - -export function parseDate(str: string): Date { - return new Date(str); -}` - ); - - // Create main file that uses utils - fs.writeFileSync( - path.join(srcDir, 'main.ts'), - `import { formatDate, parseDate } from './utils'; - -function processDate(input: string): string { - const date = parseDate(input); - return formatDate(date); -}` - ); - - // Initialize and index - cg = await CodeGraph.init(tempDir, { index: true }); - - // Check that resolver detected React framework - const frameworks = cg.getDetectedFrameworks(); - expect(frameworks).toContain('react'); - - // Get stats to verify indexing worked - const stats = cg.getStats(); - expect(stats.fileCount).toBe(2); - expect(stats.nodeCount).toBeGreaterThan(0); - }); - - it('should resolve references after indexing', async () => { - // Create a project with references - const srcDir = path.join(tempDir, 'src'); - fs.mkdirSync(srcDir, { recursive: true }); - - fs.writeFileSync( - path.join(srcDir, 'helper.ts'), - `export function helperFunction(): void { - console.log('helper'); -}` - ); - - fs.writeFileSync( - path.join(srcDir, 'main.ts'), - `import { helperFunction } from './helper'; - -function main(): void { - helperFunction(); -}` - ); - - cg = await CodeGraph.init(tempDir, { index: true }); - - // Run reference resolution - const result = cg.resolveReferences(); - - // Should have attempted resolution - expect(result.stats.total).toBeGreaterThanOrEqual(0); - }); - - it('promotes calls→instantiates when target resolves to a class (Python)', async () => { - // Python has no `new` keyword — `Foo()` is the standard - // instantiation syntax. Extraction can't tell that apart from - // a function call without symbol info, so it emits a `calls` - // ref. Resolution promotes it to `instantiates` once the - // target is known to be a class. - const srcDir = path.join(tempDir, 'src'); - fs.mkdirSync(srcDir, { recursive: true }); - - fs.writeFileSync( - path.join(srcDir, 'app.py'), - `class UserService: - def __init__(self): - self.db = None - -def bootstrap(): - return UserService() -` - ); - - cg = await CodeGraph.init(tempDir, { index: true }); - cg.resolveReferences(); - - const bootstrap = cg - .getNodesByKind('function') - .find((n) => n.name === 'bootstrap'); - expect(bootstrap).toBeDefined(); - - const outgoing = cg.getOutgoingEdges(bootstrap!.id); - const instantiates = outgoing.find((e) => e.kind === 'instantiates'); - expect(instantiates).toBeDefined(); - // Same edge must NOT also appear as a `calls` edge — promotion - // replaces the kind, doesn't duplicate. - const callsToUserService = outgoing.filter( - (e) => e.kind === 'calls' && e.target === instantiates!.target - ); - expect(callsToUserService).toHaveLength(0); - }); - }); - - describe('Name Matcher: kind bias for new ref kinds', () => { - const baseContext = (candidates: Node[]): ResolutionContext => ({ - getNodesInFile: () => [], - getNodesByName: (name) => candidates.filter((c) => c.name === name), - getNodesByQualifiedName: () => [], - getNodesByKind: () => [], - fileExists: () => true, - readFile: () => null, - getProjectRoot: () => '/test', - getAllFiles: () => [], - getNodesByLowerName: () => [], - getImportMappings: () => [], - }); - - it('prefers a class candidate over a function for `instantiates` refs', () => { - // A class and a function share a name across the codebase. - // Without the kind bias, the function (which gets the +25 `calls` - // bonus historically applied to all candidates of that kind) would - // win. Now the instantiates branch reverses it. - const fn: Node = { - id: 'func:utils.ts:Logger:5', kind: 'function', name: 'Logger', - qualifiedName: 'utils.ts::Logger', filePath: 'utils.ts', language: 'typescript', - startLine: 5, endLine: 7, startColumn: 0, endColumn: 0, updatedAt: Date.now(), - }; - const cls: Node = { - id: 'class:logger.ts:Logger:10', kind: 'class', name: 'Logger', - qualifiedName: 'logger.ts::Logger', filePath: 'logger.ts', language: 'typescript', - startLine: 10, endLine: 30, startColumn: 0, endColumn: 0, updatedAt: Date.now(), - }; - - const ref = { - fromNodeId: 'func:main.ts:bootstrap:1', - referenceName: 'Logger', - referenceKind: 'instantiates' as const, - line: 5, column: 0, filePath: 'main.ts', language: 'typescript' as const, - }; - - const result = matchReference(ref, baseContext([fn, cls])); - expect(result?.targetNodeId).toBe('class:logger.ts:Logger:10'); - }); - - it('prefers a function candidate over a non-function for `decorates` refs', () => { - const variable: Node = { - id: 'var:config.ts:Inject:5', kind: 'variable', name: 'Inject', - qualifiedName: 'config.ts::Inject', filePath: 'config.ts', language: 'typescript', - startLine: 5, endLine: 5, startColumn: 0, endColumn: 0, updatedAt: Date.now(), - }; - const decorator: Node = { - id: 'func:di.ts:Inject:10', kind: 'function', name: 'Inject', - qualifiedName: 'di.ts::Inject', filePath: 'di.ts', language: 'typescript', - startLine: 10, endLine: 20, startColumn: 0, endColumn: 0, updatedAt: Date.now(), - }; - - const ref = { - fromNodeId: 'class:svc.ts:UserService:1', - referenceName: 'Inject', - referenceKind: 'decorates' as const, - line: 5, column: 0, filePath: 'svc.ts', language: 'typescript' as const, - }; - - const result = matchReference(ref, baseContext([variable, decorator])); - expect(result?.targetNodeId).toBe('func:di.ts:Inject:10'); - }); - }); - - describe('tsconfig path aliases', () => { - it('resolves an aliased import to the alias-mapped file (not a same-named file elsewhere)', async () => { - // Two same-named exports in different directories. Without alias - // resolution, name-matcher would pick whichever it finds first; - // with alias resolution, the import path uniquely picks one. - fs.mkdirSync(path.join(tempDir, 'src/utils'), { recursive: true }); - fs.mkdirSync(path.join(tempDir, 'src/legacy'), { recursive: true }); - fs.writeFileSync( - path.join(tempDir, 'src/utils/format.ts'), - `export function pickMe(): number { return 1; }\n` - ); - fs.writeFileSync( - path.join(tempDir, 'src/legacy/format.ts'), - `export function pickMe(): number { return 99; }\n` - ); - fs.writeFileSync( - path.join(tempDir, 'src/main.ts'), - `import { pickMe } from '@utils/format';\nexport function go(): number { return pickMe(); }\n` - ); - fs.writeFileSync( - path.join(tempDir, 'tsconfig.json'), - JSON.stringify({ - compilerOptions: { - baseUrl: './src', - paths: { '@utils/*': ['utils/*'] }, - }, - }) - ); - - cg = await CodeGraph.init(tempDir, { index: true }); - cg.resolveReferences(); - - // The two pickMe nodes live in different files. The aliased - // import should attach the call edge to the @utils-mapped one, - // not the legacy duplicate. - const all = cg.getNodesByKind('function').filter((n) => n.name === 'pickMe'); - const utilsNode = all.find((n) => n.filePath === 'src/utils/format.ts'); - const legacyNode = all.find((n) => n.filePath === 'src/legacy/format.ts'); - expect(utilsNode).toBeDefined(); - expect(legacyNode).toBeDefined(); - - const utilsCallers = cg.getCallers(utilsNode!.id); - const legacyCallers = cg.getCallers(legacyNode!.id); - expect(utilsCallers.length).toBeGreaterThan(0); - expect(utilsCallers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true); - // The legacy node should NOT have a caller from src/main.ts — - // the alias correctly picked the utils version. - expect(legacyCallers.some((c) => c.node.filePath === 'src/main.ts')).toBe(false); - }); - - it('falls back gracefully when tsconfig is absent', async () => { - fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(tempDir, 'src/a.ts'), - `export function aFn(): void {}\n` - ); - fs.writeFileSync( - path.join(tempDir, 'src/b.ts'), - `import { aFn } from './a';\nexport function bFn(): void { aFn(); }\n` - ); - - cg = await CodeGraph.init(tempDir, { index: true }); - // No tsconfig present — index should still complete and the - // relative-import-based call edge should be created. - const aFn = cg.getNodesByKind('function').find((n) => n.name === 'aFn'); - expect(aFn).toBeDefined(); - const callers = cg.getCallers(aFn!.id); - expect(callers.some((c) => c.node.filePath === 'src/b.ts')).toBe(true); - }); - }); - - describe('re-export chain following', () => { - it('chases a 3-hop barrel chain (wildcard → named → declaration)', async () => { - // main.ts → all.ts (wildcard) → index.ts (named) → auth.ts (declaration). - // Without chain following, `signIn` resolves to nothing because - // none of the barrel files declare it directly. - fs.mkdirSync(path.join(tempDir, 'src/services'), { recursive: true }); - fs.writeFileSync( - path.join(tempDir, 'src/services/auth.ts'), - `export function signIn(): void {}\n` - ); - fs.writeFileSync( - path.join(tempDir, 'src/services/index.ts'), - `export { signIn } from './auth';\n` - ); - fs.writeFileSync( - path.join(tempDir, 'src/all.ts'), - `export * from './services/index';\n` - ); - fs.writeFileSync( - path.join(tempDir, 'src/main.ts'), - `import { signIn } from './all';\nexport function go(): void { signIn(); }\n` - ); - - cg = await CodeGraph.init(tempDir, { index: true }); - cg.resolveReferences(); - - const signInNode = cg - .getNodesByKind('function') - .find((n) => n.name === 'signIn' && n.filePath === 'src/services/auth.ts'); - expect(signInNode).toBeDefined(); - const callers = cg.getCallers(signInNode!.id); - expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true); - }); - - it('follows a renamed named re-export (export { foo as bar } from ...)', async () => { - // The chase has to look up `foo` in the upstream module even - // though the importer asked for `bar` — exercises the rename - // branch of findExportedSymbol. - fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(tempDir, 'src/auth.ts'), - `export function signIn(): void {}\n` - ); - fs.writeFileSync( - path.join(tempDir, 'src/index.ts'), - `export { signIn as login } from './auth';\n` - ); - fs.writeFileSync( - path.join(tempDir, 'src/main.ts'), - `import { login } from './index';\nexport function go(): void { login(); }\n` - ); - - cg = await CodeGraph.init(tempDir, { index: true }); - cg.resolveReferences(); - - const signInNode = cg - .getNodesByKind('function') - .find((n) => n.name === 'signIn' && n.filePath === 'src/auth.ts'); - expect(signInNode).toBeDefined(); - const callers = cg.getCallers(signInNode!.id); - expect(callers.some((c) => c.node.filePath === 'src/main.ts')).toBe(true); - }); - }); -}); diff --git a/__tests__/search-query-parser.test.ts b/__tests__/search-query-parser.test.ts deleted file mode 100644 index 8a7767da..00000000 --- a/__tests__/search-query-parser.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Unit tests for the field-qualified query parser and bounded - * edit distance — the two algorithms behind `kind:`/`lang:`/`path:`/ - * `name:` filtering and the fuzzy typo fallback. - */ - -import { describe, it, expect } from 'vitest'; -import { parseQuery, boundedEditDistance } from '../src/search/query-parser'; - -describe('parseQuery', () => { - it('returns plain text for a query with no field prefixes', () => { - const r = parseQuery('authenticate user'); - expect(r.text).toBe('authenticate user'); - expect(r.kinds).toEqual([]); - expect(r.languages).toEqual([]); - expect(r.pathFilters).toEqual([]); - expect(r.nameFilters).toEqual([]); - }); - - it('extracts kind: filter and removes it from text', () => { - const r = parseQuery('kind:function auth'); - expect(r.kinds).toEqual(['function']); - expect(r.text).toBe('auth'); - }); - - it('extracts lang: and language: as the same filter family', () => { - const a = parseQuery('lang:typescript foo'); - const b = parseQuery('language:typescript foo'); - expect(a.languages).toEqual(['typescript']); - expect(b.languages).toEqual(['typescript']); - }); - - it('handles multiple kind: filters as an OR set', () => { - const r = parseQuery('kind:function kind:method auth'); - expect(r.kinds.sort()).toEqual(['function', 'method']); - }); - - it('extracts path: and name: as substring filters (kept verbatim)', () => { - const r = parseQuery('path:src/api name:Handler'); - expect(r.pathFilters).toEqual(['src/api']); - expect(r.nameFilters).toEqual(['Handler']); - }); - - it('preserves quoted spans as a single token (whitespace in path:)', () => { - const r = parseQuery('path:"my dir/file" foo'); - expect(r.pathFilters).toEqual(['my dir/file']); - expect(r.text).toBe('foo'); - }); - - it('passes URL-like tokens through to text (does not match http: as a field)', () => { - const r = parseQuery('http://example.com'); - expect(r.text).toBe('http://example.com'); - expect(r.kinds).toEqual([]); - }); - - it('passes empty-value tokens through as text (kind: → "kind:")', () => { - const r = parseQuery('kind: foo'); - expect(r.kinds).toEqual([]); - // The trailing-colon token comes back as plain text - expect(r.text.includes('kind:')).toBe(true); - }); - - it('passes unknown field prefixes through as text (TODO: keeps the colon)', () => { - const r = parseQuery('TODO: needs review'); - expect(r.text).toBe('TODO: needs review'); - expect(r.kinds).toEqual([]); - }); - - it('rejects unknown values for kind: (passes the whole token to text)', () => { - const r = parseQuery('kind:invalid foo'); - // Invalid kind value falls back to text - expect(r.kinds).toEqual([]); - expect(r.text).toContain('kind:invalid'); - }); - - it('handles all-filters-no-text query', () => { - const r = parseQuery('kind:function lang:typescript'); - expect(r.kinds).toEqual(['function']); - expect(r.languages).toEqual(['typescript']); - expect(r.text).toBe(''); - }); - - it('survives empty input', () => { - const r = parseQuery(''); - expect(r.text).toBe(''); - expect(r.kinds).toEqual([]); - }); - - it('survives a very long input (no allocation explosion)', () => { - const huge = 'foo '.repeat(5000); // 20k chars - const r = parseQuery(huge); - expect(r.text.length).toBeGreaterThan(0); - }); -}); - -describe('boundedEditDistance', () => { - it('returns 0 for identical strings', () => { - expect(boundedEditDistance('user', 'user', 2)).toBe(0); - }); - - it('returns 1 for a single substitution', () => { - expect(boundedEditDistance('user', 'usar', 2)).toBe(1); - }); - - it('returns 1 for a single insertion', () => { - expect(boundedEditDistance('user', 'users', 2)).toBe(1); - }); - - it('returns 1 for a single deletion', () => { - expect(boundedEditDistance('users', 'user', 2)).toBe(1); - }); - - it('returns 2 for a transposition (two edits in basic Levenshtein)', () => { - // 'aple' vs 'palp' would be 2; pick a clearer pair. - // 'foo' vs 'fou': substitution + insertion = 2 if different lengths. - expect(boundedEditDistance('confg', 'configX', 2)).toBe(2); - }); - - it('returns maxDist+1 when distance clearly exceeds budget', () => { - expect(boundedEditDistance('foo', 'completely-different', 2)).toBe(3); - }); - - it('respects length-difference shortcut', () => { - // |len(a) - len(b)| > maxDist must immediately be over budget - expect(boundedEditDistance('a', 'aaaaaaa', 2)).toBe(3); - }); - - it('handles empty inputs', () => { - expect(boundedEditDistance('', '', 2)).toBe(0); - expect(boundedEditDistance('a', '', 2)).toBe(1); - expect(boundedEditDistance('', 'abc', 2)).toBe(3); - }); - - it('is case-sensitive — caller must lowercase if case-insensitive match wanted', () => { - expect(boundedEditDistance('Foo', 'foo', 2)).toBe(1); - }); - - it('early-exits when row min exceeds budget (correctness, not just perf)', () => { - // 'aaaaa' vs 'bbbbb': distance is 5, well over budget 2 - expect(boundedEditDistance('aaaaa', 'bbbbb', 2)).toBe(3); - }); -}); diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts deleted file mode 100644 index 782b99da..00000000 --- a/__tests__/security.test.ts +++ /dev/null @@ -1,572 +0,0 @@ -/** - * Security Tests - * - * Tests for P0/P1 security fixes: - * - FileLock (cross-process locking) - * - Path traversal prevention - * - MCP input validation - * - Atomic writes - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { FileLock } from '../src/utils'; -import CodeGraph from '../src/index'; -import { ToolHandler, tools } from '../src/mcp/tools'; -import { scanDirectory, isSourceFile } from '../src/extraction'; -import { DatabaseConnection, getDatabasePath } from '../src/db'; -import { QueryBuilder } from '../src/db/queries'; - -function createTempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-security-test-')); -} - -function cleanupTempDir(dir: string): void { - if (fs.existsSync(dir)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - -describe('FileLock', () => { - let tempDir: string; - let lockPath: string; - - beforeEach(() => { - tempDir = createTempDir(); - lockPath = path.join(tempDir, 'test.lock'); - }); - - afterEach(() => { - cleanupTempDir(tempDir); - }); - - it('should acquire and release a lock', () => { - const lock = new FileLock(lockPath); - lock.acquire(); - - expect(fs.existsSync(lockPath)).toBe(true); - const content = fs.readFileSync(lockPath, 'utf-8').trim(); - expect(parseInt(content, 10)).toBe(process.pid); - - lock.release(); - expect(fs.existsSync(lockPath)).toBe(false); - }); - - it('should prevent double acquisition within same process', () => { - const lock1 = new FileLock(lockPath); - const lock2 = new FileLock(lockPath); - - lock1.acquire(); - - // Second lock should fail because our PID is alive - expect(() => lock2.acquire()).toThrow(/locked by another process/); - - lock1.release(); - }); - - it('should detect and remove stale locks from dead processes', () => { - // Write a lock file with a PID that doesn't exist - // PID 99999999 is extremely unlikely to be a real process - fs.writeFileSync(lockPath, '99999999'); - - const lock = new FileLock(lockPath); - // Should succeed because the PID is dead - expect(() => lock.acquire()).not.toThrow(); - - lock.release(); - }); - - it('should execute function with withLock', () => { - const lock = new FileLock(lockPath); - - const result = lock.withLock(() => { - expect(fs.existsSync(lockPath)).toBe(true); - return 42; - }); - - expect(result).toBe(42); - expect(fs.existsSync(lockPath)).toBe(false); - }); - - it('should release lock even if function throws', () => { - const lock = new FileLock(lockPath); - - expect(() => { - lock.withLock(() => { - throw new Error('test error'); - }); - }).toThrow('test error'); - - expect(fs.existsSync(lockPath)).toBe(false); - }); - - it('should execute async function with withLockAsync', async () => { - const lock = new FileLock(lockPath); - - const result = await lock.withLockAsync(async () => { - expect(fs.existsSync(lockPath)).toBe(true); - return 'async-result'; - }); - - expect(result).toBe('async-result'); - expect(fs.existsSync(lockPath)).toBe(false); - }); - - it('should release lock even if async function throws', async () => { - const lock = new FileLock(lockPath); - - await expect( - lock.withLockAsync(async () => { - throw new Error('async error'); - }) - ).rejects.toThrow('async error'); - - expect(fs.existsSync(lockPath)).toBe(false); - }); - - it('release should be idempotent', () => { - const lock = new FileLock(lockPath); - lock.acquire(); - lock.release(); - // Second release should not throw - expect(() => lock.release()).not.toThrow(); - }); -}); - -describe('Path Traversal Prevention', () => { - let testDir: string; - let cg: CodeGraph; - - beforeEach(async () => { - testDir = createTempDir(); - - const srcDir = path.join(testDir, 'src'); - fs.mkdirSync(srcDir); - - fs.writeFileSync( - path.join(srcDir, 'hello.ts'), - `export function hello(): string { return "hi"; }\n` - ); - - cg = CodeGraph.initSync(testDir, { - config: { include: ['**/*.ts'], exclude: [] }, - }); - await cg.indexAll(); - }); - - afterEach(() => { - if (cg) cg.close(); - cleanupTempDir(testDir); - }); - - it('should read code for valid nodes within project', async () => { - const nodes = cg.getNodesByKind('function'); - const hello = nodes.find((n) => n.name === 'hello'); - expect(hello).toBeDefined(); - - const code = await cg.getCode(hello!.id); - expect(code).toContain('hello'); - }); - - it('should return null for non-existent node', async () => { - const code = await cg.getCode('does-not-exist'); - expect(code).toBeNull(); - }); -}); - -describe('MCP Input Validation', () => { - let testDir: string; - let cg: CodeGraph; - let handler: ToolHandler; - - beforeEach(async () => { - testDir = createTempDir(); - - const srcDir = path.join(testDir, 'src'); - fs.mkdirSync(srcDir); - - fs.writeFileSync( - path.join(srcDir, 'example.ts'), - `export function exampleFunc(): void {}\nexport class ExampleClass {}\n` - ); - - cg = CodeGraph.initSync(testDir, { - config: { include: ['**/*.ts'], exclude: [] }, - }); - await cg.indexAll(); - handler = new ToolHandler(cg); - }); - - afterEach(() => { - if (cg) cg.close(); - cleanupTempDir(testDir); - }); - - it('should reject non-string query in codegraph_search', async () => { - const result = await handler.execute('codegraph_search', { query: null }); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('non-empty string'); - }); - - it('should reject empty string query in codegraph_search', async () => { - const result = await handler.execute('codegraph_search', { query: '' }); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('non-empty string'); - }); - - it('should accept valid query in codegraph_search', async () => { - const result = await handler.execute('codegraph_search', { query: 'example' }); - expect(result.isError).toBeFalsy(); - }); - - it('should clamp limit to valid range in codegraph_search', async () => { - // Extremely large limit should still work (clamped to 100) - const result = await handler.execute('codegraph_search', { query: 'example', limit: 999999 }); - expect(result.isError).toBeFalsy(); - }); - - it('should reject non-string symbol in codegraph_callers', async () => { - const result = await handler.execute('codegraph_callers', { symbol: 123 }); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('non-empty string'); - }); - - it('should reject non-string task in codegraph_context', async () => { - const result = await handler.execute('codegraph_context', { task: undefined }); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('non-empty string'); - }); - - it('should reject non-string symbol in codegraph_impact', async () => { - const result = await handler.execute('codegraph_impact', { symbol: [] }); - expect(result.isError).toBe(true); - }); - - it('should reject non-string symbol in codegraph_node', async () => { - const result = await handler.execute('codegraph_node', { symbol: false }); - expect(result.isError).toBe(true); - }); - - it('should reject non-string symbol in codegraph_callees', async () => { - const result = await handler.execute('codegraph_callees', { symbol: {} }); - expect(result.isError).toBe(true); - }); - - it('should handle NaN limit gracefully', async () => { - const result = await handler.execute('codegraph_search', { query: 'example', limit: 'abc' }); - expect(result.isError).toBeFalsy(); - }); - - it('should handle negative limit gracefully', async () => { - const result = await handler.execute('codegraph_search', { query: 'example', limit: -5 }); - expect(result.isError).toBeFalsy(); - }); -}); - -describe('Atomic Writes', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - cleanupTempDir(tempDir); - }); - - it('should not leave temp files on success', () => { - // We test this indirectly through the config-writer module - // by checking that no .tmp files remain after writing - const configDir = path.join(tempDir, '.claude'); - fs.mkdirSync(configDir, { recursive: true }); - - const testFile = path.join(configDir, 'test.json'); - // Simulate what atomicWriteFileSync does - const tmpPath = testFile + '.tmp.' + process.pid; - fs.writeFileSync(tmpPath, '{"test": true}'); - fs.renameSync(tmpPath, testFile); - - expect(fs.existsSync(testFile)).toBe(true); - expect(fs.existsSync(tmpPath)).toBe(false); - - const content = JSON.parse(fs.readFileSync(testFile, 'utf-8')); - expect(content.test).toBe(true); - }); -}); - -describe('Source file detection (isSourceFile)', () => { - it('selects files by supported extension', () => { - expect(isSourceFile('src/index.ts')).toBe(true); - expect(isSourceFile('src/deep/nested/file.ts')).toBe(true); - expect(isSourceFile('src/component.tsx')).toBe(true); - expect(isSourceFile('lib/util.js')).toBe(true); - expect(isSourceFile('src/main.py')).toBe(true); - }); - - it('rejects unsupported extensions and extensionless files', () => { - expect(isSourceFile('src/component.css')).toBe(false); - expect(isSourceFile('README.md')).toBe(false); - expect(isSourceFile('Makefile')).toBe(false); - expect(isSourceFile('.gitignore')).toBe(false); - }); - - it('matches regardless of leading dot directories', () => { - expect(isSourceFile('.hidden/index.ts')).toBe(true); - }); -}); - -describe('JSON.parse Error Boundaries in DB', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - cleanupTempDir(tempDir); - }); - - it('should not crash when node has malformed JSON in decorators column', () => { - const dbPath = path.join(tempDir, 'test.db'); - const db = DatabaseConnection.initialize(dbPath); - const queries = new QueryBuilder(db.getDb()); - - // Insert a node with malformed JSON in the decorators column - db.getDb().prepare(` - INSERT INTO nodes (id, kind, name, qualified_name, file_path, language, start_line, end_line, start_column, end_column, decorators, is_exported, is_async, is_static, is_abstract, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - 'test-node-1', 'function', 'myFunc', 'myFunc', 'test.ts', 'typescript', - 1, 5, 0, 0, - '{not valid json!!!}', // malformed decorators - 0, 0, 0, 0, Date.now() - ); - - // Should not throw - should return node with undefined decorators - const node = queries.getNodeById('test-node-1'); - expect(node).not.toBeNull(); - expect(node!.name).toBe('myFunc'); - expect(node!.decorators).toBeUndefined(); - - db.close(); - }); - - it('should not crash when edge has malformed JSON in metadata column', () => { - const dbPath = path.join(tempDir, 'test.db'); - const db = DatabaseConnection.initialize(dbPath); - const queries = new QueryBuilder(db.getDb()); - - // Insert two nodes first - const insertNode = db.getDb().prepare(` - INSERT INTO nodes (id, kind, name, qualified_name, file_path, language, start_line, end_line, start_column, end_column, is_exported, is_async, is_static, is_abstract, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - insertNode.run('node-a', 'function', 'funcA', 'funcA', 'a.ts', 'typescript', 1, 5, 0, 0, 0, 0, 0, 0, Date.now()); - insertNode.run('node-b', 'function', 'funcB', 'funcB', 'b.ts', 'typescript', 1, 5, 0, 0, 0, 0, 0, 0, Date.now()); - - // Insert edge with malformed metadata - db.getDb().prepare(` - INSERT INTO edges (source, target, kind, metadata) - VALUES (?, ?, ?, ?) - `).run('node-a', 'node-b', 'calls', 'broken json {{{'); - - // Should not throw - should return edge with undefined metadata - const edges = queries.getOutgoingEdges('node-a'); - expect(edges.length).toBe(1); - expect(edges[0].source).toBe('node-a'); - expect(edges[0].target).toBe('node-b'); - expect(edges[0].metadata).toBeUndefined(); - - db.close(); - }); - - it('should not crash when file record has malformed JSON in errors column', () => { - const dbPath = path.join(tempDir, 'test.db'); - const db = DatabaseConnection.initialize(dbPath); - const queries = new QueryBuilder(db.getDb()); - - // Insert a file with malformed errors JSON - db.getDb().prepare(` - INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at, node_count, errors) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run('test.ts', 'abc123', 'typescript', 100, Date.now(), Date.now(), 5, 'not-an-array'); - - // Should not throw - should return file with undefined errors - const file = queries.getFileByPath('test.ts'); - expect(file).not.toBeNull(); - expect(file!.path).toBe('test.ts'); - expect(file!.errors).toBeUndefined(); - - db.close(); - }); -}); - -describe('Symlink Cycle Detection', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - cleanupTempDir(tempDir); - }); - - it('should handle symlink cycle without infinite loop', () => { - // Create directory structure with a symlink cycle - const srcDir = path.join(tempDir, 'src'); - fs.mkdirSync(srcDir); - fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;\n'); - - // Create a symlink from src/loop -> tempDir (parent directory) - try { - fs.symlinkSync(tempDir, path.join(srcDir, 'loop'), 'dir'); - } catch { - // Skip test if symlinks not supported (e.g., Windows without admin) - return; - } - - - // This should complete without hanging - const files = scanDirectory(tempDir); - - // Should find the real file but not loop infinitely - expect(files).toContain('src/index.ts'); - // Should not find duplicates via the symlink path - const indexFiles = files.filter(f => f.endsWith('index.ts')); - expect(indexFiles.length).toBe(1); - }); - - it('should follow valid symlinks to directories', () => { - // Create source directory with a file - const realDir = path.join(tempDir, 'real'); - fs.mkdirSync(realDir); - fs.writeFileSync(path.join(realDir, 'hello.ts'), 'export function hello() {}\n'); - - // Create a symlink to realDir - const srcDir = path.join(tempDir, 'src'); - fs.mkdirSync(srcDir); - try { - fs.symlinkSync(realDir, path.join(srcDir, 'linked'), 'dir'); - } catch { - return; - } - - - const files = scanDirectory(tempDir); - - // Should find files from both the real dir and via the symlink - // But deduplicate since they resolve to the same real path - expect(files.some(f => f.includes('hello.ts'))).toBe(true); - }); - - it('should skip broken symlinks gracefully', () => { - const srcDir = path.join(tempDir, 'src'); - fs.mkdirSync(srcDir); - fs.writeFileSync(path.join(srcDir, 'valid.ts'), 'export const y = 2;\n'); - - try { - fs.symlinkSync('/nonexistent/path', path.join(srcDir, 'broken'), 'dir'); - } catch { - return; - } - - - // Should not throw - const files = scanDirectory(tempDir); - expect(files).toContain('src/valid.ts'); - }); -}); - -describe('Session marker symlink resistance', () => { - // The marker write lives in src/mcp/tools.ts behind handleContext. We exercise - // it end-to-end via ToolHandler.execute so the test exercises the same code - // path Claude Code drives. The session id is per-test so other parallel test - // runs can't collide with the marker file we plant a symlink at. - const SESSION_ID = `cg-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const crypto = require('crypto') as typeof import('crypto'); - const hash = crypto.createHash('md5').update(SESSION_ID).digest('hex').slice(0, 16); - const markerPath = path.join(os.tmpdir(), `codegraph-consulted-${hash}`); - - let projectDir: string; - let victimDir: string; - let victimFile: string; - - beforeEach(async () => { - projectDir = createTempDir(); - victimDir = createTempDir(); - victimFile = path.join(victimDir, 'private.txt'); - fs.writeFileSync(victimFile, 'SECRET-DO-NOT-OVERWRITE\n'); - if (fs.existsSync(markerPath)) fs.unlinkSync(markerPath); - - // A real .codegraph/ has to exist for handleContext to get past the - // "not initialized" guard — index a tiny fixture so the call reaches the - // marker write step rather than short-circuiting on missing project state. - fs.writeFileSync(path.join(projectDir, 'a.ts'), 'export const x = 1;\n'); - const cg = await CodeGraph.init(projectDir); - await cg.indexAll(); - cg.close(); - }); - - afterEach(() => { - if (fs.existsSync(markerPath)) fs.unlinkSync(markerPath); - cleanupTempDir(projectDir); - cleanupTempDir(victimDir); - }); - - it('does not follow a pre-planted symlink at the marker path', async () => { - // Skip on platforms where the user can't create symlinks (Windows without - // dev mode + admin). The CWE-59 risk we're guarding against doesn't apply - // when symlinks aren't creatable, so the skip is correct, not a gap. - try { - fs.symlinkSync(victimFile, markerPath); - } catch { - return; - } - - const cg = await CodeGraph.open(projectDir); - const handler = new ToolHandler(cg); - process.env.CLAUDE_SESSION_ID = SESSION_ID; - try { - await handler.execute('codegraph_context', { task: 'find x' }); - } finally { - delete process.env.CLAUDE_SESSION_ID; - cg.close(); - } - - // The victim file's contents must be untouched — the old writeFileSync - // path would have followed the symlink and written an ISO timestamp here. - expect(fs.readFileSync(victimFile, 'utf8')).toBe('SECRET-DO-NOT-OVERWRITE\n'); - - // And the marker path itself must still be the symlink we planted — - // no fallback path that quietly unlinked + recreated it (which would - // also work, but is a behavior we don't want to silently rely on). - expect(fs.lstatSync(markerPath).isSymbolicLink()).toBe(true); - }); - - it('writes the marker file with 0o600 perms on a clean path', async () => { - // No symlink planted — happy path. Verifies the new openSync(mode: 0o600) - // call is what actually lands on disk (regression guard for the perm - // tightening that came with the O_NOFOLLOW fix). - const cg = await CodeGraph.open(projectDir); - const handler = new ToolHandler(cg); - process.env.CLAUDE_SESSION_ID = SESSION_ID; - try { - await handler.execute('codegraph_context', { task: 'find x' }); - } finally { - delete process.env.CLAUDE_SESSION_ID; - cg.close(); - } - - expect(fs.existsSync(markerPath)).toBe(true); - // chmod's low 9 bits — strip the file-type bits for a clean compare. - // Windows can't enforce 0o600 in the POSIX sense; skip the assertion - // there since the underlying OS will normalize the mode anyway. - if (process.platform !== 'win32') { - const mode = fs.statSync(markerPath).mode & 0o777; - expect(mode).toBe(0o600); - } - }); -}); diff --git a/__tests__/sqlite-backend.test.ts b/__tests__/sqlite-backend.test.ts deleted file mode 100644 index 0815551d..00000000 --- a/__tests__/sqlite-backend.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * SQLite backend reporting. - * - * node:sqlite (Node's built-in real SQLite) is the sole backend. Pin that - * DatabaseConnection / CodeGraph report it and come up in WAL. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { DatabaseConnection } from '../src/db'; -import { CodeGraph } from '../src'; - -describe('DatabaseConnection — backend reporting', () => { - let dir: string; - - beforeEach(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-backend-')); - }); - - afterEach(() => { - if (fs.existsSync(dir)) { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it('reports the node-sqlite backend in WAL for an initialized DB', () => { - const conn = DatabaseConnection.initialize(path.join(dir, 'test.db')); - expect(conn.getBackend()).toBe('node-sqlite'); - expect(conn.getJournalMode()).toBe('wal'); - conn.close(); - }); - - it('CodeGraph.getBackend() delegates to the underlying DatabaseConnection', async () => { - fs.writeFileSync(path.join(dir, 'x.ts'), `export function x(): void {}\n`); - const cg = await CodeGraph.init(dir, { index: true }); - try { - expect(cg.getBackend()).toBe('node-sqlite'); - } finally { - cg.destroy(); - } - }); -}); diff --git a/__tests__/strip-comments.test.ts b/__tests__/strip-comments.test.ts deleted file mode 100644 index ef2ec057..00000000 --- a/__tests__/strip-comments.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { stripCommentsForRegex } from '../src/resolution/strip-comments'; - -describe('stripCommentsForRegex', () => { - it('python: strips line comments', () => { - const src = "x = 1 # path('/fake/', View)\nreal = 2"; - const out = stripCommentsForRegex(src, 'python'); - expect(out).not.toMatch(/path\('\/fake\//); - expect(out).toMatch(/real = 2/); - }); - - it('python: strips triple-quoted docstrings', () => { - const src = `""" -path('/in-docstring/', View) -""" -real = 1 -`; - const out = stripCommentsForRegex(src, 'python'); - expect(out).not.toMatch(/in-docstring/); - expect(out).toMatch(/real = 1/); - }); - - it('python: keeps # inside strings', () => { - const src = `path('#/fragment/', View)\n`; - const out = stripCommentsForRegex(src, 'python'); - expect(out).toContain("'#/fragment/'"); - }); - - it('python: handles triple-single-quoted docstrings', () => { - const src = `'''\npath('/fake/')\n'''\nreal = 1\n`; - const out = stripCommentsForRegex(src, 'python'); - expect(out).not.toMatch(/fake/); - expect(out).toMatch(/real = 1/); - }); - - it('typescript: strips //, /* */', () => { - const src = - "// app.get('/fake', x)\n/* app.get('/also-fake', y) */\napp.get('/real', z)"; - const out = stripCommentsForRegex(src, 'typescript'); - expect(out).not.toMatch(/fake/); - expect(out).toMatch(/'\/real'/); - }); - - it('typescript: keeps // inside strings', () => { - const src = `const url = "https://example.com/path";\n`; - const out = stripCommentsForRegex(src, 'typescript'); - expect(out).toContain('https://example.com/path'); - }); - - it('php: strips //, #, and /* */', () => { - const src = - "// Route::get('/a', X::class)\n# Route::get('/b', Y::class)\n/* Route::get('/c', Z::class) */\nReal::go();"; - const out = stripCommentsForRegex(src, 'php'); - expect(out).not.toMatch(/'\/a'/); - expect(out).not.toMatch(/'\/b'/); - expect(out).not.toMatch(/'\/c'/); - expect(out).toContain('Real::go();'); - }); - - it('ruby: strips =begin/=end', () => { - const src = - "=begin\nget '/fake', to: 'x#y'\n=end\nget '/real', to: 'a#b'\n"; - const out = stripCommentsForRegex(src, 'ruby'); - expect(out).not.toMatch(/fake/); - expect(out).toMatch(/'\/real'/); - }); - - it('ruby: strips # comments', () => { - const src = "# get '/fake', to: 'x#y'\nget '/real', to: 'a#b'\n"; - const out = stripCommentsForRegex(src, 'ruby'); - expect(out).not.toMatch(/fake/); - expect(out).toMatch(/'\/real'/); - }); - - it('rust: handles nested block comments', () => { - const src = - '/* outer /* inner */ still in outer */ .route("/real", get(h))'; - const out = stripCommentsForRegex(src, 'rust'); - expect(out).not.toMatch(/inner/); - expect(out).toMatch(/\/real/); - }); - - it('go: keeps backtick raw strings intact, strips // comments', () => { - const src = '// r.GET("/fake", h)\nr.GET(`/real`, h2)\n'; - const out = stripCommentsForRegex(src, 'go'); - expect(out).not.toMatch(/fake/); - // backtick raw string contents preserved - expect(out).toMatch(/`\/real`/); - }); - - it('go: strips block comments containing route-shaped text', () => { - const src = '/* r.GET("/fake", h) */\nr.GET("/real", h2)\n'; - const out = stripCommentsForRegex(src, 'go'); - expect(out).not.toMatch(/fake/); - expect(out).toMatch(/"\/real"/); - }); - - it('java: strips // and /* */ comments', () => { - const src = - '// @GetMapping("/fake")\n/* @PostMapping("/also-fake") */\n@GetMapping("/real")\n'; - const out = stripCommentsForRegex(src, 'java'); - expect(out).not.toMatch(/fake/); - expect(out).toMatch(/"\/real"/); - }); - - it('csharp: strips // and /* */ comments', () => { - const src = - '// [HttpGet("/fake")]\n/* [HttpPost("/also-fake")] */\n[HttpGet("/real")]\n'; - const out = stripCommentsForRegex(src, 'csharp'); - expect(out).not.toMatch(/fake/); - expect(out).toMatch(/"\/real"/); - }); - - it('swift: strips // and /* */ comments', () => { - const src = - '// app.get("fake", use: x)\n/* app.get("also-fake", use: y) */\napp.get("real", use: z)\n'; - const out = stripCommentsForRegex(src, 'swift'); - expect(out).not.toMatch(/fake/); - expect(out).toMatch(/"real"/); - }); - - it('preserves line numbers (newlines retained)', () => { - const src = "line1\n# comment with path('/fake/')\nline3"; - const out = stripCommentsForRegex(src, 'python'); - expect(out.split('\n').length).toBe(3); - expect(out.split('\n')[2]).toBe('line3'); - }); - - it('preserves overall length so source offsets stay valid', () => { - const src = "x = 1 # path('/fake/', View)\nreal = 2"; - const out = stripCommentsForRegex(src, 'python'); - expect(out.length).toBe(src.length); - }); -}); diff --git a/__tests__/symbol-lookup.test.ts b/__tests__/symbol-lookup.test.ts deleted file mode 100644 index 86dda6cb..00000000 --- a/__tests__/symbol-lookup.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Module-qualified symbol lookup (`stage_apply::run`, `Session.request`, - * `configurator/stage_apply`). - * - * Pinned because the lookup vocabulary is what makes codegraph useful - * in workspaces with same-named symbols across modules — Rust - * sub-pipelines, Python `__init__.py` packages, Java packages, etc. - * See #173 for the original report: a `run` function in - * `src/configurator/stage_apply.rs` was indexed but `stage_apply::run` - * returned "not found" because (a) FTS strips colons to nothing, - * leaving a useless query, and (b) `matchesSymbol` only understood - * `.`-style qualifiers. - */ - -import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; - -beforeAll(async () => { - await initGrammars(); - await loadAllGrammars(); -}); - -function hasSqliteBindings(): boolean { - try { - const { DatabaseSync } = require('node:sqlite'); - const db = new DatabaseSync(':memory:'); - db.close(); - return true; - } catch { - return false; - } -} -const HAS_SQLITE = hasSqliteBindings(); - -function tmpRoot(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-symbol-lookup-')); -} - -function rmTree(dir: string): void { - if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); -} - -async function buildRustWorkspace(): Promise { - const root = tmpRoot(); - const cfgDir = path.join(root, 'src', 'configurator'); - fs.mkdirSync(cfgDir, { recursive: true }); - fs.writeFileSync( - path.join(root, 'Cargo.toml'), - `[package]\nname = "fixture"\nversion = "0.1.0"\nedition = "2021"\n[lib]\npath = "src/lib.rs"\n` - ); - fs.writeFileSync(path.join(root, 'src', 'lib.rs'), `pub mod configurator;\npub mod scheduler;\n`); - fs.writeFileSync( - path.join(cfgDir, 'mod.rs'), - `pub mod stage_apply;\npub mod stage_detect;\n` - ); - fs.writeFileSync( - path.join(cfgDir, 'stage_apply.rs'), - `pub async fn run() -> Result<(), ()> {\n render_and_write();\n Ok(())\n}\n\nfn render_and_write() {}\n` - ); - fs.writeFileSync( - path.join(cfgDir, 'stage_detect.rs'), - `pub async fn run() -> Result<(), ()> { Ok(()) }\n` - ); - fs.writeFileSync( - path.join(root, 'src', 'scheduler.rs'), - `pub fn run_due_tasks() -> Result<(), ()> { Ok(()) }\n` - ); - return root; -} - -describe.skipIf(!HAS_SQLITE)('matchesSymbol — module-qualified lookups (#173)', () => { - let projectRoot: string; - let cg: any; - let handler: any; - let findSymbol: (cg: any, s: string) => { node: any; note: string } | null; - let findAllSymbols: (cg: any, s: string) => { nodes: any[]; note: string }; - - beforeEach(async () => { - projectRoot = await buildRustWorkspace(); - const CodeGraph = (await import('../src/index')).default; - const { ToolHandler } = await import('../src/mcp/tools'); - cg = CodeGraph.initSync(projectRoot, { - config: { include: ['**/*.rs'], exclude: [] }, - }); - await cg.indexAll(); - handler = new ToolHandler(cg); - findSymbol = (handler as any).findSymbol.bind(handler); - findAllSymbols = (handler as any).findAllSymbols.bind(handler); - }); - - afterEach(() => { - handler?.closeAll(); - cg?.destroy(); - rmTree(projectRoot); - }); - - it('resolves `stage_apply::run` to the run in stage_apply.rs (not stage_detect.rs)', () => { - const match = findSymbol(cg, 'stage_apply::run'); - expect(match).not.toBeNull(); - expect(match!.node.name).toBe('run'); - expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/); - }); - - it('rejects `stage_apply::run` for the same-named function in a different module', () => { - const all = findAllSymbols(cg, 'stage_apply::run'); - // All returned nodes must be in stage_apply.rs — never in stage_detect.rs - for (const node of all.nodes) { - expect(node.filePath).toMatch(/stage_apply\.rs$/); - } - expect(all.nodes.length).toBeGreaterThan(0); - }); - - it('resolves `configurator::stage_apply::run` (multi-level qualifier)', () => { - const match = findSymbol(cg, 'configurator::stage_apply::run'); - expect(match).not.toBeNull(); - expect(match!.node.name).toBe('run'); - expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/); - }); - - it('resolves `crate::configurator::stage_apply::run` (Rust path prefix stripped)', () => { - const match = findSymbol(cg, 'crate::configurator::stage_apply::run'); - expect(match).not.toBeNull(); - expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/); - }); - - it('resolves `configurator/stage_apply` (slash qualifier)', () => { - const match = findSymbol(cg, 'configurator/stage_apply/run'); - expect(match).not.toBeNull(); - expect(match!.node.filePath).toMatch(/configurator\/stage_apply\.rs$/); - }); - - it('does not silently collide bare `run` with `run_due_tasks`', () => { - const match = findSymbol(cg, 'run'); - expect(match).not.toBeNull(); - // Whatever it picks, it must be an exact-name match, not a partial. - expect(match!.node.name).toBe('run'); - }); - - it('aggregates all bare-name `run` matches across modules', () => { - const all = findAllSymbols(cg, 'run'); - const names = all.nodes.map((n: any) => n.name); - expect(names.every((n: string) => n === 'run')).toBe(true); - expect(all.nodes.length).toBeGreaterThanOrEqual(2); // stage_apply + stage_detect - // The note should call out the ambiguity. - expect(all.note).toMatch(/Aggregated|symbols named "run"/); - }); - - it('still returns null for genuinely unknown qualified lookups', () => { - const match = findSymbol(cg, 'stage_apply::nonexistent_fn'); - expect(match).toBeNull(); - }); -}); - -describe.skipIf(!HAS_SQLITE)('matchesSymbol — dotted lookups (regression for #173 fix)', () => { - let projectRoot: string; - let cg: any; - let handler: any; - let findSymbol: (cg: any, s: string) => { node: any; note: string } | null; - - beforeEach(async () => { - projectRoot = tmpRoot(); - const src = path.join(projectRoot, 'src'); - fs.mkdirSync(src, { recursive: true }); - fs.writeFileSync( - path.join(src, 'session.ts'), - `export class Session {\n request(): void {}\n}\nexport function request(): void {}\n` - ); - - const CodeGraph = (await import('../src/index')).default; - const { ToolHandler } = await import('../src/mcp/tools'); - cg = CodeGraph.initSync(projectRoot, { - config: { include: ['src/**/*.ts'], exclude: [] }, - }); - await cg.indexAll(); - handler = new ToolHandler(cg); - findSymbol = (handler as any).findSymbol.bind(handler); - }); - - afterEach(() => { - handler?.closeAll(); - cg?.destroy(); - rmTree(projectRoot); - }); - - it('`Session.request` resolves to the method, not the bare function', () => { - const match = findSymbol(cg, 'Session.request'); - expect(match).not.toBeNull(); - expect(match!.node.kind).toBe('method'); - expect(match!.node.qualifiedName).toContain('Session::request'); - }); -}); diff --git a/__tests__/sync.test.ts b/__tests__/sync.test.ts deleted file mode 100644 index 708a92a4..00000000 --- a/__tests__/sync.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Sync Module Tests - * - * Tests for sync functionality (incremental updates). - * Note: Git hooks functionality has been removed in favor of codegraph's - * Claude Code hooks integration. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { execFileSync } from 'child_process'; -import CodeGraph from '../src/index'; - -describe('Sync Module', () => { - describe('Sync Functionality', () => { - let testDir: string; - let cg: CodeGraph; - - beforeEach(async () => { - testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-sync-func-')); - - // Create initial source files - const srcDir = path.join(testDir, 'src'); - fs.mkdirSync(srcDir); - fs.writeFileSync( - path.join(srcDir, 'index.ts'), - `export function hello() { return 'world'; }` - ); - - // Initialize and index - cg = CodeGraph.initSync(testDir, { - config: { - include: ['**/*.ts'], - exclude: [], - }, - }); - await cg.indexAll(); - }); - - afterEach(() => { - if (cg) { - cg.destroy(); - } - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - }); - - describe('getChangedFiles()', () => { - it('should detect added files', () => { - // Add a new file - fs.writeFileSync( - path.join(testDir, 'src', 'new.ts'), - `export function newFunc() { return 42; }` - ); - - const changes = cg.getChangedFiles(); - - expect(changes.added).toContain('src/new.ts'); - expect(changes.modified).toHaveLength(0); - expect(changes.removed).toHaveLength(0); - }); - - it('should detect modified files', () => { - // Modify existing file - fs.writeFileSync( - path.join(testDir, 'src', 'index.ts'), - `export function hello() { return 'modified'; }` - ); - - const changes = cg.getChangedFiles(); - - expect(changes.added).toHaveLength(0); - expect(changes.modified).toContain('src/index.ts'); - expect(changes.removed).toHaveLength(0); - }); - - it('should detect removed files', () => { - // Remove file - fs.unlinkSync(path.join(testDir, 'src', 'index.ts')); - - const changes = cg.getChangedFiles(); - - expect(changes.added).toHaveLength(0); - expect(changes.modified).toHaveLength(0); - expect(changes.removed).toContain('src/index.ts'); - }); - }); - - describe('sync()', () => { - it('should reindex added files', async () => { - // Add a new file - fs.writeFileSync( - path.join(testDir, 'src', 'new.ts'), - `export function newFunc() { return 42; }` - ); - - const result = await cg.sync(); - - expect(result.filesAdded).toBe(1); - expect(result.filesModified).toBe(0); - expect(result.filesRemoved).toBe(0); - - // Verify new function is in the graph - const nodes = cg.searchNodes('newFunc'); - expect(nodes.length).toBeGreaterThan(0); - }); - - it('should reindex modified files', async () => { - // Modify existing file - fs.writeFileSync( - path.join(testDir, 'src', 'index.ts'), - `export function goodbye() { return 'farewell'; }` - ); - - const result = await cg.sync(); - - expect(result.filesModified).toBe(1); - - // Verify new function is in the graph - const nodes = cg.searchNodes('goodbye'); - expect(nodes.length).toBeGreaterThan(0); - - // Verify old function is gone - const oldNodes = cg.searchNodes('hello'); - expect(oldNodes.length).toBe(0); - }); - - it('should remove nodes from deleted files', async () => { - // Remove file - fs.unlinkSync(path.join(testDir, 'src', 'index.ts')); - - const result = await cg.sync(); - - expect(result.filesRemoved).toBe(1); - - // Verify function is gone - const nodes = cg.searchNodes('hello'); - expect(nodes.length).toBe(0); - }); - - it('should report no changes when nothing changed', async () => { - const result = await cg.sync(); - - expect(result.filesAdded).toBe(0); - expect(result.filesModified).toBe(0); - expect(result.filesRemoved).toBe(0); - expect(result.filesChecked).toBeGreaterThan(0); - }); - }); - }); - - describe('Git-based sync', () => { - let testDir: string; - let cg: CodeGraph; - - function git(...args: string[]) { - execFileSync('git', args, { cwd: testDir, stdio: 'pipe' }); - } - - beforeEach(async () => { - testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-git-sync-')); - - // Initialize a git repo with an initial commit - git('init'); - git('config', 'user.email', 'test@test.com'); - git('config', 'user.name', 'Test'); - - const srcDir = path.join(testDir, 'src'); - fs.mkdirSync(srcDir); - fs.writeFileSync( - path.join(srcDir, 'index.ts'), - `export function hello() { return 'world'; }` - ); - - git('add', '-A'); - git('commit', '-m', 'initial'); - - // Initialize CodeGraph and index - cg = CodeGraph.initSync(testDir, { - config: { - include: ['**/*.ts'], - exclude: [], - }, - }); - await cg.indexAll(); - }); - - afterEach(() => { - if (cg) { - cg.destroy(); - } - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - }); - - it('should detect modified files via git', async () => { - fs.writeFileSync( - path.join(testDir, 'src', 'index.ts'), - `export function hello() { return 'modified'; }` - ); - - const result = await cg.sync(); - - expect(result.filesModified).toBe(1); - expect(result.changedFilePaths).toContain('src/index.ts'); - }); - - it('should detect new untracked files via git', async () => { - fs.writeFileSync( - path.join(testDir, 'src', 'new.ts'), - `export function newFunc() { return 42; }` - ); - - const result = await cg.sync(); - - expect(result.filesAdded).toBe(1); - expect(result.changedFilePaths).toContain('src/new.ts'); - - // Verify the function was indexed - const nodes = cg.searchNodes('newFunc'); - expect(nodes.length).toBeGreaterThan(0); - }); - - it('should stop reporting untracked files once they are indexed (issue #206)', async () => { - // Untracked files stay `??` in git status even after codegraph indexes - // them. Change detection must compare them against the DB by hash, not - // report every untracked file as "added" on every sync/status. - fs.writeFileSync( - path.join(testDir, 'src', 'new.ts'), - `export function newFunc() { return 42; }` - ); - - // First sync indexes the untracked file. - const first = await cg.sync(); - expect(first.filesAdded).toBe(1); - - // The file is still untracked in git, but now lives in the DB. - expect(cg.searchNodes('newFunc').length).toBeGreaterThan(0); - - // status must not keep flagging it as a pending addition... - const changes = cg.getChangedFiles(); - expect(changes.added).not.toContain('src/new.ts'); - expect(changes.modified).not.toContain('src/new.ts'); - - // ...and a second sync must be a no-op for it. - const second = await cg.sync(); - expect(second.filesAdded).toBe(0); - expect(second.filesModified).toBe(0); - }); - - it('should re-index an untracked file when its contents change', async () => { - const filePath = path.join(testDir, 'src', 'new.ts'); - fs.writeFileSync(filePath, `export function newFunc() { return 42; }`); - await cg.sync(); - - // Modify the still-untracked file. - fs.writeFileSync(filePath, `export function renamedFunc() { return 7; }`); - - const changes = cg.getChangedFiles(); - expect(changes.modified).toContain('src/new.ts'); - - const result = await cg.sync(); - expect(result.filesModified).toBe(1); - expect(cg.searchNodes('renamedFunc').length).toBeGreaterThan(0); - expect(cg.searchNodes('newFunc').length).toBe(0); - }); - - it('should detect deleted files via git', async () => { - fs.unlinkSync(path.join(testDir, 'src', 'index.ts')); - - const result = await cg.sync(); - - expect(result.filesRemoved).toBe(1); - - // Verify function is gone - const nodes = cg.searchNodes('hello'); - expect(nodes.length).toBe(0); - }); - - it('should skip files with unsupported extensions', async () => { - // A .txt file has no supported grammar, so sync must not index it. - fs.writeFileSync( - path.join(testDir, 'src', 'notes.txt'), - `just some notes` - ); - - const result = await cg.sync(); - - expect(result.filesAdded).toBe(0); - expect(result.filesModified).toBe(0); - }); - - it('should report no changes on clean working tree', async () => { - const result = await cg.sync(); - - expect(result.filesAdded).toBe(0); - expect(result.filesModified).toBe(0); - expect(result.filesRemoved).toBe(0); - expect(result.changedFilePaths).toBeUndefined(); - }); - }); -}); diff --git a/__tests__/wasm-runtime-flags.test.ts b/__tests__/wasm-runtime-flags.test.ts deleted file mode 100644 index a4dae8bb..00000000 --- a/__tests__/wasm-runtime-flags.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * WASM runtime flags — the workaround for the V8 turboshaft WASM Zone OOM - * (`Fatal process out of memory: Zone`) that crashed `codegraph index` on large - * polyglot repos under Node >= 22. See issues #293 and #298. - * - * The crash was reproduced with the real indexer on the bundled Node 24 runtime; - * empirically only `--liftoff-only` prevents it (`--no-wasm-tier-up` / - * `--no-wasm-dynamic-tiering` do not), and the flag must be on node's command - * line — `setFlagsFromString`, worker `execArgv`, and `NODE_OPTIONS` all fail. - * These tests pin that contract so it can't silently regress. - */ -import { describe, it, expect } from 'vitest'; -import { spawnSync } from 'child_process'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { - WASM_RUNTIME_FLAGS, - processHasWasmRuntimeFlags, - buildRelaunchArgv, -} from '../src/extraction/wasm-runtime-flags'; - -describe('WASM_RUNTIME_FLAGS', () => { - it('pins --liftoff-only (the only flag shown to stop the turboshaft Zone OOM)', () => { - // On Node 24, --no-wasm-tier-up and --no-wasm-dynamic-tiering both still - // crash; only --liftoff-only forces grammars onto the Liftoff baseline and - // off the optimizing tier. Pin it so it can't be swapped for an ineffective - // flag. - expect(WASM_RUNTIME_FLAGS).toContain('--liftoff-only'); - }); - - it('every flag is a real, accepted flag on the running Node/V8 runtime', () => { - // node rejects unknown CLI flags at startup, so a renamed/removed flag would - // break the bundled launcher and make the relaunch guard a silent no-op. - // Prove each flag actually launches node here. - const res = spawnSync( - process.execPath, - [...WASM_RUNTIME_FLAGS, '-e', 'process.exit(0)'], - { encoding: 'utf8' } - ); - expect(res.status, `node rejected ${WASM_RUNTIME_FLAGS.join(' ')}:\n${res.stderr}`).toBe(0); - }); -}); - -describe('processHasWasmRuntimeFlags', () => { - it('is true only when every required flag is present', () => { - expect(processHasWasmRuntimeFlags(['--liftoff-only'])).toBe(true); - expect(processHasWasmRuntimeFlags(['--liftoff-only', '--enable-source-maps'])).toBe(true); - }); - - it('is false when the flags are absent', () => { - expect(processHasWasmRuntimeFlags([])).toBe(false); - expect(processHasWasmRuntimeFlags(['--max-old-space-size=4096'])).toBe(false); - }); -}); - -describe('buildRelaunchArgv', () => { - it('places the wasm flags first, then the script and its args', () => { - expect(buildRelaunchArgv('/x/codegraph.js', ['index', '/repo'], [])).toEqual([ - '--liftoff-only', - '/x/codegraph.js', - 'index', - '/repo', - ]); - }); - - it('preserves other existing node flags without duplicating ours', () => { - expect( - buildRelaunchArgv('/x/codegraph.js', ['status'], ['--liftoff-only', '--enable-source-maps']) - ).toEqual(['--liftoff-only', '--enable-source-maps', '/x/codegraph.js', 'status']); - }); - - it('produces an argv that actually launches node WITH the flag applied', () => { - // End-to-end proof of the delivery mechanism without needing the crash: - // run the constructed argv and confirm the child sees the flag in execArgv. - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-relaunch-')); - try { - const harness = path.join(dir, 'harness.cjs'); - fs.writeFileSync(harness, 'process.stdout.write(JSON.stringify(process.execArgv));'); - const res = spawnSync(process.execPath, buildRelaunchArgv(harness, []), { encoding: 'utf8' }); - expect(res.status, res.stderr).toBe(0); - expect(JSON.parse(res.stdout)).toContain('--liftoff-only'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/__tests__/watch-policy.test.ts b/__tests__/watch-policy.test.ts deleted file mode 100644 index 5cb92ce7..00000000 --- a/__tests__/watch-policy.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Watch Policy Tests - * - * Covers the decision of whether the live file watcher runs, including the - * WSL2 /mnt auto-detect and the env-var escape hatches (issue #199), plus - * that FileWatcher.start() honors the decision. - */ - -import { describe, it, expect, afterEach, vi } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { watchDisabledReason } from '../src/sync/watch-policy'; -import { FileWatcher } from '../src/sync/watcher'; - -describe('watchDisabledReason', () => { - it('returns a reason when CODEGRAPH_NO_WATCH=1', () => { - const reason = watchDisabledReason('/home/me/project', { - env: { CODEGRAPH_NO_WATCH: '1' }, - isWsl: false, - }); - expect(reason).toBeTruthy(); - expect(reason).toMatch(/CODEGRAPH_NO_WATCH/); - }); - - it('auto-disables on a WSL2 /mnt drive', () => { - const reason = watchDisabledReason('/mnt/d/code/project', { env: {}, isWsl: true }); - expect(reason).toBeTruthy(); - expect(reason).toMatch(/mnt/); - }); - - it('does NOT disable on a native WSL home path', () => { - expect(watchDisabledReason('/home/me/project', { env: {}, isWsl: true })).toBeNull(); - }); - - it('does NOT disable on /mnt when not running under WSL', () => { - // A real Linux box may legitimately have a fast /mnt mount. - expect(watchDisabledReason('/mnt/d/code/project', { env: {}, isWsl: false })).toBeNull(); - }); - - it('does NOT treat /mnt/wsl (fast Linux mount) as a Windows drive', () => { - expect(watchDisabledReason('/mnt/wsl/project', { env: {}, isWsl: true })).toBeNull(); - }); - - it('CODEGRAPH_FORCE_WATCH=1 overrides WSL auto-detect', () => { - const reason = watchDisabledReason('/mnt/d/code/project', { - env: { CODEGRAPH_FORCE_WATCH: '1' }, - isWsl: true, - }); - expect(reason).toBeNull(); - }); - - it('CODEGRAPH_NO_WATCH wins over CODEGRAPH_FORCE_WATCH', () => { - const reason = watchDisabledReason('/home/me/project', { - env: { CODEGRAPH_NO_WATCH: '1', CODEGRAPH_FORCE_WATCH: '1' }, - isWsl: false, - }); - expect(reason).toBeTruthy(); - }); -}); - -describe('FileWatcher honors the watch policy', () => { - let testDir: string; - - afterEach(() => { - delete process.env.CODEGRAPH_NO_WATCH; - if (testDir && fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - }); - - it('does not start when CODEGRAPH_NO_WATCH=1', () => { - testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-nowatch-')); - process.env.CODEGRAPH_NO_WATCH = '1'; - - const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 }); - const watcher = new FileWatcher(testDir, syncFn); - - expect(watcher.start()).toBe(false); - expect(watcher.isActive()).toBe(false); - }); -}); diff --git a/__tests__/watcher.test.ts b/__tests__/watcher.test.ts deleted file mode 100644 index fde5f593..00000000 --- a/__tests__/watcher.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -/** - * FileWatcher Tests - * - * Tests for the file watcher that auto-syncs on changes. - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { FileWatcher } from '../src/sync/watcher'; -import CodeGraph from '../src/index'; - -/** - * Helper to wait for a condition with timeout - */ -function waitFor( - condition: () => boolean, - timeoutMs = 10000, - intervalMs = 100 -): Promise { - return new Promise((resolve, reject) => { - const start = Date.now(); - const check = () => { - if (condition()) return resolve(); - if (Date.now() - start > timeoutMs) return reject(new Error('waitFor timed out')); - setTimeout(check, intervalMs); - }; - check(); - }); -} - -describe('FileWatcher', () => { - let testDir: string; - - beforeEach(() => { - testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-watcher-')); - // Create a source file so the directory isn't empty - const srcDir = path.join(testDir, 'src'); - fs.mkdirSync(srcDir); - fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;'); - }); - - afterEach(() => { - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - }); - - describe('start/stop lifecycle', () => { - it('should start and stop without errors', () => { - const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 }); - const watcher = new FileWatcher(testDir, syncFn); - - const started = watcher.start(); - expect(started).toBe(true); - expect(watcher.isActive()).toBe(true); - - watcher.stop(); - expect(watcher.isActive()).toBe(false); - }); - - it('should be idempotent on double start', () => { - const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 }); - const watcher = new FileWatcher(testDir, syncFn); - - expect(watcher.start()).toBe(true); - expect(watcher.start()).toBe(true); // Should not throw - expect(watcher.isActive()).toBe(true); - - watcher.stop(); - }); - - it('should be idempotent on double stop', () => { - const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 }); - const watcher = new FileWatcher(testDir, syncFn); - - watcher.start(); - watcher.stop(); - watcher.stop(); // Should not throw - expect(watcher.isActive()).toBe(false); - }); - }); - - describe('debounced sync', () => { - it('should trigger sync after file change', async () => { - const syncFn = vi.fn().mockResolvedValue({ filesChanged: 1, durationMs: 10 }); - const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 200 }); - - watcher.start(); - - // Create a new file - fs.writeFileSync(path.join(testDir, 'src', 'new.ts'), 'export const y = 2;'); - - // Wait for debounced sync to fire - await waitFor(() => syncFn.mock.calls.length > 0, 5000); - expect(syncFn).toHaveBeenCalled(); - - watcher.stop(); - }); - - it('should debounce rapid changes into a single sync', async () => { - const syncFn = vi.fn().mockResolvedValue({ filesChanged: 1, durationMs: 10 }); - const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 500 }); - - watcher.start(); - - // Rapid-fire changes - for (let i = 0; i < 5; i++) { - fs.writeFileSync( - path.join(testDir, 'src', `file${i}.ts`), - `export const v${i} = ${i};` - ); - await new Promise((r) => setTimeout(r, 50)); - } - - // Wait for the single debounced sync - await waitFor(() => syncFn.mock.calls.length > 0, 5000); - - // Should have been called once (debounced), not 5 times - expect(syncFn.mock.calls.length).toBe(1); - - watcher.stop(); - }); - }); - - describe('filtering', () => { - it('should ignore files not matching include patterns', async () => { - const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 }); - const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 200 }); - - watcher.start(); - - // Let watcher settle — fs.watch may fire residual events from beforeEach - await new Promise((r) => setTimeout(r, 400)); - syncFn.mockClear(); - - // Create a file that doesn't match include patterns - fs.writeFileSync(path.join(testDir, 'src', 'readme.md'), '# Hello'); - - // Wait a bit longer than debounce — sync should NOT trigger - await new Promise((r) => setTimeout(r, 500)); - expect(syncFn).not.toHaveBeenCalled(); - - watcher.stop(); - }); - - it('should ignore .codegraph directory changes', async () => { - const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 }); - const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 200 }); - - watcher.start(); - - // Let watcher settle — fs.watch may fire residual events from beforeEach - await new Promise((r) => setTimeout(r, 400)); - syncFn.mockClear(); - - // Simulate a .codegraph directory change - const cgDir = path.join(testDir, '.codegraph'); - fs.mkdirSync(cgDir, { recursive: true }); - fs.writeFileSync(path.join(cgDir, 'db.sqlite'), 'fake'); - - // Wait — sync should NOT trigger - await new Promise((r) => setTimeout(r, 500)); - expect(syncFn).not.toHaveBeenCalled(); - - watcher.stop(); - }); - }); - - describe('callbacks', () => { - it('should call onSyncComplete after successful sync', async () => { - const syncFn = vi.fn().mockResolvedValue({ filesChanged: 2, durationMs: 50 }); - const onSyncComplete = vi.fn(); - const watcher = new FileWatcher(testDir, syncFn, { - debounceMs: 200, - onSyncComplete, - }); - - watcher.start(); - - fs.writeFileSync(path.join(testDir, 'src', 'test.ts'), 'export const z = 3;'); - - await waitFor(() => onSyncComplete.mock.calls.length > 0, 5000); - expect(onSyncComplete).toHaveBeenCalledWith({ filesChanged: 2, durationMs: 50 }); - - watcher.stop(); - }); - - it('should call onSyncError when sync throws', async () => { - const syncFn = vi.fn().mockRejectedValue(new Error('sync failed')); - const onSyncError = vi.fn(); - const watcher = new FileWatcher(testDir, syncFn, { - debounceMs: 200, - onSyncError, - }); - - watcher.start(); - - fs.writeFileSync(path.join(testDir, 'src', 'test.ts'), 'export const z = 3;'); - - await waitFor(() => onSyncError.mock.calls.length > 0, 5000); - expect(onSyncError).toHaveBeenCalled(); - expect(onSyncError.mock.calls[0]![0]).toBeInstanceOf(Error); - - watcher.stop(); - }); - }); - - describe('CodeGraph integration', () => { - let cg: CodeGraph; - - afterEach(() => { - if (cg) cg.close(); - }); - - it('should watch and unwatch via CodeGraph API', async () => { - cg = CodeGraph.initSync(testDir, { - config: { include: ['**/*.ts'], exclude: [] }, - }); - await cg.indexAll(); - - expect(cg.isWatching()).toBe(false); - - const started = cg.watch({ debounceMs: 200 }); - expect(started).toBe(true); - expect(cg.isWatching()).toBe(true); - - cg.unwatch(); - expect(cg.isWatching()).toBe(false); - }); - - it('should stop watching on close', async () => { - cg = CodeGraph.initSync(testDir, { - config: { include: ['**/*.ts'], exclude: [] }, - }); - await cg.indexAll(); - - cg.watch({ debounceMs: 200 }); - expect(cg.isWatching()).toBe(true); - - cg.close(); - // After close, isWatching should be false - // (we can't call isWatching after close since DB is closed, - // but we verify no errors are thrown) - }); - - it('should auto-sync when files change while watching', async () => { - cg = CodeGraph.initSync(testDir, { - config: { include: ['**/*.ts'], exclude: [] }, - }); - await cg.indexAll(); - - const initialStats = cg.getStats(); - const initialNodes = initialStats.nodeCount; - - cg.watch({ debounceMs: 300 }); - - // Add a new file with a function - fs.writeFileSync( - path.join(testDir, 'src', 'added.ts'), - 'export function added() { return 42; }' - ); - - // Wait for auto-sync to pick it up - await waitFor(() => { - const stats = cg.getStats(); - return stats.nodeCount > initialNodes; - }, 10000); - - // The new function should be in the graph - const results = cg.searchNodes('added'); - expect(results.length).toBeGreaterThan(0); - - cg.unwatch(); - }); - }); -}); diff --git a/crates/codegraph-context/Cargo.toml b/crates/codegraph-context/Cargo.toml new file mode 100644 index 00000000..536a14b1 --- /dev/null +++ b/crates/codegraph-context/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "codegraph-context" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +codegraph-core = { path = "../codegraph-core" } +codegraph-db = { path = "../codegraph-db" } +codegraph-graph = { path = "../codegraph-graph" } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/codegraph-context/src/lib.rs b/crates/codegraph-context/src/lib.rs new file mode 100644 index 00000000..114cb6b3 --- /dev/null +++ b/crates/codegraph-context/src/lib.rs @@ -0,0 +1,143 @@ +//! Context builder: search → callers + callees → markdown/json. + +use codegraph_core::{Node, Result}; +use codegraph_db::Db; +use codegraph_graph::Traversal; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt::Write; + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Format { + #[default] + Markdown, + Json, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextRequest { + pub query: String, + pub depth: u32, + pub include_source: bool, + pub limit: u32, + pub format: Format, +} + +impl Default for ContextRequest { + fn default() -> Self { + Self { + query: String::new(), + depth: 1, + include_source: false, + limit: 5, + format: Format::Markdown, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextHit { + pub node: Node, + pub callers: Vec, + pub callees: Vec, + pub source: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextResponse { + pub query: String, + pub hits: Vec, +} + +pub fn build(db: &Db, req: &ContextRequest) -> Result { + let response = build_response(db, req)?; + match req.format { + Format::Json => Ok(serde_json::to_string_pretty(&response).unwrap_or_default()), + Format::Markdown => Ok(render_markdown(&response)), + } +} + +pub fn build_response(db: &Db, req: &ContextRequest) -> Result { + let candidates = db.search_nodes(&req.query, req.limit)?; + let trav = Traversal::new(db); + + // Pre-load each unique file once when source is requested. + let file_cache: HashMap> = if req.include_source { + let mut cache = HashMap::new(); + for n in &candidates { + let key = n.file.as_str().to_owned(); + if let std::collections::hash_map::Entry::Vacant(e) = cache.entry(key) { + if let Ok(text) = std::fs::read_to_string(n.file.as_std_path()) { + e.insert(text.lines().map(str::to_owned).collect()); + } + } + } + cache + } else { + HashMap::new() + }; + + let mut hits = Vec::new(); + for n in candidates { + let callers = trav.callers(n.id, req.depth)?.nodes; + let callees = trav.callees(n.id, req.depth)?.nodes; + let source = if req.include_source { + file_cache.get(n.file.as_str()).map(|lines| { + let start = n.start_line.saturating_sub(1) as usize; + let end = (n.end_line as usize).min(lines.len()); + lines[start..end].join("\n") + }) + } else { + None + }; + hits.push(ContextHit { + node: n, + callers, + callees, + source, + }); + } + Ok(ContextResponse { + query: req.query.clone(), + hits, + }) +} + +fn render_markdown(resp: &ContextResponse) -> String { + let mut out = String::new(); + let _ = writeln!(out, "# Context: `{}`", resp.query); + if resp.hits.is_empty() { + let _ = writeln!(out, "\n_No matches._"); + return out; + } + for h in &resp.hits { + let _ = writeln!( + out, + "\n## `{}` — {} — `{}:{}`", + h.node.name, + h.node.kind.as_str(), + h.node.file, + h.node.start_line + ); + if let Some(sig) = &h.node.signature { + let _ = writeln!(out, "\n```{}\n{}\n```", h.node.language, sig); + } + if let Some(src) = &h.source { + let _ = writeln!(out, "\n```{}\n{}\n```", h.node.language, src); + } + if !h.callers.is_empty() { + let _ = writeln!(out, "\n**Callers** ({}):", h.callers.len()); + for c in &h.callers { + let _ = writeln!(out, "- `{}` — `{}:{}`", c.name, c.file, c.start_line); + } + } + if !h.callees.is_empty() { + let _ = writeln!(out, "\n**Callees** ({}):", h.callees.len()); + for c in &h.callees { + let _ = writeln!(out, "- `{}` — `{}:{}`", c.name, c.file, c.start_line); + } + } + } + out +} diff --git a/crates/codegraph-core/Cargo.toml b/crates/codegraph-core/Cargo.toml new file mode 100644 index 00000000..d31262ee --- /dev/null +++ b/crates/codegraph-core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "codegraph-core" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +camino = { workspace = true } diff --git a/crates/codegraph-core/src/error.rs b/crates/codegraph-core/src/error.rs new file mode 100644 index 00000000..cb51245d --- /dev/null +++ b/crates/codegraph-core/src/error.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum Error { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("db: {0}")] + Db(String), + #[error("parse: {0}")] + Parse(String), + #[error("invalid: {0}")] + Invalid(String), + #[error("not initialized: run `codegraph init` first")] + NotInitialized, + #[error("{0}")] + Other(String), +} diff --git a/crates/codegraph-core/src/kinds.rs b/crates/codegraph-core/src/kinds.rs new file mode 100644 index 00000000..46083836 --- /dev/null +++ b/crates/codegraph-core/src/kinds.rs @@ -0,0 +1,93 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NodeKind { + File, + Module, + Class, + Struct, + Interface, + Trait, + Protocol, + Function, + Method, + Property, + Field, + Variable, + Constant, + Enum, + EnumMember, + TypeAlias, + Namespace, + Parameter, + Import, + Export, + Route, + Component, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EdgeKind { + Contains, + Calls, + Imports, + Exports, + Extends, + Implements, + References, + TypeOf, + Returns, + Instantiates, + Overrides, + Decorates, +} + +impl NodeKind { + pub fn as_str(self) -> &'static str { + match self { + Self::File => "file", + Self::Module => "module", + Self::Class => "class", + Self::Struct => "struct", + Self::Interface => "interface", + Self::Trait => "trait", + Self::Protocol => "protocol", + Self::Function => "function", + Self::Method => "method", + Self::Property => "property", + Self::Field => "field", + Self::Variable => "variable", + Self::Constant => "constant", + Self::Enum => "enum", + Self::EnumMember => "enum_member", + Self::TypeAlias => "type_alias", + Self::Namespace => "namespace", + Self::Parameter => "parameter", + Self::Import => "import", + Self::Export => "export", + Self::Route => "route", + Self::Component => "component", + } + } +} + +impl EdgeKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Contains => "contains", + Self::Calls => "calls", + Self::Imports => "imports", + Self::Exports => "exports", + Self::Extends => "extends", + Self::Implements => "implements", + Self::References => "references", + Self::TypeOf => "type_of", + Self::Returns => "returns", + Self::Instantiates => "instantiates", + Self::Overrides => "overrides", + Self::Decorates => "decorates", + } + } +} diff --git a/crates/codegraph-core/src/lib.rs b/crates/codegraph-core/src/lib.rs new file mode 100644 index 00000000..585a3f8c --- /dev/null +++ b/crates/codegraph-core/src/lib.rs @@ -0,0 +1,9 @@ +//! Core types shared across codegraph crates: NodeKind, EdgeKind, Node, Edge, errors. + +pub mod error; +pub mod kinds; +pub mod model; + +pub use error::{Error, Result}; +pub use kinds::{EdgeKind, NodeKind}; +pub use model::{Edge, Node, NodeId}; diff --git a/crates/codegraph-core/src/model.rs b/crates/codegraph-core/src/model.rs new file mode 100644 index 00000000..32e073f5 --- /dev/null +++ b/crates/codegraph-core/src/model.rs @@ -0,0 +1,28 @@ +use crate::{EdgeKind, NodeKind}; +use camino::Utf8PathBuf; +use serde::{Deserialize, Serialize}; + +pub type NodeId = i64; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Node { + pub id: NodeId, + pub kind: NodeKind, + pub name: String, + pub qualified_name: Option, + pub file: Utf8PathBuf, + pub start_line: u32, + pub end_line: u32, + pub signature: Option, + pub docstring: Option, + pub language: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Edge { + pub from: NodeId, + pub to: NodeId, + pub kind: EdgeKind, + pub file: Option, + pub line: Option, +} diff --git a/crates/codegraph-db/Cargo.toml b/crates/codegraph-db/Cargo.toml new file mode 100644 index 00000000..34b30424 --- /dev/null +++ b/crates/codegraph-db/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "codegraph-db" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +codegraph-core = { path = "../codegraph-core" } +rusqlite = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +camino = { workspace = true } +parking_lot = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/codegraph-db/src/lib.rs b/crates/codegraph-db/src/lib.rs new file mode 100644 index 00000000..897582c1 --- /dev/null +++ b/crates/codegraph-db/src/lib.rs @@ -0,0 +1,167 @@ +//! SQLite-backed knowledge graph storage. rusqlite bundled + FTS5. +//! +//! Schema v1, no compat with archive TS DB. + +mod migrations; +mod model; +mod queries; + +pub use model::{DbStats, EdgeDraft, FileRow, NodeDraft}; + +use camino::{Utf8Path, Utf8PathBuf}; +use codegraph_core::{Edge, EdgeKind, Error, Node, NodeId, NodeKind, Result}; +use parking_lot::Mutex; +use rusqlite::{Connection, OpenFlags}; + +pub const SCHEMA_SQL: &str = include_str!("schema.sql"); +pub const SCHEMA_VERSION: u32 = 1; + +pub struct Db { + conn: Mutex, + path: Utf8PathBuf, +} + +impl Db { + pub fn open(path: &Utf8Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut conn = Connection::open(path).map_err(db_err)?; + conn.pragma_update(None, "journal_mode", "WAL") + .map_err(db_err)?; + conn.pragma_update(None, "foreign_keys", "ON") + .map_err(db_err)?; + conn.pragma_update(None, "synchronous", "NORMAL") + .map_err(db_err)?; + migrations::run(&mut conn)?; + Ok(Self { + conn: Mutex::new(conn), + path: path.to_path_buf(), + }) + } + + pub fn open_read_only(path: &Utf8Path) -> Result { + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(db_err)?; + Ok(Self { + conn: Mutex::new(conn), + path: path.to_path_buf(), + }) + } + + pub fn path(&self) -> &Utf8Path { + &self.path + } + + pub fn schema_version(&self) -> Result { + let c = self.conn.lock(); + queries::schema_version(&c) + } + + pub fn upsert_file(&self, f: &FileRow) -> Result { + let mut c = self.conn.lock(); + let tx = c.transaction().map_err(db_err)?; + let id = queries::upsert_file(&tx, f)?; + tx.commit().map_err(db_err)?; + Ok(id) + } + + pub fn delete_file_cascade(&self, file_id: i64) -> Result<()> { + let c = self.conn.lock(); + c.execute("DELETE FROM files WHERE id = ?", [file_id]) + .map_err(db_err)?; + Ok(()) + } + + pub fn insert_nodes(&self, file_id: i64, drafts: &[NodeDraft]) -> Result> { + let mut c = self.conn.lock(); + let tx = c.transaction().map_err(db_err)?; + let ids = queries::insert_nodes(&tx, file_id, drafts)?; + tx.commit().map_err(db_err)?; + Ok(ids) + } + + pub fn insert_edges(&self, edges: &[EdgeDraft]) -> Result<()> { + let mut c = self.conn.lock(); + let tx = c.transaction().map_err(db_err)?; + queries::insert_edges(&tx, edges)?; + tx.commit().map_err(db_err)?; + Ok(()) + } + + pub fn search_nodes(&self, query: &str, limit: u32) -> Result> { + let c = self.conn.lock(); + queries::search_fts(&c, query, limit) + } + + pub fn node_by_id(&self, id: NodeId) -> Result> { + let c = self.conn.lock(); + queries::node_by_id(&c, id) + } + + pub fn nodes_by_name(&self, name: &str) -> Result> { + let c = self.conn.lock(); + queries::nodes_by_name(&c, name) + } + + pub fn callers_of(&self, id: NodeId) -> Result> { + let c = self.conn.lock(); + queries::edges_to(&c, id, EdgeKind::Calls) + } + + pub fn callees_of(&self, id: NodeId) -> Result> { + let c = self.conn.lock(); + queries::edges_from(&c, id, EdgeKind::Calls) + } + + pub fn edges_from(&self, id: NodeId, kinds: &[EdgeKind]) -> Result> { + let c = self.conn.lock(); + queries::edges_from_any(&c, id, kinds) + } + + pub fn edges_to(&self, id: NodeId, kinds: &[EdgeKind]) -> Result> { + let c = self.conn.lock(); + queries::edges_to_any(&c, id, kinds) + } + + pub fn files_under(&self, prefix: &str) -> Result> { + let c = self.conn.lock(); + queries::files_under(&c, prefix) + } + + pub fn file_by_path(&self, path: &str) -> Result> { + let c = self.conn.lock(); + queries::file_by_path(&c, path) + } + + pub fn file_by_id(&self, id: i64) -> Result> { + let c = self.conn.lock(); + queries::file_by_id(&c, id) + } + + pub fn stats(&self) -> Result { + let c = self.conn.lock(); + queries::stats(&c) + } + + pub fn purge(&self) -> Result<()> { + let c = self.conn.lock(); + c.execute_batch("DELETE FROM edges; DELETE FROM nodes; DELETE FROM files;") + .map_err(db_err)?; + Ok(()) + } +} + +pub(crate) fn db_err(e: rusqlite::Error) -> Error { + Error::Db(e.to_string()) +} + +pub(crate) fn kind_str(k: NodeKind) -> &'static str { + k.as_str() +} +pub(crate) fn ekind_str(k: EdgeKind) -> &'static str { + k.as_str() +} diff --git a/crates/codegraph-db/src/migrations.rs b/crates/codegraph-db/src/migrations.rs new file mode 100644 index 00000000..222fcf59 --- /dev/null +++ b/crates/codegraph-db/src/migrations.rs @@ -0,0 +1,16 @@ +use crate::{db_err, SCHEMA_SQL, SCHEMA_VERSION}; +use codegraph_core::Result; +use rusqlite::Connection; + +pub(crate) fn run(conn: &mut Connection) -> Result<()> { + let tx = conn.transaction().map_err(db_err)?; + tx.execute_batch(SCHEMA_SQL).map_err(db_err)?; + tx.execute( + "INSERT INTO meta(key, value) VALUES('schema_version', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [SCHEMA_VERSION.to_string()], + ) + .map_err(db_err)?; + tx.commit().map_err(db_err)?; + Ok(()) +} diff --git a/crates/codegraph-db/src/model.rs b/crates/codegraph-db/src/model.rs new file mode 100644 index 00000000..c8ac50c9 --- /dev/null +++ b/crates/codegraph-db/src/model.rs @@ -0,0 +1,45 @@ +use camino::Utf8PathBuf; +use codegraph_core::{EdgeKind, NodeKind}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileRow { + pub id: Option, + pub path: Utf8PathBuf, + pub language: String, + pub sha256: String, + pub size: u64, + pub mtime: i64, + pub indexed_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeDraft { + pub kind: NodeKind, + pub name: String, + pub qualified_name: Option, + pub start_line: u32, + pub end_line: u32, + pub signature: Option, + pub docstring: Option, + pub language: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EdgeDraft { + pub from_id: i64, + pub to_id: i64, + pub kind: EdgeKind, + pub file_id: Option, + pub line: Option, + pub source: Option, // e.g. "framework:express", "resolver:imports" +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DbStats { + pub files: u64, + pub nodes: u64, + pub edges: u64, + pub size_bytes: u64, + pub schema_version: u32, +} diff --git a/crates/codegraph-db/src/queries.rs b/crates/codegraph-db/src/queries.rs new file mode 100644 index 00000000..9690d324 --- /dev/null +++ b/crates/codegraph-db/src/queries.rs @@ -0,0 +1,394 @@ +use crate::{db_err, ekind_str, kind_str, DbStats, EdgeDraft, FileRow, NodeDraft}; +use camino::Utf8PathBuf; +use codegraph_core::{Edge, EdgeKind, Error, Node, NodeId, NodeKind, Result}; +use rusqlite::{params, Connection, OptionalExtension, Row, Transaction}; + +pub(crate) fn schema_version(c: &Connection) -> Result { + let v: Option = c + .query_row( + "SELECT value FROM meta WHERE key='schema_version'", + [], + |r| r.get(0), + ) + .optional() + .map_err(db_err)?; + Ok(v.and_then(|s| s.parse().ok()).unwrap_or(0)) +} + +pub(crate) fn upsert_file(tx: &Transaction, f: &FileRow) -> Result { + let id: i64 = tx + .query_row( + "INSERT INTO files(path, language, sha256, size, mtime, indexed_at) + VALUES(?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(path) DO UPDATE SET + language=excluded.language, + sha256=excluded.sha256, + size=excluded.size, + mtime=excluded.mtime, + indexed_at=excluded.indexed_at + RETURNING id", + params![ + f.path.as_str(), + f.language, + f.sha256, + f.size as i64, + f.mtime, + f.indexed_at + ], + |r| r.get(0), + ) + .map_err(db_err)?; + Ok(id) +} + +pub(crate) fn file_by_path(c: &Connection, path: &str) -> Result> { + c.query_row( + "SELECT id, path, language, sha256, size, mtime, indexed_at FROM files WHERE path=?1", + [path], + row_to_file, + ) + .optional() + .map_err(db_err) +} + +pub(crate) fn file_by_id(c: &Connection, id: i64) -> Result> { + c.query_row( + "SELECT id, path, language, sha256, size, mtime, indexed_at FROM files WHERE id=?1", + [id], + row_to_file, + ) + .optional() + .map_err(db_err) +} + +pub(crate) fn files_under(c: &Connection, prefix: &str) -> Result> { + let mut s = c + .prepare_cached( + "SELECT id, path, language, sha256, size, mtime, indexed_at + FROM files WHERE path LIKE ?1 ORDER BY path", + ) + .map_err(db_err)?; + let pat = format!("{}%", prefix); + let it = s.query_map([pat], row_to_file).map_err(db_err)?; + let mut out = Vec::new(); + for r in it { + out.push(r.map_err(db_err)?); + } + Ok(out) +} + +pub(crate) fn insert_nodes( + tx: &Transaction, + file_id: i64, + drafts: &[NodeDraft], +) -> Result> { + let mut s = tx + .prepare_cached( + "INSERT INTO nodes(kind, name, qualified_name, file_id, start_line, end_line, signature, docstring, language) + VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + ) + .map_err(db_err)?; + let mut ids = Vec::with_capacity(drafts.len()); + for d in drafts { + s.execute(params![ + kind_str(d.kind), + d.name, + d.qualified_name, + file_id, + d.start_line, + d.end_line, + d.signature, + d.docstring, + d.language, + ]) + .map_err(db_err)?; + ids.push(tx.last_insert_rowid()); + } + Ok(ids) +} + +pub(crate) fn insert_edges(tx: &Transaction, edges: &[EdgeDraft]) -> Result<()> { + let mut s = tx + .prepare_cached( + "INSERT INTO edges(from_id, to_id, kind, file_id, line, source) + VALUES(?1, ?2, ?3, ?4, ?5, ?6)", + ) + .map_err(db_err)?; + for e in edges { + s.execute(params![ + e.from_id, + e.to_id, + ekind_str(e.kind), + e.file_id, + e.line, + e.source, + ]) + .map_err(db_err)?; + } + Ok(()) +} + +pub(crate) fn node_by_id(c: &Connection, id: NodeId) -> Result> { + c.query_row( + "SELECT n.id, n.kind, n.name, n.qualified_name, f.path, n.start_line, n.end_line, + n.signature, n.docstring, n.language + FROM nodes n JOIN files f ON f.id = n.file_id + WHERE n.id = ?1", + [id], + row_to_node, + ) + .optional() + .map_err(db_err) +} + +pub(crate) fn nodes_by_name(c: &Connection, name: &str) -> Result> { + let mut s = c + .prepare_cached( + "SELECT n.id, n.kind, n.name, n.qualified_name, f.path, n.start_line, n.end_line, + n.signature, n.docstring, n.language + FROM nodes n JOIN files f ON f.id = n.file_id + WHERE n.name = ?1 + ORDER BY n.id LIMIT 100", + ) + .map_err(db_err)?; + let it = s.query_map([name], row_to_node).map_err(db_err)?; + let mut out = Vec::new(); + for r in it { + out.push(r.map_err(db_err)?); + } + Ok(out) +} + +pub(crate) fn search_fts(c: &Connection, q: &str, limit: u32) -> Result> { + // Escape FTS5 special chars by wrapping each token in double quotes. + let escaped = q + .split_whitespace() + .map(|t| format!("\"{}\"*", t.replace('"', "\"\""))) + .collect::>() + .join(" "); + let sql = "SELECT n.id, n.kind, n.name, n.qualified_name, f.path, n.start_line, n.end_line, + n.signature, n.docstring, n.language + FROM nodes_fts ft + JOIN nodes n ON n.id = ft.rowid + JOIN files f ON f.id = n.file_id + WHERE nodes_fts MATCH ?1 + ORDER BY rank + LIMIT ?2"; + let mut s = c.prepare_cached(sql).map_err(db_err)?; + let it = s + .query_map(params![escaped, limit as i64], row_to_node) + .map_err(db_err)?; + let mut out = Vec::new(); + for r in it { + out.push(r.map_err(db_err)?); + } + Ok(out) +} + +pub(crate) fn edges_from(c: &Connection, id: NodeId, kind: EdgeKind) -> Result> { + let sql = "SELECT e.from_id, e.to_id, e.kind, f.path, e.line + FROM edges e LEFT JOIN files f ON f.id = e.file_id + WHERE e.from_id = ?1 AND e.kind = ?2"; + let mut s = c.prepare_cached(sql).map_err(db_err)?; + let it = s + .query_map(params![id, ekind_str(kind)], row_to_edge) + .map_err(db_err)?; + let mut out = Vec::new(); + for r in it { + out.push(r.map_err(db_err)?); + } + Ok(out) +} + +pub(crate) fn edges_to(c: &Connection, id: NodeId, kind: EdgeKind) -> Result> { + let sql = "SELECT e.from_id, e.to_id, e.kind, f.path, e.line + FROM edges e LEFT JOIN files f ON f.id = e.file_id + WHERE e.to_id = ?1 AND e.kind = ?2"; + let mut s = c.prepare_cached(sql).map_err(db_err)?; + let it = s + .query_map(params![id, ekind_str(kind)], row_to_edge) + .map_err(db_err)?; + let mut out = Vec::new(); + for r in it { + out.push(r.map_err(db_err)?); + } + Ok(out) +} + +pub(crate) fn edges_from_any(c: &Connection, id: NodeId, kinds: &[EdgeKind]) -> Result> { + edges_any(c, id, kinds, true) +} +pub(crate) fn edges_to_any(c: &Connection, id: NodeId, kinds: &[EdgeKind]) -> Result> { + edges_any(c, id, kinds, false) +} + +fn edges_any(c: &Connection, id: NodeId, kinds: &[EdgeKind], from: bool) -> Result> { + if kinds.is_empty() { + return Ok(Vec::new()); + } + let placeholders = std::iter::repeat_n("?", kinds.len()) + .collect::>() + .join(","); + let col = if from { "from_id" } else { "to_id" }; + let sql = format!( + "SELECT e.from_id, e.to_id, e.kind, f.path, e.line + FROM edges e LEFT JOIN files f ON f.id = e.file_id + WHERE e.{col} = ? AND e.kind IN ({placeholders})" + ); + let mut s = c.prepare(&sql).map_err(db_err)?; + let mut p: Vec> = Vec::with_capacity(kinds.len() + 1); + p.push(Box::new(id)); + for k in kinds { + p.push(Box::new(ekind_str(*k))); + } + let refs: Vec<&dyn rusqlite::ToSql> = p.iter().map(|b| b.as_ref()).collect(); + let it = s.query_map(refs.as_slice(), row_to_edge).map_err(db_err)?; + let mut out = Vec::new(); + for r in it { + out.push(r.map_err(db_err)?); + } + Ok(out) +} + +pub(crate) fn stats(c: &Connection) -> Result { + let files: i64 = c + .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0)) + .map_err(db_err)?; + let nodes: i64 = c + .query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0)) + .map_err(db_err)?; + let edges: i64 = c + .query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0)) + .map_err(db_err)?; + let page_count: i64 = c + .query_row("PRAGMA page_count", [], |r| r.get(0)) + .map_err(db_err)?; + let page_size: i64 = c + .query_row("PRAGMA page_size", [], |r| r.get(0)) + .map_err(db_err)?; + Ok(DbStats { + files: files as u64, + nodes: nodes as u64, + edges: edges as u64, + size_bytes: (page_count * page_size) as u64, + schema_version: schema_version(c)?, + }) +} + +fn row_to_file(r: &Row<'_>) -> rusqlite::Result { + let path: String = r.get(1)?; + let size: i64 = r.get(4)?; + Ok(FileRow { + id: Some(r.get(0)?), + path: Utf8PathBuf::from(path), + language: r.get(2)?, + sha256: r.get(3)?, + size: size as u64, + mtime: r.get(5)?, + indexed_at: r.get(6)?, + }) +} + +fn row_to_node(r: &Row<'_>) -> rusqlite::Result { + let kind_s: String = r.get(1)?; + let kind = parse_node_kind(&kind_s).ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure( + 1, + rusqlite::types::Type::Text, + Box::new(BadKind(kind_s.clone())), + ) + })?; + let path: String = r.get(4)?; + Ok(Node { + id: r.get(0)?, + kind, + name: r.get(2)?, + qualified_name: r.get(3)?, + file: Utf8PathBuf::from(path), + start_line: r.get(5)?, + end_line: r.get(6)?, + signature: r.get(7)?, + docstring: r.get(8)?, + language: r.get(9)?, + }) +} + +fn row_to_edge(r: &Row<'_>) -> rusqlite::Result { + let kind_s: String = r.get(2)?; + let kind = parse_edge_kind(&kind_s).ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Text, + Box::new(BadKind(kind_s.clone())), + ) + })?; + let path: Option = r.get(3)?; + Ok(Edge { + from: r.get(0)?, + to: r.get(1)?, + kind, + file: path.map(Utf8PathBuf::from), + line: r.get(4)?, + }) +} + +#[derive(Debug)] +struct BadKind(String); +impl std::fmt::Display for BadKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "bad kind: {}", self.0) + } +} +impl std::error::Error for BadKind {} + +fn parse_node_kind(s: &str) -> Option { + use NodeKind::*; + Some(match s { + "file" => File, + "module" => Module, + "class" => Class, + "struct" => Struct, + "interface" => Interface, + "trait" => Trait, + "protocol" => Protocol, + "function" => Function, + "method" => Method, + "property" => Property, + "field" => Field, + "variable" => Variable, + "constant" => Constant, + "enum" => Enum, + "enum_member" => EnumMember, + "type_alias" => TypeAlias, + "namespace" => Namespace, + "parameter" => Parameter, + "import" => Import, + "export" => Export, + "route" => Route, + "component" => Component, + _ => return None, + }) +} + +fn parse_edge_kind(s: &str) -> Option { + use EdgeKind::*; + Some(match s { + "contains" => Contains, + "calls" => Calls, + "imports" => Imports, + "exports" => Exports, + "extends" => Extends, + "implements" => Implements, + "references" => References, + "type_of" => TypeOf, + "returns" => Returns, + "instantiates" => Instantiates, + "overrides" => Overrides, + "decorates" => Decorates, + _ => return None, + }) +} + +// Suppress unused-warning if Error variant unused elsewhere +#[allow(dead_code)] +fn _check(_: &Error) {} diff --git a/crates/codegraph-db/src/schema.sql b/crates/codegraph-db/src/schema.sql new file mode 100644 index 00000000..35706667 --- /dev/null +++ b/crates/codegraph-db/src/schema.sql @@ -0,0 +1,73 @@ +-- codegraph schema v1 (Rust rewrite, fresh) +-- PRAGMAs set by Db::open before migrations. + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + language TEXT NOT NULL, + sha256 TEXT NOT NULL, + size INTEGER NOT NULL, + mtime INTEGER NOT NULL, + indexed_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_files_lang ON files(language); + +CREATE TABLE IF NOT EXISTS nodes ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL, + name TEXT NOT NULL, + qualified_name TEXT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + signature TEXT, + docstring TEXT, + language TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name); +CREATE INDEX IF NOT EXISTS idx_nodes_qname ON nodes(qualified_name); +CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_id); +CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind); + +CREATE TABLE IF NOT EXISTS edges ( + id INTEGER PRIMARY KEY, + from_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + to_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + line INTEGER, + source TEXT +); + +CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_id, kind); +CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_id, kind); +CREATE INDEX IF NOT EXISTS idx_edges_src ON edges(source); + +CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5( + name, qualified_name, signature, docstring, + content='nodes', content_rowid='id', tokenize='unicode61' +); + +CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN + INSERT INTO nodes_fts(rowid, name, qualified_name, signature, docstring) + VALUES (new.id, new.name, COALESCE(new.qualified_name,''), COALESCE(new.signature,''), COALESCE(new.docstring,'')); +END; + +CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN + INSERT INTO nodes_fts(nodes_fts, rowid, name, qualified_name, signature, docstring) + VALUES ('delete', old.id, old.name, COALESCE(old.qualified_name,''), COALESCE(old.signature,''), COALESCE(old.docstring,'')); +END; + +CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN + INSERT INTO nodes_fts(nodes_fts, rowid, name, qualified_name, signature, docstring) + VALUES ('delete', old.id, old.name, COALESCE(old.qualified_name,''), COALESCE(old.signature,''), COALESCE(old.docstring,'')); + INSERT INTO nodes_fts(rowid, name, qualified_name, signature, docstring) + VALUES (new.id, new.name, COALESCE(new.qualified_name,''), COALESCE(new.signature,''), COALESCE(new.docstring,'')); +END; diff --git a/crates/codegraph-db/tests/smoke.rs b/crates/codegraph-db/tests/smoke.rs new file mode 100644 index 00000000..2f88e48b --- /dev/null +++ b/crates/codegraph-db/tests/smoke.rs @@ -0,0 +1,150 @@ +use camino::Utf8PathBuf; +use codegraph_core::{EdgeKind, NodeKind}; +use codegraph_db::{Db, EdgeDraft, FileRow, NodeDraft, SCHEMA_VERSION}; + +fn tmp_db() -> (tempfile::TempDir, Db) { + let dir = tempfile::tempdir().unwrap(); + let path = Utf8PathBuf::from_path_buf(dir.path().join("db.sqlite")).unwrap(); + let db = Db::open(&path).unwrap(); + (dir, db) +} + +fn mk_file(path: &str) -> FileRow { + FileRow { + id: None, + path: path.into(), + language: "typescript".into(), + sha256: "deadbeef".into(), + size: 100, + mtime: 0, + indexed_at: 0, + } +} + +fn mk_node(name: &str, kind: NodeKind) -> NodeDraft { + NodeDraft { + kind, + name: name.into(), + qualified_name: Some(format!("mod::{name}")), + start_line: 1, + end_line: 10, + signature: Some(format!("fn {name}()")), + docstring: None, + language: "typescript".into(), + } +} + +#[test] +fn schema_version_set() { + let (_d, db) = tmp_db(); + assert_eq!(db.schema_version().unwrap(), SCHEMA_VERSION); +} + +#[test] +fn upsert_file_idempotent() { + let (_d, db) = tmp_db(); + let id1 = db.upsert_file(&mk_file("src/foo.ts")).unwrap(); + let id2 = db.upsert_file(&mk_file("src/foo.ts")).unwrap(); + assert_eq!(id1, id2); + assert_eq!(db.stats().unwrap().files, 1); +} + +#[test] +fn nodes_edges_roundtrip() { + let (_d, db) = tmp_db(); + let fid = db.upsert_file(&mk_file("src/a.ts")).unwrap(); + let ids = db + .insert_nodes( + fid, + &[ + mk_node("foo", NodeKind::Function), + mk_node("bar", NodeKind::Function), + ], + ) + .unwrap(); + assert_eq!(ids.len(), 2); + + db.insert_edges(&[EdgeDraft { + from_id: ids[0], + to_id: ids[1], + kind: EdgeKind::Calls, + file_id: Some(fid), + line: Some(5), + source: None, + }]) + .unwrap(); + + let callees = db.callees_of(ids[0]).unwrap(); + assert_eq!(callees.len(), 1); + assert_eq!(callees[0].to, ids[1]); + + let callers = db.callers_of(ids[1]).unwrap(); + assert_eq!(callers.len(), 1); + + let stats = db.stats().unwrap(); + assert_eq!(stats.files, 1); + assert_eq!(stats.nodes, 2); + assert_eq!(stats.edges, 1); +} + +#[test] +fn fts_search() { + let (_d, db) = tmp_db(); + let fid = db.upsert_file(&mk_file("src/a.ts")).unwrap(); + db.insert_nodes( + fid, + &[ + mk_node("processUser", NodeKind::Function), + mk_node("formatEmail", NodeKind::Function), + mk_node("randomThing", NodeKind::Variable), + ], + ) + .unwrap(); + + let hits = db.search_nodes("process", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].name, "processUser"); + + let hits = db.search_nodes("format", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].name, "formatEmail"); +} + +#[test] +fn delete_cascade() { + let (_d, db) = tmp_db(); + let fid = db.upsert_file(&mk_file("src/a.ts")).unwrap(); + let ids = db + .insert_nodes(fid, &[mk_node("foo", NodeKind::Function)]) + .unwrap(); + db.insert_edges(&[EdgeDraft { + from_id: ids[0], + to_id: ids[0], + kind: EdgeKind::Calls, + file_id: Some(fid), + line: None, + source: None, + }]) + .unwrap(); + + db.delete_file_cascade(fid).unwrap(); + let s = db.stats().unwrap(); + assert_eq!(s.files, 0); + assert_eq!(s.nodes, 0); + assert_eq!(s.edges, 0); +} + +#[test] +fn nodes_by_name_returns_all() { + let (_d, db) = tmp_db(); + let fid = db.upsert_file(&mk_file("src/a.ts")).unwrap(); + db.insert_nodes( + fid, + &[ + mk_node("foo", NodeKind::Function), + mk_node("foo", NodeKind::Variable), + ], + ) + .unwrap(); + assert_eq!(db.nodes_by_name("foo").unwrap().len(), 2); +} diff --git a/crates/codegraph-extract/Cargo.toml b/crates/codegraph-extract/Cargo.toml new file mode 100644 index 00000000..bfa36744 --- /dev/null +++ b/crates/codegraph-extract/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "codegraph-extract" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +codegraph-core = { path = "../codegraph-core" } +codegraph-db = { path = "../codegraph-db" } +codegraph-resolve = { path = "../codegraph-resolve" } +tree-sitter = { workspace = true } +sha2 = "0.10" +hex = "0.4" +crossbeam-channel = "0.5" +tree-sitter-typescript = { workspace = true, optional = true } +tree-sitter-javascript = { workspace = true, optional = true } +tree-sitter-python = { workspace = true, optional = true } +tree-sitter-rust = { workspace = true, optional = true } +tree-sitter-go = { workspace = true, optional = true } +tree-sitter-java = { workspace = true, optional = true } +tree-sitter-c = { workspace = true, optional = true } +tree-sitter-cpp = { workspace = true, optional = true } +tree-sitter-c-sharp = { workspace = true, optional = true } +tree-sitter-ruby = { workspace = true, optional = true } +tree-sitter-php = { workspace = true, optional = true } +tree-sitter-scala = { workspace = true, optional = true } +tree-sitter-swift = { workspace = true, optional = true } +# tree-sitter-kotlin uses tree-sitter 0.20 — incompatible. Re-enable when upstream upgrades. +# tree-sitter-kotlin = { workspace = true, optional = true } +tree-sitter-lua = { workspace = true, optional = true } +ignore = { workspace = true } +rayon = { workspace = true } +camino = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tempfile = "3" + +[features] +default = ["all-langs"] +all-langs = [ + "lang-typescript", "lang-javascript", "lang-python", "lang-rust", "lang-go", + "lang-java", "lang-c", "lang-cpp", "lang-csharp", "lang-ruby", "lang-php", + "lang-scala", "lang-swift", "lang-lua", +] +lang-typescript = ["dep:tree-sitter-typescript"] +lang-javascript = ["dep:tree-sitter-javascript"] +lang-python = ["dep:tree-sitter-python"] +lang-rust = ["dep:tree-sitter-rust"] +lang-go = ["dep:tree-sitter-go"] +lang-java = ["dep:tree-sitter-java"] +lang-c = ["dep:tree-sitter-c"] +lang-cpp = ["dep:tree-sitter-cpp"] +lang-csharp = ["dep:tree-sitter-c-sharp"] +lang-ruby = ["dep:tree-sitter-ruby"] +lang-php = ["dep:tree-sitter-php"] +lang-scala = ["dep:tree-sitter-scala"] +lang-swift = ["dep:tree-sitter-swift"] +# lang-kotlin = ["dep:tree-sitter-kotlin"] +lang-lua = ["dep:tree-sitter-lua"] diff --git a/crates/codegraph-extract/src/languages.rs b/crates/codegraph-extract/src/languages.rs new file mode 100644 index 00000000..95b7d90f --- /dev/null +++ b/crates/codegraph-extract/src/languages.rs @@ -0,0 +1,32 @@ +//! Per-language extractor modules. + +pub mod common; + +#[cfg(feature = "lang-c")] +pub mod c; +#[cfg(feature = "lang-cpp")] +pub mod cpp; +#[cfg(feature = "lang-csharp")] +pub mod csharp; +#[cfg(feature = "lang-go")] +pub mod go; +#[cfg(feature = "lang-java")] +pub mod java; +#[cfg(feature = "lang-javascript")] +pub mod javascript; +#[cfg(feature = "lang-lua")] +pub mod lua; +#[cfg(feature = "lang-php")] +pub mod php; +#[cfg(feature = "lang-python")] +pub mod python; +#[cfg(feature = "lang-ruby")] +pub mod ruby; +#[cfg(feature = "lang-rust")] +pub mod rust; +#[cfg(feature = "lang-scala")] +pub mod scala; +#[cfg(feature = "lang-swift")] +pub mod swift; +#[cfg(feature = "lang-typescript")] +pub mod typescript; diff --git a/crates/codegraph-extract/src/languages/c.rs b/crates/codegraph-extract/src/languages/c.rs new file mode 100644 index 00000000..fcf2254c --- /dev/null +++ b/crates/codegraph-extract/src/languages/c.rs @@ -0,0 +1,41 @@ +use crate::lang_extractor; +use crate::languages::common::LangSpec; +use codegraph_core::NodeKind; +use tree_sitter::Node; + +fn ts_language() -> tree_sitter::Language { + tree_sitter_c::LANGUAGE.into() +} + +fn import_path(n: &Node, src: &[u8]) -> Option { + let mut c = n.walk(); + for ch in n.children(&mut c) { + if matches!(ch.kind(), "string_literal" | "system_lib_string") { + return ch.utf8_text(src).ok().map(|s| { + s.trim_matches(|c| c == '"' || c == '<' || c == '>') + .to_string() + }); + } + } + None +} + +pub static SPEC: LangSpec = LangSpec { + language_name: "c", + extensions: &["c", "h"], + ts_language, + decls: &[ + ("function_definition", NodeKind::Function), + ("struct_specifier", NodeKind::Struct), + ("enum_specifier", NodeKind::Enum), + ("union_specifier", NodeKind::Struct), + ("type_definition", NodeKind::TypeAlias), + ], + call_kind: Some("call_expression"), + callee_field: Some("function"), + callee_ident_kinds: &["identifier", "field_identifier"], + import_kinds: &["preproc_include"], + import_extract: Some(import_path), +}; + +lang_extractor!(CExtractor, SPEC); diff --git a/crates/codegraph-extract/src/languages/common.rs b/crates/codegraph-extract/src/languages/common.rs new file mode 100644 index 00000000..db10c5ef --- /dev/null +++ b/crates/codegraph-extract/src/languages/common.rs @@ -0,0 +1,228 @@ +//! Shared walker used by simple language extractors. +//! +//! A language provides a [`LangSpec`] (node kinds, callee field, etc.) and the +//! common walker handles tree-sitter traversal, name extraction, signature +//! capture, `contains` edges, and import/call emission. + +use crate::{ExtractResult, LocalEdge, PendingCall, RawImport}; + +pub type ImportExtractFn = fn(&tree_sitter::Node, &[u8]) -> Option; +use codegraph_core::{EdgeKind, NodeKind, Result}; +use codegraph_db::NodeDraft; +use tree_sitter::{Node, Parser, Tree}; + +/// Declarative configuration of a language's extractor. +pub struct LangSpec { + pub language_name: &'static str, + pub extensions: &'static [&'static str], + pub ts_language: fn() -> tree_sitter::Language, + /// (tree-sitter node kind, codegraph NodeKind) — first match wins. + pub decls: &'static [(&'static str, NodeKind)], + /// Tree-sitter kind of a call site. Callee is read from `callee_field`. + pub call_kind: Option<&'static str>, + pub callee_field: Option<&'static str>, + /// Identifier kinds inside a callee expression (e.g. "identifier", + /// "field_identifier"). Used to extract the called name. + pub callee_ident_kinds: &'static [&'static str], + /// Tree-sitter kinds that represent an import statement at the top level. + pub import_kinds: &'static [&'static str], + /// Optional custom import path extractor; falls back to the entire node text. + pub import_extract: Option, +} + +pub fn run(spec: &'static LangSpec, source: &str) -> Result { + let lang = (spec.ts_language)(); + let mut parser = Parser::new(); + parser + .set_language(&lang) + .map_err(|e| crate::parse_err(format!("set_language: {e}")))?; + let tree: Tree = parser + .parse(source, None) + .ok_or_else(|| crate::parse_err("parse failed"))?; + let mut ctx = Ctx { + spec, + src: source.as_bytes(), + result: ExtractResult::default(), + parent_idx: None, + }; + walk(&tree.root_node(), &mut ctx); + Ok(ctx.result) +} + +struct Ctx<'a> { + spec: &'static LangSpec, + src: &'a [u8], + result: ExtractResult, + parent_idx: Option, +} + +fn walk(node: &Node, ctx: &mut Ctx) { + let k = node.kind(); + let mut pushed: Option = None; + + if let Some((_, nk)) = ctx.spec.decls.iter().find(|(s, _)| *s == k) { + pushed = push_named(ctx, node, *nk); + } else if ctx.spec.import_kinds.contains(&k) { + emit_import(node, ctx); + } else if ctx.spec.call_kind == Some(k) { + emit_call(node, ctx); + } + + let prev = ctx.parent_idx; + if let Some(idx) = pushed { + if let Some(p) = prev { + ctx.result.edges.push(LocalEdge { + from_idx: p, + to_idx: idx, + kind: EdgeKind::Contains, + line: None, + }); + } + ctx.parent_idx = Some(idx); + } + + let mut c = node.walk(); + for ch in node.children(&mut c) { + walk(&ch, ctx); + } + ctx.parent_idx = prev; +} + +fn push_named(ctx: &mut Ctx, node: &Node, kind: NodeKind) -> Option { + let name_node = node + .child_by_field_name("name") + .or_else(|| first_identifier(node))?; + let name = name_node.utf8_text(ctx.src).ok()?.to_string(); + if name.is_empty() { + return None; + } + let start = node.start_position().row as u32 + 1; + let end = node.end_position().row as u32 + 1; + let body = node + .child_by_field_name("body") + .map(|b| b.start_byte()) + .unwrap_or(node.end_byte()); + let sig = std::str::from_utf8(&ctx.src[node.start_byte()..body.min(ctx.src.len())]) + .ok() + .map(|s| s.trim().lines().next().unwrap_or("").to_string()); + ctx.result.nodes.push(NodeDraft { + kind, + name, + qualified_name: None, + start_line: start, + end_line: end, + signature: sig, + docstring: None, + language: ctx.spec.language_name.into(), + }); + Some(ctx.result.nodes.len() - 1) +} + +fn first_identifier<'a>(n: &Node<'a>) -> Option> { + let mut c = n.walk(); + let mut found = None; + for ch in n.children(&mut c) { + if matches!( + ch.kind(), + "identifier" + | "type_identifier" + | "field_identifier" + | "property_identifier" + | "simple_identifier" + ) { + found = Some(ch); + break; + } + } + found +} + +fn emit_call(node: &Node, ctx: &mut Ctx) { + let Some(field) = ctx.spec.callee_field else { + return; + }; + let Some(callee) = node.child_by_field_name(field) else { + return; + }; + let name = if ctx.spec.callee_ident_kinds.contains(&callee.kind()) { + callee.utf8_text(ctx.src).ok().map(|s| s.to_string()) + } else { + first_identifier_of_kinds(&callee, ctx.spec.callee_ident_kinds, ctx.src) + }; + let Some(n) = name else { return }; + let Some(from) = ctx.parent_idx else { return }; + ctx.result.pending_calls.push(PendingCall { + from_idx: from, + target_name: n, + line: node.start_position().row as u32 + 1, + }); +} + +fn first_identifier_of_kinds(n: &Node, kinds: &[&str], src: &[u8]) -> Option { + let mut c = n.walk(); + let mut last = None; + let mut stack = vec![n.children(&mut c).collect::>()]; + while let Some(level) = stack.last_mut() { + if let Some(ch) = level.pop() { + if kinds.contains(&ch.kind()) { + if let Ok(t) = ch.utf8_text(src) { + last = Some(t.to_string()); + } + } + let mut cc = ch.walk(); + let next: Vec<_> = ch.children(&mut cc).collect(); + if !next.is_empty() { + stack.push(next); + } + } else { + stack.pop(); + } + } + last +} + +fn emit_import(node: &Node, ctx: &mut Ctx) { + let module = if let Some(f) = ctx.spec.import_extract { + f(node, ctx.src) + } else { + node.utf8_text(ctx.src).ok().map(|s| s.trim().to_string()) + }; + let Some(m) = module else { return }; + let from = ctx.parent_idx.unwrap_or(usize::MAX); + if from == usize::MAX { + return; + } + ctx.result.imports.push(RawImport { + from_idx: from, + module: m, + line: node.start_position().row as u32 + 1, + }); +} + +/// Convenience macro: define an `Extractor` impl that delegates to a `LangSpec`. +#[macro_export] +macro_rules! lang_extractor { + ($struct:ident, $spec:expr) => { + #[derive(Default)] + pub struct $struct; + impl $struct { + pub fn new() -> Self { + Self + } + } + impl $crate::Extractor for $struct { + fn language(&self) -> &'static str { + $spec.language_name + } + fn extensions(&self) -> &'static [&'static str] { + $spec.extensions + } + fn ts_language(&self) -> tree_sitter::Language { + ($spec.ts_language)() + } + fn extract(&self, source: &str) -> codegraph_core::Result<$crate::ExtractResult> { + $crate::languages::common::run(&$spec, source) + } + } + }; +} diff --git a/crates/codegraph-extract/src/languages/cpp.rs b/crates/codegraph-extract/src/languages/cpp.rs new file mode 100644 index 00000000..eb81f935 --- /dev/null +++ b/crates/codegraph-extract/src/languages/cpp.rs @@ -0,0 +1,43 @@ +use crate::lang_extractor; +use crate::languages::common::LangSpec; +use codegraph_core::NodeKind; +use tree_sitter::Node; + +fn ts_language() -> tree_sitter::Language { + tree_sitter_cpp::LANGUAGE.into() +} + +fn import_path(n: &Node, src: &[u8]) -> Option { + let mut c = n.walk(); + for ch in n.children(&mut c) { + if matches!(ch.kind(), "string_literal" | "system_lib_string") { + return ch.utf8_text(src).ok().map(|s| { + s.trim_matches(|c| c == '"' || c == '<' || c == '>') + .to_string() + }); + } + } + None +} + +pub static SPEC: LangSpec = LangSpec { + language_name: "cpp", + extensions: &["cpp", "cc", "cxx", "hpp", "hh", "hxx"], + ts_language, + decls: &[ + ("function_definition", NodeKind::Function), + ("class_specifier", NodeKind::Class), + ("struct_specifier", NodeKind::Struct), + ("union_specifier", NodeKind::Struct), + ("namespace_definition", NodeKind::Namespace), + ("enum_specifier", NodeKind::Enum), + ("template_declaration", NodeKind::TypeAlias), + ], + call_kind: Some("call_expression"), + callee_field: Some("function"), + callee_ident_kinds: &["identifier", "field_identifier"], + import_kinds: &["preproc_include"], + import_extract: Some(import_path), +}; + +lang_extractor!(CppExtractor, SPEC); diff --git a/crates/codegraph-extract/src/languages/csharp.rs b/crates/codegraph-extract/src/languages/csharp.rs new file mode 100644 index 00000000..0a8e2aee --- /dev/null +++ b/crates/codegraph-extract/src/languages/csharp.rs @@ -0,0 +1,39 @@ +use crate::lang_extractor; +use crate::languages::common::LangSpec; +use codegraph_core::NodeKind; +use tree_sitter::Node; + +fn ts_language() -> tree_sitter::Language { + tree_sitter_c_sharp::LANGUAGE.into() +} + +fn import_path(n: &Node, src: &[u8]) -> Option { + n.child_by_field_name("name") + .and_then(|x| x.utf8_text(src).ok()) + .map(|s| s.to_string()) +} + +pub static SPEC: LangSpec = LangSpec { + language_name: "csharp", + extensions: &["cs"], + ts_language, + decls: &[ + ("class_declaration", NodeKind::Class), + ("struct_declaration", NodeKind::Struct), + ("interface_declaration", NodeKind::Interface), + ("enum_declaration", NodeKind::Enum), + ("namespace_declaration", NodeKind::Namespace), + ("method_declaration", NodeKind::Method), + ("constructor_declaration", NodeKind::Method), + ("property_declaration", NodeKind::Property), + ("field_declaration", NodeKind::Field), + ("record_declaration", NodeKind::Class), + ], + call_kind: Some("invocation_expression"), + callee_field: Some("function"), + callee_ident_kinds: &["identifier"], + import_kinds: &["using_directive"], + import_extract: Some(import_path), +}; + +lang_extractor!(CSharpExtractor, SPEC); diff --git a/crates/codegraph-extract/src/languages/go.rs b/crates/codegraph-extract/src/languages/go.rs new file mode 100644 index 00000000..c0ff3b37 --- /dev/null +++ b/crates/codegraph-extract/src/languages/go.rs @@ -0,0 +1,189 @@ +use crate::{parse_err, ExtractResult, Extractor, LocalEdge, PendingCall, RawImport}; +use codegraph_core::{EdgeKind, NodeKind, Result}; +use codegraph_db::NodeDraft; +use tree_sitter::{Node, Parser, Tree}; + +pub struct GoExtractor { + lang: tree_sitter::Language, +} +impl Default for GoExtractor { + fn default() -> Self { + Self::new() + } +} + +impl GoExtractor { + pub fn new() -> Self { + Self { + lang: tree_sitter_go::LANGUAGE.into(), + } + } +} + +impl Extractor for GoExtractor { + fn language(&self) -> &'static str { + "go" + } + fn extensions(&self) -> &'static [&'static str] { + &["go"] + } + fn ts_language(&self) -> tree_sitter::Language { + self.lang.clone() + } + fn extract(&self, source: &str) -> Result { + let mut p = Parser::new(); + p.set_language(&self.lang) + .map_err(|e| parse_err(format!("set_language: {e}")))?; + let tree: Tree = p + .parse(source, None) + .ok_or_else(|| parse_err("parse failed"))?; + let mut ctx = Ctx { + src: source.as_bytes(), + result: ExtractResult::default(), + parent_idx: None, + }; + walk(&tree.root_node(), &mut ctx); + Ok(ctx.result) + } +} + +struct Ctx<'a> { + src: &'a [u8], + result: ExtractResult, + parent_idx: Option, +} + +fn walk(node: &Node, ctx: &mut Ctx) { + let mut pushed: Option = None; + match node.kind() { + "function_declaration" => { + pushed = push_named(ctx, node, NodeKind::Function); + } + "method_declaration" => { + pushed = push_named(ctx, node, NodeKind::Method); + } + "type_spec" => { + // Inspect inner type: struct -> Struct, interface -> Interface + let kind = node + .child_by_field_name("type") + .map(|t| match t.kind() { + "struct_type" => NodeKind::Struct, + "interface_type" => NodeKind::Interface, + _ => NodeKind::TypeAlias, + }) + .unwrap_or(NodeKind::TypeAlias); + pushed = push_named(ctx, node, kind); + } + "const_spec" => { + pushed = push_named(ctx, node, NodeKind::Constant); + } + "var_spec" => { + pushed = push_named(ctx, node, NodeKind::Variable); + } + "import_spec" => { + emit_import(node, ctx); + } + "call_expression" => { + emit_call(node, ctx); + } + _ => {} + } + let prev = ctx.parent_idx; + if let Some(idx) = pushed { + if let Some(p) = prev { + ctx.result.edges.push(LocalEdge { + from_idx: p, + to_idx: idx, + kind: EdgeKind::Contains, + line: None, + }); + } + ctx.parent_idx = Some(idx); + } + let mut c = node.walk(); + for ch in node.children(&mut c) { + walk(&ch, ctx); + } + ctx.parent_idx = prev; +} + +fn push_named(ctx: &mut Ctx, node: &Node, kind: NodeKind) -> Option { + let name_node = node.child_by_field_name("name").or_else(|| { + let mut c = node.walk(); + let mut found = None; + for ch in node.children(&mut c) { + if matches!( + ch.kind(), + "identifier" | "field_identifier" | "type_identifier" + ) { + found = Some(ch); + break; + } + } + found + })?; + let name = name_node.utf8_text(ctx.src).ok()?.to_string(); + if name.is_empty() { + return None; + } + let start = node.start_position().row as u32 + 1; + let end = node.end_position().row as u32 + 1; + let body = node + .child_by_field_name("body") + .map(|b| b.start_byte()) + .unwrap_or(node.end_byte()); + let sig = std::str::from_utf8(&ctx.src[node.start_byte()..body.min(ctx.src.len())]) + .ok() + .map(|s| s.trim().lines().next().unwrap_or("").to_string()); + ctx.result.nodes.push(NodeDraft { + kind, + name, + qualified_name: None, + start_line: start, + end_line: end, + signature: sig, + docstring: None, + language: "go".into(), + }); + Some(ctx.result.nodes.len() - 1) +} + +fn emit_call(node: &Node, ctx: &mut Ctx) { + let Some(callee) = node.child_by_field_name("function") else { + return; + }; + let name = match callee.kind() { + "identifier" => callee.utf8_text(ctx.src).ok().map(|s| s.to_string()), + "selector_expression" => callee + .child_by_field_name("field") + .and_then(|f| f.utf8_text(ctx.src).ok()) + .map(|s| s.to_string()), + _ => None, + }; + let Some(n) = name else { return }; + let Some(from) = ctx.parent_idx else { return }; + ctx.result.pending_calls.push(PendingCall { + from_idx: from, + target_name: n, + line: node.start_position().row as u32 + 1, + }); +} + +fn emit_import(node: &Node, ctx: &mut Ctx) { + let Some(path) = node.child_by_field_name("path") else { + return; + }; + let Ok(text) = path.utf8_text(ctx.src) else { + return; + }; + let module = text.trim_matches('"').to_string(); + let from = ctx.parent_idx.unwrap_or(usize::MAX); + if from == usize::MAX { + return; + } + ctx.result.imports.push(RawImport { + from_idx: from, + module, + line: node.start_position().row as u32 + 1, + }); +} diff --git a/crates/codegraph-extract/src/languages/java.rs b/crates/codegraph-extract/src/languages/java.rs new file mode 100644 index 00000000..600b2246 --- /dev/null +++ b/crates/codegraph-extract/src/languages/java.rs @@ -0,0 +1,40 @@ +use crate::lang_extractor; +use crate::languages::common::LangSpec; +use codegraph_core::NodeKind; +use tree_sitter::Node; + +fn ts_language() -> tree_sitter::Language { + tree_sitter_java::LANGUAGE.into() +} + +fn import_path(n: &Node, src: &[u8]) -> Option { + let mut c = n.walk(); + for ch in n.children(&mut c) { + if matches!(ch.kind(), "scoped_identifier" | "identifier") { + return ch.utf8_text(src).ok().map(|s| s.to_string()); + } + } + None +} + +pub static SPEC: LangSpec = LangSpec { + language_name: "java", + extensions: &["java"], + ts_language, + decls: &[ + ("class_declaration", NodeKind::Class), + ("interface_declaration", NodeKind::Interface), + ("enum_declaration", NodeKind::Enum), + ("record_declaration", NodeKind::Class), + ("method_declaration", NodeKind::Method), + ("constructor_declaration", NodeKind::Method), + ("field_declaration", NodeKind::Field), + ], + call_kind: Some("method_invocation"), + callee_field: Some("name"), + callee_ident_kinds: &["identifier"], + import_kinds: &["import_declaration"], + import_extract: Some(import_path), +}; + +lang_extractor!(JavaExtractor, SPEC); diff --git a/crates/codegraph-extract/src/languages/javascript.rs b/crates/codegraph-extract/src/languages/javascript.rs new file mode 100644 index 00000000..fc2c52ed --- /dev/null +++ b/crates/codegraph-extract/src/languages/javascript.rs @@ -0,0 +1,176 @@ +use crate::{parse_err, ExtractResult, Extractor, LocalEdge, PendingCall, RawImport}; +use codegraph_core::{EdgeKind, NodeKind, Result}; +use codegraph_db::NodeDraft; +use tree_sitter::{Node, Parser, Tree}; + +pub struct JavaScriptExtractor { + lang: tree_sitter::Language, +} +impl Default for JavaScriptExtractor { + fn default() -> Self { + Self::new() + } +} + +impl JavaScriptExtractor { + pub fn new() -> Self { + Self { + lang: tree_sitter_javascript::LANGUAGE.into(), + } + } +} + +impl Extractor for JavaScriptExtractor { + fn language(&self) -> &'static str { + "javascript" + } + fn extensions(&self) -> &'static [&'static str] { + &["js", "jsx", "mjs", "cjs"] + } + fn ts_language(&self) -> tree_sitter::Language { + self.lang.clone() + } + fn extract(&self, source: &str) -> Result { + let mut p = Parser::new(); + p.set_language(&self.lang) + .map_err(|e| parse_err(format!("set_language: {e}")))?; + let tree: Tree = p + .parse(source, None) + .ok_or_else(|| parse_err("parse failed"))?; + let mut ctx = Ctx { + src: source.as_bytes(), + result: ExtractResult::default(), + parent_idx: None, + }; + walk(&tree.root_node(), &mut ctx); + Ok(ctx.result) + } +} + +struct Ctx<'a> { + src: &'a [u8], + result: ExtractResult, + parent_idx: Option, +} + +fn walk(node: &Node, ctx: &mut Ctx) { + let mut pushed: Option = None; + match node.kind() { + "function_declaration" | "function_expression" | "arrow_function" => { + pushed = push_named(ctx, node, NodeKind::Function); + } + "method_definition" => { + pushed = push_named(ctx, node, NodeKind::Method); + } + "class_declaration" | "class" => { + pushed = push_named(ctx, node, NodeKind::Class); + } + "variable_declarator" => { + pushed = push_named(ctx, node, NodeKind::Variable); + } + "import_statement" => { + emit_import(node, ctx); + } + "call_expression" => { + emit_call(node, ctx); + } + _ => {} + } + let prev = ctx.parent_idx; + if let Some(idx) = pushed { + if let Some(p) = prev { + ctx.result.edges.push(LocalEdge { + from_idx: p, + to_idx: idx, + kind: EdgeKind::Contains, + line: None, + }); + } + ctx.parent_idx = Some(idx); + } + let mut c = node.walk(); + for ch in node.children(&mut c) { + walk(&ch, ctx); + } + ctx.parent_idx = prev; +} + +fn push_named(ctx: &mut Ctx, node: &Node, kind: NodeKind) -> Option { + let name_node = node.child_by_field_name("name").or_else(|| { + let mut c = node.walk(); + let mut found = None; + for ch in node.children(&mut c) { + if matches!(ch.kind(), "identifier" | "property_identifier") { + found = Some(ch); + break; + } + } + found + })?; + let name = name_node.utf8_text(ctx.src).ok()?.to_string(); + if name.is_empty() { + return None; + } + let start = node.start_position().row as u32 + 1; + let end = node.end_position().row as u32 + 1; + let body = node + .child_by_field_name("body") + .map(|b| b.start_byte()) + .unwrap_or(node.end_byte()); + let sig = std::str::from_utf8(&ctx.src[node.start_byte()..body.min(ctx.src.len())]) + .ok() + .map(|s| s.trim().lines().next().unwrap_or("").to_string()); + ctx.result.nodes.push(NodeDraft { + kind, + name, + qualified_name: None, + start_line: start, + end_line: end, + signature: sig, + docstring: None, + language: "javascript".into(), + }); + Some(ctx.result.nodes.len() - 1) +} + +fn emit_call(node: &Node, ctx: &mut Ctx) { + let Some(callee) = node.child_by_field_name("function") else { + return; + }; + let name = match callee.kind() { + "identifier" => callee.utf8_text(ctx.src).ok().map(|s| s.to_string()), + "member_expression" => callee + .child_by_field_name("property") + .and_then(|p| p.utf8_text(ctx.src).ok()) + .map(|s| s.to_string()), + _ => None, + }; + let Some(n) = name else { return }; + let Some(from) = ctx.parent_idx else { return }; + ctx.result.pending_calls.push(PendingCall { + from_idx: from, + target_name: n, + line: node.start_position().row as u32 + 1, + }); +} + +fn emit_import(node: &Node, ctx: &mut Ctx) { + let Some(src) = node.child_by_field_name("source") else { + return; + }; + let Ok(text) = src.utf8_text(ctx.src) else { + return; + }; + let module = text + .trim_matches(|c| c == '"' || c == '\'' || c == '`') + .to_string(); + let from = ctx.parent_idx.unwrap_or(usize::MAX); + if from == usize::MAX { + return; + } + ctx.result.imports.push(RawImport { + from_idx: from, + module, + line: node.start_position().row as u32 + 1, + }); +} diff --git a/crates/codegraph-extract/src/languages/lua.rs b/crates/codegraph-extract/src/languages/lua.rs new file mode 100644 index 00000000..f08ce55b --- /dev/null +++ b/crates/codegraph-extract/src/languages/lua.rs @@ -0,0 +1,25 @@ +use crate::lang_extractor; +use crate::languages::common::LangSpec; +use codegraph_core::NodeKind; + +fn ts_language() -> tree_sitter::Language { + tree_sitter_lua::LANGUAGE.into() +} + +pub static SPEC: LangSpec = LangSpec { + language_name: "lua", + extensions: &["lua"], + ts_language, + decls: &[ + ("function_declaration", NodeKind::Function), + ("function_definition", NodeKind::Function), + ("local_function", NodeKind::Function), + ], + call_kind: Some("function_call"), + callee_field: Some("name"), + callee_ident_kinds: &["identifier"], + import_kinds: &[], + import_extract: None, +}; + +lang_extractor!(LuaExtractor, SPEC); diff --git a/crates/codegraph-extract/src/languages/php.rs b/crates/codegraph-extract/src/languages/php.rs new file mode 100644 index 00000000..4a58093f --- /dev/null +++ b/crates/codegraph-extract/src/languages/php.rs @@ -0,0 +1,39 @@ +use crate::lang_extractor; +use crate::languages::common::LangSpec; +use codegraph_core::NodeKind; +use tree_sitter::Node; + +fn ts_language() -> tree_sitter::Language { + tree_sitter_php::LANGUAGE_PHP.into() +} + +fn import_path(n: &Node, src: &[u8]) -> Option { + let mut c = n.walk(); + for ch in n.children(&mut c) { + if matches!(ch.kind(), "namespace_name" | "qualified_name") { + return ch.utf8_text(src).ok().map(|s| s.to_string()); + } + } + None +} + +pub static SPEC: LangSpec = LangSpec { + language_name: "php", + extensions: &["php"], + ts_language, + decls: &[ + ("function_definition", NodeKind::Function), + ("method_declaration", NodeKind::Method), + ("class_declaration", NodeKind::Class), + ("interface_declaration", NodeKind::Interface), + ("trait_declaration", NodeKind::Trait), + ("namespace_definition", NodeKind::Namespace), + ], + call_kind: Some("function_call_expression"), + callee_field: Some("function"), + callee_ident_kinds: &["name", "qualified_name"], + import_kinds: &["namespace_use_declaration"], + import_extract: Some(import_path), +}; + +lang_extractor!(PhpExtractor, SPEC); diff --git a/crates/codegraph-extract/src/languages/python.rs b/crates/codegraph-extract/src/languages/python.rs new file mode 100644 index 00000000..cf413066 --- /dev/null +++ b/crates/codegraph-extract/src/languages/python.rs @@ -0,0 +1,194 @@ +use crate::{parse_err, ExtractResult, Extractor, LocalEdge, PendingCall, RawImport}; +use codegraph_core::{EdgeKind, NodeKind, Result}; +use codegraph_db::NodeDraft; +use tree_sitter::{Node, Parser, Tree}; + +pub struct PythonExtractor { + lang: tree_sitter::Language, +} +impl Default for PythonExtractor { + fn default() -> Self { + Self::new() + } +} + +impl PythonExtractor { + pub fn new() -> Self { + Self { + lang: tree_sitter_python::LANGUAGE.into(), + } + } +} + +impl Extractor for PythonExtractor { + fn language(&self) -> &'static str { + "python" + } + fn extensions(&self) -> &'static [&'static str] { + &["py", "pyi"] + } + fn ts_language(&self) -> tree_sitter::Language { + self.lang.clone() + } + fn extract(&self, source: &str) -> Result { + let mut p = Parser::new(); + p.set_language(&self.lang) + .map_err(|e| parse_err(format!("set_language: {e}")))?; + let tree: Tree = p + .parse(source, None) + .ok_or_else(|| parse_err("parse failed"))?; + let mut ctx = Ctx { + src: source.as_bytes(), + result: ExtractResult::default(), + parent_idx: None, + }; + walk(&tree.root_node(), &mut ctx); + Ok(ctx.result) + } +} + +struct Ctx<'a> { + src: &'a [u8], + result: ExtractResult, + parent_idx: Option, +} + +fn walk(node: &Node, ctx: &mut Ctx) { + let mut pushed: Option = None; + match node.kind() { + "function_definition" => { + pushed = push_named(ctx, node, NodeKind::Function); + } + "class_definition" => { + pushed = push_named(ctx, node, NodeKind::Class); + } + "import_statement" | "import_from_statement" => { + emit_import(node, ctx); + } + "call" => { + emit_call(node, ctx); + } + _ => {} + } + let prev = ctx.parent_idx; + if let Some(idx) = pushed { + if let Some(p) = prev { + ctx.result.edges.push(LocalEdge { + from_idx: p, + to_idx: idx, + kind: EdgeKind::Contains, + line: None, + }); + } + ctx.parent_idx = Some(idx); + } + let mut c = node.walk(); + for ch in node.children(&mut c) { + walk(&ch, ctx); + } + ctx.parent_idx = prev; +} + +fn push_named(ctx: &mut Ctx, node: &Node, kind: NodeKind) -> Option { + let name_node = node.child_by_field_name("name")?; + let name = name_node.utf8_text(ctx.src).ok()?.to_string(); + if name.is_empty() { + return None; + } + let start = node.start_position().row as u32 + 1; + let end = node.end_position().row as u32 + 1; + let body = node + .child_by_field_name("body") + .map(|b| b.start_byte()) + .unwrap_or(node.end_byte()); + let sig = std::str::from_utf8(&ctx.src[node.start_byte()..body.min(ctx.src.len())]) + .ok() + .map(|s| s.trim().lines().next().unwrap_or("").to_string()); + + // Docstring: first string literal in body block. + let docstring = node + .child_by_field_name("body") + .and_then(|b| extract_docstring(&b, ctx.src)); + + ctx.result.nodes.push(NodeDraft { + kind, + name, + qualified_name: None, + start_line: start, + end_line: end, + signature: sig, + docstring, + language: "python".into(), + }); + Some(ctx.result.nodes.len() - 1) +} + +fn extract_docstring(body: &Node, src: &[u8]) -> Option { + let mut c = body.walk(); + let first = body.children(&mut c).next()?; + let stmt = if first.kind() == "expression_statement" { + first + } else { + return None; + }; + let mut cc = stmt.walk(); + let s = stmt.children(&mut cc).next()?; + if s.kind() == "string" { + let text = s.utf8_text(src).ok()?; + Some( + text.trim_matches(|c: char| c == '"' || c == '\'') + .to_string(), + ) + } else { + None + } +} + +fn emit_call(node: &Node, ctx: &mut Ctx) { + let Some(callee) = node.child_by_field_name("function") else { + return; + }; + let name = match callee.kind() { + "identifier" => callee.utf8_text(ctx.src).ok().map(|s| s.to_string()), + "attribute" => callee + .child_by_field_name("attribute") + .and_then(|a| a.utf8_text(ctx.src).ok()) + .map(|s| s.to_string()), + _ => None, + }; + let Some(n) = name else { return }; + let Some(from) = ctx.parent_idx else { return }; + ctx.result.pending_calls.push(PendingCall { + from_idx: from, + target_name: n, + line: node.start_position().row as u32 + 1, + }); +} + +fn emit_import(node: &Node, ctx: &mut Ctx) { + let module = if node.kind() == "import_from_statement" { + node.child_by_field_name("module_name") + .and_then(|n| n.utf8_text(ctx.src).ok()) + .map(|s| s.to_string()) + } else { + let mut c = node.walk(); + let mut found = None; + for ch in node.children(&mut c) { + if ch.kind() == "dotted_name" { + found = ch.utf8_text(ctx.src).ok().map(|s| s.to_string()); + break; + } + } + found + }; + let Some(m) = module else { return }; + let from = ctx.parent_idx.unwrap_or(usize::MAX); + if from == usize::MAX { + return; + } + ctx.result.imports.push(RawImport { + from_idx: from, + module: m, + line: node.start_position().row as u32 + 1, + }); +} diff --git a/crates/codegraph-extract/src/languages/ruby.rs b/crates/codegraph-extract/src/languages/ruby.rs new file mode 100644 index 00000000..55066cc5 --- /dev/null +++ b/crates/codegraph-extract/src/languages/ruby.rs @@ -0,0 +1,26 @@ +use crate::lang_extractor; +use crate::languages::common::LangSpec; +use codegraph_core::NodeKind; + +fn ts_language() -> tree_sitter::Language { + tree_sitter_ruby::LANGUAGE.into() +} + +pub static SPEC: LangSpec = LangSpec { + language_name: "ruby", + extensions: &["rb"], + ts_language, + decls: &[ + ("method", NodeKind::Method), + ("singleton_method", NodeKind::Method), + ("class", NodeKind::Class), + ("module", NodeKind::Module), + ], + call_kind: Some("call"), + callee_field: Some("method"), + callee_ident_kinds: &["identifier", "constant"], + import_kinds: &[], + import_extract: None, +}; + +lang_extractor!(RubyExtractor, SPEC); diff --git a/crates/codegraph-extract/src/languages/rust.rs b/crates/codegraph-extract/src/languages/rust.rs new file mode 100644 index 00000000..6b6fb425 --- /dev/null +++ b/crates/codegraph-extract/src/languages/rust.rs @@ -0,0 +1,187 @@ +use crate::{parse_err, ExtractResult, Extractor, LocalEdge, PendingCall, RawImport}; +use codegraph_core::{EdgeKind, NodeKind, Result}; +use codegraph_db::NodeDraft; +use tree_sitter::{Node, Parser, Tree}; + +pub struct RustExtractor { + lang: tree_sitter::Language, +} +impl Default for RustExtractor { + fn default() -> Self { + Self::new() + } +} + +impl RustExtractor { + pub fn new() -> Self { + Self { + lang: tree_sitter_rust::LANGUAGE.into(), + } + } +} + +impl Extractor for RustExtractor { + fn language(&self) -> &'static str { + "rust" + } + fn extensions(&self) -> &'static [&'static str] { + &["rs"] + } + fn ts_language(&self) -> tree_sitter::Language { + self.lang.clone() + } + fn extract(&self, source: &str) -> Result { + let mut p = Parser::new(); + p.set_language(&self.lang) + .map_err(|e| parse_err(format!("set_language: {e}")))?; + let tree: Tree = p + .parse(source, None) + .ok_or_else(|| parse_err("parse failed"))?; + let mut ctx = Ctx { + src: source.as_bytes(), + result: ExtractResult::default(), + parent_idx: None, + }; + walk(&tree.root_node(), &mut ctx); + Ok(ctx.result) + } +} + +struct Ctx<'a> { + src: &'a [u8], + result: ExtractResult, + parent_idx: Option, +} + +fn walk(node: &Node, ctx: &mut Ctx) { + let mut pushed: Option = None; + match node.kind() { + "function_item" => { + pushed = push_named(ctx, node, NodeKind::Function); + } + "struct_item" => { + pushed = push_named(ctx, node, NodeKind::Struct); + } + "enum_item" => { + pushed = push_named(ctx, node, NodeKind::Enum); + } + "trait_item" => { + pushed = push_named(ctx, node, NodeKind::Trait); + } + "impl_item" => { + // Treat impls as containers via the type name. + pushed = push_named(ctx, node, NodeKind::Namespace); + } + "mod_item" => { + pushed = push_named(ctx, node, NodeKind::Module); + } + "const_item" => { + pushed = push_named(ctx, node, NodeKind::Constant); + } + "static_item" => { + pushed = push_named(ctx, node, NodeKind::Variable); + } + "type_item" => { + pushed = push_named(ctx, node, NodeKind::TypeAlias); + } + "use_declaration" => { + emit_use(node, ctx); + } + "call_expression" => { + emit_call(node, ctx); + } + _ => {} + } + + let prev = ctx.parent_idx; + if let Some(idx) = pushed { + if let Some(p) = prev { + ctx.result.edges.push(LocalEdge { + from_idx: p, + to_idx: idx, + kind: EdgeKind::Contains, + line: None, + }); + } + ctx.parent_idx = Some(idx); + } + + let mut c = node.walk(); + for ch in node.children(&mut c) { + walk(&ch, ctx); + } + ctx.parent_idx = prev; +} + +fn push_named(ctx: &mut Ctx, node: &Node, kind: NodeKind) -> Option { + let name_node = node + .child_by_field_name("name") + .or_else(|| node.child_by_field_name("type"))?; + let name = name_node.utf8_text(ctx.src).ok()?.to_string(); + if name.is_empty() { + return None; + } + let start = node.start_position().row as u32 + 1; + let end = node.end_position().row as u32 + 1; + let body = node + .child_by_field_name("body") + .map(|b| b.start_byte()) + .unwrap_or(node.end_byte()); + let sig = std::str::from_utf8(&ctx.src[node.start_byte()..body.min(ctx.src.len())]) + .ok() + .map(|s| s.trim().lines().next().unwrap_or("").to_string()); + + ctx.result.nodes.push(NodeDraft { + kind, + name, + qualified_name: None, + start_line: start, + end_line: end, + signature: sig, + docstring: None, + language: "rust".into(), + }); + Some(ctx.result.nodes.len() - 1) +} + +fn emit_call(node: &Node, ctx: &mut Ctx) { + let Some(callee) = node.child_by_field_name("function") else { + return; + }; + let name = match callee.kind() { + "identifier" => callee.utf8_text(ctx.src).ok().map(|s| s.to_string()), + "field_expression" => callee + .child_by_field_name("field") + .and_then(|f| f.utf8_text(ctx.src).ok()) + .map(|s| s.to_string()), + "scoped_identifier" => callee + .child_by_field_name("name") + .and_then(|n| n.utf8_text(ctx.src).ok()) + .map(|s| s.to_string()), + _ => None, + }; + let Some(n) = name else { return }; + let Some(from) = ctx.parent_idx else { return }; + ctx.result.pending_calls.push(PendingCall { + from_idx: from, + target_name: n, + line: node.start_position().row as u32 + 1, + }); +} + +fn emit_use(node: &Node, ctx: &mut Ctx) { + if let Ok(text) = node.utf8_text(ctx.src) { + // Crude: take token after "use " up to ; or as. + let s = text.trim().trim_start_matches("use ").trim_end_matches(';'); + let module = s.split_whitespace().next().unwrap_or(s).to_string(); + let from = ctx.parent_idx.unwrap_or(usize::MAX); + if from == usize::MAX { + return; + } + ctx.result.imports.push(RawImport { + from_idx: from, + module, + line: node.start_position().row as u32 + 1, + }); + } +} diff --git a/crates/codegraph-extract/src/languages/scala.rs b/crates/codegraph-extract/src/languages/scala.rs new file mode 100644 index 00000000..d099557e --- /dev/null +++ b/crates/codegraph-extract/src/languages/scala.rs @@ -0,0 +1,29 @@ +use crate::lang_extractor; +use crate::languages::common::LangSpec; +use codegraph_core::NodeKind; + +fn ts_language() -> tree_sitter::Language { + tree_sitter_scala::LANGUAGE.into() +} + +pub static SPEC: LangSpec = LangSpec { + language_name: "scala", + extensions: &["scala", "sc"], + ts_language, + decls: &[ + ("function_definition", NodeKind::Function), + ("function_declaration", NodeKind::Function), + ("class_definition", NodeKind::Class), + ("object_definition", NodeKind::Module), + ("trait_definition", NodeKind::Trait), + ("val_definition", NodeKind::Constant), + ("var_definition", NodeKind::Variable), + ], + call_kind: Some("call_expression"), + callee_field: Some("function"), + callee_ident_kinds: &["identifier"], + import_kinds: &["import_declaration"], + import_extract: None, +}; + +lang_extractor!(ScalaExtractor, SPEC); diff --git a/crates/codegraph-extract/src/languages/swift.rs b/crates/codegraph-extract/src/languages/swift.rs new file mode 100644 index 00000000..8b94a493 --- /dev/null +++ b/crates/codegraph-extract/src/languages/swift.rs @@ -0,0 +1,26 @@ +use crate::lang_extractor; +use crate::languages::common::LangSpec; +use codegraph_core::NodeKind; + +fn ts_language() -> tree_sitter::Language { + tree_sitter_swift::LANGUAGE.into() +} + +pub static SPEC: LangSpec = LangSpec { + language_name: "swift", + extensions: &["swift"], + ts_language, + decls: &[ + ("function_declaration", NodeKind::Function), + ("class_declaration", NodeKind::Class), + ("protocol_declaration", NodeKind::Protocol), + ("property_declaration", NodeKind::Property), + ], + call_kind: Some("call_expression"), + callee_field: Some("name"), + callee_ident_kinds: &["simple_identifier"], + import_kinds: &["import_declaration"], + import_extract: None, +}; + +lang_extractor!(SwiftExtractor, SPEC); diff --git a/crates/codegraph-extract/src/languages/typescript.rs b/crates/codegraph-extract/src/languages/typescript.rs new file mode 100644 index 00000000..416126ec --- /dev/null +++ b/crates/codegraph-extract/src/languages/typescript.rs @@ -0,0 +1,257 @@ +use crate::{parse_err, ExtractResult, Extractor, LocalEdge, PendingCall, RawImport}; +use codegraph_core::{EdgeKind, NodeKind, Result}; +use codegraph_db::NodeDraft; +use tree_sitter::{Node, Parser, Tree}; + +pub struct TypeScriptExtractor { + lang: tree_sitter::Language, +} +pub struct TsxExtractor { + lang: tree_sitter::Language, +} + +impl Default for TypeScriptExtractor { + fn default() -> Self { + Self::new() + } +} +impl Default for TsxExtractor { + fn default() -> Self { + Self::new() + } +} + +impl TypeScriptExtractor { + pub fn new() -> Self { + Self { + lang: tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + } + } +} +impl TsxExtractor { + pub fn new() -> Self { + Self { + lang: tree_sitter_typescript::LANGUAGE_TSX.into(), + } + } +} + +impl Extractor for TypeScriptExtractor { + fn language(&self) -> &'static str { + "typescript" + } + fn extensions(&self) -> &'static [&'static str] { + &["ts", "mts", "cts"] + } + fn ts_language(&self) -> tree_sitter::Language { + self.lang.clone() + } + fn extract(&self, source: &str) -> Result { + extract_ts(self.lang.clone(), source, "typescript") + } +} + +impl Extractor for TsxExtractor { + fn language(&self) -> &'static str { + "tsx" + } + fn extensions(&self) -> &'static [&'static str] { + &["tsx"] + } + fn ts_language(&self) -> tree_sitter::Language { + self.lang.clone() + } + fn extract(&self, source: &str) -> Result { + extract_ts(self.lang.clone(), source, "tsx") + } +} + +fn extract_ts(lang: tree_sitter::Language, source: &str, lang_name: &str) -> Result { + let mut parser = Parser::new(); + parser + .set_language(&lang) + .map_err(|e| parse_err(format!("set_language: {e}")))?; + let tree: Tree = parser + .parse(source, None) + .ok_or_else(|| parse_err("parse failed"))?; + let mut ctx = Ctx { + src: source.as_bytes(), + lang_name, + result: ExtractResult::default(), + parent_idx: None, + }; + walk(&tree.root_node(), &mut ctx); + Ok(ctx.result) +} + +struct Ctx<'a> { + src: &'a [u8], + lang_name: &'a str, + result: ExtractResult, + parent_idx: Option, +} + +fn walk(node: &Node, ctx: &mut Ctx) { + let kind = node.kind(); + let mut pushed: Option = None; + + match kind { + "function_declaration" | "function_expression" | "arrow_function" => { + if let Some(idx) = push_named(ctx, node, NodeKind::Function) { + pushed = Some(idx); + } + } + "method_definition" | "method_signature" => { + if let Some(idx) = push_named(ctx, node, NodeKind::Method) { + pushed = Some(idx); + } + } + "class_declaration" | "class" => { + if let Some(idx) = push_named(ctx, node, NodeKind::Class) { + pushed = Some(idx); + emit_heritage(node, ctx, idx); + } + } + "interface_declaration" => { + if let Some(idx) = push_named(ctx, node, NodeKind::Interface) { + pushed = Some(idx); + } + } + "type_alias_declaration" => { + if let Some(idx) = push_named(ctx, node, NodeKind::TypeAlias) { + pushed = Some(idx); + } + } + "enum_declaration" => { + if let Some(idx) = push_named(ctx, node, NodeKind::Enum) { + pushed = Some(idx); + } + } + "variable_declarator" => { + if let Some(idx) = push_named(ctx, node, NodeKind::Variable) { + pushed = Some(idx); + } + } + "import_statement" => { + emit_import(node, ctx); + } + "call_expression" => { + emit_call(node, ctx); + } + _ => {} + } + + let prev = ctx.parent_idx; + if let Some(idx) = pushed { + if let Some(parent) = prev { + ctx.result.edges.push(LocalEdge { + from_idx: parent, + to_idx: idx, + kind: EdgeKind::Contains, + line: None, + }); + } + ctx.parent_idx = Some(idx); + } + + let mut c = node.walk(); + for child in node.children(&mut c) { + walk(&child, ctx); + } + + ctx.parent_idx = prev; +} + +fn push_named(ctx: &mut Ctx, node: &Node, kind: NodeKind) -> Option { + let name_node = node + .child_by_field_name("name") + .or_else(|| find_first_identifier(node)); + let name = name_node + .and_then(|n| n.utf8_text(ctx.src).ok())? + .to_string(); + if name.is_empty() { + return None; + } + let start = node.start_position().row as u32 + 1; + let end = node.end_position().row as u32 + 1; + let sig_end = node + .child_by_field_name("body") + .map(|b| b.start_byte()) + .unwrap_or(node.end_byte()); + let sig = std::str::from_utf8(&ctx.src[node.start_byte()..sig_end.min(ctx.src.len())]) + .ok() + .map(|s| s.trim().lines().next().unwrap_or("").to_string()); + + ctx.result.nodes.push(NodeDraft { + kind, + name, + qualified_name: None, + start_line: start, + end_line: end, + signature: sig, + docstring: None, + language: ctx.lang_name.to_string(), + }); + Some(ctx.result.nodes.len() - 1) +} + +fn find_first_identifier<'a>(n: &Node<'a>) -> Option> { + let mut c = n.walk(); + let mut found = None; + for ch in n.children(&mut c) { + if matches!( + ch.kind(), + "identifier" | "type_identifier" | "property_identifier" + ) { + found = Some(ch); + break; + } + } + found +} + +fn emit_call(node: &Node, ctx: &mut Ctx) { + let Some(callee) = node.child_by_field_name("function") else { + return; + }; + let target = match callee.kind() { + "identifier" => callee.utf8_text(ctx.src).ok().map(|s| s.to_string()), + "member_expression" => callee + .child_by_field_name("property") + .and_then(|p| p.utf8_text(ctx.src).ok()) + .map(|s| s.to_string()), + _ => None, + }; + let Some(name) = target else { return }; + let Some(from) = ctx.parent_idx else { return }; + ctx.result.pending_calls.push(PendingCall { + from_idx: from, + target_name: name, + line: node.start_position().row as u32 + 1, + }); +} + +fn emit_import(node: &Node, ctx: &mut Ctx) { + let Some(src) = node.child_by_field_name("source") else { + return; + }; + let Ok(text) = src.utf8_text(ctx.src) else { + return; + }; + let module = text + .trim_matches(|c| c == '"' || c == '\'' || c == '`') + .to_string(); + let from = ctx.parent_idx.unwrap_or(usize::MAX); + if from == usize::MAX { + return; + } + ctx.result.imports.push(RawImport { + from_idx: from, + module, + line: node.start_position().row as u32 + 1, + }); +} + +fn emit_heritage(_node: &Node, _ctx: &mut Ctx, _class_idx: usize) { + // TODO: emit extends/implements pending references for the resolver. +} diff --git a/crates/codegraph-extract/src/lib.rs b/crates/codegraph-extract/src/lib.rs new file mode 100644 index 00000000..fa807fa5 --- /dev/null +++ b/crates/codegraph-extract/src/lib.rs @@ -0,0 +1,96 @@ +//! Tree-sitter extraction orchestrator + per-language extractors. + +pub mod languages; +mod orchestrator; +mod walker; + +pub use orchestrator::{ExtractStats, Orchestrator}; + +use codegraph_core::{Error, NodeKind, Result}; +use codegraph_db::NodeDraft; +use std::sync::Arc; + +/// Local edge using node-indices into the same ExtractResult.nodes vec. +#[derive(Debug, Clone)] +pub struct LocalEdge { + pub from_idx: usize, + pub to_idx: usize, + pub kind: codegraph_core::EdgeKind, + pub line: Option, +} + +/// Unresolved call site: target is a name; resolved post-pass by name-matcher. +#[derive(Debug, Clone)] +pub struct PendingCall { + pub from_idx: usize, // index into ExtractResult.nodes + pub target_name: String, + pub line: u32, +} + +/// Raw import for later resolution by codegraph-resolve. +#[derive(Debug, Clone)] +pub struct RawImport { + pub from_idx: usize, + pub module: String, + pub line: u32, +} + +#[derive(Debug, Default)] +pub struct ExtractResult { + pub nodes: Vec, + pub edges: Vec, + pub pending_calls: Vec, + pub imports: Vec, +} + +pub trait Extractor: Send + Sync { + fn language(&self) -> &'static str; + fn extensions(&self) -> &'static [&'static str]; + fn ts_language(&self) -> tree_sitter::Language; + fn extract(&self, source: &str) -> Result; +} + +pub fn registry() -> Vec> { + let mut v: Vec> = Vec::new(); + #[cfg(feature = "lang-typescript")] + { + v.push(Arc::new(languages::typescript::TypeScriptExtractor::new())); + v.push(Arc::new(languages::typescript::TsxExtractor::new())); + } + #[cfg(feature = "lang-javascript")] + v.push(Arc::new(languages::javascript::JavaScriptExtractor::new())); + #[cfg(feature = "lang-python")] + v.push(Arc::new(languages::python::PythonExtractor::new())); + #[cfg(feature = "lang-rust")] + v.push(Arc::new(languages::rust::RustExtractor::new())); + #[cfg(feature = "lang-go")] + v.push(Arc::new(languages::go::GoExtractor::new())); + #[cfg(feature = "lang-java")] + v.push(Arc::new(languages::java::JavaExtractor::new())); + #[cfg(feature = "lang-c")] + v.push(Arc::new(languages::c::CExtractor::new())); + #[cfg(feature = "lang-cpp")] + v.push(Arc::new(languages::cpp::CppExtractor::new())); + #[cfg(feature = "lang-csharp")] + v.push(Arc::new(languages::csharp::CSharpExtractor::new())); + #[cfg(feature = "lang-ruby")] + v.push(Arc::new(languages::ruby::RubyExtractor::new())); + #[cfg(feature = "lang-php")] + v.push(Arc::new(languages::php::PhpExtractor::new())); + #[cfg(feature = "lang-scala")] + v.push(Arc::new(languages::scala::ScalaExtractor::new())); + #[cfg(feature = "lang-swift")] + v.push(Arc::new(languages::swift::SwiftExtractor::new())); + #[cfg(feature = "lang-lua")] + v.push(Arc::new(languages::lua::LuaExtractor::new())); + v +} + +pub(crate) fn parse_err(s: impl Into) -> Error { + Error::Parse(s.into()) +} + +#[allow(dead_code)] +pub(crate) fn _node_kind_smoke() -> NodeKind { + NodeKind::Function +} diff --git a/crates/codegraph-extract/src/orchestrator.rs b/crates/codegraph-extract/src/orchestrator.rs new file mode 100644 index 00000000..87fdd915 --- /dev/null +++ b/crates/codegraph-extract/src/orchestrator.rs @@ -0,0 +1,140 @@ +use crate::{walker, ExtractResult, Extractor}; +use camino::Utf8Path; +use codegraph_core::Result; +use codegraph_db::{Db, EdgeDraft, FileRow, NodeDraft}; +use codegraph_resolve::{PendingCallRow, Resolver}; +use rayon::prelude::*; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use std::time::SystemTime; + +#[derive(Debug, Default, Clone)] +pub struct ExtractStats { + pub files: u64, + pub nodes: u64, + pub edges: u64, + pub skipped: u64, + pub resolved_calls: u64, +} + +pub struct Orchestrator { + extractors: Vec>, +} + +impl Orchestrator { + pub fn new(extractors: Vec>) -> Self { + Self { extractors } + } + + pub fn with_registry() -> Self { + Self::new(crate::registry()) + } + + pub fn index_all(&self, root: &Utf8Path, db: &Db) -> Result { + db.purge()?; + self.sync(root, db) + } + + pub fn sync(&self, root: &Utf8Path, db: &Db) -> Result { + let files = walker::walk(root, &self.extractors); + let parsed: Vec<_> = files + .par_iter() + .filter_map(|fm| parse_one(fm).ok().flatten()) + .collect(); + + let mut stats = ExtractStats::default(); + let mut all_pending: Vec = Vec::new(); + for Parsed { row, result } in parsed { + // Skip if file's existing sha matches — no-op sync optimization. + if let Ok(Some(existing)) = db.file_by_path(row.path.as_str()) { + if existing.sha256 == row.sha256 { + stats.skipped += 1; + continue; + } + if let Some(eid) = existing.id { + db.delete_file_cascade(eid)?; + } + } + + let fid = db.upsert_file(&row)?; + let drafts: Vec = result.nodes; + let ids = db.insert_nodes(fid, &drafts)?; + let edges: Vec = result + .edges + .into_iter() + .filter_map(|e| { + let f = *ids.get(e.from_idx)?; + let t = *ids.get(e.to_idx)?; + Some(EdgeDraft { + from_id: f, + to_id: t, + kind: e.kind, + file_id: Some(fid), + line: e.line, + source: Some("extract".into()), + }) + }) + .collect(); + stats.nodes += ids.len() as u64; + stats.edges += edges.len() as u64; + db.insert_edges(&edges)?; + // Translate pending_calls (local node indices) into resolver rows. + for pc in &result.pending_calls { + if let Some(from_id) = ids.get(pc.from_idx) { + all_pending.push(PendingCallRow { + from_id: *from_id, + target_name: pc.target_name.clone(), + file_id: fid, + line: pc.line, + }); + } + } + stats.files += 1; + } + let resolved = Resolver::new(db).resolve_calls(&all_pending)?; + stats.resolved_calls = resolved as u64; + stats.edges += resolved as u64; + Ok(stats) + } +} + +struct Parsed { + row: FileRow, + result: ExtractResult, +} + +fn parse_one(fm: &walker::FileMatch) -> Result> { + let bytes = match std::fs::read(fm.path.as_std_path()) { + Ok(b) if b.len() < 4 * 1024 * 1024 => b, + _ => return Ok(None), + }; + let source = match std::str::from_utf8(&bytes) { + Ok(s) => s, + Err(_) => return Ok(None), + }; + let mut h = Sha256::new(); + h.update(&bytes); + let sha = hex::encode(h.finalize()); + let meta = std::fs::metadata(fm.path.as_std_path())?; + let mtime = meta + .modified() + .ok() + .and_then(|m| m.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + let result = fm.extractor.extract(source)?; + let row = FileRow { + id: None, + path: fm.path.clone(), + language: fm.extractor.language().to_string(), + sha256: sha, + size: bytes.len() as u64, + mtime, + indexed_at: SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0), + }; + Ok(Some(Parsed { row, result })) +} diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs new file mode 100644 index 00000000..7714c911 --- /dev/null +++ b/crates/codegraph-extract/src/walker.rs @@ -0,0 +1,49 @@ +use crate::Extractor; +use camino::{Utf8Path, Utf8PathBuf}; +use ignore::WalkBuilder; +use std::collections::HashMap; +use std::sync::Arc; + +pub struct FileMatch { + pub path: Utf8PathBuf, + pub extractor: Arc, +} + +pub fn walk(root: &Utf8Path, extractors: &[Arc]) -> Vec { + let mut ext_map: HashMap<&'static str, Arc> = HashMap::new(); + for ex in extractors { + for e in ex.extensions() { + ext_map.insert(*e, ex.clone()); + } + } + + let mut out = Vec::new(); + let walker = WalkBuilder::new(root) + .hidden(true) + .git_ignore(true) + .git_exclude(true) + .parents(true) + .add_custom_ignore_filename(".codegraphignore") + .build(); + + for entry in walker.flatten() { + let path = entry.path(); + if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) { + continue; + } + let Some(ext) = path.extension().and_then(|s| s.to_str()) else { + continue; + }; + let Some(ex) = ext_map.get(ext) else { + continue; + }; + let Ok(p) = Utf8PathBuf::from_path_buf(path.to_path_buf()) else { + continue; + }; + out.push(FileMatch { + path: p, + extractor: ex.clone(), + }); + } + out +} diff --git a/crates/codegraph-extract/tests/extract.rs b/crates/codegraph-extract/tests/extract.rs new file mode 100644 index 00000000..d56d28c6 --- /dev/null +++ b/crates/codegraph-extract/tests/extract.rs @@ -0,0 +1,95 @@ +use camino::Utf8PathBuf; +use codegraph_db::Db; +use codegraph_extract::Orchestrator; + +fn open() -> (tempfile::TempDir, Db) { + let d = tempfile::tempdir().unwrap(); + let p = Utf8PathBuf::from_path_buf(d.path().join("db.sqlite")).unwrap(); + let db = Db::open(&p).unwrap(); + (d, db) +} + +#[test] +fn index_fixtures_dir() { + let (_keep, db) = open(); + let orch = Orchestrator::with_registry(); + let root = Utf8PathBuf::from_path_buf( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"), + ) + .unwrap(); + + let stats = orch.index_all(&root, &db).unwrap(); + assert!( + stats.files >= 7, + "expected at least 7 files, got {}", + stats.files + ); + assert!(stats.nodes > 0); + + // Java + let hits = db.search_nodes("UserService", 10).unwrap(); + assert!( + hits.iter().any(|n| n.language == "java"), + "expected java hit" + ); + + // Ruby + let hits = db.search_nodes("UserService", 10).unwrap(); + assert!( + hits.iter().any(|n| n.language == "ruby"), + "expected ruby hit" + ); + + // Python + let hits = db.search_nodes("process_user", 10).unwrap(); + assert!( + hits.iter().any(|n| n.language == "python"), + "expected python hit" + ); + + // Go + let hits = db.search_nodes("ProcessUser", 10).unwrap(); + assert!(hits.iter().any(|n| n.language == "go"), "expected go hit"); + + // JS + let hits = db.search_nodes("processUser", 10).unwrap(); + assert!( + hits.iter().any(|n| n.language == "javascript"), + "expected js hit" + ); + + // TS-specific: should have processUser + let hits = db.search_nodes("processUser", 10).unwrap(); + assert!( + hits.iter().any(|n| n.name == "processUser"), + "missing processUser in {:?}", + hits + ); + + // Rust-specific: should have process_user + let hits = db.search_nodes("process_user", 10).unwrap(); + assert!(hits.iter().any(|n| n.name == "process_user")); + + // UserService should appear (TS class + Rust struct) + let hits = db.search_nodes("UserService", 10).unwrap(); + assert!( + hits.len() >= 2, + "expected UserService from both TS and Rust, got {}", + hits.len() + ); +} + +#[test] +fn sync_skips_unchanged() { + let (_keep, db) = open(); + let orch = Orchestrator::with_registry(); + let root = Utf8PathBuf::from_path_buf( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"), + ) + .unwrap(); + + orch.index_all(&root, &db).unwrap(); + let s2 = orch.sync(&root, &db).unwrap(); + assert_eq!(s2.files, 0, "no new files should be indexed"); + assert!(s2.skipped >= 2); +} diff --git a/crates/codegraph-extract/tests/fixtures/sample.go b/crates/codegraph-extract/tests/fixtures/sample.go new file mode 100644 index 00000000..dc6b0b43 --- /dev/null +++ b/crates/codegraph-extract/tests/fixtures/sample.go @@ -0,0 +1,19 @@ +package main + +import "strings" + +func ProcessUser(id string) string { + return formatEmail(id) +} + +func formatEmail(s string) string { + return strings.ToLower(s) +} + +type UserService struct { + Name string +} + +func (u *UserService) Greet() string { + return ProcessUser(u.Name) +} diff --git a/crates/codegraph-extract/tests/fixtures/sample.java b/crates/codegraph-extract/tests/fixtures/sample.java new file mode 100644 index 00000000..20ebc3c7 --- /dev/null +++ b/crates/codegraph-extract/tests/fixtures/sample.java @@ -0,0 +1,13 @@ +package com.example; + +import java.util.List; + +public class UserService { + public String greet(String name) { + return formatGreeting(name); + } + + private String formatGreeting(String s) { + return "Hi " + s; + } +} diff --git a/crates/codegraph-extract/tests/fixtures/sample.js b/crates/codegraph-extract/tests/fixtures/sample.js new file mode 100644 index 00000000..984324b7 --- /dev/null +++ b/crates/codegraph-extract/tests/fixtures/sample.js @@ -0,0 +1,14 @@ +import path from "path"; + +export function processUser(id) { + return formatEmail(id); +} + +function formatEmail(s) { + return s.toLowerCase(); +} + +export class UserService { + constructor(name) { this.name = name; } + greet() { return processUser(this.name); } +} diff --git a/crates/codegraph-extract/tests/fixtures/sample.py b/crates/codegraph-extract/tests/fixtures/sample.py new file mode 100644 index 00000000..e990b12f --- /dev/null +++ b/crates/codegraph-extract/tests/fixtures/sample.py @@ -0,0 +1,15 @@ +from typing import List + +def process_user(uid: str) -> str: + """Format and return a user identifier.""" + return format_email(uid) + +def format_email(s: str) -> str: + return s.lower() + +class UserService: + def __init__(self, name: str): + self.name = name + + def greet(self) -> str: + return process_user(self.name) diff --git a/crates/codegraph-extract/tests/fixtures/sample.rb b/crates/codegraph-extract/tests/fixtures/sample.rb new file mode 100644 index 00000000..53d04a48 --- /dev/null +++ b/crates/codegraph-extract/tests/fixtures/sample.rb @@ -0,0 +1,9 @@ +class UserService + def greet(name) + format_greeting(name) + end + + def format_greeting(s) + "Hi #{s}" + end +end diff --git a/crates/codegraph-extract/tests/fixtures/sample.rs b/crates/codegraph-extract/tests/fixtures/sample.rs new file mode 100644 index 00000000..f1aa1410 --- /dev/null +++ b/crates/codegraph-extract/tests/fixtures/sample.rs @@ -0,0 +1,19 @@ +use std::collections::HashMap; + +pub fn process_user(id: &str) -> String { + format_email(id) +} + +fn format_email(s: &str) -> String { + s.to_lowercase() +} + +pub struct UserService { + pub name: String, +} + +impl UserService { + pub fn greet(&self) -> String { + process_user(&self.name) + } +} diff --git a/crates/codegraph-extract/tests/fixtures/sample.ts b/crates/codegraph-extract/tests/fixtures/sample.ts new file mode 100644 index 00000000..46de56c1 --- /dev/null +++ b/crates/codegraph-extract/tests/fixtures/sample.ts @@ -0,0 +1,19 @@ +import { join } from "node:path"; + +export function processUser(id: string): string { + return formatEmail(id); +} + +function formatEmail(s: string): string { + return s.toLowerCase(); +} + +export class UserService { + constructor(public name: string) {} + + greet(): string { + return processUser(this.name); + } +} + +const ROUTE = "/users"; diff --git a/crates/codegraph-graph/Cargo.toml b/crates/codegraph-graph/Cargo.toml new file mode 100644 index 00000000..38f108af --- /dev/null +++ b/crates/codegraph-graph/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "codegraph-graph" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +codegraph-core = { path = "../codegraph-core" } +codegraph-db = { path = "../codegraph-db" } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = "3" +camino = { workspace = true } diff --git a/crates/codegraph-graph/src/lib.rs b/crates/codegraph-graph/src/lib.rs new file mode 100644 index 00000000..ab41cb15 --- /dev/null +++ b/crates/codegraph-graph/src/lib.rs @@ -0,0 +1,163 @@ +//! Graph traversal: callers, callees, impact radius. + +use codegraph_core::{Edge, EdgeKind, Node, NodeId, Result}; +use codegraph_db::Db; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet, VecDeque}; + +const HARD_LIMIT: usize = 5000; + +pub struct Traversal<'a> { + db: &'a Db, +} + +impl<'a> Traversal<'a> { + pub fn new(db: &'a Db) -> Self { + Self { db } + } + + pub fn callers(&self, id: NodeId, depth: u32) -> Result { + self.traverse(id, depth, &[EdgeKind::Calls], false) + } + + pub fn callees(&self, id: NodeId, depth: u32) -> Result { + self.traverse(id, depth, &[EdgeKind::Calls], true) + } + + /// All nodes that reference this node (depth 1, all non-containment edge kinds). + pub fn references(&self, id: NodeId) -> Result { + let kinds = [ + EdgeKind::Calls, + EdgeKind::Imports, + EdgeKind::Extends, + EdgeKind::Implements, + EdgeKind::References, + EdgeKind::TypeOf, + EdgeKind::Instantiates, + EdgeKind::Overrides, + EdgeKind::Decorates, + ]; + let root = self + .db + .node_by_id(id)? + .ok_or_else(|| codegraph_core::Error::Invalid(format!("node {id} not found")))?; + let edges = self.db.edges_to(id, &kinds)?; + let mut by_kind: HashMap> = HashMap::new(); + for e in &edges { + if let Some(n) = self.db.node_by_id(e.from)? { + by_kind.entry(e.kind.as_str().into()).or_default().push(n); + } + } + Ok(ReferencesReport { root, by_kind }) + } + + /// Forward impact across calls/references/imports/extends/implements. + pub fn impact_radius(&self, id: NodeId, max_depth: u32) -> Result { + let kinds = [ + EdgeKind::Calls, + EdgeKind::References, + EdgeKind::Imports, + EdgeKind::Extends, + EdgeKind::Implements, + ]; + let hits = self.traverse(id, max_depth, &kinds, false)?; // who depends on us = incoming + let root = self + .db + .node_by_id(id)? + .ok_or_else(|| codegraph_core::Error::Invalid(format!("node {id} not found")))?; + let mut by_kind: HashMap = HashMap::new(); + for n in &hits.nodes { + *by_kind.entry(n.kind.as_str().into()).or_insert(0) += 1; + } + let mut direct = Vec::new(); + let mut transitive = Vec::new(); + for (n, d) in hits.nodes.iter().zip(hits.depths.iter()) { + if *d == 1 { + direct.push(n.clone()); + } else { + transitive.push(n.clone()); + } + } + Ok(ImpactReport { + root, + direct, + transitive, + by_kind, + truncated: hits.truncated, + }) + } + + fn traverse( + &self, + start: NodeId, + max_depth: u32, + kinds: &[EdgeKind], + forward: bool, + ) -> Result { + let mut visited: HashSet = HashSet::new(); + let mut queue: VecDeque<(NodeId, u32)> = VecDeque::new(); + let mut nodes = Vec::new(); + let mut depths = Vec::new(); + let mut edges = Vec::new(); + let mut truncated = false; + visited.insert(start); + queue.push_back((start, 0)); + + while let Some((cur, d)) = queue.pop_front() { + if d >= max_depth { + continue; + } + if visited.len() > HARD_LIMIT { + truncated = true; + break; + } + let next_edges = if forward { + self.db.edges_from(cur, kinds)? + } else { + self.db.edges_to(cur, kinds)? + }; + for e in next_edges { + let next_id = if forward { e.to } else { e.from }; + edges.push(e); + if visited.insert(next_id) { + if let Some(n) = self.db.node_by_id(next_id)? { + nodes.push(n); + depths.push(d + 1); + } + queue.push_back((next_id, d + 1)); + } + } + } + + Ok(TraverseHits { + nodes, + depths, + edges, + truncated, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraverseHits { + pub nodes: Vec, + pub depths: Vec, + pub edges: Vec, + pub truncated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReferencesReport { + pub root: Node, + /// Inbound references grouped by edge kind (calls, imports, extends, …). + pub by_kind: HashMap>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImpactReport { + pub root: Node, + pub direct: Vec, + pub transitive: Vec, + pub by_kind: HashMap, + pub truncated: bool, +} diff --git a/crates/codegraph-graph/tests/traversal.rs b/crates/codegraph-graph/tests/traversal.rs new file mode 100644 index 00000000..e5188139 --- /dev/null +++ b/crates/codegraph-graph/tests/traversal.rs @@ -0,0 +1,112 @@ +use camino::Utf8PathBuf; +use codegraph_core::{EdgeKind, NodeKind}; +use codegraph_db::{Db, EdgeDraft, FileRow, NodeDraft}; +use codegraph_graph::Traversal; + +fn db() -> (tempfile::TempDir, Db) { + let d = tempfile::tempdir().unwrap(); + let p = Utf8PathBuf::from_path_buf(d.path().join("db.sqlite")).unwrap(); + (d, Db::open(&p).unwrap()) +} + +fn mk_file(db: &Db, p: &str) -> i64 { + db.upsert_file(&FileRow { + id: None, + path: p.into(), + language: "test".into(), + sha256: "x".into(), + size: 0, + mtime: 0, + indexed_at: 0, + }) + .unwrap() +} + +fn node(name: &str) -> NodeDraft { + NodeDraft { + kind: NodeKind::Function, + name: name.into(), + qualified_name: None, + start_line: 1, + end_line: 1, + signature: None, + docstring: None, + language: "test".into(), + } +} + +#[test] +fn callers_callees_chain() { + // A -> B -> C -> D + let (_d, db) = db(); + let f = mk_file(&db, "a.ts"); + let ids = db + .insert_nodes(f, &[node("a"), node("b"), node("c"), node("d")]) + .unwrap(); + let calls = |from: usize, to: usize| EdgeDraft { + from_id: ids[from], + to_id: ids[to], + kind: EdgeKind::Calls, + file_id: Some(f), + line: None, + source: None, + }; + db.insert_edges(&[calls(0, 1), calls(1, 2), calls(2, 3)]) + .unwrap(); + + let t = Traversal::new(&db); + let cees = t.callees(ids[0], 3).unwrap(); + assert_eq!(cees.nodes.len(), 3); + assert!(cees.nodes.iter().any(|n| n.name == "d")); + + let cers = t.callers(ids[3], 3).unwrap(); + assert_eq!(cers.nodes.len(), 3); + assert!(cers.nodes.iter().any(|n| n.name == "a")); + + // depth limit + let cees2 = t.callees(ids[0], 1).unwrap(); + assert_eq!(cees2.nodes.len(), 1); + assert_eq!(cees2.nodes[0].name, "b"); +} + +#[test] +fn impact_groups_by_depth() { + let (_d, db) = db(); + let f = mk_file(&db, "a.ts"); + let ids = db + .insert_nodes(f, &[node("root"), node("d1"), node("d2"), node("d2b")]) + .unwrap(); + // d1 -> root, d2 -> d1, d2b -> d1 + db.insert_edges(&[ + EdgeDraft { + from_id: ids[1], + to_id: ids[0], + kind: EdgeKind::Calls, + file_id: Some(f), + line: None, + source: None, + }, + EdgeDraft { + from_id: ids[2], + to_id: ids[1], + kind: EdgeKind::Calls, + file_id: Some(f), + line: None, + source: None, + }, + EdgeDraft { + from_id: ids[3], + to_id: ids[1], + kind: EdgeKind::Calls, + file_id: Some(f), + line: None, + source: None, + }, + ]) + .unwrap(); + let t = Traversal::new(&db); + let imp = t.impact_radius(ids[0], 3).unwrap(); + assert_eq!(imp.direct.len(), 1); + assert_eq!(imp.transitive.len(), 2); + assert!(!imp.truncated); +} diff --git a/crates/codegraph-installer/Cargo.toml b/crates/codegraph-installer/Cargo.toml new file mode 100644 index 00000000..a5cb2d4d --- /dev/null +++ b/crates/codegraph-installer/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "codegraph-installer" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +jsonc-parser = { workspace = true } +toml_edit = { workspace = true } +dirs = { workspace = true } +which = { workspace = true } +camino = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/codegraph-installer/src/instructions-template.md b/crates/codegraph-installer/src/instructions-template.md new file mode 100644 index 00000000..cc5111c1 --- /dev/null +++ b/crates/codegraph-installer/src/instructions-template.md @@ -0,0 +1,34 @@ +# CodeGraph + +This project has a CodeGraph MCP server configured. CodeGraph is a tree-sitter +knowledge graph of every symbol, edge, and file in the workspace. Reads are +sub-millisecond and return structural information grep cannot. + +## When to prefer codegraph + +Use codegraph for **structural** questions — what calls what, what would +break, where is X defined, what is X's signature. Use native grep/read only +for literal text queries. + +| Question | Tool | +|---|---| +| "Where is X defined?" | `codegraph_search` | +| "What calls Y?" | `codegraph_callers` | +| "What does Y call?" | `codegraph_callees` | +| "What would break if I changed Z?" | `codegraph_impact` | +| "Show me Y's signature / source" | `codegraph_node` | +| "Give me focused context for a task" | `codegraph_context` | +| "What files exist under path/" | `codegraph_files` | +| "Is the index healthy?" | `codegraph_status` | + +## Rules of thumb + +- **Trust codegraph results.** They come from a full AST parse. Do NOT + re-verify with grep. +- **Don't grep first** when looking up a symbol by name. +- **`codegraph_context` is one call** — don't chain search + node yourself. + +## If `.codegraph/` doesn't exist + +The MCP server returns "not initialized." Run `codegraph init -i` to build +the index. diff --git a/crates/codegraph-installer/src/lib.rs b/crates/codegraph-installer/src/lib.rs new file mode 100644 index 00000000..c10fd0e0 --- /dev/null +++ b/crates/codegraph-installer/src/lib.rs @@ -0,0 +1,73 @@ +//! Multi-agent installer. One target per file in `targets/`. + +pub mod targets; + +use anyhow::Result; +use camino::Utf8PathBuf; +use std::sync::Arc; + +pub const INSTRUCTIONS_MD: &str = include_str!("instructions-template.md"); + +#[derive(Debug, Clone)] +pub struct InstallOpts { + /// Workspace root (for project-scoped installs). + pub project_root: Option, + /// Install globally (in user home) rather than project-local. + pub global: bool, + /// Absolute path to the `codegraph` binary (for MCP `command`). + pub binary_path: Utf8PathBuf, + /// Override home directory (used in tests for isolation). + /// None = use dirs::home_dir(). + pub home_dir: Option, +} + +impl InstallOpts { + pub fn home_dir(&self) -> Option { + self.home_dir + .as_ref() + .map(|p| p.as_std_path().to_path_buf()) + .or_else(dirs::home_dir) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetectStatus { + NotFound, + Found, + AlreadyConfigured, +} + +#[derive(Debug, Clone)] +pub enum InstallReport { + Installed(Vec), + Unchanged, + Updated(Vec), + Skipped(String), +} + +pub trait AgentTarget: Send + Sync { + fn id(&self) -> &'static str; + fn label(&self) -> &'static str; + fn detect(&self, opts: &InstallOpts) -> DetectStatus; + fn install(&self, opts: &InstallOpts) -> Result; + fn uninstall(&self, opts: &InstallOpts) -> Result; +} + +pub fn registry() -> Vec> { + vec![ + Arc::new(targets::claude::ClaudeTarget), + Arc::new(targets::cursor::CursorTarget), + Arc::new(targets::codex::CodexTarget), + Arc::new(targets::opencode::OpencodeTarget), + Arc::new(targets::hermes::HermesTarget), + Arc::new(targets::antigravity::AntigravityTarget), + ] +} + +/// Project-scoped targets only (skip ones that only support global config). +pub fn project_registry() -> Vec> { + registry() + .into_iter() + .filter(|t| t.id() != "codex") + .collect() +} diff --git a/crates/codegraph-installer/src/targets/antigravity.rs b/crates/codegraph-installer/src/targets/antigravity.rs new file mode 100644 index 00000000..d0832b67 --- /dev/null +++ b/crates/codegraph-installer/src/targets/antigravity.rs @@ -0,0 +1,156 @@ +//! Antigravity CLI — Google's Go-based terminal agent (successor to Gemini CLI). +//! MCP config lives in a dedicated `mcp_config.json`, not inline in settings. +//! Global: ~/.gemini/antigravity-cli/mcp_config.json +//! Workspace: .agents/mcp_config.json + +use crate::{ + targets::jsonutil, AgentTarget, DetectStatus, InstallOpts, InstallReport, INSTRUCTIONS_MD, +}; +use anyhow::Result; +use camino::Utf8PathBuf; +use serde_json::{json, Value}; + +pub struct AntigravityTarget; + +impl AntigravityTarget { + fn mcp_path(&self, opts: &InstallOpts) -> Option { + if opts.global { + let home = dirs::home_dir()?; + Utf8PathBuf::from_path_buf( + home.join(".gemini") + .join("antigravity-cli") + .join("mcp_config.json"), + ) + .ok() + } else { + opts.project_root + .as_ref() + .map(|r| r.join(".agents").join("mcp_config.json")) + } + } + + fn instructions_path(&self, opts: &InstallOpts) -> Option { + if opts.global { + let home = dirs::home_dir()?; + Utf8PathBuf::from_path_buf( + home.join(".gemini") + .join("antigravity-cli") + .join("AGENTS.md"), + ) + .ok() + } else { + opts.project_root + .as_ref() + .map(|r| r.join(".agents").join("AGENTS.md")) + } + } +} + +impl AgentTarget for AntigravityTarget { + fn id(&self) -> &'static str { + "antigravity" + } + + fn label(&self) -> &'static str { + "Antigravity CLI" + } + + fn detect(&self, opts: &InstallOpts) -> DetectStatus { + let Some(home) = opts.home_dir() else { + return DetectStatus::NotFound; + }; + if !home.join(".gemini").join("antigravity-cli").exists() { + return DetectStatus::NotFound; + } + let Some(p) = self.mcp_path(opts) else { + return DetectStatus::Found; + }; + if !p.exists() { + return DetectStatus::Found; + } + let Ok(v) = jsonutil::read_or_default(&p) else { + return DetectStatus::Found; + }; + if v.pointer("/mcpServers/codegraph").is_some() { + DetectStatus::AlreadyConfigured + } else { + DetectStatus::Found + } + } + + fn install(&self, opts: &InstallOpts) -> Result { + let mcp = self + .mcp_path(opts) + .ok_or_else(|| anyhow::anyhow!("no antigravity path"))?; + let mut v = jsonutil::read_or_default(&mcp)?; + + let mut args = vec![Value::String("serve".into()), Value::String("--mcp".into())]; + if let Some(root) = &opts.project_root { + args.push(Value::String("--path".into())); + args.push(Value::String(root.to_string())); + } + let entry = json!({ "command": opts.binary_path.as_str(), "args": args }); + + let mut changed = false; + { + let obj = v + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("mcp_config.json not an object"))?; + let servers = obj + .entry("mcpServers") + .or_insert_with(|| Value::Object(Default::default())); + let servers = servers + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("mcpServers not an object"))?; + if servers.get("codegraph") != Some(&entry) { + servers.insert("codegraph".into(), entry); + changed = true; + } + } + + let mut written = Vec::new(); + if changed { + jsonutil::write_pretty(&mcp, &v)?; + written.push(mcp); + } + + if let Some(md) = self.instructions_path(opts) { + let existing = std::fs::read_to_string(md.as_std_path()).ok(); + if existing.as_deref() != Some(INSTRUCTIONS_MD) { + if let Some(parent) = md.parent() { + std::fs::create_dir_all(parent.as_std_path())?; + } + std::fs::write(md.as_std_path(), INSTRUCTIONS_MD)?; + written.push(md); + } + } + + if written.is_empty() { + Ok(InstallReport::Unchanged) + } else { + Ok(InstallReport::Installed(written)) + } + } + + fn uninstall(&self, opts: &InstallOpts) -> Result { + let Some(mcp) = self.mcp_path(opts) else { + return Ok(InstallReport::Unchanged); + }; + if !mcp.exists() { + return Ok(InstallReport::Unchanged); + } + let mut v = jsonutil::read_or_default(&mcp)?; + let mut changed = false; + if let Some(servers) = v.pointer_mut("/mcpServers").and_then(|s| s.as_object_mut()) { + if servers.remove("codegraph").is_some() { + changed = true; + } + } + if changed { + jsonutil::write_pretty(&mcp, &v)?; + Ok(InstallReport::Updated(vec![mcp])) + } else { + Ok(InstallReport::Unchanged) + } + } +} diff --git a/crates/codegraph-installer/src/targets/claude.rs b/crates/codegraph-installer/src/targets/claude.rs new file mode 100644 index 00000000..0801497c --- /dev/null +++ b/crates/codegraph-installer/src/targets/claude.rs @@ -0,0 +1,146 @@ +use crate::{ + targets::jsonutil, AgentTarget, DetectStatus, InstallOpts, InstallReport, INSTRUCTIONS_MD, +}; +use anyhow::Result; +use camino::Utf8PathBuf; +use serde_json::{json, Value}; + +pub struct ClaudeTarget; + +impl ClaudeTarget { + fn settings_path(&self, opts: &InstallOpts) -> Option { + if opts.global { + let home = dirs::home_dir()?; + Utf8PathBuf::from_path_buf(home.join(".claude").join("settings.json")).ok() + } else { + let root = opts.project_root.as_ref()?; + Some(root.join(".claude").join("settings.local.json")) + } + } + fn instructions_path(&self, opts: &InstallOpts) -> Option { + if opts.global { + let home = dirs::home_dir()?; + Utf8PathBuf::from_path_buf(home.join(".claude").join("CLAUDE.md")).ok() + } else { + opts.project_root.as_ref().map(|r| r.join("CLAUDE.md")) + } + } +} + +impl AgentTarget for ClaudeTarget { + fn id(&self) -> &'static str { + "claude" + } + fn label(&self) -> &'static str { + "Claude Code" + } + + fn detect(&self, opts: &InstallOpts) -> DetectStatus { + let Some(home) = opts.home_dir() else { + return DetectStatus::NotFound; + }; + if !home.join(".claude").exists() { + return DetectStatus::NotFound; + } + let Some(p) = self.settings_path(opts) else { + return DetectStatus::Found; + }; + if !p.exists() { + return DetectStatus::Found; + } + let Ok(v) = jsonutil::read_or_default(&p) else { + return DetectStatus::Found; + }; + if v.pointer("/mcpServers/codegraph").is_some() { + DetectStatus::AlreadyConfigured + } else { + DetectStatus::Found + } + } + + fn install(&self, opts: &InstallOpts) -> Result { + let settings = self + .settings_path(opts) + .ok_or_else(|| anyhow::anyhow!("no settings path"))?; + let mut v = jsonutil::read_or_default(&settings)?; + + let mcp_entry = json!({ + "command": opts.binary_path.as_str(), + "args": serve_args(opts), + }); + let mut changed = false; + { + let obj = v + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("settings.json not an object"))?; + let servers = obj + .entry("mcpServers") + .or_insert_with(|| Value::Object(Default::default())); + let servers = servers + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("mcpServers not an object"))?; + if servers.get("codegraph") != Some(&mcp_entry) { + servers.insert("codegraph".into(), mcp_entry); + changed = true; + } + } + + let mut written = Vec::new(); + if changed { + jsonutil::write_pretty(&settings, &v)?; + written.push(settings); + } + + if let Some(md) = self.instructions_path(opts) { + let want = INSTRUCTIONS_MD; + let existing = std::fs::read_to_string(md.as_std_path()).ok(); + if existing.as_deref() != Some(want) { + if let Some(parent) = md.parent() { + std::fs::create_dir_all(parent.as_std_path())?; + } + std::fs::write(md.as_std_path(), want)?; + written.push(md); + } + } + + if written.is_empty() { + Ok(InstallReport::Unchanged) + } else { + Ok(InstallReport::Installed(written)) + } + } + + fn uninstall(&self, opts: &InstallOpts) -> Result { + let mut removed = Vec::new(); + if let Some(settings) = self.settings_path(opts) { + if settings.exists() { + let mut v = jsonutil::read_or_default(&settings)?; + let mut changed = false; + if let Some(servers) = v.pointer_mut("/mcpServers").and_then(|s| s.as_object_mut()) + { + if servers.remove("codegraph").is_some() { + changed = true; + } + } + if changed { + jsonutil::write_pretty(&settings, &v)?; + removed.push(settings); + } + } + } + if removed.is_empty() { + Ok(InstallReport::Unchanged) + } else { + Ok(InstallReport::Updated(removed)) + } + } +} + +fn serve_args(opts: &InstallOpts) -> Value { + let mut args = vec![Value::String("serve".into()), Value::String("--mcp".into())]; + if let Some(root) = &opts.project_root { + args.push(Value::String("--path".into())); + args.push(Value::String(root.to_string())); + } + Value::Array(args) +} diff --git a/crates/codegraph-installer/src/targets/codex.rs b/crates/codegraph-installer/src/targets/codex.rs new file mode 100644 index 00000000..cdeae9b2 --- /dev/null +++ b/crates/codegraph-installer/src/targets/codex.rs @@ -0,0 +1,140 @@ +//! Codex CLI — config at `~/.codex/config.toml`, instructions at `~/.codex/AGENTS.md`. +//! Uses toml_edit to preserve sibling tables and user formatting. + +use crate::{AgentTarget, DetectStatus, InstallOpts, InstallReport, INSTRUCTIONS_MD}; +use anyhow::Result; +use camino::Utf8PathBuf; +use toml_edit::{value, Array, DocumentMut, Item, Table}; + +pub struct CodexTarget; + +impl CodexTarget { + fn config_path(&self) -> Option { + let home = dirs::home_dir()?; + Utf8PathBuf::from_path_buf(home.join(".codex").join("config.toml")).ok() + } + fn agents_path(&self) -> Option { + let home = dirs::home_dir()?; + Utf8PathBuf::from_path_buf(home.join(".codex").join("AGENTS.md")).ok() + } +} + +impl AgentTarget for CodexTarget { + fn id(&self) -> &'static str { + "codex" + } + fn label(&self) -> &'static str { + "Codex CLI" + } + + fn detect(&self, _opts: &InstallOpts) -> DetectStatus { + let Some(p) = self.config_path() else { + return DetectStatus::NotFound; + }; + if !p.exists() { + return DetectStatus::NotFound; + } + let Ok(text) = std::fs::read_to_string(p.as_std_path()) else { + return DetectStatus::Found; + }; + let Ok(doc) = text.parse::() else { + return DetectStatus::Found; + }; + if doc + .get("mcp_servers") + .and_then(|v| v.as_table()) + .and_then(|t| t.get("codegraph")) + .is_some() + { + DetectStatus::AlreadyConfigured + } else { + DetectStatus::Found + } + } + + fn install(&self, opts: &InstallOpts) -> Result { + let config = self + .config_path() + .ok_or_else(|| anyhow::anyhow!("no codex config path"))?; + let text = std::fs::read_to_string(config.as_std_path()).unwrap_or_default(); + let mut doc: DocumentMut = if text.is_empty() { + DocumentMut::new() + } else { + text.parse()? + }; + + let mut servers = match doc.remove("mcp_servers") { + Some(Item::Table(t)) => t, + _ => { + let mut t = Table::new(); + t.set_implicit(true); + t + } + }; + + let mut cg = Table::new(); + cg["command"] = value(opts.binary_path.as_str()); + let mut args = Array::new(); + args.push("serve"); + args.push("--mcp"); + if let Some(root) = &opts.project_root { + args.push("--path"); + args.push(root.as_str()); + } + cg["args"] = value(args); + + let existing = servers.get("codegraph").map(|i| i.to_string()); + let new_str = Item::Table(cg.clone()).to_string(); + let changed = existing.as_deref() != Some(new_str.as_str()); + + servers["codegraph"] = Item::Table(cg); + doc["mcp_servers"] = Item::Table(servers); + + let mut written = Vec::new(); + if changed { + if let Some(parent) = config.parent() { + std::fs::create_dir_all(parent.as_std_path())?; + } + std::fs::write(config.as_std_path(), doc.to_string())?; + written.push(config); + } + if let Some(md) = self.agents_path() { + let existing = std::fs::read_to_string(md.as_std_path()).ok(); + if existing.as_deref() != Some(INSTRUCTIONS_MD) { + if let Some(parent) = md.parent() { + std::fs::create_dir_all(parent.as_std_path())?; + } + std::fs::write(md.as_std_path(), INSTRUCTIONS_MD)?; + written.push(md); + } + } + if written.is_empty() { + Ok(InstallReport::Unchanged) + } else { + Ok(InstallReport::Installed(written)) + } + } + + fn uninstall(&self, _opts: &InstallOpts) -> Result { + let Some(config) = self.config_path() else { + return Ok(InstallReport::Unchanged); + }; + if !config.exists() { + return Ok(InstallReport::Unchanged); + } + let text = std::fs::read_to_string(config.as_std_path())?; + let mut doc: DocumentMut = text.parse()?; + let mut changed = false; + if let Some(Item::Table(servers)) = doc.get_mut("mcp_servers") { + if servers.remove("codegraph").is_some() { + changed = true; + } + } + if changed { + std::fs::write(config.as_std_path(), doc.to_string())?; + Ok(InstallReport::Updated(vec![config])) + } else { + Ok(InstallReport::Unchanged) + } + } +} diff --git a/crates/codegraph-installer/src/targets/cursor.rs b/crates/codegraph-installer/src/targets/cursor.rs new file mode 100644 index 00000000..87690e52 --- /dev/null +++ b/crates/codegraph-installer/src/targets/cursor.rs @@ -0,0 +1,142 @@ +use crate::{ + targets::jsonutil, AgentTarget, DetectStatus, InstallOpts, InstallReport, INSTRUCTIONS_MD, +}; +use anyhow::Result; +use camino::Utf8PathBuf; +use serde_json::{json, Value}; + +pub struct CursorTarget; + +impl CursorTarget { + fn mcp_path(&self, opts: &InstallOpts) -> Option { + if opts.global { + let home = dirs::home_dir()?; + Utf8PathBuf::from_path_buf(home.join(".cursor").join("mcp.json")).ok() + } else { + opts.project_root + .as_ref() + .map(|r| r.join(".cursor").join("mcp.json")) + } + } + fn rule_path(&self, opts: &InstallOpts) -> Option { + opts.project_root + .as_ref() + .map(|r| r.join(".cursor").join("rules").join("codegraph.mdc")) + } +} + +impl AgentTarget for CursorTarget { + fn id(&self) -> &'static str { + "cursor" + } + fn label(&self) -> &'static str { + "Cursor" + } + + fn detect(&self, opts: &InstallOpts) -> DetectStatus { + let Some(home) = opts.home_dir() else { + return DetectStatus::NotFound; + }; + if !home.join(".cursor").exists() { + return DetectStatus::NotFound; + } + let Some(p) = self.mcp_path(opts) else { + return DetectStatus::Found; + }; + if !p.exists() { + return DetectStatus::Found; + } + let Ok(v) = jsonutil::read_or_default(&p) else { + return DetectStatus::Found; + }; + if v.pointer("/mcpServers/codegraph").is_some() { + DetectStatus::AlreadyConfigured + } else { + DetectStatus::Found + } + } + + fn install(&self, opts: &InstallOpts) -> Result { + let mcp = self + .mcp_path(opts) + .ok_or_else(|| anyhow::anyhow!("no mcp path"))?; + let mut v = jsonutil::read_or_default(&mcp)?; + + // Cursor MCP working-dir quirk: inject --path explicitly. + let path_arg = match (&opts.project_root, opts.global) { + (Some(root), false) => root.to_string(), + _ => "${workspaceFolder}".to_string(), + }; + let entry = json!({ + "command": opts.binary_path.as_str(), + "args": ["serve", "--mcp", "--path", path_arg], + }); + + let mut changed = false; + { + let obj = v + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("mcp.json not an object"))?; + let servers = obj + .entry("mcpServers") + .or_insert_with(|| Value::Object(Default::default())); + let servers = servers + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("mcpServers not an object"))?; + if servers.get("codegraph") != Some(&entry) { + servers.insert("codegraph".into(), entry); + changed = true; + } + } + + let mut written = Vec::new(); + if changed { + jsonutil::write_pretty(&mcp, &v)?; + written.push(mcp); + } + if let Some(rule) = self.rule_path(opts) { + let want = format!( + "---\ndescription: CodeGraph usage\nalwaysApply: true\n---\n\n{}", + INSTRUCTIONS_MD + ); + let existing = std::fs::read_to_string(rule.as_std_path()).ok(); + if existing.as_deref() != Some(want.as_str()) { + if let Some(parent) = rule.parent() { + std::fs::create_dir_all(parent.as_std_path())?; + } + std::fs::write(rule.as_std_path(), &want)?; + written.push(rule); + } + } + if written.is_empty() { + Ok(InstallReport::Unchanged) + } else { + Ok(InstallReport::Installed(written)) + } + } + + fn uninstall(&self, opts: &InstallOpts) -> Result { + let mut removed = Vec::new(); + if let Some(mcp) = self.mcp_path(opts) { + if mcp.exists() { + let mut v = jsonutil::read_or_default(&mcp)?; + let mut changed = false; + if let Some(servers) = v.pointer_mut("/mcpServers").and_then(|s| s.as_object_mut()) + { + if servers.remove("codegraph").is_some() { + changed = true; + } + } + if changed { + jsonutil::write_pretty(&mcp, &v)?; + removed.push(mcp); + } + } + } + if removed.is_empty() { + Ok(InstallReport::Unchanged) + } else { + Ok(InstallReport::Updated(removed)) + } + } +} diff --git a/crates/codegraph-installer/src/targets/hermes.rs b/crates/codegraph-installer/src/targets/hermes.rs new file mode 100644 index 00000000..71394329 --- /dev/null +++ b/crates/codegraph-installer/src/targets/hermes.rs @@ -0,0 +1,137 @@ +//! Hermes — JSON config at `~/.hermes/mcp.json`. +//! Mirrors Claude wiring; adjust as Hermes spec evolves. + +use crate::{ + targets::jsonutil, AgentTarget, DetectStatus, InstallOpts, InstallReport, INSTRUCTIONS_MD, +}; +use anyhow::Result; +use camino::Utf8PathBuf; +use serde_json::{json, Value}; + +pub struct HermesTarget; + +impl HermesTarget { + fn mcp_path(&self, opts: &InstallOpts) -> Option { + if opts.global { + let home = dirs::home_dir()?; + Utf8PathBuf::from_path_buf(home.join(".hermes").join("mcp.json")).ok() + } else { + opts.project_root + .as_ref() + .map(|r| r.join(".hermes").join("mcp.json")) + } + } + fn instructions_path(&self, opts: &InstallOpts) -> Option { + if opts.global { + let home = dirs::home_dir()?; + Utf8PathBuf::from_path_buf(home.join(".hermes").join("AGENTS.md")).ok() + } else { + opts.project_root + .as_ref() + .map(|r| r.join(".hermes").join("AGENTS.md")) + } + } +} + +impl AgentTarget for HermesTarget { + fn id(&self) -> &'static str { + "hermes" + } + fn label(&self) -> &'static str { + "Hermes" + } + + fn detect(&self, opts: &InstallOpts) -> DetectStatus { + let Some(home) = opts.home_dir() else { + return DetectStatus::NotFound; + }; + if !home.join(".hermes").exists() { + return DetectStatus::NotFound; + } + let Some(p) = self.mcp_path(opts) else { + return DetectStatus::Found; + }; + if !p.exists() { + return DetectStatus::Found; + } + let Ok(v) = jsonutil::read_or_default(&p) else { + return DetectStatus::Found; + }; + if v.pointer("/mcpServers/codegraph").is_some() { + DetectStatus::AlreadyConfigured + } else { + DetectStatus::Found + } + } + + fn install(&self, opts: &InstallOpts) -> Result { + let mcp = self + .mcp_path(opts) + .ok_or_else(|| anyhow::anyhow!("no hermes path"))?; + let mut v = jsonutil::read_or_default(&mcp)?; + let mut args = vec![Value::String("serve".into()), Value::String("--mcp".into())]; + if let Some(root) = &opts.project_root { + args.push(Value::String("--path".into())); + args.push(Value::String(root.to_string())); + } + let entry = json!({ "command": opts.binary_path.as_str(), "args": args }); + let mut changed = false; + { + let obj = v + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("hermes config not an object"))?; + let servers = obj + .entry("mcpServers") + .or_insert_with(|| Value::Object(Default::default())); + let servers = servers + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("mcpServers not an object"))?; + if servers.get("codegraph") != Some(&entry) { + servers.insert("codegraph".into(), entry); + changed = true; + } + } + let mut written = Vec::new(); + if changed { + jsonutil::write_pretty(&mcp, &v)?; + written.push(mcp); + } + if let Some(md) = self.instructions_path(opts) { + let existing = std::fs::read_to_string(md.as_std_path()).ok(); + if existing.as_deref() != Some(INSTRUCTIONS_MD) { + if let Some(parent) = md.parent() { + std::fs::create_dir_all(parent.as_std_path())?; + } + std::fs::write(md.as_std_path(), INSTRUCTIONS_MD)?; + written.push(md); + } + } + if written.is_empty() { + Ok(InstallReport::Unchanged) + } else { + Ok(InstallReport::Installed(written)) + } + } + + fn uninstall(&self, opts: &InstallOpts) -> Result { + let Some(mcp) = self.mcp_path(opts) else { + return Ok(InstallReport::Unchanged); + }; + if !mcp.exists() { + return Ok(InstallReport::Unchanged); + } + let mut v = jsonutil::read_or_default(&mcp)?; + let mut changed = false; + if let Some(servers) = v.pointer_mut("/mcpServers").and_then(|s| s.as_object_mut()) { + if servers.remove("codegraph").is_some() { + changed = true; + } + } + if changed { + jsonutil::write_pretty(&mcp, &v)?; + Ok(InstallReport::Updated(vec![mcp])) + } else { + Ok(InstallReport::Unchanged) + } + } +} diff --git a/crates/codegraph-installer/src/targets/jsonutil.rs b/crates/codegraph-installer/src/targets/jsonutil.rs new file mode 100644 index 00000000..710daa2a --- /dev/null +++ b/crates/codegraph-installer/src/targets/jsonutil.rs @@ -0,0 +1,23 @@ +use anyhow::Result; +use camino::Utf8Path; +use serde_json::Value; + +pub fn read_or_default(path: &Utf8Path) -> Result { + if !path.exists() { + return Ok(Value::Object(serde_json::Map::new())); + } + let bytes = std::fs::read(path.as_std_path())?; + if bytes.is_empty() { + return Ok(Value::Object(serde_json::Map::new())); + } + Ok(serde_json::from_slice(&bytes)?) +} + +pub fn write_pretty(path: &Utf8Path, v: &Value) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent.as_std_path())?; + } + let s = serde_json::to_string_pretty(v)?; + std::fs::write(path.as_std_path(), format!("{s}\n"))?; + Ok(()) +} diff --git a/crates/codegraph-installer/src/targets/mod.rs b/crates/codegraph-installer/src/targets/mod.rs new file mode 100644 index 00000000..6a4933e5 --- /dev/null +++ b/crates/codegraph-installer/src/targets/mod.rs @@ -0,0 +1,7 @@ +pub mod antigravity; +pub mod claude; +pub mod codex; +pub mod cursor; +pub mod hermes; +pub(crate) mod jsonutil; +pub mod opencode; diff --git a/crates/codegraph-installer/src/targets/opencode.rs b/crates/codegraph-installer/src/targets/opencode.rs new file mode 100644 index 00000000..323502e8 --- /dev/null +++ b/crates/codegraph-installer/src/targets/opencode.rs @@ -0,0 +1,222 @@ +//! opencode — prefers `opencode.jsonc` if present, falls back to `.json`. +//! For greenfield installs, creates `.jsonc`. Surgical edits via jsonc-parser +//! to preserve user comments and formatting. + +use crate::{ + targets::jsonutil, AgentTarget, DetectStatus, InstallOpts, InstallReport, INSTRUCTIONS_MD, +}; +use anyhow::Result; +use camino::Utf8PathBuf; +use serde_json::{json, Value}; + +pub struct OpencodeTarget; + +impl OpencodeTarget { + fn config_path(&self, opts: &InstallOpts) -> Option { + if opts.global { + let home = dirs::config_dir()?; + Utf8PathBuf::from_path_buf(home.join("opencode").join("opencode.jsonc")).ok() + } else { + opts.project_root.as_ref().map(|r| { + let jsonc = r.join("opencode.jsonc"); + if jsonc.exists() { + return jsonc; + } + let json = r.join("opencode.json"); + if json.exists() { + return json; + } + jsonc + }) + } + } + + fn agents_path(&self, opts: &InstallOpts) -> Option { + if opts.global { + let cfg = dirs::config_dir()?; + Utf8PathBuf::from_path_buf(cfg.join("opencode").join("AGENTS.md")).ok() + } else { + opts.project_root.as_ref().map(|r| r.join("AGENTS.md")) + } + } + + fn parse_jsonc(&self, text: &str) -> Result { + if text.trim().is_empty() { + return Ok(Value::Object(Default::default())); + } + let stripped: String = strip_jsonc_comments(text); + Ok(serde_json::from_str(&stripped)?) + } +} + +fn strip_jsonc_comments(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let bytes = text.as_bytes(); + let mut i = 0; + let mut in_str = false; + let mut escape = false; + while i < bytes.len() { + let c = bytes[i]; + if in_str { + out.push(c as char); + if escape { + escape = false; + } else if c == b'\\' { + escape = true; + } else if c == b'"' { + in_str = false; + } + i += 1; + continue; + } + if c == b'"' { + in_str = true; + out.push('"'); + i += 1; + continue; + } + if c == b'/' && i + 1 < bytes.len() { + if bytes[i + 1] == b'/' { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + continue; + } + if bytes[i + 1] == b'*' { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(bytes.len()); + continue; + } + } + out.push(c as char); + i += 1; + } + out +} + +impl AgentTarget for OpencodeTarget { + fn id(&self) -> &'static str { + "opencode" + } + fn label(&self) -> &'static str { + "opencode" + } + + fn detect(&self, opts: &InstallOpts) -> DetectStatus { + let installed = which::which("opencode").is_ok() + || opts + .home_dir() + .map(|h| h.join(".config").join("opencode").exists()) + .unwrap_or(false) + || dirs::config_dir() + .map(|d| d.join("opencode").exists()) + .unwrap_or(false); + if !installed { + return DetectStatus::NotFound; + } + let Some(p) = self.config_path(opts) else { + return DetectStatus::Found; + }; + if !p.exists() { + return DetectStatus::Found; + } + let Ok(text) = std::fs::read_to_string(p.as_std_path()) else { + return DetectStatus::Found; + }; + let Ok(v) = self.parse_jsonc(&text) else { + return DetectStatus::Found; + }; + if v.pointer("/mcp/codegraph").is_some() { + DetectStatus::AlreadyConfigured + } else { + DetectStatus::Found + } + } + + fn install(&self, opts: &InstallOpts) -> Result { + let config = self + .config_path(opts) + .ok_or_else(|| anyhow::anyhow!("no opencode config path"))?; + let text = std::fs::read_to_string(config.as_std_path()).unwrap_or_default(); + let mut v = self.parse_jsonc(&text)?; + + let mut cmd: Vec = vec![ + Value::String(opts.binary_path.to_string()), + Value::String("serve".into()), + Value::String("--mcp".into()), + ]; + if let Some(root) = &opts.project_root { + cmd.push(Value::String("--path".into())); + cmd.push(Value::String(root.to_string())); + } + let entry = json!({ + "type": "local", + "command": cmd, + "enabled": true, + }); + + let mut changed = false; + { + let obj = v + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("opencode config not an object"))?; + let mcp = obj + .entry("mcp") + .or_insert_with(|| Value::Object(Default::default())); + let mcp = mcp + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("mcp not an object"))?; + if mcp.get("codegraph") != Some(&entry) { + mcp.insert("codegraph".into(), entry); + changed = true; + } + } + + let mut written = Vec::new(); + if changed { + jsonutil::write_pretty(&config, &v)?; + written.push(config); + } + if let Some(md) = self.agents_path(opts) { + let existing = std::fs::read_to_string(md.as_std_path()).ok(); + if existing.as_deref() != Some(INSTRUCTIONS_MD) { + if let Some(parent) = md.parent() { + std::fs::create_dir_all(parent.as_std_path())?; + } + std::fs::write(md.as_std_path(), INSTRUCTIONS_MD)?; + written.push(md); + } + } + if written.is_empty() { + Ok(InstallReport::Unchanged) + } else { + Ok(InstallReport::Installed(written)) + } + } + + fn uninstall(&self, opts: &InstallOpts) -> Result { + let Some(config) = self.config_path(opts) else { + return Ok(InstallReport::Unchanged); + }; + if !config.exists() { + return Ok(InstallReport::Unchanged); + } + let text = std::fs::read_to_string(config.as_std_path())?; + let mut v = self.parse_jsonc(&text)?; + let mut changed = false; + if let Some(mcp) = v.pointer_mut("/mcp").and_then(|m| m.as_object_mut()) { + if mcp.remove("codegraph").is_some() { + changed = true; + } + } + if changed { + jsonutil::write_pretty(&config, &v)?; + Ok(InstallReport::Updated(vec![config])) + } else { + Ok(InstallReport::Unchanged) + } + } +} diff --git a/crates/codegraph-installer/tests/install.rs b/crates/codegraph-installer/tests/install.rs new file mode 100644 index 00000000..fe766136 --- /dev/null +++ b/crates/codegraph-installer/tests/install.rs @@ -0,0 +1,99 @@ +use camino::Utf8PathBuf; +use codegraph_installer::{project_registry as registry, DetectStatus, InstallOpts, InstallReport}; + +fn opts(root: &Utf8PathBuf) -> InstallOpts { + // Create presence markers for every agent so detect() returns Found/AlreadyConfigured + // instead of NotFound in the isolated temp dir. + for marker in &[ + ".claude", + ".cursor", + ".hermes", + ".gemini/antigravity-cli", + ".config/opencode", + ] { + std::fs::create_dir_all(root.join(marker).as_std_path()).unwrap(); + } + InstallOpts { + project_root: Some(root.clone()), + global: false, + binary_path: Utf8PathBuf::from("/usr/local/bin/codegraph"), + home_dir: Some(root.clone()), + } +} + +#[test] +fn install_idempotent() { + let d = tempfile::tempdir().unwrap(); + let root = Utf8PathBuf::from_path_buf(d.path().to_path_buf()).unwrap(); + let o = opts(&root); + for target in registry() { + assert_eq!(target.detect(&o), DetectStatus::Found); + let r1 = target.install(&o).unwrap(); + assert!( + matches!(r1, InstallReport::Installed(_)), + "{} first install: {:?}", + target.id(), + r1 + ); + assert_eq!(target.detect(&o), DetectStatus::AlreadyConfigured); + let r2 = target.install(&o).unwrap(); + assert!( + matches!(r2, InstallReport::Unchanged), + "{} re-install: {:?}", + target.id(), + r2 + ); + } +} + +#[test] +fn uninstall_removes_mcp_entry() { + let d = tempfile::tempdir().unwrap(); + let root = Utf8PathBuf::from_path_buf(d.path().to_path_buf()).unwrap(); + let o = opts(&root); + for target in registry() { + target.install(&o).unwrap(); + let r = target.uninstall(&o).unwrap(); + assert!( + matches!(r, InstallReport::Updated(_)), + "{} uninstall: {:?}", + target.id(), + r + ); + assert_eq!( + target.detect(&o), + DetectStatus::Found, + "{} should remain installed-but-not-configured", + target.id() + ); + } +} + +#[test] +fn sibling_keys_preserved() { + let d = tempfile::tempdir().unwrap(); + let root = Utf8PathBuf::from_path_buf(d.path().to_path_buf()).unwrap(); + let claude_settings = root.join(".claude").join("settings.local.json"); + std::fs::create_dir_all(claude_settings.parent().unwrap().as_std_path()).unwrap(); + std::fs::write( + claude_settings.as_std_path(), + r#"{"mcpServers":{"other":{"command":"foo"}},"theme":"dark"}"#, + ) + .unwrap(); + let o = opts(&root); + let claude = registry().into_iter().find(|t| t.id() == "claude").unwrap(); + claude.install(&o).unwrap(); + let v: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(claude_settings.as_std_path()).unwrap()) + .unwrap(); + assert!( + v.pointer("/mcpServers/other").is_some(), + "sibling MCP entry must survive" + ); + assert_eq!( + v.pointer("/theme").and_then(|v| v.as_str()), + Some("dark"), + "sibling field must survive" + ); + assert!(v.pointer("/mcpServers/codegraph").is_some()); +} diff --git a/crates/codegraph-mcp/Cargo.toml b/crates/codegraph-mcp/Cargo.toml new file mode 100644 index 00000000..f072c6e5 --- /dev/null +++ b/crates/codegraph-mcp/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "codegraph-mcp" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +codegraph-core = { path = "../codegraph-core" } +codegraph-db = { path = "../codegraph-db" } +codegraph-graph = { path = "../codegraph-graph" } +codegraph-context = { path = "../codegraph-context" } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +anyhow = { workspace = true } +camino = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/codegraph-mcp/src/lib.rs b/crates/codegraph-mcp/src/lib.rs new file mode 100644 index 00000000..67ac8a0b --- /dev/null +++ b/crates/codegraph-mcp/src/lib.rs @@ -0,0 +1,116 @@ +//! MCP server (stdio JSON-RPC 2.0). Hand-rolled, no SDK. + +mod protocol; +mod tools; + +pub use protocol::{ErrorObj, JsonRpcMessage, Response}; +pub use tools::tool_definitions; + +use codegraph_db::Db; +use codegraph_graph::Traversal; +use serde_json::{json, Value}; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +pub const SERVER_INSTRUCTIONS: &str = include_str!("server-instructions.md"); +pub const PROTOCOL_VERSION: &str = "2024-11-05"; +pub const SERVER_NAME: &str = "codegraph"; +pub const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub struct McpServer { + db: Arc, +} + +impl McpServer { + pub fn new(db: Arc) -> Self { + Self { db } + } + + pub async fn run_stdio(self) -> anyhow::Result<()> { + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin); + let mut stdout = tokio::io::stdout(); + let mut line = String::new(); + + loop { + line.clear(); + let n = reader.read_line(&mut line).await?; + if n == 0 { + break; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let msg: JsonRpcMessage = match serde_json::from_str(trimmed) { + Ok(m) => m, + Err(e) => { + write_response( + &mut stdout, + Response::error(Value::Null, -32700, &format!("parse error: {e}")), + ) + .await?; + continue; + } + }; + if msg.id.is_none() { + // notification — no response + continue; + } + let id = msg.id.clone().unwrap_or(Value::Null); + let resp = self.dispatch(msg).await; + let final_resp = match resp { + Ok(v) => Response::ok(id, v), + Err(e) => Response::error(id, -32603, &e.to_string()), + }; + write_response(&mut stdout, final_resp).await?; + } + Ok(()) + } + + async fn dispatch(&self, msg: JsonRpcMessage) -> anyhow::Result { + match msg.method.as_deref() { + Some("initialize") => Ok(json!({ + "protocolVersion": PROTOCOL_VERSION, + "capabilities": { "tools": {} }, + "serverInfo": { "name": SERVER_NAME, "version": SERVER_VERSION }, + "instructions": SERVER_INSTRUCTIONS, + })), + Some("ping") => Ok(json!({})), + Some("tools/list") => Ok(json!({ "tools": tool_definitions() })), + Some("tools/call") => { + self.handle_tool_call(msg.params.unwrap_or(Value::Null)) + .await + } + Some(m) => Err(anyhow::anyhow!("method not found: {m}")), + None => Err(anyhow::anyhow!("missing method")), + } + } + + async fn handle_tool_call(&self, params: Value) -> anyhow::Result { + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let args = params.get("arguments").cloned().unwrap_or(Value::Null); + let text = tools::dispatch(&self.db, name, args)?; + Ok(json!({ + "content": [{ "type": "text", "text": text }], + "isError": false, + })) + } +} + +async fn write_response( + w: &mut W, + r: Response, +) -> anyhow::Result<()> { + let s = serde_json::to_string(&r)?; + w.write_all(s.as_bytes()).await?; + w.write_all(b"\n").await?; + w.flush().await?; + Ok(()) +} + +// Re-export for binary use without exposing Traversal lifetime annoyances. +pub fn traversal_for(db: &Db) -> Traversal<'_> { + Traversal::new(db) +} diff --git a/crates/codegraph-mcp/src/protocol.rs b/crates/codegraph-mcp/src/protocol.rs new file mode 100644 index 00000000..7a36a8b1 --- /dev/null +++ b/crates/codegraph-mcp/src/protocol.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Deserialize)] +pub struct JsonRpcMessage { + pub jsonrpc: Option, + pub id: Option, + pub method: Option, + pub params: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Response { + pub jsonrpc: &'static str, + pub id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ErrorObj { + pub code: i32, + pub message: String, +} + +impl Response { + pub fn ok(id: Value, result: Value) -> Self { + Self { + jsonrpc: "2.0", + id, + result: Some(result), + error: None, + } + } + pub fn error(id: Value, code: i32, message: &str) -> Self { + Self { + jsonrpc: "2.0", + id, + result: None, + error: Some(ErrorObj { + code, + message: message.into(), + }), + } + } +} diff --git a/crates/codegraph-mcp/src/server-instructions.md b/crates/codegraph-mcp/src/server-instructions.md new file mode 100644 index 00000000..9465b6ad --- /dev/null +++ b/crates/codegraph-mcp/src/server-instructions.md @@ -0,0 +1,31 @@ +# Codegraph — code intelligence over an indexed knowledge graph + +Codegraph is a SQLite knowledge graph of every symbol, edge, and file in the +workspace. Reads are sub-millisecond. Consult it BEFORE writing or editing +code, not during. + +## Answer directly — don't delegate exploration + +For "how does X work", architecture, trace, or where-is-X questions, answer +DIRECTLY using 2-3 codegraph calls: `codegraph_context` first, then drill +down with `codegraph_node` or `codegraph_callers`/`codegraph_callees`. +Codegraph IS the pre-built search index — delegating the lookup to a separate +file-reading sub-task repeats work codegraph already did. + +## Tool selection by intent + +| Intent | Tool | +|---|---| +| "What is the symbol named X?" | `codegraph_search` | +| "What's the deal with this task / area?" | `codegraph_context` (primary) | +| "What calls this?" | `codegraph_callers` | +| "What does this call?" | `codegraph_callees` | +| "What would changing this break?" | `codegraph_impact` | +| "Show me this symbol's source / signature." | `codegraph_node` | +| "What's in directory X?" | `codegraph_files` | +| "Is the index ready / what's its size?" | `codegraph_status` | + +## Trust the results + +Codegraph returns AST-derived structural data. Do NOT re-verify with grep — +that's slower, less accurate, and wastes context. diff --git a/crates/codegraph-mcp/src/tools.rs b/crates/codegraph-mcp/src/tools.rs new file mode 100644 index 00000000..89f2814c --- /dev/null +++ b/crates/codegraph-mcp/src/tools.rs @@ -0,0 +1,156 @@ +use codegraph_context::{build, ContextRequest, Format}; +use codegraph_db::Db; +use codegraph_graph::{ReferencesReport, Traversal}; +use serde_json::{json, Value}; + +pub fn tool_definitions() -> Vec { + vec![ + tool( + "codegraph_search", + "Search the knowledge graph by name / signature / docstring (FTS5).", + json!({ "type": "object", "properties": { + "query": { "type": "string" }, + "limit": { "type": "integer", "default": 20 } + }, "required": ["query"] }), + ), + tool( + "codegraph_node", + "Look up a node by id or exact name.", + json!({ "type": "object", "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" } + } }), + ), + tool( + "codegraph_callers", + "Find functions that call the given node.", + json!({ "type": "object", "properties": { + "node": { "type": "integer" }, + "depth": { "type": "integer", "default": 1 } + }, "required": ["node"] }), + ), + tool( + "codegraph_callees", + "Find functions called by the given node.", + json!({ "type": "object", "properties": { + "node": { "type": "integer" }, + "depth": { "type": "integer", "default": 1 } + }, "required": ["node"] }), + ), + tool( + "codegraph_impact", + "Impact radius: who transitively depends on this node.", + json!({ "type": "object", "properties": { + "node": { "type": "integer" }, + "max_depth": { "type": "integer", "default": 3 } + }, "required": ["node"] }), + ), + tool( + "codegraph_context", + "Composed context for a symbol or topic (search + callers + callees).", + json!({ "type": "object", "properties": { + "query": { "type": "string" }, + "depth": { "type": "integer", "default": 1 }, + "include_source": { "type": "boolean", "default": false }, + "limit": { "type": "integer", "default": 5 } + }, "required": ["query"] }), + ), + tool( + "codegraph_references", + "All nodes that reference this node (calls, imports, extends, implements, type_of, instantiates, …), grouped by relationship kind.", + json!({ "type": "object", "properties": { + "node": { "type": "integer", "description": "Node id to find references for" } + }, "required": ["node"] }), + ), + tool( + "codegraph_files", + "List indexed files under a path prefix.", + json!({ "type": "object", "properties": { "path": { "type": "string" } } }), + ), + tool( + "codegraph_status", + "Index health: counts, size, schema version.", + json!({ "type": "object", "properties": {} }), + ), + ] +} + +fn tool(name: &str, desc: &str, schema: Value) -> Value { + json!({ "name": name, "description": desc, "inputSchema": schema }) +} + +pub fn dispatch(db: &Db, name: &str, args: Value) -> anyhow::Result { + match name { + "codegraph_search" => { + let q = arg_str(&args, "query")?; + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as u32; + let hits = db.search_nodes(q, limit)?; + Ok(serde_json::to_string_pretty(&hits)?) + } + "codegraph_node" => { + if let Some(id) = args.get("id").and_then(|v| v.as_i64()) { + let n = db.node_by_id(id)?; + return Ok(serde_json::to_string_pretty(&n)?); + } + if let Some(name) = args.get("name").and_then(|v| v.as_str()) { + let n = db.nodes_by_name(name)?; + return Ok(serde_json::to_string_pretty(&n)?); + } + Err(anyhow::anyhow!("provide id or name")) + } + "codegraph_callers" => { + let id = arg_i64(&args, "node")?; + let depth = args.get("depth").and_then(|v| v.as_u64()).unwrap_or(1) as u32; + let t = Traversal::new(db); + Ok(serde_json::to_string_pretty(&t.callers(id, depth)?)?) + } + "codegraph_callees" => { + let id = arg_i64(&args, "node")?; + let depth = args.get("depth").and_then(|v| v.as_u64()).unwrap_or(1) as u32; + let t = Traversal::new(db); + Ok(serde_json::to_string_pretty(&t.callees(id, depth)?)?) + } + "codegraph_impact" => { + let id = arg_i64(&args, "node")?; + let depth = args.get("max_depth").and_then(|v| v.as_u64()).unwrap_or(3) as u32; + let t = Traversal::new(db); + Ok(serde_json::to_string_pretty(&t.impact_radius(id, depth)?)?) + } + "codegraph_context" => { + let req = ContextRequest { + query: arg_str(&args, "query")?.to_string(), + depth: args.get("depth").and_then(|v| v.as_u64()).unwrap_or(1) as u32, + include_source: args + .get("include_source") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + limit: args.get("limit").and_then(|v| v.as_u64()).unwrap_or(5) as u32, + format: Format::Markdown, + }; + Ok(build(db, &req)?) + } + "codegraph_references" => { + let id = arg_i64(&args, "node")?; + let t = Traversal::new(db); + let report: ReferencesReport = t.references(id)?; + Ok(serde_json::to_string_pretty(&report)?) + } + "codegraph_files" => { + let prefix = args.get("path").and_then(|v| v.as_str()).unwrap_or(""); + Ok(serde_json::to_string_pretty(&db.files_under(prefix)?)?) + } + "codegraph_status" => Ok(serde_json::to_string_pretty(&db.stats()?)?), + _ => Err(anyhow::anyhow!("unknown tool: {name}")), + } +} + +fn arg_str<'a>(v: &'a Value, k: &str) -> anyhow::Result<&'a str> { + v.get(k) + .and_then(|x| x.as_str()) + .ok_or_else(|| anyhow::anyhow!("missing string arg: {k}")) +} +fn arg_i64(v: &Value, k: &str) -> anyhow::Result { + v.get(k) + .and_then(|x| x.as_i64()) + .ok_or_else(|| anyhow::anyhow!("missing int arg: {k}")) +} diff --git a/crates/codegraph-resolve/Cargo.toml b/crates/codegraph-resolve/Cargo.toml new file mode 100644 index 00000000..e8d74574 --- /dev/null +++ b/crates/codegraph-resolve/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "codegraph-resolve" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +codegraph-core = { path = "../codegraph-core" } +codegraph-db = { path = "../codegraph-db" } +serde = { workspace = true } +serde_json = { workspace = true } +camino = { workspace = true } +globset = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/codegraph-resolve/src/frameworks.rs b/crates/codegraph-resolve/src/frameworks.rs new file mode 100644 index 00000000..4e215ff9 --- /dev/null +++ b/crates/codegraph-resolve/src/frameworks.rs @@ -0,0 +1 @@ +// TODO: trait FrameworkResolver { fn detect(...) -> bool; fn resolve(...); } diff --git a/crates/codegraph-resolve/src/imports.rs b/crates/codegraph-resolve/src/imports.rs new file mode 100644 index 00000000..29699504 --- /dev/null +++ b/crates/codegraph-resolve/src/imports.rs @@ -0,0 +1 @@ +// TODO: import path resolution with tsconfig path aliases + cargo workspace globs. diff --git a/crates/codegraph-resolve/src/lib.rs b/crates/codegraph-resolve/src/lib.rs new file mode 100644 index 00000000..0ec1f3f2 --- /dev/null +++ b/crates/codegraph-resolve/src/lib.rs @@ -0,0 +1,119 @@ +//! Reference resolution: name-match pending calls into actual `calls` edges. +//! +//! Strategy: for each PendingCall { from, target_name, line }, look up nodes +//! named `target_name` of kind function|method, then pick the closest match +//! by proximity score (same file > same directory > anywhere). + +pub mod frameworks; +pub mod imports; +pub mod name_match; + +use codegraph_core::{EdgeKind, Node, NodeId, Result}; +use codegraph_db::{Db, EdgeDraft}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Input from an extractor pass: ready-to-resolve call sites. +#[derive(Debug, Clone)] +pub struct PendingCallRow { + pub from_id: NodeId, + pub target_name: String, + pub file_id: i64, + pub line: u32, +} + +pub struct Resolver<'a> { + db: &'a Db, +} + +impl<'a> Resolver<'a> { + pub fn new(db: &'a Db) -> Self { + Self { db } + } + + pub fn resolve_calls(&self, pending: &[PendingCallRow]) -> Result { + if pending.is_empty() { + return Ok(0); + } + + let mut file_cache: HashMap> = HashMap::new(); + for p in pending { + file_cache.entry(p.file_id).or_insert_with(|| { + self.db.file_by_id(p.file_id).ok().flatten().and_then(|f| { + let dir = std::path::Path::new(f.path.as_str()) + .parent() + .map(|d| d.to_path_buf())?; + Some((f.path.to_string(), dir)) + }) + }); + } + + let mut by_name: HashMap<&str, Vec<&PendingCallRow>> = HashMap::new(); + for p in pending { + by_name.entry(p.target_name.as_str()).or_default().push(p); + } + + let mut edges: Vec = Vec::new(); + for (name, sites) in by_name { + let candidates = self.db.nodes_by_name(name)?; + if candidates.is_empty() { + continue; + } + let callable: Vec<_> = candidates + .into_iter() + .filter(|n| { + matches!( + n.kind, + codegraph_core::NodeKind::Function | codegraph_core::NodeKind::Method + ) + }) + .collect(); + if callable.is_empty() { + continue; + } + + for site in sites { + let caller_info = file_cache.get(&site.file_id).and_then(|v| v.as_ref()); + let best_score = callable + .iter() + .map(|n| proximity_score(n, caller_info)) + .max() + .unwrap_or(1); + let targets: Vec<_> = callable + .iter() + .filter(|n| proximity_score(n, caller_info) == best_score) + .collect(); + for t in targets { + edges.push(EdgeDraft { + from_id: site.from_id, + to_id: t.id, + kind: EdgeKind::Calls, + file_id: Some(site.file_id), + line: Some(site.line), + source: Some("resolver:name-match".into()), + }); + } + } + } + let n = edges.len(); + self.db.insert_edges(&edges)?; + Ok(n) + } +} + +/// Score a candidate node by proximity to the caller. +/// 3 = same file, 2 = same directory, 1 = elsewhere. +fn proximity_score(candidate: &Node, caller_info: Option<&(String, PathBuf)>) -> u8 { + let Some((caller_path, caller_dir)) = caller_info else { + return 1; + }; + if candidate.file.as_str() == caller_path.as_str() { + return 3; + } + let candidate_dir = std::path::Path::new(candidate.file.as_str()).parent(); + if candidate_dir == Some(caller_dir.as_path()) { + 2 + } else { + 1 + } +} diff --git a/crates/codegraph-resolve/src/name_match.rs b/crates/codegraph-resolve/src/name_match.rs new file mode 100644 index 00000000..76e0806d --- /dev/null +++ b/crates/codegraph-resolve/src/name_match.rs @@ -0,0 +1 @@ +// TODO: fuzzy + exact name matching against the symbol table. diff --git a/crates/codegraph/Cargo.toml b/crates/codegraph/Cargo.toml new file mode 100644 index 00000000..f5a91265 --- /dev/null +++ b/crates/codegraph/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "codegraph" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Local-first code intelligence: tree-sitter knowledge graph + MCP server." + +[[bin]] +name = "codegraph" +path = "src/main.rs" + +[dependencies] +codegraph-core = { path = "../codegraph-core" } +codegraph-db = { path = "../codegraph-db" } +codegraph-extract = { path = "../codegraph-extract" } +codegraph-resolve = { path = "../codegraph-resolve" } +codegraph-graph = { path = "../codegraph-graph" } +codegraph-context = { path = "../codegraph-context" } +codegraph-mcp = { path = "../codegraph-mcp" } +codegraph-installer = { path = "../codegraph-installer" } +dirs = { workspace = true } +clap = { workspace = true } +tokio = { workspace = true } +notify = { workspace = true } +notify-debouncer-full = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } +camino = { workspace = true } +dialoguer = { workspace = true } +console = "0.15" diff --git a/crates/codegraph/src/main.rs b/crates/codegraph/src/main.rs new file mode 100644 index 00000000..187b9507 --- /dev/null +++ b/crates/codegraph/src/main.rs @@ -0,0 +1,322 @@ +use anyhow::{anyhow, Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use clap::{Parser, Subcommand}; +use codegraph_db::Db; +use codegraph_extract::Orchestrator; +use codegraph_mcp::McpServer; +use std::sync::Arc; + +mod watcher; + +const CODEGRAPH_DIR: &str = ".codegraph"; +const DB_FILE: &str = "db.sqlite"; + +#[derive(Parser, Debug)] +#[command(name = "codegraph", version, about = "Local-first code intelligence")] +struct Cli { + /// Workspace root (default: current dir). + #[arg(long, global = true)] + path: Option, + + #[command(subcommand)] + cmd: Option, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Initialize .codegraph/ in the current directory and index immediately. + /// Pass --no-index to skip indexing. + Init { + #[arg(long)] + no_index: bool, + }, + /// Remove the .codegraph/ directory. + Uninit, + /// Full re-index. + Index, + /// Incremental sync of changed files. + Sync, + /// Show index health. + Status, + /// Search nodes (FTS). + Query { + query: String, + #[arg(long, default_value_t = 20)] + limit: u32, + }, + /// List indexed files under a path prefix. + Files { path: Option }, + /// Build markdown context for a symbol. + Context { + target: String, + #[arg(long, default_value_t = 1)] + depth: u32, + #[arg(long)] + source: bool, + }, + /// Run as MCP server over stdio. + Serve { + #[arg(long)] + mcp: bool, + }, + /// Configure agents (alias for the agent setup step in `init`). + Install, +} + +fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("codegraph=info")), + ) + .with_writer(std::io::stderr) + .init(); + + let cli = Cli::parse(); + let root = match &cli.path { + Some(p) => p.clone(), + None => Utf8PathBuf::from_path_buf(std::env::current_dir()?) + .map_err(|p| anyhow!("non-UTF8 cwd: {}", p.display()))?, + }; + + let cmd = cli + .cmd + .ok_or_else(|| anyhow!("no subcommand. Try `codegraph init`"))?; + match cmd { + Cmd::Init { no_index } => cmd_init(&root, !no_index), + Cmd::Uninit => cmd_uninit(&root), + Cmd::Index => cmd_index(&root), + Cmd::Sync => cmd_sync(&root), + Cmd::Status => cmd_status(&root), + Cmd::Query { query, limit } => cmd_query(&root, &query, limit), + Cmd::Files { path } => cmd_files(&root, path.as_deref()), + Cmd::Context { + target, + depth, + source, + } => cmd_context(&root, &target, depth, source), + Cmd::Serve { mcp } => cmd_serve(&root, mcp), + Cmd::Install => cmd_agents(&root), + } +} + +fn db_path(root: &Utf8Path) -> Utf8PathBuf { + root.join(CODEGRAPH_DIR).join(DB_FILE) +} + +fn ensure_initialized(root: &Utf8Path) -> Result<()> { + if !db_path(root).exists() { + return Err(anyhow!("not initialized: run `codegraph init` in {}", root)); + } + Ok(()) +} + +fn cmd_init(root: &Utf8Path, do_index: bool) -> Result<()> { + let dir = root.join(CODEGRAPH_DIR); + std::fs::create_dir_all(&dir)?; + std::fs::write(dir.join(".gitignore"), "*\n")?; + std::fs::write(dir.join("version"), env!("CARGO_PKG_VERSION"))?; + let db = Db::open(&db_path(root))?; + eprintln!("initialized {}", dir); + + if do_index { + let stats = Orchestrator::with_registry().index_all(root, &db)?; + eprintln!( + "indexed {} files, {} nodes, {} edges", + stats.files, stats.nodes, stats.edges + ); + } + + eprintln!(); + cmd_agents(root) +} + +fn cmd_agents(root: &Utf8Path) -> Result<()> { + use codegraph_installer::{project_registry, DetectStatus, InstallOpts, InstallReport}; + use console::style; + use dialoguer::{theme::ColorfulTheme, MultiSelect}; + + let bin = std::env::current_exe()?; + let bin = Utf8PathBuf::from_path_buf(bin) + .map_err(|p| anyhow!("non-UTF8 bin path: {}", p.display()))?; + let opts = InstallOpts { + project_root: Some(root.to_path_buf()), + global: false, + binary_path: bin, + home_dir: None, + }; + + let all_targets = project_registry(); + let statuses: Vec = all_targets.iter().map(|t| t.detect(&opts)).collect(); + + let found_indices: Vec = statuses + .iter() + .enumerate() + .filter(|(_, s)| matches!(s, DetectStatus::Found)) + .map(|(i, _)| i) + .collect(); + + let already_indices: Vec = statuses + .iter() + .enumerate() + .filter(|(_, s)| matches!(s, DetectStatus::AlreadyConfigured)) + .map(|(i, _)| i) + .collect(); + + let not_found_indices: Vec = statuses + .iter() + .enumerate() + .filter(|(_, s)| matches!(s, DetectStatus::NotFound)) + .map(|(i, _)| i) + .collect(); + + if !already_indices.is_empty() { + eprintln!("{}", style("Already configured:").blue()); + for i in &already_indices { + eprintln!(" {}", style(all_targets[*i].label()).blue()); + } + eprintln!(); + } + + if !not_found_indices.is_empty() { + eprintln!("{}", style("Not detected:").dim()); + for i in ¬_found_indices { + eprintln!(" {}", style(all_targets[*i].label()).dim()); + } + eprintln!(); + } + + if found_indices.is_empty() { + return Ok(()); + } + + let labels: Vec = found_indices + .iter() + .map(|&i| all_targets[i].label().to_string()) + .collect(); + + let chosen = MultiSelect::with_theme(&ColorfulTheme::default()) + .with_prompt("Select agents to configure (space = toggle, enter = confirm)") + .items(&labels) + .defaults(&vec![false; found_indices.len()]) + .interact()?; + + if chosen.is_empty() { + return Ok(()); + } + + eprintln!(); + for pos in chosen { + let target = &all_targets[found_indices[pos]]; + let report = target.install(&opts)?; + match report { + InstallReport::Installed(p) | InstallReport::Updated(p) => { + for f in &p { + eprintln!("[{}] wrote {}", target.id(), f); + } + } + InstallReport::Unchanged => eprintln!("[{}] unchanged", target.id()), + InstallReport::Skipped(r) => eprintln!("[{}] skipped: {}", target.id(), r), + } + } + Ok(()) +} + +fn cmd_uninit(root: &Utf8Path) -> Result<()> { + let dir = root.join(CODEGRAPH_DIR); + if dir.exists() { + std::fs::remove_dir_all(&dir)?; + eprintln!("removed {}", dir); + } + Ok(()) +} + +fn cmd_index(root: &Utf8Path) -> Result<()> { + ensure_initialized(root)?; + let db = Db::open(&db_path(root))?; + let stats = Orchestrator::with_registry().index_all(root, &db)?; + eprintln!( + "indexed {} files, {} nodes, {} edges (skipped {})", + stats.files, stats.nodes, stats.edges, stats.skipped + ); + Ok(()) +} + +fn cmd_sync(root: &Utf8Path) -> Result<()> { + ensure_initialized(root)?; + let db = Db::open(&db_path(root))?; + let stats = Orchestrator::with_registry().sync(root, &db)?; + eprintln!( + "synced {} files (skipped {}), nodes={} edges={}", + stats.files, stats.skipped, stats.nodes, stats.edges + ); + Ok(()) +} + +fn cmd_status(root: &Utf8Path) -> Result<()> { + ensure_initialized(root)?; + let db = Db::open(&db_path(root))?; + let s = db.stats()?; + println!("schema: v{}", s.schema_version); + println!("files: {}", s.files); + println!("nodes: {}", s.nodes); + println!("edges: {}", s.edges); + println!("size: {} bytes", s.size_bytes); + Ok(()) +} + +fn cmd_query(root: &Utf8Path, q: &str, limit: u32) -> Result<()> { + ensure_initialized(root)?; + let db = Db::open(&db_path(root))?; + let hits = db.search_nodes(q, limit)?; + for h in hits { + println!( + "[{}] {} {} {}:{}", + h.id, + h.kind.as_str(), + h.name, + h.file, + h.start_line + ); + } + Ok(()) +} + +fn cmd_files(root: &Utf8Path, prefix: Option<&str>) -> Result<()> { + ensure_initialized(root)?; + let db = Db::open(&db_path(root))?; + for f in db.files_under(prefix.unwrap_or(""))? { + println!("{} ({})", f.path, f.language); + } + Ok(()) +} + +fn cmd_context(root: &Utf8Path, target: &str, depth: u32, include_source: bool) -> Result<()> { + ensure_initialized(root)?; + let db = Db::open(&db_path(root))?; + let req = codegraph_context::ContextRequest { + query: target.into(), + depth, + include_source, + limit: 5, + format: codegraph_context::Format::Markdown, + }; + print!("{}", codegraph_context::build(&db, &req)?); + Ok(()) +} + +fn cmd_serve(root: &Utf8Path, mcp: bool) -> Result<()> { + if !mcp { + return Err(anyhow!("only --mcp transport supported")); + } + ensure_initialized(root).context("init the index before serving")?; + let db = Arc::new(Db::open(&db_path(root))?); + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + rt.block_on(async { + watcher::spawn(root.to_path_buf(), db.clone()); + McpServer::new(db).run_stdio().await + })?; + Ok(()) +} diff --git a/crates/codegraph/src/watcher.rs b/crates/codegraph/src/watcher.rs new file mode 100644 index 00000000..90b524bf --- /dev/null +++ b/crates/codegraph/src/watcher.rs @@ -0,0 +1,44 @@ +use anyhow::Result; +use camino::Utf8PathBuf; +use codegraph_db::Db; +use codegraph_extract::Orchestrator; +use notify::RecursiveMode; +use notify_debouncer_full::{new_debouncer, DebouncedEvent}; +use std::sync::Arc; +use std::time::Duration; + +/// Spawn a debounced watcher that re-syncs the workspace on file changes. +/// Runs on a background tokio task; cancellation when the runtime drops. +pub fn spawn(root: Utf8PathBuf, db: Arc) { + tokio::task::spawn_blocking(move || { + if let Err(e) = run(root, db) { + tracing::error!("watcher error: {e}"); + } + }); +} + +fn run(root: Utf8PathBuf, db: Arc) -> Result<()> { + let (tx, rx) = std::sync::mpsc::channel::>(); + let mut debouncer = new_debouncer( + Duration::from_millis(500), + None, + move |res: notify_debouncer_full::DebounceEventResult| { + if let Ok(events) = res { + let _ = tx.send(events); + } + }, + )?; + debouncer.watch(root.as_std_path(), RecursiveMode::Recursive)?; + + let orch = Orchestrator::with_registry(); + while let Ok(_events) = rx.recv() { + match orch.sync(&root, &db) { + Ok(s) if s.files > 0 => { + tracing::info!("watch sync: {} files, {} edges", s.files, s.edges) + } + Ok(_) => {} + Err(e) => tracing::warn!("sync failed: {e}"), + } + } + Ok(()) +} diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 00000000..10318aea --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,71 @@ +# CodeGraph — Rust Rewrite Plan + +Port intégral du projet TS (`archive/`) vers Rust natif. Objectif: binaire `<15MB` stripped (vs 140MB Node bundle), parse 2-5× plus rapide, zero runtime dep. + +## Non-objectifs + +- Pas de compatibilité DB avec `archive/.codegraph/`. Schema repart neuf. +- Pas de wrapper npm. Distribution = `cargo install` + binaires GitHub Releases. +- Pas de port 1:1 du code TS. On reproduit le comportement observable (NodeKind, EdgeKind, surface MCP, CLI), pas la structure interne. + +## Architecture cible + +``` +crates/ + codegraph-core/ types + erreurs (NodeKind, EdgeKind, Node, Edge) + codegraph-db/ rusqlite + schema + prepared stmts + FTS5 + codegraph-extract/ tree-sitter natif + extractors par langage + codegraph-resolve/ imports, name-match, frameworks + codegraph-graph/ traversal (callers/callees/impact) + codegraph-context/ builder markdown/json + codegraph-mcp/ stdio JSON-RPC 2.0 hand-rolled + codegraph-installer/ 5 cibles agents (claude/cursor/codex/opencode/hermes) + codegraph/ binaire CLI (clap) + watcher (notify) +``` + +Pipeline runtime: +``` +files → ignore-walker → parse-workers (rayon, tree-sitter) → batch DB tx + ↓ + ReferenceResolver (imports + frameworks) + ↓ + GraphTraverser ← ContextBuilder + ↓ + MCP server / CLI commands +``` + +## Ordre d'implémentation + +| # | Étape | Spec | Dépend de | +|---|---|---|---| +| 1 | Bootstrap workspace | [01-bootstrap.md](specs/01-bootstrap.md) | — | +| 2 | Core types | [02-core-types.md](specs/02-core-types.md) | 1 | +| 3 | DB layer | [03-db-layer.md](specs/03-db-layer.md) | 2 | +| 4 | Extraction + langages | [04-extraction.md](specs/04-extraction.md) | 3 | +| 5 | Résolution + frameworks | [05-resolution.md](specs/05-resolution.md) | 4 | +| 6 | Graph + context | [06-graph-context.md](specs/06-graph-context.md) | 3 | +| 7 | MCP server | [07-mcp-server.md](specs/07-mcp-server.md) | 6 | +| 8 | Installer | [08-installer.md](specs/08-installer.md) | 1 | +| 9 | CLI + watcher | [09-cli-watcher.md](specs/09-cli-watcher.md) | 4,5,6,7,8 | +| 10 | Release pipeline | [10-release.md](specs/10-release.md) | 9 | + +Étapes 1-2 done. Étape 6 peut paralléliser avec 4-5 (utilise seulement DB read). +Étape 8 indépendante du reste (pure file ops). + +## Cibles binaire + +- `cargo build --release`: profil `release` (LTO fat, codegen-units=1, strip, panic=abort) +- Estimation: ~12MB Linux x86_64 stripped avec 15 grammaires tree-sitter statiques + SQLite bundled +- Si dépasse 20MB: profil `release-small` (`opt-level=z`) + features off pour langages exotiques + +## Tests + +- Unit tests in-crate avec `#[cfg(test)]` +- Integration tests dans `crates/*/tests/` +- Fixtures synthétiques par langage dans `tests/fixtures/` +- Pas de DB mock — tempdir + rusqlite réel (cf archive/__tests__) +- Eval harness reporté post-MVP (équivalent `__tests__/evaluation/`) + +## Suivi + +État des tâches dans TaskList runtime. Cette doc + specs sont source de vérité pour le quoi/pourquoi. diff --git a/docs/SEARCH_QUALITY_LOOP.md b/docs/SEARCH_QUALITY_LOOP.md deleted file mode 100644 index 97d57ded..00000000 --- a/docs/SEARCH_QUALITY_LOOP.md +++ /dev/null @@ -1,558 +0,0 @@ -# CodeGraph Language Verification Guide - -You are verifying that CodeGraph fully supports a specific programming language. The user will give you a path to a real-world, popular open-source codebase cloned locally. Your job is to run a battery of realistic prompts against it using CodeGraph's API and verify the results are good enough to say that language is **covered and supported**. - -A language is NOT verified until an LLM can reliably use CodeGraph's MCP tools to navigate that codebase — finding the right symbols, understanding call chains, exploring subsystems, and getting useful context for real tasks. - -## Setup - -### 1. Build and index - -```bash -npm run build -rm -rf /.codegraph -node dist/bin/codegraph.js init -iv -``` - -The `-iv` flag gives verbose output showing extraction progress, node/edge counts, and timing. - -### 2. Quick sanity check - -```bash -# Verify nodes were extracted with proper qualified names -sqlite3 /.codegraph/codegraph.db \ - "SELECT name, kind, qualified_name FROM nodes WHERE kind = 'method' LIMIT 10;" - -# GOOD: file.go::StructName::method_name (owner type present) -# BAD: file.go::file.go::method_name (owner type missing — needs getReceiverType) - -# Check edge counts -sqlite3 /.codegraph/codegraph.db \ - "SELECT kind, COUNT(*) FROM edges GROUP BY kind ORDER BY COUNT(*) DESC;" - -# Check node kind distribution -sqlite3 /.codegraph/codegraph.db \ - "SELECT kind, COUNT(*) FROM nodes GROUP BY kind ORDER BY COUNT(*) DESC;" -``` - -If methods are missing their owner type in `qualified_name`, fix that first (see [Adding getReceiverType](#adding-getreceivertype)) before proceeding with the full test battery. - -## The Test Battery - -Run **all** of the following test categories against the codebase. Use the Node.js API directly — the test scripts below are templates. Adapt the queries to match real types, methods, and subsystems in the codebase you're testing. - -**Pass criteria for each test:** Does the result give an LLM enough correct information to answer the question or complete the task? Would you trust these results if you were the LLM? - ---- - -### Test 1: `codegraph_explore` — Deep Exploration (MOST IMPORTANT) - -This is the primary tool LLMs use. It must return relevant source code grouped by file, with correct relationships, for a natural language query. Test it with **at least 5 different query types**: - -```bash -node -e " -const { CodeGraph } = require('./dist/index.js'); -async function test() { - const cg = await CodeGraph.open(''); - - const queries = [ - // A. Subsystem exploration — broad topic, should find the right files and key classes - 'How does the caching system work?', - - // B. Specific class/type deep dive — should return that class, its methods, and related types - 'CacheBuilder configuration and build process', - - // C. Cross-cutting concern — should find implementations across multiple files - 'How are errors handled and propagated?', - - // D. Data flow question — should trace through multiple layers - 'How does data flow from input to storage?', - - // E. Implementation detail — specific method behavior - 'How does eviction decide which entries to remove?', - ]; - - for (const query of queries) { - console.log(\`\n========================================\`); - console.log(\`QUERY: \${query}\`); - console.log(\`========================================\`); - - const subgraph = await cg.findRelevantContext(query, { - searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2, - }); - - // Show entry points — these are what the LLM sees first - console.log(\`\nEntry points (\${subgraph.roots.length}):\`); - for (const rootId of subgraph.roots.slice(0, 8)) { - const node = subgraph.nodes.get(rootId); - if (node) console.log(\` \${node.name} (\${node.kind}) — \${node.filePath}:\${node.startLine}\`); - } - - // Show file distribution — are the right files surfacing? - const fileGroups = new Map(); - for (const node of subgraph.nodes.values()) { - if (!fileGroups.has(node.filePath)) fileGroups.set(node.filePath, []); - fileGroups.get(node.filePath).push(node.name); - } - console.log(\`\nFiles (\${fileGroups.size}):\`); - for (const [file, nodes] of [...fileGroups.entries()].sort((a,b) => b[1].length - a[1].length).slice(0, 8)) { - console.log(\` \${file} (\${nodes.length} symbols): \${nodes.slice(0, 6).join(', ')}\`); - } - - // Show edge distribution — are relationships being captured? - const edgeKinds = new Map(); - for (const edge of subgraph.edges) { - edgeKinds.set(edge.kind, (edgeKinds.get(edge.kind) || 0) + 1); - } - console.log(\`\nEdges (\${subgraph.edges.length}):\`); - for (const [kind, count] of [...edgeKinds.entries()].sort((a,b) => b - a)) { - console.log(\` \${kind}: \${count}\`); - } - - console.log(\`\nTotal: \${subgraph.nodes.size} nodes, \${subgraph.edges.length} edges, \${fileGroups.size} files\`); - } - - await cg.close(); -} -test().catch(console.error); -" -``` - -**What to check for each query:** -- Do the entry points make sense for the question? -- Are the right files surfacing (not just test files or unrelated code)? -- Is there a mix of edge types (calls, contains, extends, implements) — not just `contains`? -- Does the node count feel right? Too few (<5) means search failed. Too many irrelevant ones means noise. - ---- - -### Test 2: `codegraph_search` — Symbol Lookup - -Test that searching for specific symbols returns the right results ranked correctly. - -```bash -node -e " -const { CodeGraph } = require('./dist/index.js'); -async function test() { - const cg = await CodeGraph.open(''); - - const searches = [ - // A. Class by name - { query: 'CacheBuilder', kinds: ['class'], desc: 'Find a specific class' }, - - // B. Method on a specific type (the classic disambiguation test) - { query: 'CacheBuilder build', kinds: ['method'], desc: 'Method on specific class' }, - - // C. Common method name — should still find relevant ones - { query: 'get', kinds: ['method'], desc: 'Common method name' }, - - // D. Interface/trait - { query: 'Cache', kinds: ['interface'], desc: 'Find an interface' }, - - // E. Enum - { query: 'Strength', kinds: ['enum'], desc: 'Find an enum' }, - ]; - - for (const s of searches) { - console.log(\`\n--- \${s.desc}: \"\${s.query}\" (kinds: \${s.kinds}) ---\`); - const results = cg.searchNodes(s.query, { limit: 10, kinds: s.kinds }); - for (const r of results) { - console.log(\` \${r.score.toFixed(1)} | \${r.node.name} (\${r.node.kind}) | \${r.node.qualifiedName}\`); - } - if (results.length === 0) console.log(' *** NO RESULTS ***'); - } - - await cg.close(); -} -test().catch(console.error); -" -``` - -**What to check:** -- Does the target symbol rank in the top 3? -- For common names like `get`, do the results include qualified names that help disambiguate? -- Are there zero-result queries? That's a bug. - ---- - -### Test 3: `codegraph_callers` / `codegraph_callees` — Call Chain Tracing - -Test that call relationships were extracted correctly. - -```bash -node -e " -const { CodeGraph } = require('./dist/index.js'); -async function test() { - const cg = await CodeGraph.open(''); - - // Pick 3-4 important methods and check their call graphs - const symbols = ['build', 'get', 'put', 'invalidate']; - - for (const sym of symbols) { - // Find the symbol - const results = cg.searchNodes(sym, { limit: 5, kinds: ['method'] }); - if (results.length === 0) { console.log(\`\${sym}: not found\`); continue; } - - const node = results[0].node; - console.log(\`\n--- \${node.name} (\${node.qualifiedName}) ---\`); - - // Check callees (what does it call?) - const callees = cg.getCallees(node.id); - console.log(\` Callees (\${callees.length}): \${callees.slice(0, 10).map(c => c.node.name).join(', ')}\`); - - // Check callers (what calls it?) - const callers = cg.getCallers(node.id); - console.log(\` Callers (\${callers.length}): \${callers.slice(0, 10).map(c => c.node.name).join(', ')}\`); - } - - await cg.close(); -} -test().catch(console.error); -" -``` - -**What to check:** -- Do methods have callers AND callees? If a method has 0 of both, edge extraction may be broken. -- Do the callers/callees make sense? A `build()` method should call constructor-like things, and be called by setup/initialization code. -- Are the counts reasonable? A core method in a popular codebase should have multiple callers. - ---- - -### Test 4: `codegraph_impact` — Change Impact Analysis - -Test that the impact radius correctly identifies affected code. - -```bash -node -e " -const { CodeGraph } = require('./dist/index.js'); -async function test() { - const cg = await CodeGraph.open(''); - - // Pick a core class or interface that many things depend on - const results = cg.searchNodes('', { limit: 1, kinds: ['class', 'interface'] }); - if (results.length === 0) { console.log('Not found'); return; } - - const node = results[0].node; - console.log(\`Impact analysis for: \${node.name} (\${node.kind}) — \${node.filePath}\`); - - const impact = cg.getImpactRadius(node.id, 2); - console.log(\`\nAffected nodes: \${impact.nodes.size}\`); - console.log(\`Affected edges: \${impact.edges.length}\`); - - // Group by file - const files = new Map(); - for (const n of impact.nodes.values()) { - if (!files.has(n.filePath)) files.set(n.filePath, []); - files.get(n.filePath).push(n.name); - } - console.log(\`Affected files: \${files.size}\`); - for (const [file, nodes] of [...files.entries()].sort((a,b) => b[1].length - a[1].length).slice(0, 10)) { - console.log(\` \${file}: \${nodes.slice(0, 5).join(', ')}\`); - } - - await cg.close(); -} -test().catch(console.error); -" -``` - -**What to check:** -- Does changing a core interface/class show a wide impact radius? -- Are the affected files reasonable (things that import/extend/use it)? -- Is the impact radius non-empty? Zero impact on a core type means edges are missing. - ---- - -### Test 5: Edge Extraction Quality - -Directly verify that the major edge types are being extracted for this language. - -```bash -node -e " -const { CodeGraph } = require('./dist/index.js'); -async function test() { - const cg = await CodeGraph.open(''); - - // Check overall edge distribution - console.log('=== Edge distribution ==='); - // (Use sqlite3 query from sanity check above) - - // Find a class that extends another - const classes = cg.searchNodes('', { limit: 100, kinds: ['class'] }); - let foundExtends = false, foundImplements = false; - for (const r of classes) { - const callees = cg.getCallees(r.node.id); - // getCallees returns all outgoing edges, check for extends/implements - // Better: use graph traversal - } - - // Verify specific relationship types exist - const checks = [ - { desc: 'contains edges (class → method)', query: 'SELECT COUNT(*) FROM edges WHERE kind = \"contains\"' }, - { desc: 'calls edges', query: 'SELECT COUNT(*) FROM edges WHERE kind = \"calls\"' }, - { desc: 'imports edges', query: 'SELECT COUNT(*) FROM edges WHERE kind = \"imports\"' }, - { desc: 'extends edges', query: 'SELECT COUNT(*) FROM edges WHERE kind = \"extends\"' }, - { desc: 'implements edges', query: 'SELECT COUNT(*) FROM edges WHERE kind = \"implements\"' }, - ]; - // Run these via sqlite3 (shown in sanity check section) - - await cg.close(); -} -test().catch(console.error); -" -``` - -```bash -sqlite3 /.codegraph/codegraph.db " - SELECT kind, COUNT(*) as cnt FROM edges GROUP BY kind ORDER BY cnt DESC; -" -``` - -**What to check:** -- `contains` should be the most common (structural hierarchy). -- `calls` should be plentiful — if near zero, call extraction is broken for this language. -- `imports` should exist — if zero, import parsing is broken. -- `extends` and `implements` should exist if the language has inheritance — if zero, `extractInheritance()` may not handle this language's AST. - ---- - -### Test 6: Node Extraction Completeness - -Verify all expected node kinds are being extracted. - -```bash -sqlite3 /.codegraph/codegraph.db " - SELECT kind, COUNT(*) as cnt FROM nodes GROUP BY kind ORDER BY cnt DESC; -" -``` - -**What to check for each language:** - -| Node Kind | Expected? | Notes | -|-----------|-----------|-------| -| `file` | Always | One per source file | -| `class` | If language has classes | | -| `method` | If language has methods | Should include owner type in `qualified_name` | -| `function` | If language has top-level functions | | -| `interface` | If language has interfaces/protocols | | -| `enum` | If language has enums | | -| `enum_member` | If language has enums | Values inside enums | -| `import` | Always | One per import statement | -| `variable` / `field` | Usually | Fields, constants, top-level vars | -| `struct` | If language has structs | Go, Rust, C, Swift | -| `trait` | If language has traits | Rust | - -If an expected node kind has 0 count, the language extractor is missing that AST type. - ---- - -### Test 7: Real-World LLM Prompts - -This is the final and most important test. Simulate the kinds of questions a developer would actually ask an LLM that's using CodeGraph. For each prompt, run `findRelevantContext` (which powers `codegraph_explore`) and evaluate whether the returned context would let an LLM give a correct, complete answer. - -**Run at least 5 of these prompt styles, adapted to the actual codebase:** - -```bash -node -e " -const { CodeGraph } = require('./dist/index.js'); -async function test() { - const cg = await CodeGraph.open(''); - - const prompts = [ - // 1. \"How does X work?\" — subsystem understanding - 'How does the cache eviction policy work?', - - // 2. \"Where is X implemented?\" — symbol location - 'Where is the LRU eviction logic implemented?', - - // 3. \"What calls X?\" — usage discovery - 'What code triggers cache invalidation?', - - // 4. \"I want to change X, what breaks?\" — impact assessment - 'If I change the Cache interface, what else is affected?', - - // 5. \"How do X and Y interact?\" — cross-component relationships - 'How does CacheBuilder connect to LocalCache?', - - // 6. \"Show me the flow from A to B\" — data/control flow - 'What happens when a cache entry expires?', - - // 7. \"What are all the implementations of X?\" — polymorphism - 'What classes implement the Cache interface?', - - // 8. Bug investigation prompt - 'Cache entries are not being evicted when they should be — where should I look?', - ]; - - for (const prompt of prompts) { - console.log(\`\n========================================\`); - console.log(\`PROMPT: \${prompt}\`); - console.log(\`========================================\`); - - const subgraph = await cg.findRelevantContext(prompt, { - searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2, - }); - - console.log(\`Result: \${subgraph.nodes.size} nodes, \${subgraph.edges.length} edges, \${subgraph.roots.length} entry points\`); - - console.log('Entry points:'); - for (const rootId of subgraph.roots.slice(0, 5)) { - const node = subgraph.nodes.get(rootId); - if (node) console.log(\` \${node.name} (\${node.kind}) — \${node.filePath}:\${node.startLine}\`); - } - - const fileGroups = new Map(); - for (const node of subgraph.nodes.values()) { - if (!fileGroups.has(node.filePath)) fileGroups.set(node.filePath, []); - fileGroups.get(node.filePath).push(node.name); - } - console.log('Top files:'); - for (const [file, nodes] of [...fileGroups.entries()].sort((a,b) => b[1].length - a[1].length).slice(0, 5)) { - console.log(\` \${file} (\${nodes.length}): \${nodes.slice(0, 5).join(', ')}\`); - } - - // PASS/FAIL judgment - const hasEntryPoints = subgraph.roots.length > 0; - const hasEdges = subgraph.edges.length > 0; - const hasMultipleFiles = fileGroups.size > 1; - console.log(\`\\nVERDICT: \${hasEntryPoints && hasEdges && hasMultipleFiles ? 'PASS' : 'FAIL — needs investigation'}\`); - } - - await cg.close(); -} -test().catch(console.error); -" -``` - -**What to check for each prompt:** -- Does it return entry points? Zero entry points = total failure. -- Are the entry points **relevant** to the question? (Not just random symbols that happen to share a word.) -- Does it span multiple files? Most real questions involve cross-file understanding. -- Are relationships present? An LLM needs to understand how symbols connect, not just a list of names. -- Would **you** be able to answer the question from this context? - ---- - -## Diagnosing Failures - -| Symptom | Likely Cause | Where to Fix | -|---------|-------------|--------------| -| Method missing owner type in `qualified_name` | Language needs `getReceiverType` | `src/extraction/languages/.ts` | -| `codegraph_explore` returns irrelevant files | Common names flooding FTS; co-location boost not helping | `src/db/queries.ts: findNodesByExactName`, `src/context/index.ts` | -| Zero `calls` edges | `callTypes` missing or wrong AST node type | `src/extraction/languages/.ts: callTypes` | -| Zero `extends`/`implements` edges | `extractInheritance()` doesn't handle this language's AST | `src/extraction/tree-sitter.ts: extractInheritance()` | -| Missing node kinds (no enums, no interfaces) | AST type not listed in extractor | `src/extraction/languages/.ts: enumTypes`, `interfaceTypes`, etc. | -| Search term dropped from query | Term is in the stop words list | `src/search/query-utils.ts: STOP_WORDS` | -| `qualified_name` missing class for nested methods | Extraction not walking parent stack correctly | `src/extraction/tree-sitter.ts: visitNode()` | -| Import edges missing | `extractImport` returns null for this syntax | `src/extraction/languages/.ts: extractImport` | -| C++ classes/structs/enums missing from macro namespaces | Macros like `NLOHMANN_JSON_NAMESPACE_BEGIN` cause tree-sitter to misparse namespace blocks as `function_definition` | `src/extraction/languages/c-cpp.ts: isMisparsedFunction` filters bad names; `src/extraction/tree-sitter.ts: visitFunctionBody` extracts structural nodes | -| C++ classes missing from `.h` headers | `.h` files default to `c` language which has `classTypes: []` | `src/extraction/grammars.ts: looksLikeCpp()` — content-based heuristic promotes `.h` files to `cpp` when C++ patterns detected | -| Ruby methods inside modules missing owner in `qualified_name` | Ruby `module` AST nodes not being extracted | `src/extraction/languages/ruby.ts: visitNode` hook extracts modules; `src/extraction/tree-sitter.ts: isInsideClassLikeNode` includes `module` kind | -| TypeScript abstract classes missing | `abstract_class_declaration` not in `classTypes` | `src/extraction/languages/typescript.ts: classTypes` — add `abstract_class_declaration` | -| Single-expression arrow functions silently dropped | `extractName` finds identifier in expression body instead of returning `` | `src/extraction/tree-sitter.ts: extractName` — skip identifier search for `arrow_function`/`function_expression` nodes | -| Kotlin interfaces/enums extracted as classes | `class_declaration` matches `classTypes` first; `interfaceTypes`/`enumTypes` never fire | `src/extraction/languages/kotlin.ts: classifyClassNode` detects `interface`/`enum` keywords in AST children | -| Kotlin functions have zero calls extracted | Tree-sitter grammar doesn't use field names, so `getChildByField(node, 'function_body')` returns null | `src/extraction/languages/kotlin.ts: resolveBody` finds body by type (`function_body`, `class_body`, `enum_class_body`) | -| Kotlin `navigation_expression` calls not resolved cleanly | `navigation_expression` fell through to `getNodeText` producing messy names with parentheses | `src/extraction/tree-sitter.ts: extractCall` — handle `navigation_expression` by extracting method name from `navigation_suffix > simple_identifier` | -| Kotlin `fun interface` declarations invisible | Tree-sitter-kotlin doesn't support `fun interface` syntax (Kotlin 1.4+), producing ERROR or misparse as `function_declaration` | `src/extraction/languages/kotlin.ts: visitNode` detects three misparse patterns: (1) ERROR node + lambda body, (2) function_declaration with `user_type("interface")` direct child + name in ERROR child, (3) function_declaration with ERROR child containing `user_type("interface")` + name. `isFunInterfaceNode` checks both direct and ERROR-nested `user_type` children | -| Kotlin class/interface methods missing when nested `fun interface` present | Tree-sitter misparsed parent body as ERROR (starting with `{`) + class_body (nested interface body); `resolveBody` found wrong body | `src/extraction/languages/kotlin.ts: resolveBody` prefers ERROR bodies starting with `{`; `visitNode` excludes body-like ERROR from `fun interface` detection | -| Svelte `$props()` destructuring produces ugly variable names | `let { x, y } = $props()` has `object_pattern` as variable name node; `getNodeText` returns full pattern | `src/extraction/tree-sitter.ts: extractVariable` skips `object_pattern`/`array_pattern` named declarators | -| Svelte template function calls invisible (e.g. `class={cn(...)}`) | SvelteExtractor only parsed ` and ranges - const tagRegex = /<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1>/g; - let tagMatch; - while ((tagMatch = tagRegex.exec(this.source)) !== null) { - const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length; - const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length; - coveredRanges.push([startLine, endLine]); - } - - // Find template expressions: {...} outside of script/style blocks - // Matches curly-brace expressions, excluding Svelte block syntax ({#if}, {:else}, {/if}, {@html}, {@render}) - const lines = this.source.split('\n'); - const exprRegex = /\{([^}#/:@][^}]*)\}/g; - - for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { - // Skip lines inside script/style blocks - if (coveredRanges.some(([start, end]) => lineIdx >= start && lineIdx <= end)) continue; - - const line = lines[lineIdx]!; - let exprMatch; - while ((exprMatch = exprRegex.exec(line)) !== null) { - const expr = exprMatch[1]!; - // Extract function calls: identifiers followed by ( - // Matches: cn(...), buttonVariants(...), obj.method(...) - const callRegex = /\b([a-zA-Z_$][\w$.]*)\s*\(/g; - let callMatch; - while ((callMatch = callRegex.exec(expr)) !== null) { - const calleeName = callMatch[1]!; - // Skip Svelte runes, control flow keywords, and common non-function patterns - if (SVELTE_RUNES.has(calleeName)) continue; - if (calleeName === 'if' || calleeName === 'else' || calleeName === 'each' || calleeName === 'await') continue; - - this.unresolvedReferences.push({ - fromNodeId: componentNodeId, - referenceName: calleeName, - referenceKind: 'calls', - line: lineIdx + 1, // 1-indexed - column: exprMatch.index + callMatch.index, - filePath: this.filePath, - language: 'svelte', - }); - } - } - } - } - - /** - * Extract component usages from the Svelte template. - * - * PascalCase tags like ,