From 8d36ebe7df0ff8c9109f58985a03b3e8b4d275fc Mon Sep 17 00:00:00 2001 From: Cleboost Date: Fri, 22 May 2026 19:25:49 +0200 Subject: [PATCH 01/33] chore: move old file --- .claude/skills/add-lang/SKILL.md | 219 ------------------ .claude/skills/agent-eval/SKILL.md | 74 ------ .claude/skills/agent-eval/corpus.json | 73 ------ .../.cursor}/rules/codegraph.mdc | 0 .../.github}/workflows/release.yml | 0 .gitignore => archive/.gitignore | 2 +- BUNDLING.md => archive/BUNDLING.md | 0 CHANGELOG.md => archive/CHANGELOG.md | 0 CLAUDE.md => archive/CLAUDE.md | 0 LICENSE => archive/LICENSE | 0 README.md => archive/README.md | 0 .../__tests__}/concurrent-locking.test.ts | 0 .../__tests__}/context.test.ts | 0 .../__tests__}/drupal.test.ts | 0 .../__tests__}/evaluation/runner.ts | 0 .../__tests__}/evaluation/scoring.ts | 0 .../__tests__}/evaluation/test-cases.ts | 0 .../__tests__}/evaluation/types.ts | 0 .../__tests__}/explore-output-budget.test.ts | 0 .../__tests__}/extraction.test.ts | 0 .../__tests__}/foundation.test.ts | 0 .../__tests__}/frameworks-integration.test.ts | 0 .../__tests__}/frameworks.test.ts | 0 .../__tests__}/git-hooks.test.ts | 0 .../__tests__}/glyphs.test.ts | 0 .../__tests__}/graph.test.ts | 0 .../__tests__}/installer-targets.test.ts | 0 .../__tests__}/installer.test.ts | 0 .../__tests__}/is-test-file.test.ts | 0 .../__tests__}/mcp-initialize.test.ts | 0 .../__tests__}/mcp-roots.test.ts | 0 .../__tests__}/node-sqlite-backend.test.ts | 0 .../__tests__}/node-version-check.test.ts | 0 .../__tests__}/npm-shim.test.ts | 0 .../__tests__}/pr19-improvements.test.ts | 0 .../__tests__}/resolution.test.ts | 0 .../__tests__}/search-query-parser.test.ts | 0 .../__tests__}/security.test.ts | 0 .../__tests__}/sqlite-backend.test.ts | 0 .../__tests__}/strip-comments.test.ts | 0 .../__tests__}/symbol-lookup.test.ts | 0 {__tests__ => archive/__tests__}/sync.test.ts | 0 .../__tests__}/wasm-runtime-flags.test.ts | 0 .../__tests__}/watch-policy.test.ts | 0 .../__tests__}/watcher.test.ts | 0 {docs => archive/docs}/SEARCH_QUALITY_LOOP.md | 0 .../2026-04-24-framework-resolver-extract.md | 0 install.ps1 => archive/install.ps1 | 0 install.sh => archive/install.sh | 0 .../package-lock.json | 0 package.json => archive/package.json | 0 .../scripts}/add-lang/bench.sh | 0 .../scripts}/add-lang/check-grammar.mjs | 0 .../scripts}/add-lang/dump-ast.mjs | 0 .../scripts}/add-lang/verify-extraction.mjs | 0 .../scripts}/agent-eval/audit.sh | 0 .../scripts}/agent-eval/itrun.sh | 0 .../scripts}/agent-eval/parse-run.mjs | 0 .../scripts}/agent-eval/parse-session.mjs | 0 .../scripts}/agent-eval/run-agent.sh | 0 .../scripts}/agent-eval/run-all.sh | 0 {scripts => archive/scripts}/build-bundle.sh | 0 .../scripts}/extract-release-notes.mjs | 0 {scripts => archive/scripts}/local-install.sh | 0 {scripts => archive/scripts}/npm-shim.js | 0 {scripts => archive/scripts}/pack-npm.sh | 0 {src => archive/src}/bin/codegraph.ts | 0 .../src}/bin/node-version-check.ts | 0 {src => archive/src}/bin/uninstall.ts | 0 {src => archive/src}/context/formatter.ts | 0 {src => archive/src}/context/index.ts | 0 {src => archive/src}/db/index.ts | 0 {src => archive/src}/db/migrations.ts | 0 {src => archive/src}/db/queries.ts | 0 {src => archive/src}/db/schema.sql | 0 {src => archive/src}/db/sqlite-adapter.ts | 0 {src => archive/src}/directory.ts | 0 {src => archive/src}/errors.ts | 0 .../src}/extraction/dfm-extractor.ts | 0 {src => archive/src}/extraction/grammars.ts | 0 {src => archive/src}/extraction/index.ts | 0 .../src}/extraction/languages/c-cpp.ts | 0 .../src}/extraction/languages/csharp.ts | 0 .../src}/extraction/languages/dart.ts | 0 .../src}/extraction/languages/go.ts | 0 .../src}/extraction/languages/index.ts | 0 .../src}/extraction/languages/java.ts | 0 .../src}/extraction/languages/javascript.ts | 0 .../src}/extraction/languages/kotlin.ts | 0 .../src}/extraction/languages/lua.ts | 0 .../src}/extraction/languages/luau.ts | 0 .../src}/extraction/languages/pascal.ts | 0 .../src}/extraction/languages/php.ts | 0 .../src}/extraction/languages/python.ts | 0 .../src}/extraction/languages/ruby.ts | 0 .../src}/extraction/languages/rust.ts | 0 .../src}/extraction/languages/scala.ts | 0 .../src}/extraction/languages/swift.ts | 0 .../src}/extraction/languages/typescript.ts | 0 .../src}/extraction/liquid-extractor.ts | 0 .../src}/extraction/parse-worker.ts | 0 .../src}/extraction/svelte-extractor.ts | 0 .../src}/extraction/tree-sitter-helpers.ts | 0 .../src}/extraction/tree-sitter-types.ts | 0 .../src}/extraction/tree-sitter.ts | 0 .../src}/extraction/vue-extractor.ts | 0 .../src}/extraction/wasm-runtime-flags.ts | 0 .../src}/extraction/wasm/tree-sitter-lua.wasm | Bin .../extraction/wasm/tree-sitter-luau.wasm | Bin .../extraction/wasm/tree-sitter-pascal.wasm | Bin .../extraction/wasm/tree-sitter-scala.wasm | Bin {src => archive/src}/graph/index.ts | 0 {src => archive/src}/graph/queries.ts | 0 {src => archive/src}/graph/traversal.ts | 0 {src => archive/src}/index.ts | 0 {src => archive/src}/installer/clack.d.ts | 0 .../src}/installer/claude-md-template.ts | 0 .../src}/installer/config-writer.ts | 0 {src => archive/src}/installer/index.ts | 0 .../src}/installer/instructions-template.ts | 0 .../src}/installer/targets/claude.ts | 0 .../src}/installer/targets/codex.ts | 0 .../src}/installer/targets/cursor.ts | 0 .../src}/installer/targets/hermes.ts | 0 .../src}/installer/targets/opencode.ts | 0 .../src}/installer/targets/registry.ts | 0 .../src}/installer/targets/shared.ts | 0 .../src}/installer/targets/toml.ts | 0 .../src}/installer/targets/types.ts | 0 {src => archive/src}/mcp/index.ts | 0 .../src}/mcp/server-instructions.ts | 0 {src => archive/src}/mcp/tools.ts | 0 {src => archive/src}/mcp/transport.ts | 0 .../resolution/frameworks/cargo-workspace.ts | 0 .../src}/resolution/frameworks/csharp.ts | 0 .../src}/resolution/frameworks/drupal.ts | 0 .../src}/resolution/frameworks/express.ts | 0 .../src}/resolution/frameworks/go.ts | 0 .../src}/resolution/frameworks/index.ts | 0 .../src}/resolution/frameworks/java.ts | 0 .../src}/resolution/frameworks/laravel.ts | 0 .../src}/resolution/frameworks/nestjs.ts | 0 .../src}/resolution/frameworks/python.ts | 0 .../src}/resolution/frameworks/react.ts | 0 .../src}/resolution/frameworks/ruby.ts | 0 .../src}/resolution/frameworks/rust.ts | 0 .../src}/resolution/frameworks/svelte.ts | 0 .../src}/resolution/frameworks/swift.ts | 0 .../src}/resolution/frameworks/vue.ts | 0 .../src}/resolution/import-resolver.ts | 0 {src => archive/src}/resolution/index.ts | 0 .../src}/resolution/name-matcher.ts | 0 .../src}/resolution/path-aliases.ts | 0 .../src}/resolution/strip-comments.ts | 0 {src => archive/src}/resolution/types.ts | 0 {src => archive/src}/search/query-parser.ts | 0 {src => archive/src}/search/query-utils.ts | 0 {src => archive/src}/sync/git-hooks.ts | 0 {src => archive/src}/sync/index.ts | 0 {src => archive/src}/sync/watch-policy.ts | 0 {src => archive/src}/sync/watcher.ts | 0 {src => archive/src}/types.ts | 0 {src => archive/src}/ui/glyphs.ts | 0 {src => archive/src}/ui/shimmer-progress.ts | 0 {src => archive/src}/ui/shimmer-worker.ts | 0 {src => archive/src}/ui/types.ts | 0 {src => archive/src}/utils.ts | 0 {src => archive/src}/web-tree-sitter.d.ts | 0 tsconfig.json => archive/tsconfig.json | 0 vitest.config.ts => archive/vitest.config.ts | 0 170 files changed, 1 insertion(+), 367 deletions(-) delete mode 100644 .claude/skills/add-lang/SKILL.md delete mode 100644 .claude/skills/agent-eval/SKILL.md delete mode 100644 .claude/skills/agent-eval/corpus.json rename {.cursor => archive/.cursor}/rules/codegraph.mdc (100%) rename {.github => archive/.github}/workflows/release.yml (100%) rename .gitignore => archive/.gitignore (98%) rename BUNDLING.md => archive/BUNDLING.md (100%) rename CHANGELOG.md => archive/CHANGELOG.md (100%) rename CLAUDE.md => archive/CLAUDE.md (100%) rename LICENSE => archive/LICENSE (100%) rename README.md => archive/README.md (100%) rename {__tests__ => archive/__tests__}/concurrent-locking.test.ts (100%) rename {__tests__ => archive/__tests__}/context.test.ts (100%) rename {__tests__ => archive/__tests__}/drupal.test.ts (100%) rename {__tests__ => archive/__tests__}/evaluation/runner.ts (100%) rename {__tests__ => archive/__tests__}/evaluation/scoring.ts (100%) rename {__tests__ => archive/__tests__}/evaluation/test-cases.ts (100%) rename {__tests__ => archive/__tests__}/evaluation/types.ts (100%) rename {__tests__ => archive/__tests__}/explore-output-budget.test.ts (100%) rename {__tests__ => archive/__tests__}/extraction.test.ts (100%) rename {__tests__ => archive/__tests__}/foundation.test.ts (100%) rename {__tests__ => archive/__tests__}/frameworks-integration.test.ts (100%) rename {__tests__ => archive/__tests__}/frameworks.test.ts (100%) rename {__tests__ => archive/__tests__}/git-hooks.test.ts (100%) rename {__tests__ => archive/__tests__}/glyphs.test.ts (100%) rename {__tests__ => archive/__tests__}/graph.test.ts (100%) rename {__tests__ => archive/__tests__}/installer-targets.test.ts (100%) rename {__tests__ => archive/__tests__}/installer.test.ts (100%) rename {__tests__ => archive/__tests__}/is-test-file.test.ts (100%) rename {__tests__ => archive/__tests__}/mcp-initialize.test.ts (100%) rename {__tests__ => archive/__tests__}/mcp-roots.test.ts (100%) rename {__tests__ => archive/__tests__}/node-sqlite-backend.test.ts (100%) rename {__tests__ => archive/__tests__}/node-version-check.test.ts (100%) rename {__tests__ => archive/__tests__}/npm-shim.test.ts (100%) rename {__tests__ => archive/__tests__}/pr19-improvements.test.ts (100%) rename {__tests__ => archive/__tests__}/resolution.test.ts (100%) rename {__tests__ => archive/__tests__}/search-query-parser.test.ts (100%) rename {__tests__ => archive/__tests__}/security.test.ts (100%) rename {__tests__ => archive/__tests__}/sqlite-backend.test.ts (100%) rename {__tests__ => archive/__tests__}/strip-comments.test.ts (100%) rename {__tests__ => archive/__tests__}/symbol-lookup.test.ts (100%) rename {__tests__ => archive/__tests__}/sync.test.ts (100%) rename {__tests__ => archive/__tests__}/wasm-runtime-flags.test.ts (100%) rename {__tests__ => archive/__tests__}/watch-policy.test.ts (100%) rename {__tests__ => archive/__tests__}/watcher.test.ts (100%) rename {docs => archive/docs}/SEARCH_QUALITY_LOOP.md (100%) rename {docs => archive/docs}/plans/2026-04-24-framework-resolver-extract.md (100%) rename install.ps1 => archive/install.ps1 (100%) rename install.sh => archive/install.sh (100%) rename package-lock.json => archive/package-lock.json (100%) rename package.json => archive/package.json (100%) rename {scripts => archive/scripts}/add-lang/bench.sh (100%) rename {scripts => archive/scripts}/add-lang/check-grammar.mjs (100%) rename {scripts => archive/scripts}/add-lang/dump-ast.mjs (100%) rename {scripts => archive/scripts}/add-lang/verify-extraction.mjs (100%) rename {scripts => archive/scripts}/agent-eval/audit.sh (100%) rename {scripts => archive/scripts}/agent-eval/itrun.sh (100%) rename {scripts => archive/scripts}/agent-eval/parse-run.mjs (100%) rename {scripts => archive/scripts}/agent-eval/parse-session.mjs (100%) rename {scripts => archive/scripts}/agent-eval/run-agent.sh (100%) rename {scripts => archive/scripts}/agent-eval/run-all.sh (100%) rename {scripts => archive/scripts}/build-bundle.sh (100%) rename {scripts => archive/scripts}/extract-release-notes.mjs (100%) rename {scripts => archive/scripts}/local-install.sh (100%) rename {scripts => archive/scripts}/npm-shim.js (100%) rename {scripts => archive/scripts}/pack-npm.sh (100%) rename {src => archive/src}/bin/codegraph.ts (100%) rename {src => archive/src}/bin/node-version-check.ts (100%) rename {src => archive/src}/bin/uninstall.ts (100%) rename {src => archive/src}/context/formatter.ts (100%) rename {src => archive/src}/context/index.ts (100%) rename {src => archive/src}/db/index.ts (100%) rename {src => archive/src}/db/migrations.ts (100%) rename {src => archive/src}/db/queries.ts (100%) rename {src => archive/src}/db/schema.sql (100%) rename {src => archive/src}/db/sqlite-adapter.ts (100%) rename {src => archive/src}/directory.ts (100%) rename {src => archive/src}/errors.ts (100%) rename {src => archive/src}/extraction/dfm-extractor.ts (100%) rename {src => archive/src}/extraction/grammars.ts (100%) rename {src => archive/src}/extraction/index.ts (100%) rename {src => archive/src}/extraction/languages/c-cpp.ts (100%) rename {src => archive/src}/extraction/languages/csharp.ts (100%) rename {src => archive/src}/extraction/languages/dart.ts (100%) rename {src => archive/src}/extraction/languages/go.ts (100%) rename {src => archive/src}/extraction/languages/index.ts (100%) rename {src => archive/src}/extraction/languages/java.ts (100%) rename {src => archive/src}/extraction/languages/javascript.ts (100%) rename {src => archive/src}/extraction/languages/kotlin.ts (100%) rename {src => archive/src}/extraction/languages/lua.ts (100%) rename {src => archive/src}/extraction/languages/luau.ts (100%) rename {src => archive/src}/extraction/languages/pascal.ts (100%) rename {src => archive/src}/extraction/languages/php.ts (100%) rename {src => archive/src}/extraction/languages/python.ts (100%) rename {src => archive/src}/extraction/languages/ruby.ts (100%) rename {src => archive/src}/extraction/languages/rust.ts (100%) rename {src => archive/src}/extraction/languages/scala.ts (100%) rename {src => archive/src}/extraction/languages/swift.ts (100%) rename {src => archive/src}/extraction/languages/typescript.ts (100%) rename {src => archive/src}/extraction/liquid-extractor.ts (100%) rename {src => archive/src}/extraction/parse-worker.ts (100%) rename {src => archive/src}/extraction/svelte-extractor.ts (100%) rename {src => archive/src}/extraction/tree-sitter-helpers.ts (100%) rename {src => archive/src}/extraction/tree-sitter-types.ts (100%) rename {src => archive/src}/extraction/tree-sitter.ts (100%) rename {src => archive/src}/extraction/vue-extractor.ts (100%) rename {src => archive/src}/extraction/wasm-runtime-flags.ts (100%) rename {src => archive/src}/extraction/wasm/tree-sitter-lua.wasm (100%) rename {src => archive/src}/extraction/wasm/tree-sitter-luau.wasm (100%) rename {src => archive/src}/extraction/wasm/tree-sitter-pascal.wasm (100%) rename {src => archive/src}/extraction/wasm/tree-sitter-scala.wasm (100%) rename {src => archive/src}/graph/index.ts (100%) rename {src => archive/src}/graph/queries.ts (100%) rename {src => archive/src}/graph/traversal.ts (100%) rename {src => archive/src}/index.ts (100%) rename {src => archive/src}/installer/clack.d.ts (100%) rename {src => archive/src}/installer/claude-md-template.ts (100%) rename {src => archive/src}/installer/config-writer.ts (100%) rename {src => archive/src}/installer/index.ts (100%) rename {src => archive/src}/installer/instructions-template.ts (100%) rename {src => archive/src}/installer/targets/claude.ts (100%) rename {src => archive/src}/installer/targets/codex.ts (100%) rename {src => archive/src}/installer/targets/cursor.ts (100%) rename {src => archive/src}/installer/targets/hermes.ts (100%) rename {src => archive/src}/installer/targets/opencode.ts (100%) rename {src => archive/src}/installer/targets/registry.ts (100%) rename {src => archive/src}/installer/targets/shared.ts (100%) rename {src => archive/src}/installer/targets/toml.ts (100%) rename {src => archive/src}/installer/targets/types.ts (100%) rename {src => archive/src}/mcp/index.ts (100%) rename {src => archive/src}/mcp/server-instructions.ts (100%) rename {src => archive/src}/mcp/tools.ts (100%) rename {src => archive/src}/mcp/transport.ts (100%) rename {src => archive/src}/resolution/frameworks/cargo-workspace.ts (100%) rename {src => archive/src}/resolution/frameworks/csharp.ts (100%) rename {src => archive/src}/resolution/frameworks/drupal.ts (100%) rename {src => archive/src}/resolution/frameworks/express.ts (100%) rename {src => archive/src}/resolution/frameworks/go.ts (100%) rename {src => archive/src}/resolution/frameworks/index.ts (100%) rename {src => archive/src}/resolution/frameworks/java.ts (100%) rename {src => archive/src}/resolution/frameworks/laravel.ts (100%) rename {src => archive/src}/resolution/frameworks/nestjs.ts (100%) rename {src => archive/src}/resolution/frameworks/python.ts (100%) rename {src => archive/src}/resolution/frameworks/react.ts (100%) rename {src => archive/src}/resolution/frameworks/ruby.ts (100%) rename {src => archive/src}/resolution/frameworks/rust.ts (100%) rename {src => archive/src}/resolution/frameworks/svelte.ts (100%) rename {src => archive/src}/resolution/frameworks/swift.ts (100%) rename {src => archive/src}/resolution/frameworks/vue.ts (100%) rename {src => archive/src}/resolution/import-resolver.ts (100%) rename {src => archive/src}/resolution/index.ts (100%) rename {src => archive/src}/resolution/name-matcher.ts (100%) rename {src => archive/src}/resolution/path-aliases.ts (100%) rename {src => archive/src}/resolution/strip-comments.ts (100%) rename {src => archive/src}/resolution/types.ts (100%) rename {src => archive/src}/search/query-parser.ts (100%) rename {src => archive/src}/search/query-utils.ts (100%) rename {src => archive/src}/sync/git-hooks.ts (100%) rename {src => archive/src}/sync/index.ts (100%) rename {src => archive/src}/sync/watch-policy.ts (100%) rename {src => archive/src}/sync/watcher.ts (100%) rename {src => archive/src}/types.ts (100%) rename {src => archive/src}/ui/glyphs.ts (100%) rename {src => archive/src}/ui/shimmer-progress.ts (100%) rename {src => archive/src}/ui/shimmer-worker.ts (100%) rename {src => archive/src}/ui/types.ts (100%) rename {src => archive/src}/utils.ts (100%) rename {src => archive/src}/web-tree-sitter.d.ts (100%) rename tsconfig.json => archive/tsconfig.json (100%) rename vitest.config.ts => archive/vitest.config.ts (100%) 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/archive/.cursor/rules/codegraph.mdc similarity index 100% rename from .cursor/rules/codegraph.mdc rename to archive/.cursor/rules/codegraph.mdc diff --git a/.github/workflows/release.yml b/archive/.github/workflows/release.yml similarity index 100% rename from .github/workflows/release.yml rename to archive/.github/workflows/release.yml diff --git a/.gitignore b/archive/.gitignore similarity index 98% rename from .gitignore rename to archive/.gitignore index 435882b3..55b34a8f 100644 --- a/.gitignore +++ b/archive/.gitignore @@ -7,7 +7,7 @@ dist/ .cmem # IDE -.idea/ +../.idea/ .vscode/ *.swp *.swo diff --git a/BUNDLING.md b/archive/BUNDLING.md similarity index 100% rename from BUNDLING.md rename to archive/BUNDLING.md diff --git a/CHANGELOG.md b/archive/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to archive/CHANGELOG.md diff --git a/CLAUDE.md b/archive/CLAUDE.md similarity index 100% rename from CLAUDE.md rename to archive/CLAUDE.md diff --git a/LICENSE b/archive/LICENSE similarity index 100% rename from LICENSE rename to archive/LICENSE diff --git a/README.md b/archive/README.md similarity index 100% rename from README.md rename to archive/README.md diff --git a/__tests__/concurrent-locking.test.ts b/archive/__tests__/concurrent-locking.test.ts similarity index 100% rename from __tests__/concurrent-locking.test.ts rename to archive/__tests__/concurrent-locking.test.ts diff --git a/__tests__/context.test.ts b/archive/__tests__/context.test.ts similarity index 100% rename from __tests__/context.test.ts rename to archive/__tests__/context.test.ts diff --git a/__tests__/drupal.test.ts b/archive/__tests__/drupal.test.ts similarity index 100% rename from __tests__/drupal.test.ts rename to archive/__tests__/drupal.test.ts diff --git a/__tests__/evaluation/runner.ts b/archive/__tests__/evaluation/runner.ts similarity index 100% rename from __tests__/evaluation/runner.ts rename to archive/__tests__/evaluation/runner.ts diff --git a/__tests__/evaluation/scoring.ts b/archive/__tests__/evaluation/scoring.ts similarity index 100% rename from __tests__/evaluation/scoring.ts rename to archive/__tests__/evaluation/scoring.ts diff --git a/__tests__/evaluation/test-cases.ts b/archive/__tests__/evaluation/test-cases.ts similarity index 100% rename from __tests__/evaluation/test-cases.ts rename to archive/__tests__/evaluation/test-cases.ts diff --git a/__tests__/evaluation/types.ts b/archive/__tests__/evaluation/types.ts similarity index 100% rename from __tests__/evaluation/types.ts rename to archive/__tests__/evaluation/types.ts diff --git a/__tests__/explore-output-budget.test.ts b/archive/__tests__/explore-output-budget.test.ts similarity index 100% rename from __tests__/explore-output-budget.test.ts rename to archive/__tests__/explore-output-budget.test.ts diff --git a/__tests__/extraction.test.ts b/archive/__tests__/extraction.test.ts similarity index 100% rename from __tests__/extraction.test.ts rename to archive/__tests__/extraction.test.ts diff --git a/__tests__/foundation.test.ts b/archive/__tests__/foundation.test.ts similarity index 100% rename from __tests__/foundation.test.ts rename to archive/__tests__/foundation.test.ts diff --git a/__tests__/frameworks-integration.test.ts b/archive/__tests__/frameworks-integration.test.ts similarity index 100% rename from __tests__/frameworks-integration.test.ts rename to archive/__tests__/frameworks-integration.test.ts diff --git a/__tests__/frameworks.test.ts b/archive/__tests__/frameworks.test.ts similarity index 100% rename from __tests__/frameworks.test.ts rename to archive/__tests__/frameworks.test.ts diff --git a/__tests__/git-hooks.test.ts b/archive/__tests__/git-hooks.test.ts similarity index 100% rename from __tests__/git-hooks.test.ts rename to archive/__tests__/git-hooks.test.ts diff --git a/__tests__/glyphs.test.ts b/archive/__tests__/glyphs.test.ts similarity index 100% rename from __tests__/glyphs.test.ts rename to archive/__tests__/glyphs.test.ts diff --git a/__tests__/graph.test.ts b/archive/__tests__/graph.test.ts similarity index 100% rename from __tests__/graph.test.ts rename to archive/__tests__/graph.test.ts diff --git a/__tests__/installer-targets.test.ts b/archive/__tests__/installer-targets.test.ts similarity index 100% rename from __tests__/installer-targets.test.ts rename to archive/__tests__/installer-targets.test.ts diff --git a/__tests__/installer.test.ts b/archive/__tests__/installer.test.ts similarity index 100% rename from __tests__/installer.test.ts rename to archive/__tests__/installer.test.ts diff --git a/__tests__/is-test-file.test.ts b/archive/__tests__/is-test-file.test.ts similarity index 100% rename from __tests__/is-test-file.test.ts rename to archive/__tests__/is-test-file.test.ts diff --git a/__tests__/mcp-initialize.test.ts b/archive/__tests__/mcp-initialize.test.ts similarity index 100% rename from __tests__/mcp-initialize.test.ts rename to archive/__tests__/mcp-initialize.test.ts diff --git a/__tests__/mcp-roots.test.ts b/archive/__tests__/mcp-roots.test.ts similarity index 100% rename from __tests__/mcp-roots.test.ts rename to archive/__tests__/mcp-roots.test.ts diff --git a/__tests__/node-sqlite-backend.test.ts b/archive/__tests__/node-sqlite-backend.test.ts similarity index 100% rename from __tests__/node-sqlite-backend.test.ts rename to archive/__tests__/node-sqlite-backend.test.ts diff --git a/__tests__/node-version-check.test.ts b/archive/__tests__/node-version-check.test.ts similarity index 100% rename from __tests__/node-version-check.test.ts rename to archive/__tests__/node-version-check.test.ts diff --git a/__tests__/npm-shim.test.ts b/archive/__tests__/npm-shim.test.ts similarity index 100% rename from __tests__/npm-shim.test.ts rename to archive/__tests__/npm-shim.test.ts diff --git a/__tests__/pr19-improvements.test.ts b/archive/__tests__/pr19-improvements.test.ts similarity index 100% rename from __tests__/pr19-improvements.test.ts rename to archive/__tests__/pr19-improvements.test.ts diff --git a/__tests__/resolution.test.ts b/archive/__tests__/resolution.test.ts similarity index 100% rename from __tests__/resolution.test.ts rename to archive/__tests__/resolution.test.ts diff --git a/__tests__/search-query-parser.test.ts b/archive/__tests__/search-query-parser.test.ts similarity index 100% rename from __tests__/search-query-parser.test.ts rename to archive/__tests__/search-query-parser.test.ts diff --git a/__tests__/security.test.ts b/archive/__tests__/security.test.ts similarity index 100% rename from __tests__/security.test.ts rename to archive/__tests__/security.test.ts diff --git a/__tests__/sqlite-backend.test.ts b/archive/__tests__/sqlite-backend.test.ts similarity index 100% rename from __tests__/sqlite-backend.test.ts rename to archive/__tests__/sqlite-backend.test.ts diff --git a/__tests__/strip-comments.test.ts b/archive/__tests__/strip-comments.test.ts similarity index 100% rename from __tests__/strip-comments.test.ts rename to archive/__tests__/strip-comments.test.ts diff --git a/__tests__/symbol-lookup.test.ts b/archive/__tests__/symbol-lookup.test.ts similarity index 100% rename from __tests__/symbol-lookup.test.ts rename to archive/__tests__/symbol-lookup.test.ts diff --git a/__tests__/sync.test.ts b/archive/__tests__/sync.test.ts similarity index 100% rename from __tests__/sync.test.ts rename to archive/__tests__/sync.test.ts diff --git a/__tests__/wasm-runtime-flags.test.ts b/archive/__tests__/wasm-runtime-flags.test.ts similarity index 100% rename from __tests__/wasm-runtime-flags.test.ts rename to archive/__tests__/wasm-runtime-flags.test.ts diff --git a/__tests__/watch-policy.test.ts b/archive/__tests__/watch-policy.test.ts similarity index 100% rename from __tests__/watch-policy.test.ts rename to archive/__tests__/watch-policy.test.ts diff --git a/__tests__/watcher.test.ts b/archive/__tests__/watcher.test.ts similarity index 100% rename from __tests__/watcher.test.ts rename to archive/__tests__/watcher.test.ts diff --git a/docs/SEARCH_QUALITY_LOOP.md b/archive/docs/SEARCH_QUALITY_LOOP.md similarity index 100% rename from docs/SEARCH_QUALITY_LOOP.md rename to archive/docs/SEARCH_QUALITY_LOOP.md diff --git a/docs/plans/2026-04-24-framework-resolver-extract.md b/archive/docs/plans/2026-04-24-framework-resolver-extract.md similarity index 100% rename from docs/plans/2026-04-24-framework-resolver-extract.md rename to archive/docs/plans/2026-04-24-framework-resolver-extract.md diff --git a/install.ps1 b/archive/install.ps1 similarity index 100% rename from install.ps1 rename to archive/install.ps1 diff --git a/install.sh b/archive/install.sh similarity index 100% rename from install.sh rename to archive/install.sh diff --git a/package-lock.json b/archive/package-lock.json similarity index 100% rename from package-lock.json rename to archive/package-lock.json diff --git a/package.json b/archive/package.json similarity index 100% rename from package.json rename to archive/package.json diff --git a/scripts/add-lang/bench.sh b/archive/scripts/add-lang/bench.sh similarity index 100% rename from scripts/add-lang/bench.sh rename to archive/scripts/add-lang/bench.sh diff --git a/scripts/add-lang/check-grammar.mjs b/archive/scripts/add-lang/check-grammar.mjs similarity index 100% rename from scripts/add-lang/check-grammar.mjs rename to archive/scripts/add-lang/check-grammar.mjs diff --git a/scripts/add-lang/dump-ast.mjs b/archive/scripts/add-lang/dump-ast.mjs similarity index 100% rename from scripts/add-lang/dump-ast.mjs rename to archive/scripts/add-lang/dump-ast.mjs diff --git a/scripts/add-lang/verify-extraction.mjs b/archive/scripts/add-lang/verify-extraction.mjs similarity index 100% rename from scripts/add-lang/verify-extraction.mjs rename to archive/scripts/add-lang/verify-extraction.mjs diff --git a/scripts/agent-eval/audit.sh b/archive/scripts/agent-eval/audit.sh similarity index 100% rename from scripts/agent-eval/audit.sh rename to archive/scripts/agent-eval/audit.sh diff --git a/scripts/agent-eval/itrun.sh b/archive/scripts/agent-eval/itrun.sh similarity index 100% rename from scripts/agent-eval/itrun.sh rename to archive/scripts/agent-eval/itrun.sh diff --git a/scripts/agent-eval/parse-run.mjs b/archive/scripts/agent-eval/parse-run.mjs similarity index 100% rename from scripts/agent-eval/parse-run.mjs rename to archive/scripts/agent-eval/parse-run.mjs diff --git a/scripts/agent-eval/parse-session.mjs b/archive/scripts/agent-eval/parse-session.mjs similarity index 100% rename from scripts/agent-eval/parse-session.mjs rename to archive/scripts/agent-eval/parse-session.mjs diff --git a/scripts/agent-eval/run-agent.sh b/archive/scripts/agent-eval/run-agent.sh similarity index 100% rename from scripts/agent-eval/run-agent.sh rename to archive/scripts/agent-eval/run-agent.sh diff --git a/scripts/agent-eval/run-all.sh b/archive/scripts/agent-eval/run-all.sh similarity index 100% rename from scripts/agent-eval/run-all.sh rename to archive/scripts/agent-eval/run-all.sh diff --git a/scripts/build-bundle.sh b/archive/scripts/build-bundle.sh similarity index 100% rename from scripts/build-bundle.sh rename to archive/scripts/build-bundle.sh diff --git a/scripts/extract-release-notes.mjs b/archive/scripts/extract-release-notes.mjs similarity index 100% rename from scripts/extract-release-notes.mjs rename to archive/scripts/extract-release-notes.mjs diff --git a/scripts/local-install.sh b/archive/scripts/local-install.sh similarity index 100% rename from scripts/local-install.sh rename to archive/scripts/local-install.sh diff --git a/scripts/npm-shim.js b/archive/scripts/npm-shim.js similarity index 100% rename from scripts/npm-shim.js rename to archive/scripts/npm-shim.js diff --git a/scripts/pack-npm.sh b/archive/scripts/pack-npm.sh similarity index 100% rename from scripts/pack-npm.sh rename to archive/scripts/pack-npm.sh diff --git a/src/bin/codegraph.ts b/archive/src/bin/codegraph.ts similarity index 100% rename from src/bin/codegraph.ts rename to archive/src/bin/codegraph.ts diff --git a/src/bin/node-version-check.ts b/archive/src/bin/node-version-check.ts similarity index 100% rename from src/bin/node-version-check.ts rename to archive/src/bin/node-version-check.ts diff --git a/src/bin/uninstall.ts b/archive/src/bin/uninstall.ts similarity index 100% rename from src/bin/uninstall.ts rename to archive/src/bin/uninstall.ts diff --git a/src/context/formatter.ts b/archive/src/context/formatter.ts similarity index 100% rename from src/context/formatter.ts rename to archive/src/context/formatter.ts diff --git a/src/context/index.ts b/archive/src/context/index.ts similarity index 100% rename from src/context/index.ts rename to archive/src/context/index.ts diff --git a/src/db/index.ts b/archive/src/db/index.ts similarity index 100% rename from src/db/index.ts rename to archive/src/db/index.ts diff --git a/src/db/migrations.ts b/archive/src/db/migrations.ts similarity index 100% rename from src/db/migrations.ts rename to archive/src/db/migrations.ts diff --git a/src/db/queries.ts b/archive/src/db/queries.ts similarity index 100% rename from src/db/queries.ts rename to archive/src/db/queries.ts diff --git a/src/db/schema.sql b/archive/src/db/schema.sql similarity index 100% rename from src/db/schema.sql rename to archive/src/db/schema.sql diff --git a/src/db/sqlite-adapter.ts b/archive/src/db/sqlite-adapter.ts similarity index 100% rename from src/db/sqlite-adapter.ts rename to archive/src/db/sqlite-adapter.ts diff --git a/src/directory.ts b/archive/src/directory.ts similarity index 100% rename from src/directory.ts rename to archive/src/directory.ts diff --git a/src/errors.ts b/archive/src/errors.ts similarity index 100% rename from src/errors.ts rename to archive/src/errors.ts diff --git a/src/extraction/dfm-extractor.ts b/archive/src/extraction/dfm-extractor.ts similarity index 100% rename from src/extraction/dfm-extractor.ts rename to archive/src/extraction/dfm-extractor.ts diff --git a/src/extraction/grammars.ts b/archive/src/extraction/grammars.ts similarity index 100% rename from src/extraction/grammars.ts rename to archive/src/extraction/grammars.ts diff --git a/src/extraction/index.ts b/archive/src/extraction/index.ts similarity index 100% rename from src/extraction/index.ts rename to archive/src/extraction/index.ts diff --git a/src/extraction/languages/c-cpp.ts b/archive/src/extraction/languages/c-cpp.ts similarity index 100% rename from src/extraction/languages/c-cpp.ts rename to archive/src/extraction/languages/c-cpp.ts diff --git a/src/extraction/languages/csharp.ts b/archive/src/extraction/languages/csharp.ts similarity index 100% rename from src/extraction/languages/csharp.ts rename to archive/src/extraction/languages/csharp.ts diff --git a/src/extraction/languages/dart.ts b/archive/src/extraction/languages/dart.ts similarity index 100% rename from src/extraction/languages/dart.ts rename to archive/src/extraction/languages/dart.ts diff --git a/src/extraction/languages/go.ts b/archive/src/extraction/languages/go.ts similarity index 100% rename from src/extraction/languages/go.ts rename to archive/src/extraction/languages/go.ts diff --git a/src/extraction/languages/index.ts b/archive/src/extraction/languages/index.ts similarity index 100% rename from src/extraction/languages/index.ts rename to archive/src/extraction/languages/index.ts diff --git a/src/extraction/languages/java.ts b/archive/src/extraction/languages/java.ts similarity index 100% rename from src/extraction/languages/java.ts rename to archive/src/extraction/languages/java.ts diff --git a/src/extraction/languages/javascript.ts b/archive/src/extraction/languages/javascript.ts similarity index 100% rename from src/extraction/languages/javascript.ts rename to archive/src/extraction/languages/javascript.ts diff --git a/src/extraction/languages/kotlin.ts b/archive/src/extraction/languages/kotlin.ts similarity index 100% rename from src/extraction/languages/kotlin.ts rename to archive/src/extraction/languages/kotlin.ts diff --git a/src/extraction/languages/lua.ts b/archive/src/extraction/languages/lua.ts similarity index 100% rename from src/extraction/languages/lua.ts rename to archive/src/extraction/languages/lua.ts diff --git a/src/extraction/languages/luau.ts b/archive/src/extraction/languages/luau.ts similarity index 100% rename from src/extraction/languages/luau.ts rename to archive/src/extraction/languages/luau.ts diff --git a/src/extraction/languages/pascal.ts b/archive/src/extraction/languages/pascal.ts similarity index 100% rename from src/extraction/languages/pascal.ts rename to archive/src/extraction/languages/pascal.ts diff --git a/src/extraction/languages/php.ts b/archive/src/extraction/languages/php.ts similarity index 100% rename from src/extraction/languages/php.ts rename to archive/src/extraction/languages/php.ts diff --git a/src/extraction/languages/python.ts b/archive/src/extraction/languages/python.ts similarity index 100% rename from src/extraction/languages/python.ts rename to archive/src/extraction/languages/python.ts diff --git a/src/extraction/languages/ruby.ts b/archive/src/extraction/languages/ruby.ts similarity index 100% rename from src/extraction/languages/ruby.ts rename to archive/src/extraction/languages/ruby.ts diff --git a/src/extraction/languages/rust.ts b/archive/src/extraction/languages/rust.ts similarity index 100% rename from src/extraction/languages/rust.ts rename to archive/src/extraction/languages/rust.ts diff --git a/src/extraction/languages/scala.ts b/archive/src/extraction/languages/scala.ts similarity index 100% rename from src/extraction/languages/scala.ts rename to archive/src/extraction/languages/scala.ts diff --git a/src/extraction/languages/swift.ts b/archive/src/extraction/languages/swift.ts similarity index 100% rename from src/extraction/languages/swift.ts rename to archive/src/extraction/languages/swift.ts diff --git a/src/extraction/languages/typescript.ts b/archive/src/extraction/languages/typescript.ts similarity index 100% rename from src/extraction/languages/typescript.ts rename to archive/src/extraction/languages/typescript.ts diff --git a/src/extraction/liquid-extractor.ts b/archive/src/extraction/liquid-extractor.ts similarity index 100% rename from src/extraction/liquid-extractor.ts rename to archive/src/extraction/liquid-extractor.ts diff --git a/src/extraction/parse-worker.ts b/archive/src/extraction/parse-worker.ts similarity index 100% rename from src/extraction/parse-worker.ts rename to archive/src/extraction/parse-worker.ts diff --git a/src/extraction/svelte-extractor.ts b/archive/src/extraction/svelte-extractor.ts similarity index 100% rename from src/extraction/svelte-extractor.ts rename to archive/src/extraction/svelte-extractor.ts diff --git a/src/extraction/tree-sitter-helpers.ts b/archive/src/extraction/tree-sitter-helpers.ts similarity index 100% rename from src/extraction/tree-sitter-helpers.ts rename to archive/src/extraction/tree-sitter-helpers.ts diff --git a/src/extraction/tree-sitter-types.ts b/archive/src/extraction/tree-sitter-types.ts similarity index 100% rename from src/extraction/tree-sitter-types.ts rename to archive/src/extraction/tree-sitter-types.ts diff --git a/src/extraction/tree-sitter.ts b/archive/src/extraction/tree-sitter.ts similarity index 100% rename from src/extraction/tree-sitter.ts rename to archive/src/extraction/tree-sitter.ts diff --git a/src/extraction/vue-extractor.ts b/archive/src/extraction/vue-extractor.ts similarity index 100% rename from src/extraction/vue-extractor.ts rename to archive/src/extraction/vue-extractor.ts diff --git a/src/extraction/wasm-runtime-flags.ts b/archive/src/extraction/wasm-runtime-flags.ts similarity index 100% rename from src/extraction/wasm-runtime-flags.ts rename to archive/src/extraction/wasm-runtime-flags.ts diff --git a/src/extraction/wasm/tree-sitter-lua.wasm b/archive/src/extraction/wasm/tree-sitter-lua.wasm similarity index 100% rename from src/extraction/wasm/tree-sitter-lua.wasm rename to archive/src/extraction/wasm/tree-sitter-lua.wasm diff --git a/src/extraction/wasm/tree-sitter-luau.wasm b/archive/src/extraction/wasm/tree-sitter-luau.wasm similarity index 100% rename from src/extraction/wasm/tree-sitter-luau.wasm rename to archive/src/extraction/wasm/tree-sitter-luau.wasm diff --git a/src/extraction/wasm/tree-sitter-pascal.wasm b/archive/src/extraction/wasm/tree-sitter-pascal.wasm similarity index 100% rename from src/extraction/wasm/tree-sitter-pascal.wasm rename to archive/src/extraction/wasm/tree-sitter-pascal.wasm diff --git a/src/extraction/wasm/tree-sitter-scala.wasm b/archive/src/extraction/wasm/tree-sitter-scala.wasm similarity index 100% rename from src/extraction/wasm/tree-sitter-scala.wasm rename to archive/src/extraction/wasm/tree-sitter-scala.wasm diff --git a/src/graph/index.ts b/archive/src/graph/index.ts similarity index 100% rename from src/graph/index.ts rename to archive/src/graph/index.ts diff --git a/src/graph/queries.ts b/archive/src/graph/queries.ts similarity index 100% rename from src/graph/queries.ts rename to archive/src/graph/queries.ts diff --git a/src/graph/traversal.ts b/archive/src/graph/traversal.ts similarity index 100% rename from src/graph/traversal.ts rename to archive/src/graph/traversal.ts diff --git a/src/index.ts b/archive/src/index.ts similarity index 100% rename from src/index.ts rename to archive/src/index.ts diff --git a/src/installer/clack.d.ts b/archive/src/installer/clack.d.ts similarity index 100% rename from src/installer/clack.d.ts rename to archive/src/installer/clack.d.ts diff --git a/src/installer/claude-md-template.ts b/archive/src/installer/claude-md-template.ts similarity index 100% rename from src/installer/claude-md-template.ts rename to archive/src/installer/claude-md-template.ts diff --git a/src/installer/config-writer.ts b/archive/src/installer/config-writer.ts similarity index 100% rename from src/installer/config-writer.ts rename to archive/src/installer/config-writer.ts diff --git a/src/installer/index.ts b/archive/src/installer/index.ts similarity index 100% rename from src/installer/index.ts rename to archive/src/installer/index.ts diff --git a/src/installer/instructions-template.ts b/archive/src/installer/instructions-template.ts similarity index 100% rename from src/installer/instructions-template.ts rename to archive/src/installer/instructions-template.ts diff --git a/src/installer/targets/claude.ts b/archive/src/installer/targets/claude.ts similarity index 100% rename from src/installer/targets/claude.ts rename to archive/src/installer/targets/claude.ts diff --git a/src/installer/targets/codex.ts b/archive/src/installer/targets/codex.ts similarity index 100% rename from src/installer/targets/codex.ts rename to archive/src/installer/targets/codex.ts diff --git a/src/installer/targets/cursor.ts b/archive/src/installer/targets/cursor.ts similarity index 100% rename from src/installer/targets/cursor.ts rename to archive/src/installer/targets/cursor.ts diff --git a/src/installer/targets/hermes.ts b/archive/src/installer/targets/hermes.ts similarity index 100% rename from src/installer/targets/hermes.ts rename to archive/src/installer/targets/hermes.ts diff --git a/src/installer/targets/opencode.ts b/archive/src/installer/targets/opencode.ts similarity index 100% rename from src/installer/targets/opencode.ts rename to archive/src/installer/targets/opencode.ts diff --git a/src/installer/targets/registry.ts b/archive/src/installer/targets/registry.ts similarity index 100% rename from src/installer/targets/registry.ts rename to archive/src/installer/targets/registry.ts diff --git a/src/installer/targets/shared.ts b/archive/src/installer/targets/shared.ts similarity index 100% rename from src/installer/targets/shared.ts rename to archive/src/installer/targets/shared.ts diff --git a/src/installer/targets/toml.ts b/archive/src/installer/targets/toml.ts similarity index 100% rename from src/installer/targets/toml.ts rename to archive/src/installer/targets/toml.ts diff --git a/src/installer/targets/types.ts b/archive/src/installer/targets/types.ts similarity index 100% rename from src/installer/targets/types.ts rename to archive/src/installer/targets/types.ts diff --git a/src/mcp/index.ts b/archive/src/mcp/index.ts similarity index 100% rename from src/mcp/index.ts rename to archive/src/mcp/index.ts diff --git a/src/mcp/server-instructions.ts b/archive/src/mcp/server-instructions.ts similarity index 100% rename from src/mcp/server-instructions.ts rename to archive/src/mcp/server-instructions.ts diff --git a/src/mcp/tools.ts b/archive/src/mcp/tools.ts similarity index 100% rename from src/mcp/tools.ts rename to archive/src/mcp/tools.ts diff --git a/src/mcp/transport.ts b/archive/src/mcp/transport.ts similarity index 100% rename from src/mcp/transport.ts rename to archive/src/mcp/transport.ts diff --git a/src/resolution/frameworks/cargo-workspace.ts b/archive/src/resolution/frameworks/cargo-workspace.ts similarity index 100% rename from src/resolution/frameworks/cargo-workspace.ts rename to archive/src/resolution/frameworks/cargo-workspace.ts diff --git a/src/resolution/frameworks/csharp.ts b/archive/src/resolution/frameworks/csharp.ts similarity index 100% rename from src/resolution/frameworks/csharp.ts rename to archive/src/resolution/frameworks/csharp.ts diff --git a/src/resolution/frameworks/drupal.ts b/archive/src/resolution/frameworks/drupal.ts similarity index 100% rename from src/resolution/frameworks/drupal.ts rename to archive/src/resolution/frameworks/drupal.ts diff --git a/src/resolution/frameworks/express.ts b/archive/src/resolution/frameworks/express.ts similarity index 100% rename from src/resolution/frameworks/express.ts rename to archive/src/resolution/frameworks/express.ts diff --git a/src/resolution/frameworks/go.ts b/archive/src/resolution/frameworks/go.ts similarity index 100% rename from src/resolution/frameworks/go.ts rename to archive/src/resolution/frameworks/go.ts diff --git a/src/resolution/frameworks/index.ts b/archive/src/resolution/frameworks/index.ts similarity index 100% rename from src/resolution/frameworks/index.ts rename to archive/src/resolution/frameworks/index.ts diff --git a/src/resolution/frameworks/java.ts b/archive/src/resolution/frameworks/java.ts similarity index 100% rename from src/resolution/frameworks/java.ts rename to archive/src/resolution/frameworks/java.ts diff --git a/src/resolution/frameworks/laravel.ts b/archive/src/resolution/frameworks/laravel.ts similarity index 100% rename from src/resolution/frameworks/laravel.ts rename to archive/src/resolution/frameworks/laravel.ts diff --git a/src/resolution/frameworks/nestjs.ts b/archive/src/resolution/frameworks/nestjs.ts similarity index 100% rename from src/resolution/frameworks/nestjs.ts rename to archive/src/resolution/frameworks/nestjs.ts diff --git a/src/resolution/frameworks/python.ts b/archive/src/resolution/frameworks/python.ts similarity index 100% rename from src/resolution/frameworks/python.ts rename to archive/src/resolution/frameworks/python.ts diff --git a/src/resolution/frameworks/react.ts b/archive/src/resolution/frameworks/react.ts similarity index 100% rename from src/resolution/frameworks/react.ts rename to archive/src/resolution/frameworks/react.ts diff --git a/src/resolution/frameworks/ruby.ts b/archive/src/resolution/frameworks/ruby.ts similarity index 100% rename from src/resolution/frameworks/ruby.ts rename to archive/src/resolution/frameworks/ruby.ts diff --git a/src/resolution/frameworks/rust.ts b/archive/src/resolution/frameworks/rust.ts similarity index 100% rename from src/resolution/frameworks/rust.ts rename to archive/src/resolution/frameworks/rust.ts diff --git a/src/resolution/frameworks/svelte.ts b/archive/src/resolution/frameworks/svelte.ts similarity index 100% rename from src/resolution/frameworks/svelte.ts rename to archive/src/resolution/frameworks/svelte.ts diff --git a/src/resolution/frameworks/swift.ts b/archive/src/resolution/frameworks/swift.ts similarity index 100% rename from src/resolution/frameworks/swift.ts rename to archive/src/resolution/frameworks/swift.ts diff --git a/src/resolution/frameworks/vue.ts b/archive/src/resolution/frameworks/vue.ts similarity index 100% rename from src/resolution/frameworks/vue.ts rename to archive/src/resolution/frameworks/vue.ts diff --git a/src/resolution/import-resolver.ts b/archive/src/resolution/import-resolver.ts similarity index 100% rename from src/resolution/import-resolver.ts rename to archive/src/resolution/import-resolver.ts diff --git a/src/resolution/index.ts b/archive/src/resolution/index.ts similarity index 100% rename from src/resolution/index.ts rename to archive/src/resolution/index.ts diff --git a/src/resolution/name-matcher.ts b/archive/src/resolution/name-matcher.ts similarity index 100% rename from src/resolution/name-matcher.ts rename to archive/src/resolution/name-matcher.ts diff --git a/src/resolution/path-aliases.ts b/archive/src/resolution/path-aliases.ts similarity index 100% rename from src/resolution/path-aliases.ts rename to archive/src/resolution/path-aliases.ts diff --git a/src/resolution/strip-comments.ts b/archive/src/resolution/strip-comments.ts similarity index 100% rename from src/resolution/strip-comments.ts rename to archive/src/resolution/strip-comments.ts diff --git a/src/resolution/types.ts b/archive/src/resolution/types.ts similarity index 100% rename from src/resolution/types.ts rename to archive/src/resolution/types.ts diff --git a/src/search/query-parser.ts b/archive/src/search/query-parser.ts similarity index 100% rename from src/search/query-parser.ts rename to archive/src/search/query-parser.ts diff --git a/src/search/query-utils.ts b/archive/src/search/query-utils.ts similarity index 100% rename from src/search/query-utils.ts rename to archive/src/search/query-utils.ts diff --git a/src/sync/git-hooks.ts b/archive/src/sync/git-hooks.ts similarity index 100% rename from src/sync/git-hooks.ts rename to archive/src/sync/git-hooks.ts diff --git a/src/sync/index.ts b/archive/src/sync/index.ts similarity index 100% rename from src/sync/index.ts rename to archive/src/sync/index.ts diff --git a/src/sync/watch-policy.ts b/archive/src/sync/watch-policy.ts similarity index 100% rename from src/sync/watch-policy.ts rename to archive/src/sync/watch-policy.ts diff --git a/src/sync/watcher.ts b/archive/src/sync/watcher.ts similarity index 100% rename from src/sync/watcher.ts rename to archive/src/sync/watcher.ts diff --git a/src/types.ts b/archive/src/types.ts similarity index 100% rename from src/types.ts rename to archive/src/types.ts diff --git a/src/ui/glyphs.ts b/archive/src/ui/glyphs.ts similarity index 100% rename from src/ui/glyphs.ts rename to archive/src/ui/glyphs.ts diff --git a/src/ui/shimmer-progress.ts b/archive/src/ui/shimmer-progress.ts similarity index 100% rename from src/ui/shimmer-progress.ts rename to archive/src/ui/shimmer-progress.ts diff --git a/src/ui/shimmer-worker.ts b/archive/src/ui/shimmer-worker.ts similarity index 100% rename from src/ui/shimmer-worker.ts rename to archive/src/ui/shimmer-worker.ts diff --git a/src/ui/types.ts b/archive/src/ui/types.ts similarity index 100% rename from src/ui/types.ts rename to archive/src/ui/types.ts diff --git a/src/utils.ts b/archive/src/utils.ts similarity index 100% rename from src/utils.ts rename to archive/src/utils.ts diff --git a/src/web-tree-sitter.d.ts b/archive/src/web-tree-sitter.d.ts similarity index 100% rename from src/web-tree-sitter.d.ts rename to archive/src/web-tree-sitter.d.ts diff --git a/tsconfig.json b/archive/tsconfig.json similarity index 100% rename from tsconfig.json rename to archive/tsconfig.json diff --git a/vitest.config.ts b/archive/vitest.config.ts similarity index 100% rename from vitest.config.ts rename to archive/vitest.config.ts From 3f55eb96c3c95fa0560fa62c5e150722fffdddb2 Mon Sep 17 00:00:00 2001 From: Cleboost Date: Fri, 22 May 2026 19:27:56 +0200 Subject: [PATCH 02/33] chore: add initial structure and TODOs for codegraph components --- .gitignore | 7 + Cargo.lock | 1636 +++++++++++++++++ Cargo.toml | 84 + crates/codegraph-context/Cargo.toml | 13 + crates/codegraph-context/src/lib.rs | 2 + crates/codegraph-core/Cargo.toml | 12 + crates/codegraph-core/src/error.rs | 19 + crates/codegraph-core/src/kinds.rs | 93 + crates/codegraph-core/src/lib.rs | 9 + crates/codegraph-core/src/model.rs | 28 + crates/codegraph-db/Cargo.toml | 15 + crates/codegraph-db/src/lib.rs | 7 + crates/codegraph-db/src/schema.sql | 55 + crates/codegraph-extract/Cargo.toml | 52 + crates/codegraph-extract/src/languages.rs | 4 + crates/codegraph-extract/src/lib.rs | 8 + crates/codegraph-graph/Cargo.toml | 10 + crates/codegraph-graph/src/lib.rs | 2 + crates/codegraph-installer/Cargo.toml | 16 + crates/codegraph-installer/src/lib.rs | 9 + crates/codegraph-installer/src/targets.rs | 1 + crates/codegraph-mcp/Cargo.toml | 17 + crates/codegraph-mcp/src/lib.rs | 9 + .../codegraph-mcp/src/server-instructions.md | 3 + crates/codegraph-resolve/Cargo.toml | 15 + crates/codegraph-resolve/src/frameworks.rs | 1 + crates/codegraph-resolve/src/imports.rs | 1 + crates/codegraph-resolve/src/lib.rs | 10 + crates/codegraph-resolve/src/name_match.rs | 1 + crates/codegraph/Cargo.toml | 29 + crates/codegraph/src/main.rs | 67 + docs/PLAN.md | 71 + docs/specs/01-bootstrap.md | 26 + docs/specs/02-core-types.md | 26 + docs/specs/03-db-layer.md | 71 + docs/specs/04-extraction.md | 101 + docs/specs/05-resolution.md | 91 + docs/specs/06-graph-context.md | 91 + docs/specs/07-mcp-server.md | 79 + docs/specs/08-installer.md | 76 + docs/specs/09-cli-watcher.md | 67 + docs/specs/10-release.md | 77 + rust-toolchain.toml | 3 + 43 files changed, 3014 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/codegraph-context/Cargo.toml create mode 100644 crates/codegraph-context/src/lib.rs create mode 100644 crates/codegraph-core/Cargo.toml create mode 100644 crates/codegraph-core/src/error.rs create mode 100644 crates/codegraph-core/src/kinds.rs create mode 100644 crates/codegraph-core/src/lib.rs create mode 100644 crates/codegraph-core/src/model.rs create mode 100644 crates/codegraph-db/Cargo.toml create mode 100644 crates/codegraph-db/src/lib.rs create mode 100644 crates/codegraph-db/src/schema.sql create mode 100644 crates/codegraph-extract/Cargo.toml create mode 100644 crates/codegraph-extract/src/languages.rs create mode 100644 crates/codegraph-extract/src/lib.rs create mode 100644 crates/codegraph-graph/Cargo.toml create mode 100644 crates/codegraph-graph/src/lib.rs create mode 100644 crates/codegraph-installer/Cargo.toml create mode 100644 crates/codegraph-installer/src/lib.rs create mode 100644 crates/codegraph-installer/src/targets.rs create mode 100644 crates/codegraph-mcp/Cargo.toml create mode 100644 crates/codegraph-mcp/src/lib.rs create mode 100644 crates/codegraph-mcp/src/server-instructions.md create mode 100644 crates/codegraph-resolve/Cargo.toml create mode 100644 crates/codegraph-resolve/src/frameworks.rs create mode 100644 crates/codegraph-resolve/src/imports.rs create mode 100644 crates/codegraph-resolve/src/lib.rs create mode 100644 crates/codegraph-resolve/src/name_match.rs create mode 100644 crates/codegraph/Cargo.toml create mode 100644 crates/codegraph/src/main.rs create mode 100644 docs/PLAN.md create mode 100644 docs/specs/01-bootstrap.md create mode 100644 docs/specs/02-core-types.md create mode 100644 docs/specs/03-db-layer.md create mode 100644 docs/specs/04-extraction.md create mode 100644 docs/specs/05-resolution.md create mode 100644 docs/specs/06-graph-context.md create mode 100644 docs/specs/07-mcp-server.md create mode 100644 docs/specs/08-installer.md create mode 100644 docs/specs/09-cli-watcher.md create mode 100644 docs/specs/10-release.md create mode 100644 rust-toolchain.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..707c6eb7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +**/*.rs.bk +Cargo.lock.bak +.codegraph/ +.DS_Store +.idea/ +bun.lock diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..e0d5442d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1636 @@ +# 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 = "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 = "0.1.0" +dependencies = [ + "anyhow", + "camino", + "clap", + "codegraph-context", + "codegraph-core", + "codegraph-db", + "codegraph-extract", + "codegraph-graph", + "codegraph-installer", + "codegraph-mcp", + "codegraph-resolve", + "notify", + "notify-debouncer-full", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "codegraph-context" +version = "0.1.0" +dependencies = [ + "codegraph-core", + "codegraph-db", + "codegraph-graph", + "serde", + "serde_json", +] + +[[package]] +name = "codegraph-core" +version = "0.1.0" +dependencies = [ + "camino", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "codegraph-db" +version = "0.1.0" +dependencies = [ + "camino", + "codegraph-core", + "parking_lot", + "rusqlite", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "codegraph-extract" +version = "0.1.0" +dependencies = [ + "camino", + "codegraph-core", + "ignore", + "rayon", + "tracing", + "tree-sitter 0.24.7", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cpp", + "tree-sitter-go", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-kotlin", + "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 = "0.1.0" +dependencies = [ + "codegraph-core", + "codegraph-db", +] + +[[package]] +name = "codegraph-installer" +version = "0.1.0" +dependencies = [ + "anyhow", + "camino", + "dirs", + "jsonc-parser", + "serde", + "serde_json", + "toml_edit", + "tracing", +] + +[[package]] +name = "codegraph-mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "codegraph-context", + "codegraph-core", + "codegraph-db", + "codegraph-graph", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "codegraph-resolve" +version = "0.1.0" +dependencies = [ + "camino", + "codegraph-core", + "codegraph-db", + "globset", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[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 = "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 = "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 = "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 = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[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 = "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.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 = "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", +] + +[[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 = "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 = "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 = "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", + "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 = "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 = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[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 = "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.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e747b1f9b7b931ed39a548c1fae149101497de3c1fc8d9e18c62c1a66c683d3d" +dependencies = [ + "cc", + "regex", +] + +[[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-kotlin" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df217a0e1fec649f3e13157de932439f3d37ea4e265038dd0873971ef56e726" +dependencies = [ + "cc", + "tree-sitter 0.20.10", +] + +[[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 = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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 = "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.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 = "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 = "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..b66b0074 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,84 @@ +[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 = "0.1.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" + +[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/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..aa67110a --- /dev/null +++ b/crates/codegraph-context/src/lib.rs @@ -0,0 +1,2 @@ +//! Context builder: composes search + node + callers + callees into markdown/json. +//! TODO: formatter enum {Markdown, Json}, ContextBuilder API matching MCP tool surface. 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..9734dc38 --- /dev/null +++ b/crates/codegraph-db/Cargo.toml @@ -0,0 +1,15 @@ +[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 } diff --git a/crates/codegraph-db/src/lib.rs b/crates/codegraph-db/src/lib.rs new file mode 100644 index 00000000..924743a7 --- /dev/null +++ b/crates/codegraph-db/src/lib.rs @@ -0,0 +1,7 @@ +//! SQLite-backed knowledge graph storage (rusqlite, bundled, FTS5). +//! +//! Fresh schema — no backwards compatibility with archive TS DB. + +pub const SCHEMA_SQL: &str = include_str!("schema.sql"); + +// TODO: Connection wrapper, prepared statement cache, migrations, FTS5 index. diff --git a/crates/codegraph-db/src/schema.sql b/crates/codegraph-db/src/schema.sql new file mode 100644 index 00000000..17436423 --- /dev/null +++ b/crates/codegraph-db/src/schema.sql @@ -0,0 +1,55 @@ +-- codegraph schema v1 (Rust rewrite, fresh) +-- TODO: port from archive/src/db/schema.sql with adjustments. + +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; + +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 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 +); + +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 VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5( + name, qualified_name, signature, docstring, + content='nodes', content_rowid='id', tokenize='unicode61' +); diff --git a/crates/codegraph-extract/Cargo.toml b/crates/codegraph-extract/Cargo.toml new file mode 100644 index 00000000..cb0b13d5 --- /dev/null +++ b/crates/codegraph-extract/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "codegraph-extract" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +codegraph-core = { path = "../codegraph-core" } +tree-sitter = { workspace = true } +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 = { workspace = true, optional = true } +tree-sitter-lua = { workspace = true, optional = true } +ignore = { workspace = true } +rayon = { workspace = true } +camino = { workspace = true } +tracing = { workspace = true } + +[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-kotlin", "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..bc6cff13 --- /dev/null +++ b/crates/codegraph-extract/src/languages.rs @@ -0,0 +1,4 @@ +//! Per-language extractor registry. One module per language behind a feature. +//! +//! TODO: trait Extractor { fn language() -> tree_sitter::Language; fn extract(...) -> ...; } +//! and one impl per supported language. diff --git a/crates/codegraph-extract/src/lib.rs b/crates/codegraph-extract/src/lib.rs new file mode 100644 index 00000000..debcefc9 --- /dev/null +++ b/crates/codegraph-extract/src/lib.rs @@ -0,0 +1,8 @@ +//! Tree-sitter extraction orchestrator + per-language extractors. +//! +//! Native tree-sitter bindings (no WASM). Parallel parse via rayon. + +pub mod languages; + +// TODO: Orchestrator, file discovery (via `ignore`), parse worker pool, +// per-language extractor trait, standalone extractors (Svelte/Vue/Liquid/DFM). diff --git a/crates/codegraph-graph/Cargo.toml b/crates/codegraph-graph/Cargo.toml new file mode 100644 index 00000000..b1c8d335 --- /dev/null +++ b/crates/codegraph-graph/Cargo.toml @@ -0,0 +1,10 @@ +[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" } diff --git a/crates/codegraph-graph/src/lib.rs b/crates/codegraph-graph/src/lib.rs new file mode 100644 index 00000000..25b9e6f7 --- /dev/null +++ b/crates/codegraph-graph/src/lib.rs @@ -0,0 +1,2 @@ +//! Graph traversal: callers, callees, impact radius, path finding. +//! TODO: BFS/DFS over edges table, depth limits, kind filters. diff --git a/crates/codegraph-installer/Cargo.toml b/crates/codegraph-installer/Cargo.toml new file mode 100644 index 00000000..060af5da --- /dev/null +++ b/crates/codegraph-installer/Cargo.toml @@ -0,0 +1,16 @@ +[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 } +camino = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } diff --git a/crates/codegraph-installer/src/lib.rs b/crates/codegraph-installer/src/lib.rs new file mode 100644 index 00000000..fee3a0a6 --- /dev/null +++ b/crates/codegraph-installer/src/lib.rs @@ -0,0 +1,9 @@ +//! Multi-agent installer. One target per file in `targets/`. +//! +//! Targets: claude, cursor, codex, opencode, hermes. +//! Idempotent install/uninstall, surgical edits preserving user formatting. + +pub mod targets; + +// TODO: trait AgentTarget { fn name(); fn install(); fn uninstall(); fn detect(); } +// TODO: instructions-template (shared, agent-agnostic). diff --git a/crates/codegraph-installer/src/targets.rs b/crates/codegraph-installer/src/targets.rs new file mode 100644 index 00000000..062c38aa --- /dev/null +++ b/crates/codegraph-installer/src/targets.rs @@ -0,0 +1 @@ +// TODO: target registry + per-agent modules (claude/cursor/codex/opencode/hermes). diff --git a/crates/codegraph-mcp/Cargo.toml b/crates/codegraph-mcp/Cargo.toml new file mode 100644 index 00000000..329aff2d --- /dev/null +++ b/crates/codegraph-mcp/Cargo.toml @@ -0,0 +1,17 @@ +[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 } diff --git a/crates/codegraph-mcp/src/lib.rs b/crates/codegraph-mcp/src/lib.rs new file mode 100644 index 00000000..2b496864 --- /dev/null +++ b/crates/codegraph-mcp/src/lib.rs @@ -0,0 +1,9 @@ +//! MCP server (stdio JSON-RPC 2.0). Hand-rolled — no MCP SDK dep. +//! +//! Tools exposed: codegraph_search, codegraph_node, codegraph_callers, +//! codegraph_callees, codegraph_impact, codegraph_context, codegraph_explore, +//! codegraph_files, codegraph_status. +//! +//! TODO: transport (stdio framing), dispatch, tool handlers, server-instructions string. + +pub const SERVER_INSTRUCTIONS: &str = include_str!("server-instructions.md"); diff --git a/crates/codegraph-mcp/src/server-instructions.md b/crates/codegraph-mcp/src/server-instructions.md new file mode 100644 index 00000000..2d76fb19 --- /dev/null +++ b/crates/codegraph-mcp/src/server-instructions.md @@ -0,0 +1,3 @@ +# Codegraph — code intelligence over an indexed knowledge graph + +(TODO: port from archive/src/mcp/server-instructions.ts) diff --git a/crates/codegraph-resolve/Cargo.toml b/crates/codegraph-resolve/Cargo.toml new file mode 100644 index 00000000..bcd844b8 --- /dev/null +++ b/crates/codegraph-resolve/Cargo.toml @@ -0,0 +1,15 @@ +[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 } 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..a1e36007 --- /dev/null +++ b/crates/codegraph-resolve/src/lib.rs @@ -0,0 +1,10 @@ +//! Reference resolution: imports, name-matching, framework patterns. +//! +//! TODO: import-resolver (tsconfig path aliases, cargo workspace members), +//! name-matcher, frameworks/{express,laravel,rails,fastapi,django,flask, +//! spring,gin,axum,aspnet,vapor,react-router,sveltekit,vue-nuxt,cargo,nestjs, +//! drupal}. Emits route nodes + references edges. + +pub mod frameworks; +pub mod imports; +pub mod name_match; 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..e951a802 --- /dev/null +++ b/crates/codegraph/Cargo.toml @@ -0,0 +1,29 @@ +[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" } +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 } diff --git a/crates/codegraph/src/main.rs b/crates/codegraph/src/main.rs new file mode 100644 index 00000000..bc19e80d --- /dev/null +++ b/crates/codegraph/src/main.rs @@ -0,0 +1,67 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; + +/// codegraph — local-first code intelligence +#[derive(Parser, Debug)] +#[command(name = "codegraph", version, about)] +struct Cli { + #[command(subcommand)] + cmd: Option, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Interactive multi-agent installer (default when run with no args). + Install, + /// Initialize .codegraph/ in the current directory and build the index. + Init { + #[arg(short, long)] + index: bool, + }, + /// Remove the .codegraph/ directory. + Uninit, + /// Full re-index. + Index, + /// Incremental sync of changed files. + Sync, + /// Show index health, backend, sizes. + Status, + /// Search nodes by name / signature / docstring. + Query { query: String }, + /// List indexed files under a path. + Files { path: Option }, + /// Build context for a symbol or topic. + Context { target: String }, + /// Show impact radius for a node. + Affected { node: String }, + /// Run as MCP server over stdio. + Serve { + #[arg(long)] + mcp: bool, + }, +} + +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(); + match cli.cmd.unwrap_or(Cmd::Install) { + Cmd::Install => todo!("installer"), + Cmd::Init { .. } => todo!("init"), + Cmd::Uninit => todo!("uninit"), + Cmd::Index => todo!("index"), + Cmd::Sync => todo!("sync"), + Cmd::Status => todo!("status"), + Cmd::Query { .. } => todo!("query"), + Cmd::Files { .. } => todo!("files"), + Cmd::Context { .. } => todo!("context"), + Cmd::Affected { .. } => todo!("affected"), + Cmd::Serve { .. } => todo!("mcp serve"), + } +} 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/specs/01-bootstrap.md b/docs/specs/01-bootstrap.md new file mode 100644 index 00000000..d2ac11e4 --- /dev/null +++ b/docs/specs/01-bootstrap.md @@ -0,0 +1,26 @@ +# Spec 01 — Bootstrap workspace + +**État**: ✅ done + +## Objectif + +Workspace Cargo compilable avec les 9 crates squelettes. Aucun comportement, juste structure. + +## Livré + +- `/Cargo.toml`: workspace resolver=2, `[workspace.package]` (version, edition, license, repo), `[workspace.dependencies]` centralisées (serde, rusqlite, tree-sitter + 15 grammaires, clap, tokio, notify, ignore, rayon, dirs, jsonc-parser, toml_edit). +- Profils: + - `release`: `lto="fat"`, `codegen-units=1`, `strip="symbols"`, `panic="abort"`. + - `release-small`: hérite + `opt-level="z"`. +- `rust-toolchain.toml`: channel stable + rustfmt + clippy. +- `.gitignore`: `/target`, `.codegraph/`, IDE noise. +- 9 crates avec `Cargo.toml` + `src/lib.rs` (ou `main.rs` pour le binaire) commenté TODO. + +## Validation + +`cargo check --workspace` finit sans erreur (~6s clean rebuild). + +## Notes + +- Versions tree-sitter grammars: `swift=0.7`, `scala=0.26`, `lua=0.5`, `kotlin=0.3`, le reste `0.23`. Certaines crates communautaires lèveraient des conflits — surveiller à l'ajout d'une grammaire neuve. +- Crate principal `codegraph` (binaire) — `Cargo.toml` workspace dir `crates/codegraph`. Nom du paquet sur crates.io reste `codegraph`. diff --git a/docs/specs/02-core-types.md b/docs/specs/02-core-types.md new file mode 100644 index 00000000..5f90602a --- /dev/null +++ b/docs/specs/02-core-types.md @@ -0,0 +1,26 @@ +# Spec 02 — Core types (NodeKind, EdgeKind, errors) + +**État**: ✅ done + +## Objectif + +Types partagés stable entre toutes les crates. Source unique pour les chaînes serialisées dans DB/MCP. + +## Choix + +- `NodeKind` / `EdgeKind`: enums C-like `#[derive(Serialize, Deserialize)]` `#[serde(rename_all = "snake_case")]`. Méthode `as_str(self) -> &'static str` pour insertion DB sans alloc. +- `Node`: id `i64` (rowid SQLite), `kind`, `name`, `qualified_name: Option`, `file: Utf8PathBuf` (camino — pas de `OsString` partout), `start_line`, `end_line`, `signature`, `docstring`, `language`. +- `Edge`: `from`, `to`, `kind`, `file: Option`, `line: Option`. +- `Error`: `thiserror`, variantes `Io`, `Db`, `Parse`, `Invalid`, `NotInitialized`, `Other`. `Result = std::result::Result`. + +## Mapping avec archive + +NodeKind (22): 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 (12): contains, calls, imports, exports, extends, implements, references, type_of, returns, instantiates, overrides, decorates. + +Strings exacts identiques à `archive/src/types.ts` — agents prompts existants restent valides. + +## Hors scope + +Pas de méthode `Node::new()` — construction directe par struct literal jusqu'à ce qu'un besoin émerge. diff --git a/docs/specs/03-db-layer.md b/docs/specs/03-db-layer.md new file mode 100644 index 00000000..3be99549 --- /dev/null +++ b/docs/specs/03-db-layer.md @@ -0,0 +1,71 @@ +# Spec 03 — DB layer + +**État**: pending + +## Objectif + +Couche SQLite minimale et rapide. Crate `codegraph-db`. + +## Stack + +- `rusqlite` features `bundled` + `backup`. Bundled = SQLite statique → zero dep système. +- Pas de pool — SQLite WAL gère écriture mono, lectures parallèles depuis autres connexions. Un `Connection` par thread suffisant; pour les batches d'extraction, une connexion writer + N readers via `parking_lot::Mutex`. +- Schema versionné via table `meta(key, value)`; clé `schema_version`. + +## API publique + +```rust +pub struct Db { conn: Mutex } + +impl Db { + pub fn open(path: &Utf8Path) -> Result; // create + migrate + pub fn open_read_only(path: &Utf8Path) -> Result; + pub fn close(self) -> Result<()>; + pub fn schema_version(&self) -> u32; + + // Writes (transaction-scoped) + pub fn upsert_file(&self, f: &FileRow) -> Result; + pub fn insert_nodes(&self, nodes: &[Node]) -> Result>; + pub fn insert_edges(&self, edges: &[Edge]) -> Result<()>; + pub fn delete_file_cascade(&self, file_id: i64) -> Result<()>; + + // Reads + pub fn search_nodes(&self, q: &str, limit: u32) -> Result>; + pub fn node_by_id(&self, id: i64) -> Result>; + pub fn nodes_by_name(&self, name: &str) -> Result>; + pub fn callers_of(&self, id: i64) -> Result>; + pub fn callees_of(&self, id: i64) -> Result>; + pub fn files_under(&self, prefix: &str) -> Result>; + pub fn stats(&self) -> Result; +} +``` + +## Schema + +`schema.sql` (déjà ébauché): +- `meta(key, value)`: schema_version, last_index_ts, indexer_version. +- `files(id, path, language, sha256, size, mtime, indexed_at)` — path unique. +- `nodes(id, kind, name, qualified_name, file_id, start_line, end_line, signature, docstring, language)` — indices sur `name`, `qualified_name`, `file_id`, `kind`. +- `edges(id, from_id, to_id, kind, file_id, line)` — indices `(from_id, kind)` et `(to_id, kind)`. +- `nodes_fts` virtual FTS5 sur `name, qualified_name, signature, docstring`, `content='nodes' content_rowid='id'`, tokenizer `unicode61`. +- Triggers `nodes_ai`, `nodes_ad`, `nodes_au` pour sync FTS↔table. + +## Migrations + +`fn migrate(conn: &mut Connection)`: +1. Lit `meta.schema_version` (NULL = fresh). +2. Pour chaque version `) + ├── ParsePool (rayon) + │ └── for each file: + │ extractor.extract(source) -> ExtractResult + └── DbBatcher (chunks de 500, une tx par chunk) +``` + +## Trait extractor + +```rust +pub trait Extractor: Send + Sync { + fn language(&self) -> &'static str; // "typescript" + fn extensions(&self) -> &'static [&'static str]; + fn ts_language(&self) -> tree_sitter::Language; + fn extract(&self, source: &str, file: &Utf8Path) -> Result; +} + +pub struct ExtractResult { + pub nodes: Vec, // no id yet + pub edges: Vec, // refer to NodeDraft by local index + pub imports: Vec, // resolved later by codegraph-resolve +} +``` + +`NodeDraft` = `Node` sans `id`, `EdgeDraft` = indices locaux dans le Vec de nodes; orchestrator résoud après insert. + +## Langages + +Un module par langage dans `src/languages/`: +- typescript.rs (gère aussi tsx via grammaire séparée du même crate) +- javascript.rs (jsx) +- python.rs +- rust.rs +- go.rs +- java.rs +- c.rs / cpp.rs +- csharp.rs +- ruby.rs +- php.rs +- scala.rs +- swift.rs +- kotlin.rs +- lua.rs + +Chacun: +1. Parse source en arbre tree-sitter. +2. Walk avec `tree-sitter::Query` quand possible (queries S-expr déclaratives) sinon visit récursif. +3. Émet nodes pour: déclarations (fn/class/struct/etc.), imports, exports. +4. Émet edges: `contains` (parent → enfant), `calls` (sites d'appel), `extends`/`implements`. + +Queries stockées en `include_str!("queries/typescript/symbols.scm")` — fichiers `.scm` versionnés avec le code. + +## File walker + +`ignore::WalkBuilder` avec: +- `.gitignore` honoré +- `.codegraphignore` custom (suffix layer) +- `hidden(true)` (skip `.git`, `.node_modules` etc — `ignore` les a déjà) +- `parents(true)` pour héritage gitignore amont +- Filtre extension via `LanguageRegistry::extension_set()` + +## Parallélisme + +`rayon::ThreadPoolBuilder` configuré sur `num_cpus`. Chaque worker: +- Reçoit `(PathBuf, &dyn Extractor)`. +- Lit fichier (`fs::read_to_string` — taille limite 4MB sinon skip). +- Hash sha256 du contenu pour `files.sha256`. +- Parse + extract. +- Pousse `(FileRow, ExtractResult)` dans un crossbeam channel. + +Thread principal lit le channel, batch 500 → `Db::insert_*` en transaction. + +## Modes + +- `index_all(root)` — purge + reindex tout. +- `sync(root)` — compare sha256 par fichier; reindex seulement les changés. + +## Tests + +- Fixtures `tests/fixtures/typescript/sample.ts` etc. +- Assert: count nodes/edges, présence symbole précis, contains edge parent. +- `pr19-improvements.test.ts` archive → ré-utiliser fixtures comme regression suite. + +## Pièges + +- Tree-sitter `Language` n'est pas `Sync` pour certaines versions; wrap dans `parking_lot::Mutex` par thread OU créer parser par fichier (cheap). +- Encoding non-UTF8: skip avec warn. +- Fichiers générés (`*.min.js`, `dist/`, `build/`): filtrer par défaut via `.codegraphignore` template. diff --git a/docs/specs/05-resolution.md b/docs/specs/05-resolution.md new file mode 100644 index 00000000..4b65d6f6 --- /dev/null +++ b/docs/specs/05-resolution.md @@ -0,0 +1,91 @@ +# Spec 05 — Reference resolution + frameworks + +**État**: pending + +## Objectif + +Transformer imports textuels et patterns de framework en edges précis (`imports`, `references`, `route → handler`). + +## Pipeline + +``` +Db (post-extraction) + ↓ +ImportResolver + ↓ +NameMatcher + ↓ +FrameworkResolvers (express, laravel, rails, fastapi, django, flask, + spring, gin, axum, aspnet, vapor, react-router, + sveltekit, vue-nuxt, cargo-workspace, nestjs, drupal) + ↓ +new edges + new route nodes inserted +``` + +## ImportResolver + +Input: `RawImport { from_file, module_spec, imported_names }`. + +Étapes: +1. **Relative** (`./foo`, `../bar`): join + résolution extension (`.ts → .tsx → /index.ts`...). +2. **Alias** (tsconfig `paths`, jsconfig, vite alias, cargo workspace members, pyproject src layout): lus une fois via `path-aliases.rs` à l'init du resolver. +3. **Bare module** (`react`, `lodash`): pas résolu — emis comme edge `imports → external` (target = node fictif `external:react` ou skip selon flag). + +Output: edges `imports(file_node → file_node or symbol_node)`. + +## NameMatcher + +Pour les appels `calls` où la cible n'a été identifiée que par nom à l'extraction, résolution post-pass: +- Cherche `nodes` de kind `function|method|class` avec `name = target_name`. +- Si 1 candidat dans le même fichier ou un fichier importé: lien direct. +- Sinon: skip (évite faux positifs). + +## Frameworks + +Un module par framework. Trait commun: + +```rust +pub trait FrameworkResolver: Send + Sync { + fn name(&self) -> &'static str; + fn detect(&self, root: &Utf8Path) -> bool; // package.json scan, Gemfile, etc. + fn resolve(&self, db: &Db) -> Result; +} + +pub struct FrameworkArtifacts { + pub route_nodes: Vec, + pub edges: Vec, +} +``` + +### Patterns critiques (référence archive) + +| Framework | Détection | Pattern | +|---|---|---| +| Express | `express` dans package.json | `app.get('/x', handler)` → route node + ref edge | +| Laravel | `composer.json/laravel` | `Route::get(...)`, controller@method | +| Rails | `Gemfile/rails` | `routes.rb` DSL | +| FastAPI | `pyproject/fastapi` | `@app.get('/x')` décorateur | +| Django | `manage.py` | `urls.py` `path()` | +| Flask | `flask` dep | `@app.route('/x')` | +| Spring | `pom.xml` / gradle | `@GetMapping` etc | +| Gin | go.mod gin-gonic | `r.GET("/x", handler)` | +| Axum | Cargo.toml axum | `Router::new().route("/x", get(h))` | +| ASP.NET | `.csproj` | `[HttpGet("/x")]` | +| Vapor | `Package.swift` vapor | `app.get("x", use: h)` | +| React Router | `react-router` | `} />` | +| SvelteKit | `svelte.config.js` | `src/routes/**/+page.svelte` | +| Vue/Nuxt | `nuxt.config` | `pages/**/*.vue` | +| Cargo workspace | `[workspace]` | members glob → cross-crate imports | +| NestJS | `@nestjs/core` | `@Controller('x')` + `@Get('y')` | +| Drupal | `*.info.yml` | hooks + services.yml | + +Chaque framework émet `route` node avec `qualified_name = METHOD path` (ex `"GET /users/:id"`), edge `references → handler symbol`. + +## Tests + +`tests/frameworks-integration.rs` (équivalent archive). Fixture par framework avec 2-3 routes attendues. + +## Pièges + +- Détection multi-framework: un projet peut avoir Vue + Express; tous les resolvers qui détectent run, pas de mutex exclusion. +- Réentrant: appel `sync` ne doit pas dupliquer routes — purge edges de kind `references` issues des resolvers avant ré-exécution. Marqueur `meta.source='framework:express'` sur l'edge. diff --git a/docs/specs/06-graph-context.md b/docs/specs/06-graph-context.md new file mode 100644 index 00000000..c86e8c9c --- /dev/null +++ b/docs/specs/06-graph-context.md @@ -0,0 +1,91 @@ +# Spec 06 — Graph traversal + context builder + +**État**: pending + +## Objectif + +Requêtes graphe haut niveau pour MCP/CLI: callers, callees, impact radius. Builder qui compose tout en markdown/json pour l'agent. + +## Crate `codegraph-graph` + +```rust +pub struct Traversal<'a> { db: &'a Db } + +impl<'a> Traversal<'a> { + pub fn callers(&self, node: NodeId, depth: u32) -> Result>; + pub fn callees(&self, node: NodeId, depth: u32) -> Result>; + pub fn impact_radius(&self, node: NodeId, max_depth: u32) -> Result; + pub fn path(&self, from: NodeId, to: NodeId, max_depth: u32) -> Result>>; +} +``` + +- BFS avec `VecDeque<(NodeId, u32 depth)>`, set visited `HashSet`. +- Edge kind filter: callers/callees → `calls`; impact → `calls|references|imports|extends|implements`. +- Limite dure: 5000 visités, retourne `Truncated` flag. + +`ImpactReport`: +```rust +pub struct ImpactReport { + pub root: Node, + pub direct: Vec, // depth 1 + pub transitive: Vec, // depth 2..=max + pub by_kind: HashMap, + pub truncated: bool, +} +``` + +## Crate `codegraph-context` + +Compose les briques pour répondre "give me context for X" — analogue à `codegraph_context` MCP tool. + +```rust +pub enum Format { Markdown, Json } + +pub struct ContextRequest { + pub query: String, // symbol name OR free-text topic + pub depth: u32, + pub include_source: bool, + pub format: Format, +} + +pub fn build(db: &Db, req: &ContextRequest) -> Result; +``` + +Algorithme (port du `archive/src/context/`): +1. `search_nodes(query)` → top N candidates par FTS rank. +2. Pour chaque candidate: charge node, callers (d=1), callees (d=1), file siblings. +3. Si `include_source`: charge slice `start_line..=end_line` depuis disque (cache LRU sur fichier). +4. Sérialise selon Format. + +## Format markdown + +``` +## `getName` — function — src/foo.ts:42 + +```ts + +``` + +**Callers** (3): +- `processUser` — src/users.ts:118 (calls) +- ... + +**Callees** (2): +- `formatString` — src/utils.ts:5 (calls) +- ... +``` + +## Format json + +Structure tagged identique surface MCP `codegraph_context` archive — agents prompts existants compatibles. + +## Tests + +- Fixture: 4 fichiers TS avec chaîne d'appels `A → B → C → D`. +- Assert: `callers(D, depth=3)` retourne A,B,C. +- Assert: `impact_radius(A, max=2).by_kind` count exact. + +## Pièges + +- Charger source à la demande → IO sur traversal large; cache file→string LRU 32 entrées suffit. +- Tronquage profondeur: documenter le flag dans la sortie markdown ("⚠ truncated at depth 5"). diff --git a/docs/specs/07-mcp-server.md b/docs/specs/07-mcp-server.md new file mode 100644 index 00000000..86f94ebc --- /dev/null +++ b/docs/specs/07-mcp-server.md @@ -0,0 +1,79 @@ +# Spec 07 — MCP server (stdio JSON-RPC) + +**État**: pending + +## Objectif + +Serveur MCP minimaliste sur stdio. Pas de SDK Rust officiel mature → hand-roll JSON-RPC 2.0 + framing MCP. ~300 LOC. + +## Protocole + +- Transport: stdin/stdout. Framing JSON-RPC en LSP-style? Non — MCP utilise une ligne JSON par message (LSP-style headers seulement pour le mode HTTP). Pour stdio: une ligne `\n`-terminée par message. +- Méthodes obligatoires: + - `initialize` → renvoie `serverInfo`, `capabilities.tools`, `instructions` (le contenu de `server-instructions.md`). + - `initialized` (notification, no-op côté serveur). + - `tools/list` → array de tools. + - `tools/call` → invoque le tool. + - `ping` → `{}`. + - `shutdown` (optionnel selon agent). + +## Tools + +| Nom MCP | Handler | Args | +|---|---|---| +| `codegraph_search` | `db.search_nodes` | `{ query, limit?, kind? }` | +| `codegraph_node` | `db.node_by_id` ou by_name | `{ id?, name? }` | +| `codegraph_callers` | `traversal.callers` | `{ node, depth? }` | +| `codegraph_callees` | `traversal.callees` | `{ node, depth? }` | +| `codegraph_impact` | `traversal.impact_radius` | `{ node, max_depth? }` | +| `codegraph_context` | `context::build` | `{ query, depth?, include_source?, format? }` | +| `codegraph_explore` | `context::explore` | `{ paths[], depth? }` | +| `codegraph_files` | `db.files_under` | `{ path? }` | +| `codegraph_status` | `db.stats` | `{}` | + +Chaque tool a un JSON Schema `inputSchema` exposé dans `tools/list`. + +## Architecture + +```rust +pub struct McpServer { + db: Arc, + traversal: Arc>, // ... ou re-create par call +} + +impl McpServer { + pub async fn run(self, stdin: impl AsyncBufRead, stdout: impl AsyncWrite) -> Result<()>; +} +``` + +Boucle: +1. `read_line` → parse `JsonRpcMessage` (request/notification). +2. Dispatch async via `tokio::spawn` (un task par call — concurrence). +3. Réponse écrite avec `Mutex` pour sérialisation des writes. + +## Server instructions + +`include_str!("server-instructions.md")` — contenu identique à `archive/src/mcp/server-instructions.ts`. Renvoyé dans `initialize.result.instructions`. + +À garder en sync avec `instructions-template` de l'installer (spec 08). + +## Erreurs + +JSON-RPC 2.0 standard: +- `-32700` parse error +- `-32600` invalid request +- `-32601` method not found +- `-32602` invalid params +- `-32603` internal error +- `-32000..-32099` server-defined (NotInitialized → `-32001`) + +## Tests + +- Integration: spawn `codegraph serve --mcp` sur fixture indexé, écris séquence `initialize` → `tools/call codegraph_search`, assert response. +- Pas de SDK client — fabrique requêtes JSON à la main. + +## Pièges + +- `tracing_subscriber` doit écrire sur **stderr** (jamais stdout — corrompt le protocole). +- Si DB pas init (`.codegraph/` absent): `initialize` OK mais tous tools renvoient `-32001 NotInitialized` avec message guidant `codegraph init`. +- Multi-instance: lockfile sur `.codegraph/db.sqlite` pour éviter writer concurrent. diff --git a/docs/specs/08-installer.md b/docs/specs/08-installer.md new file mode 100644 index 00000000..52d1bbd6 --- /dev/null +++ b/docs/specs/08-installer.md @@ -0,0 +1,76 @@ +# Spec 08 — Multi-agent installer + +**État**: pending + +## Objectif + +Configurer 5 agents (Claude Code, Cursor, Codex, opencode, Hermes) en une commande, idempotent, sans casser config existante. + +## Trait + +```rust +pub trait AgentTarget: Send + Sync { + fn id(&self) -> &'static str; // "claude" + fn label(&self) -> &'static str; // "Claude Code" + fn detect(&self) -> DetectStatus; // NotInstalled | Installed | PartiallyInstalled + fn install(&self, opts: &InstallOpts) -> Result; + fn uninstall(&self) -> Result; +} + +pub enum DetectStatus { NotFound, Found, AlreadyConfigured } +pub enum InstallReport { Installed, Unchanged, Updated(Vec) } +``` + +## Cibles + +| Agent | Config | Notes | +|---|---|---| +| Claude Code | `~/.claude/settings.json` (global) ou `.claude/settings.local.json` (project) + `CLAUDE.md` | JSON, `mcpServers.codegraph` | +| Cursor | `.cursor/mcp.json` + `.cursor/rules/codegraph.mdc` | **Quirk**: cwd faux → injecter `--path` (absolu si project, `${workspaceFolder}` si global) | +| Codex | `~/.codex/config.toml` + `~/.codex/AGENTS.md` | TOML, table `[mcp_servers.codegraph]` — sérializer maison qui préserve siblings | +| opencode | `opencode.jsonc` ou `.json` + `~/.config/opencode/AGENTS.md` | Préfère `.jsonc`, edits via `jsonc-parser` pour préserver commentaires | +| Hermes | `~/.hermes/...` (TBD à partir d'archive `targets/hermes.ts`) | À documenter en porting | + +Chaque cible vit dans `crates/codegraph-installer/src/targets/{id}.rs`. + +## Shared + +- `instructions-template.rs`: une seule chaîne agent-agnostique (titre + tableau tools + chains). Source de vérité partagée avec `codegraph-mcp/server-instructions.md` — un test compare les deux contenus. +- `config_writer.rs`: helpers pour JSON / JSONC / TOML surgical edits. +- `toml.rs`: sérializer minimal pour `[mcp_servers.X]` qui préserve tables sœurs (cf. archive `targets/toml.ts`). + +## Détection installation existante + +`detect()`: +- Lit le fichier config s'il existe. +- Parse, check présence de la clé `codegraph` dans le bloc MCP. +- Retourne `Found` si présente et args valides, `NotFound` sinon, `Installed` si match exact attendu. + +## Idempotence + +Test obligatoire (spec depuis archive `__tests__/installer-targets.test.ts`): +- `install` deux fois → second call retourne `Unchanged`, fichier byte-equal après le premier. +- `uninstall` après `install` restaure fichier à l'état initial (avec une tolérance EOL). +- Tables/clés sœurs (`[mcp_servers.other]`, `mcpServers.other`) intactes. + +## CLI + +`codegraph install` (interactif via `dialoguer` ou `inquire`): +1. Détecte agents présents. +2. Multi-select prompt — coche par défaut ceux détectés. +3. Per-agent confirm + install. +4. Résumé final. + +Flags: `--all` pour install non-interactif sur agents détectés. + +## Tests + +- Per-target: parameterized contract suite. Pour chacun: fresh install, re-install (byte-equal), sibling preservation, uninstall reversal, partial-state recovery. +- ~50 tests cible. + +## Pièges + +- Cursor MCP working-dir: oublier `--path` casse silencieusement. +- Codex `~/.codex/config.toml`: arrays `[[mcp_servers]]` (table arrays) à preserver — pas le format qu'on écrit, mais on doit le rendre verbatim. +- opencode `.jsonc` peut contenir des commentaires importants — toujours passer par `jsonc-parser` edits. +- Permissions Windows sur `~/.claude/` — créer le dossier si absent. diff --git a/docs/specs/09-cli-watcher.md b/docs/specs/09-cli-watcher.md new file mode 100644 index 00000000..14e6b223 --- /dev/null +++ b/docs/specs/09-cli-watcher.md @@ -0,0 +1,67 @@ +# Spec 09 — CLI + file watcher + +**État**: pending (squelette CLI fait) + +## Objectif + +Binaire `codegraph` final qui orchestre tout. Watcher live pour sync auto. + +## Sous-commandes + +| Cmd | Action | +|---|---| +| `codegraph` (no arg) | → `install` interactif | +| `install` | installer multi-agent (spec 08) | +| `init [-i/--index]` | crée `.codegraph/` + DB; `-i` lance indexation après | +| `uninit` | supprime `.codegraph/` après confirmation | +| `index` | full reindex | +| `sync` | incremental: rescan fichiers modifiés (compare mtime+sha256) | +| `status` | size DB, count nodes/edges/files, backend SQLite, dernière indexation | +| `query ` | search FTS, sortie tableau | +| `files [path]` | liste fichiers indexés sous path | +| `context ` | build markdown context, stdout | +| `affected ` | impact radius, stdout | +| `serve --mcp` | run MCP server stdio (spec 07) | +| `watch` | run file watcher en foreground (debug) | + +## Watcher + +- Crate `notify` + `notify-debouncer-full` (debounce ~500ms). +- Démarré automatiquement quand `serve --mcp` tourne — réindex live pendant que l'agent code. +- Filtre: même `ignore::WalkBuilder` qu'à l'index pour rejeter événements sur fichiers ignorés. +- Sur event: + - Create/Modify → enqueue `sync_file(path)`. + - Delete → `db.delete_file_cascade`. + - Rename → delete old + sync new. +- Worker tokio task dédié. + +## Output + +- `--json` global flag → toutes les commandes sortent JSON au lieu de texte humain. +- Couleurs via `anstream` (auto-detect TTY). +- Progress bar via `indicatif` pour `index` / `sync` long. + +## .codegraph layout + +``` +.codegraph/ + db.sqlite // schéma v1 + config.toml // ignore patterns custom, lang overrides + .gitignore // contient "*" (jamais commité) + version // texte: version du binaire ayant créé le dossier +``` + +## CLAUDE.md detection (existant archive) + +`init` détecte si project a CLAUDE.md / AGENTS.md / `.cursor/rules/` → propose `codegraph install` à la suite. + +## Tests + +- Smoke test: `init -i` sur fixture, `status` montre N nodes > 0, `query foo` répond. +- Watcher test: créer fichier dans tempdir, attendre debounce, assert node apparaît dans DB. + +## Pièges + +- `tracing` doit écrire stderr — `serve --mcp` corrompt le protocole sinon. +- Lockfile concurrent: `.codegraph/db.sqlite.lock` (advisory `fs2::FileExt::try_lock_exclusive`) pour bloquer `index` + `serve --mcp` simultanés sur le même writer. +- Signal handling: SIGINT pendant index → flush transaction en cours puis exit clean. diff --git a/docs/specs/10-release.md b/docs/specs/10-release.md new file mode 100644 index 00000000..ec750ebb --- /dev/null +++ b/docs/specs/10-release.md @@ -0,0 +1,77 @@ +# Spec 10 — Release pipeline (GitHub Actions) + +**État**: pending + +## Objectif + +Builds reproductibles cross-platform + GitHub Releases avec binaires attachés. `cargo install codegraph` fonctionne en parallèle. + +## Cibles + +| OS | Target triple | Runner | +|---|---|---| +| Linux x86_64 | `x86_64-unknown-linux-gnu` | ubuntu-latest | +| Linux x86_64 musl | `x86_64-unknown-linux-musl` | ubuntu-latest (cross) | +| Linux aarch64 | `aarch64-unknown-linux-gnu` | ubuntu-latest (cross) | +| macOS x86_64 | `x86_64-apple-darwin` | macos-13 | +| macOS aarch64 | `aarch64-apple-darwin` | macos-latest | +| Windows x86_64 | `x86_64-pc-windows-msvc` | windows-latest | + +Linux musl = bin statique zéro dep glibc → recommandé pour `curl | sh` install. + +## Workflow `.github/workflows/release.yml` + +Déclencheur: `push: tags: ['v*']`. + +Steps: +1. Checkout. +2. `actions/cache` sur `~/.cargo`, `target/`. +3. Setup rust stable + target triple. +4. `cargo build --release --target $TRIPLE -p codegraph`. +5. Strip + UPX (optionnel — UPX casse macOS signing, à valider). +6. Archive: `tar.gz` Linux/macOS, `zip` Windows. +7. Checksums SHA256 par archive. +8. `gh release create $TAG --notes-file CHANGELOG_EXTRACT.md` (extract section `## [X.Y.Z]`). +9. `gh release upload` toutes les archives + `.sha256`. + +## CI hors release `.github/workflows/ci.yml` + +- Push/PR: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`. +- Matrix: Linux + macOS + Windows. +- Bench (optionnel): `cargo bench` sur Linux, comparaison vs baseline stockée. + +## Install script + +`scripts/install.sh`: +```sh +#!/bin/sh +# detect OS+arch, download from GH Releases latest, verify sha256, install to ~/.local/bin +``` + +Equivalent `install.ps1` pour Windows. + +## crates.io + +`cargo publish` manuel (pas dans CI) pour éviter publish accidentel. Publier dans l'ordre des deps: +1. codegraph-core +2. codegraph-db +3. codegraph-extract, codegraph-resolve, codegraph-graph +4. codegraph-context +5. codegraph-mcp, codegraph-installer +6. codegraph (binaire — utilisateurs feront `cargo install codegraph`) + +## Tailles cibles + +- Bin Linux x86_64 stripped + LTO: viser **<15MB**. +- Si dépasse: profil `release-small` ou retirer langages exotiques (Lua/Scala/Swift via feature flags off). + +## Tests + +- Job `release-smoke`: après build, run `codegraph --version`, `codegraph init -i` sur fixture, assert exit=0. + +## Pièges + +- macOS notarization: hors scope MVP, signature ad-hoc OK. +- musl + rusqlite bundled: vérifier que `cc` est statique (devrait être OK avec bundled). +- Windows: `\r\n` dans archives — utiliser `7z` propre, pas `tar` GNU sur Win. +- CHANGELOG.md: réutiliser format archive (sections Added/Changed/Fixed) pour script d'extraction notes. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..73cb934d --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] From 18a24aaa36c6ed2986412b506cac2394732d2098 Mon Sep 17 00:00:00 2001 From: Cleboost Date: Fri, 22 May 2026 20:11:22 +0200 Subject: [PATCH 03/33] feat(modules): add new language modules and user service implementations --- .github/workflows/ci.yml | 45 +++ .github/workflows/release.yml | 93 +++++ Cargo.lock | 356 ++++++++++++++++-- README.md | 237 ++++++++++++ crates/codegraph-context/src/lib.rs | 107 +++++- crates/codegraph-db/Cargo.toml | 3 + crates/codegraph-db/src/lib.rs | 145 ++++++- crates/codegraph-db/src/migrations.rs | 16 + crates/codegraph-db/src/model.rs | 45 +++ crates/codegraph-db/src/queries.rs | 305 +++++++++++++++ crates/codegraph-db/src/schema.sql | 56 ++- crates/codegraph-db/tests/smoke.rs | 137 +++++++ crates/codegraph-extract/Cargo.toml | 15 +- crates/codegraph-extract/src/languages.rs | 36 +- crates/codegraph-extract/src/languages/c.rs | 36 ++ .../codegraph-extract/src/languages/common.rs | 168 +++++++++ crates/codegraph-extract/src/languages/cpp.rs | 38 ++ .../codegraph-extract/src/languages/csharp.rs | 35 ++ crates/codegraph-extract/src/languages/go.rs | 105 ++++++ .../codegraph-extract/src/languages/java.rs | 38 ++ .../src/languages/javascript.rs | 95 +++++ crates/codegraph-extract/src/languages/lua.rs | 23 ++ crates/codegraph-extract/src/languages/php.rs | 37 ++ .../codegraph-extract/src/languages/python.rs | 111 ++++++ .../codegraph-extract/src/languages/ruby.rs | 24 ++ .../codegraph-extract/src/languages/rust.rs | 124 ++++++ .../codegraph-extract/src/languages/scala.rs | 27 ++ .../codegraph-extract/src/languages/swift.rs | 24 ++ .../src/languages/typescript.rs | 184 +++++++++ crates/codegraph-extract/src/lib.rs | 92 ++++- crates/codegraph-extract/src/orchestrator.rs | 131 +++++++ crates/codegraph-extract/src/walker.rs | 36 ++ crates/codegraph-extract/tests/extract.rs | 69 ++++ .../tests/fixtures/sample.go | 19 + .../tests/fixtures/sample.java | 13 + .../tests/fixtures/sample.js | 14 + .../tests/fixtures/sample.py | 15 + .../tests/fixtures/sample.rb | 9 + .../tests/fixtures/sample.rs | 19 + .../tests/fixtures/sample.ts | 19 + crates/codegraph-graph/Cargo.toml | 6 + crates/codegraph-graph/src/lib.rs | 105 +++++- crates/codegraph-graph/tests/traversal.rs | 69 ++++ crates/codegraph-installer/Cargo.toml | 3 + .../src/instructions-template.md | 34 ++ crates/codegraph-installer/src/lib.rs | 54 ++- crates/codegraph-installer/src/targets.rs | 1 - .../codegraph-installer/src/targets/claude.rs | 107 ++++++ .../codegraph-installer/src/targets/codex.rs | 103 +++++ .../codegraph-installer/src/targets/cursor.rs | 96 +++++ .../codegraph-installer/src/targets/hermes.rs | 88 +++++ .../src/targets/jsonutil.rs | 23 ++ crates/codegraph-installer/src/targets/mod.rs | 6 + .../src/targets/opencode.rs | 155 ++++++++ crates/codegraph-installer/tests/install.rs | 59 +++ crates/codegraph-mcp/Cargo.toml | 4 + crates/codegraph-mcp/src/lib.rs | 103 ++++- crates/codegraph-mcp/src/protocol.rs | 38 ++ .../codegraph-mcp/src/server-instructions.md | 30 +- crates/codegraph-mcp/src/tools.rs | 114 ++++++ crates/codegraph-resolve/Cargo.toml | 3 + crates/codegraph-resolve/src/lib.rs | 85 ++++- crates/codegraph/Cargo.toml | 1 + crates/codegraph/src/main.rs | 209 ++++++++-- crates/codegraph/src/watcher.rs | 40 ++ scripts/install.sh | 39 ++ 66 files changed, 4570 insertions(+), 106 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 README.md create mode 100644 crates/codegraph-db/src/migrations.rs create mode 100644 crates/codegraph-db/src/model.rs create mode 100644 crates/codegraph-db/src/queries.rs create mode 100644 crates/codegraph-db/tests/smoke.rs create mode 100644 crates/codegraph-extract/src/languages/c.rs create mode 100644 crates/codegraph-extract/src/languages/common.rs create mode 100644 crates/codegraph-extract/src/languages/cpp.rs create mode 100644 crates/codegraph-extract/src/languages/csharp.rs create mode 100644 crates/codegraph-extract/src/languages/go.rs create mode 100644 crates/codegraph-extract/src/languages/java.rs create mode 100644 crates/codegraph-extract/src/languages/javascript.rs create mode 100644 crates/codegraph-extract/src/languages/lua.rs create mode 100644 crates/codegraph-extract/src/languages/php.rs create mode 100644 crates/codegraph-extract/src/languages/python.rs create mode 100644 crates/codegraph-extract/src/languages/ruby.rs create mode 100644 crates/codegraph-extract/src/languages/rust.rs create mode 100644 crates/codegraph-extract/src/languages/scala.rs create mode 100644 crates/codegraph-extract/src/languages/swift.rs create mode 100644 crates/codegraph-extract/src/languages/typescript.rs create mode 100644 crates/codegraph-extract/src/orchestrator.rs create mode 100644 crates/codegraph-extract/src/walker.rs create mode 100644 crates/codegraph-extract/tests/extract.rs create mode 100644 crates/codegraph-extract/tests/fixtures/sample.go create mode 100644 crates/codegraph-extract/tests/fixtures/sample.java create mode 100644 crates/codegraph-extract/tests/fixtures/sample.js create mode 100644 crates/codegraph-extract/tests/fixtures/sample.py create mode 100644 crates/codegraph-extract/tests/fixtures/sample.rb create mode 100644 crates/codegraph-extract/tests/fixtures/sample.rs create mode 100644 crates/codegraph-extract/tests/fixtures/sample.ts create mode 100644 crates/codegraph-graph/tests/traversal.rs create mode 100644 crates/codegraph-installer/src/instructions-template.md delete mode 100644 crates/codegraph-installer/src/targets.rs create mode 100644 crates/codegraph-installer/src/targets/claude.rs create mode 100644 crates/codegraph-installer/src/targets/codex.rs create mode 100644 crates/codegraph-installer/src/targets/cursor.rs create mode 100644 crates/codegraph-installer/src/targets/hermes.rs create mode 100644 crates/codegraph-installer/src/targets/jsonutil.rs create mode 100644 crates/codegraph-installer/src/targets/mod.rs create mode 100644 crates/codegraph-installer/src/targets/opencode.rs create mode 100644 crates/codegraph-installer/tests/install.rs create mode 100644 crates/codegraph-mcp/src/protocol.rs create mode 100644 crates/codegraph-mcp/src/tools.rs create mode 100644 crates/codegraph/src/watcher.rs create mode 100755 scripts/install.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e68ce919 --- /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@v4 + - 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@v4 + - 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 new file mode 100644 index 00000000..e5e3506b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,93 @@ +name: Release + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +jobs: + 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-13, 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@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - 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: Build (native) + if: ${{ !matrix.cross }} + run: cargo build --release --target ${{ matrix.target }} -p codegraph + + - name: Package + shell: bash + run: | + set -euo pipefail + bin="target/${{ matrix.target }}/release/codegraph${{ matrix.ext }}" + name="codegraph-${{ matrix.target }}" + mkdir -p dist + if [[ "${{ matrix.ext }}" == ".exe" ]]; then + 7z a "dist/${name}.zip" "$bin" README.md LICENSE 2>/dev/null || zip -j "dist/${name}.zip" "$bin" + else + tar -czf "dist/${name}.tar.gz" -C "$(dirname "$bin")" "codegraph" + fi + cd dist + if command -v shasum >/dev/null; then + shasum -a 256 "${name}".* > "${name}.sha256" + else + sha256sum "${name}".* > "${name}.sha256" + fi + + - uses: actions/upload-artifact@v4 + with: + name: codegraph-${{ matrix.target }} + path: dist/* + + release: + name: GitHub Release + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + - name: Create release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag="${GITHUB_REF#refs/tags/}" + gh release create "$tag" --draft --title "$tag" --generate-notes artifacts/* diff --git a/Cargo.lock b/Cargo.lock index e0d5442d..fe4b0cf4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,15 @@ 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" @@ -188,6 +197,7 @@ dependencies = [ "codegraph-installer", "codegraph-mcp", "codegraph-resolve", + "dirs", "notify", "notify-debouncer-full", "tokio", @@ -226,6 +236,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "tempfile", "tracing", ] @@ -235,17 +246,22 @@ version = "0.1.0" dependencies = [ "camino", "codegraph-core", + "codegraph-db", + "codegraph-resolve", + "crossbeam-channel", + "hex", "ignore", "rayon", + "sha2", + "tempfile", "tracing", - "tree-sitter 0.24.7", + "tree-sitter", "tree-sitter-c", "tree-sitter-c-sharp", "tree-sitter-cpp", "tree-sitter-go", "tree-sitter-java", "tree-sitter-javascript", - "tree-sitter-kotlin", "tree-sitter-lua", "tree-sitter-php", "tree-sitter-python", @@ -260,8 +276,12 @@ dependencies = [ name = "codegraph-graph" version = "0.1.0" dependencies = [ + "camino", "codegraph-core", "codegraph-db", + "serde", + "serde_json", + "tempfile", ] [[package]] @@ -274,6 +294,7 @@ dependencies = [ "jsonc-parser", "serde", "serde_json", + "tempfile", "toml_edit", "tracing", ] @@ -283,12 +304,14 @@ name = "codegraph-mcp" version = "0.1.0" dependencies = [ "anyhow", + "camino", "codegraph-context", "codegraph-core", "codegraph-db", "codegraph-graph", "serde", "serde_json", + "tempfile", "tokio", "tracing", ] @@ -303,6 +326,7 @@ dependencies = [ "globset", "serde", "serde_json", + "tempfile", "tracing", ] @@ -312,6 +336,24 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[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" @@ -337,6 +379,26 @@ 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 = "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" @@ -392,6 +454,12 @@ 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" @@ -417,6 +485,12 @@ 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" @@ -426,6 +500,16 @@ 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" @@ -437,6 +521,19 @@ dependencies = [ "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" @@ -459,6 +556,15 @@ 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" @@ -480,6 +586,18 @@ 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" @@ -504,6 +622,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -579,6 +699,12 @@ 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" @@ -756,6 +882,16 @@ 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" @@ -774,6 +910,12 @@ 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" @@ -809,7 +951,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] @@ -885,6 +1027,12 @@ 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" @@ -928,6 +1076,17 @@ dependencies = [ "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" @@ -972,6 +1131,19 @@ dependencies = [ "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" @@ -1138,16 +1310,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "tree-sitter" -version = "0.20.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e747b1f9b7b931ed39a548c1fae149101497de3c1fc8d9e18c62c1a66c683d3d" -dependencies = [ - "cc", - "regex", -] - [[package]] name = "tree-sitter" version = "0.24.7" @@ -1221,16 +1383,6 @@ dependencies = [ "tree-sitter-language", ] -[[package]] -name = "tree-sitter-kotlin" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df217a0e1fec649f3e13157de932439f3d37ea4e265038dd0873971ef56e726" -dependencies = [ - "cc", - "tree-sitter 0.20.10", -] - [[package]] name = "tree-sitter-language" version = "0.1.7" @@ -1317,12 +1469,24 @@ dependencies = [ "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-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1363,6 +1527,58 @@ 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 = "winapi-util" version = "0.1.11" @@ -1609,6 +1825,100 @@ dependencies = [ "memchr", ] +[[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" diff --git a/README.md b/README.md new file mode 100644 index 00000000..a10d36e1 --- /dev/null +++ b/README.md @@ -0,0 +1,237 @@ +# CodeGraph + +[![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) + +> Local-first code intelligence for AI agents. Built in Rust. Single static +> binary, ~30 MB. Tree-sitter knowledge graph in SQLite, served over MCP. + +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). + +Agents that consult the graph instead of grepping the filesystem make +**fewer tool calls**, **explore faster**, and **stay within context**. + +## Highlights + +- **One binary.** Rust + statically-linked SQLite + native tree-sitter + grammars. No Node runtime, no `.wasm`, no `node_modules`. +- **Small.** ~30 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 and Hermes in one go. +- **Live.** Built-in file watcher keeps the index in sync while the MCP server + serves your agent. + +## Install + +### From a release binary (recommended) + +```sh +curl -fsSL https://raw.githubusercontent.com/cleboost/codegraph/main/scripts/install.sh | sh +``` + +Drops `codegraph` into `~/.local/bin` (override with `CODEGRAPH_INSTALL_DIR`). +Linux x86_64/aarch64, macOS x86_64/arm64, Windows x86_64 supported. + +### From Cargo + +```sh +cargo install --git https://github.com/cleboost/codegraph codegraph +``` + +### From source + +```sh +git clone https://github.com/cleboost/codegraph +cd codegraph +cargo build --release -p codegraph +# binary at target/release/codegraph +``` + +## Quick start + +```sh +# 1. Index this project +cd ~/code/my-project +codegraph init -i + +# 2. Hook up your editor(s) +codegraph install + +# 3. Use it +codegraph query UserService +codegraph context "auth middleware" +``` + +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 + +| Command | What it does | +|---|---| +| `codegraph install` | Detect installed agents and wire them up | +| `codegraph init [-i]` | Create `.codegraph/`; `-i` indexes immediately | +| `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) | + +Global flag `--path ` overrides the workspace root. + +## Supported languages + +15 languages with full tree-sitter extraction: + +TypeScript · TSX · JavaScript · Python · Go · Rust · Java · C · C++ · C# · +Ruby · PHP · Scala · Swift · Lua + +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) + +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. + +## MCP tools + +Agents see nine tools through the MCP server: + +| 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 | + +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. + +## Architecture + +``` +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) +``` + +Pipeline: + +``` +files → ignore::WalkBuilder → rayon parse pool (tree-sitter) + ↓ + batched DB transactions (rusqlite WAL) + ↓ + ReferenceResolver (name-matcher, frameworks) + ↓ + GraphTraverser ← ContextBuilder + ↓ + MCP server / CLI commands +``` + +Full design in [`docs/PLAN.md`](docs/PLAN.md). One spec per crate in +[`docs/specs/`](docs/specs/). + +## Configuration + +A `.codegraph/` directory is created next to your project: + +``` +.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. + +## Why Rust? + +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. + +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` + +Result: **~30 MB** stripped, **sub-second** startup, **~5× faster** indexing +on the same workspace. + +## Status + +This is a **0.x** release. The MVP is functional end-to-end: + +- ✅ 15 languages indexed +- ✅ FTS5 search, graph traversal, impact analysis +- ✅ MCP stdio server with 9 tools +- ✅ Multi-agent installer (idempotent, sibling-preserving) +- ✅ File watcher + incremental sync +- ✅ Cross-platform release pipeline (Linux/macOS/Windows) + +Still to come: + +- 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 + +See [`docs/PLAN.md`](docs/PLAN.md) for the roadmap. + +## Development + +```sh +cargo build --workspace +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +cargo fmt --all +``` + +Per-crate test runs: + +```sh +cargo test -p codegraph-db +cargo test -p codegraph-extract +cargo test -p codegraph-installer +``` + +## License + +MIT. See [LICENSE](LICENSE). + +## 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/crates/codegraph-context/src/lib.rs b/crates/codegraph-context/src/lib.rs index aa67110a..171aaa60 100644 --- a/crates/codegraph-context/src/lib.rs +++ b/crates/codegraph-context/src/lib.rs @@ -1,2 +1,105 @@ -//! Context builder: composes search + node + callers + callees into markdown/json. -//! TODO: formatter enum {Markdown, Json}, ContextBuilder API matching MCP tool surface. +//! 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::fmt::Write; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Format { Markdown, Json } + +impl Default for Format { fn default() -> Self { Format::Markdown } } + +#[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 mut hits = Vec::new(); + let candidates = db.search_nodes(&req.query, req.limit)?; + let trav = Traversal::new(db); + 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 { read_source_slice(&n) } else { None }; + hits.push(ContextHit { node: n, callers, callees, source }); + } + Ok(ContextResponse { query: req.query.clone(), hits }) +} + +fn read_source_slice(n: &Node) -> Option { + let text = std::fs::read_to_string(n.file.as_std_path()).ok()?; + let start = n.start_line.saturating_sub(1) as usize; + let end = (n.end_line as usize).min(text.lines().count()); + Some(text.lines().skip(start).take(end - start).collect::>().join("\n")) +} + +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-db/Cargo.toml b/crates/codegraph-db/Cargo.toml index 9734dc38..34b30424 100644 --- a/crates/codegraph-db/Cargo.toml +++ b/crates/codegraph-db/Cargo.toml @@ -13,3 +13,6 @@ 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 index 924743a7..fda2b029 100644 --- a/crates/codegraph-db/src/lib.rs +++ b/crates/codegraph-db/src/lib.rs @@ -1,7 +1,146 @@ -//! SQLite-backed knowledge graph storage (rusqlite, bundled, FTS5). +//! SQLite-backed knowledge graph storage. rusqlite bundled + FTS5. //! -//! Fresh schema — no backwards compatibility with archive TS DB. +//! Schema v1, no compat with archive TS DB. + +mod migrations; +mod model; +mod queries; + +pub use model::{DbStats, FileRow, NodeDraft, EdgeDraft}; + +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 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()) +} -// TODO: Connection wrapper, prepared statement cache, migrations, FTS5 index. +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..1a1c1768 --- /dev/null +++ b/crates/codegraph-db/src/queries.rs @@ -0,0 +1,305 @@ +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 { + tx.execute( + "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", + params![f.path.as_str(), f.language, f.sha256, f.size as i64, f.mtime, f.indexed_at], + ) + .map_err(db_err)?; + let id: i64 = tx + .query_row("SELECT id FROM files WHERE path=?1", [f.path.as_str()], |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 files_under(c: &Connection, prefix: &str) -> Result> { + let mut s = c + .prepare( + "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( + "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(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(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(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("?").take(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 index 17436423..35706667 100644 --- a/crates/codegraph-db/src/schema.sql +++ b/crates/codegraph-db/src/schema.sql @@ -1,8 +1,5 @@ -- codegraph schema v1 (Rust rewrite, fresh) --- TODO: port from archive/src/db/schema.sql with adjustments. - -PRAGMA journal_mode = WAL; -PRAGMA foreign_keys = ON; +-- PRAGMAs set by Db::open before migrations. CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, @@ -10,15 +7,17 @@ CREATE TABLE IF NOT EXISTS meta ( ); 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, + 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, @@ -32,24 +31,43 @@ CREATE TABLE IF NOT EXISTS nodes ( language TEXT NOT NULL ); -CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name); +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 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 + 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..444e6092 --- /dev/null +++ b/crates/codegraph-db/tests/smoke.rs @@ -0,0 +1,137 @@ +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 index cb0b13d5..bfa36744 100644 --- a/crates/codegraph-extract/Cargo.toml +++ b/crates/codegraph-extract/Cargo.toml @@ -7,7 +7,12 @@ 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 } @@ -21,19 +26,23 @@ 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 = { 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-kotlin", "lang-lua", + "lang-scala", "lang-swift", "lang-lua", ] lang-typescript = ["dep:tree-sitter-typescript"] lang-javascript = ["dep:tree-sitter-javascript"] @@ -48,5 +57,5 @@ 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-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 index bc6cff13..91b5a829 100644 --- a/crates/codegraph-extract/src/languages.rs +++ b/crates/codegraph-extract/src/languages.rs @@ -1,4 +1,32 @@ -//! Per-language extractor registry. One module per language behind a feature. -//! -//! TODO: trait Extractor { fn language() -> tree_sitter::Language; fn extract(...) -> ...; } -//! and one impl per supported language. +//! Per-language extractor modules. + +pub mod common; + +#[cfg(feature = "lang-typescript")] +pub mod typescript; +#[cfg(feature = "lang-javascript")] +pub mod javascript; +#[cfg(feature = "lang-python")] +pub mod python; +#[cfg(feature = "lang-rust")] +pub mod rust; +#[cfg(feature = "lang-go")] +pub mod go; +#[cfg(feature = "lang-java")] +pub mod java; +#[cfg(feature = "lang-c")] +pub mod c; +#[cfg(feature = "lang-cpp")] +pub mod cpp; +#[cfg(feature = "lang-csharp")] +pub mod csharp; +#[cfg(feature = "lang-ruby")] +pub mod ruby; +#[cfg(feature = "lang-php")] +pub mod php; +#[cfg(feature = "lang-scala")] +pub mod scala; +#[cfg(feature = "lang-swift")] +pub mod swift; +#[cfg(feature = "lang-lua")] +pub mod lua; diff --git a/crates/codegraph-extract/src/languages/c.rs b/crates/codegraph-extract/src/languages/c.rs new file mode 100644 index 00000000..3ec729b8 --- /dev/null +++ b/crates/codegraph-extract/src/languages/c.rs @@ -0,0 +1,36 @@ +use crate::languages::common::LangSpec; +use crate::lang_extractor; +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..7620087c --- /dev/null +++ b/crates/codegraph-extract/src/languages/common.rs @@ -0,0 +1,168 @@ +//! 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::{Extractor, ExtractResult, LocalEdge, PendingCall, RawImport}; +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 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 { + // Find a known identifier-like child (member/selector/field expressions). + 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 { + // Search depth-first for the last identifier-like token — usually the + // method/property being invoked in a member expression. + 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) => { + 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..d72b7f2d --- /dev/null +++ b/crates/codegraph-extract/src/languages/cpp.rs @@ -0,0 +1,38 @@ +use crate::languages::common::LangSpec; +use crate::lang_extractor; +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..b1d98530 --- /dev/null +++ b/crates/codegraph-extract/src/languages/csharp.rs @@ -0,0 +1,35 @@ +use crate::languages::common::LangSpec; +use crate::lang_extractor; +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..d96b6f05 --- /dev/null +++ b/crates/codegraph-extract/src/languages/go.rs @@ -0,0 +1,105 @@ +use crate::{parse_err, Extractor, ExtractResult, 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 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..b5d069f8 --- /dev/null +++ b/crates/codegraph-extract/src/languages/java.rs @@ -0,0 +1,38 @@ +use crate::languages::common::LangSpec; +use crate::lang_extractor; +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..d11076fd --- /dev/null +++ b/crates/codegraph-extract/src/languages/javascript.rs @@ -0,0 +1,95 @@ +use crate::{parse_err, Extractor, ExtractResult, 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 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..567dec16 --- /dev/null +++ b/crates/codegraph-extract/src/languages/lua.rs @@ -0,0 +1,23 @@ +use crate::languages::common::LangSpec; +use crate::lang_extractor; +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..156b66f8 --- /dev/null +++ b/crates/codegraph-extract/src/languages/php.rs @@ -0,0 +1,37 @@ +use crate::languages::common::LangSpec; +use crate::lang_extractor; +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..d7ff0347 --- /dev/null +++ b/crates/codegraph-extract/src/languages/python.rs @@ -0,0 +1,111 @@ +use crate::{parse_err, Extractor, ExtractResult, 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 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..fb327d10 --- /dev/null +++ b/crates/codegraph-extract/src/languages/ruby.rs @@ -0,0 +1,24 @@ +use crate::languages::common::LangSpec; +use crate::lang_extractor; +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..0170a64c --- /dev/null +++ b/crates/codegraph-extract/src/languages/rust.rs @@ -0,0 +1,124 @@ +use crate::{parse_err, Extractor, ExtractResult, 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 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..8c4b2eb5 --- /dev/null +++ b/crates/codegraph-extract/src/languages/scala.rs @@ -0,0 +1,27 @@ +use crate::languages::common::LangSpec; +use crate::lang_extractor; +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..acd9761a --- /dev/null +++ b/crates/codegraph-extract/src/languages/swift.rs @@ -0,0 +1,24 @@ +use crate::languages::common::LangSpec; +use crate::lang_extractor; +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..4996544c --- /dev/null +++ b/crates/codegraph-extract/src/languages/typescript.rs @@ -0,0 +1,184 @@ +use crate::{parse_err, Extractor, ExtractResult, 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 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) { + let mut c = node.walk(); + for ch in node.children(&mut c) { + if ch.kind() == "class_heritage" { + // Could emit extends/implements pending references — left as TODO for resolver. + let _ = ch; + } + } +} diff --git a/crates/codegraph-extract/src/lib.rs b/crates/codegraph-extract/src/lib.rs index debcefc9..58264602 100644 --- a/crates/codegraph-extract/src/lib.rs +++ b/crates/codegraph-extract/src/lib.rs @@ -1,8 +1,92 @@ //! Tree-sitter extraction orchestrator + per-language extractors. -//! -//! Native tree-sitter bindings (no WASM). Parallel parse via rayon. pub mod languages; +mod orchestrator; +mod walker; -// TODO: Orchestrator, file discovery (via `ignore`), parse worker pool, -// per-language extractor trait, standalone extractors (Svelte/Vue/Liquid/DFM). +pub use orchestrator::{ExtractStats, Orchestrator}; + +use codegraph_core::{Error, NodeKind, Result}; +use codegraph_db::{EdgeDraft, 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..65225d36 --- /dev/null +++ b/crates/codegraph-extract/src/orchestrator.rs @@ -0,0 +1,131 @@ +use crate::{walker, Extractor, ExtractResult}; +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, ext_idx: _ } 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, + ext_idx: usize, +} + +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, ext_idx: 0 })) +} diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs new file mode 100644 index 00000000..04e6b947 --- /dev/null +++ b/crates/codegraph-extract/src/walker.rs @@ -0,0 +1,36 @@ +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..90a39acc --- /dev/null +++ b/crates/codegraph-extract/tests/extract.rs @@ -0,0 +1,69 @@ +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 index b1c8d335..38f108af 100644 --- a/crates/codegraph-graph/Cargo.toml +++ b/crates/codegraph-graph/Cargo.toml @@ -8,3 +8,9 @@ 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 index 25b9e6f7..d0758df3 100644 --- a/crates/codegraph-graph/src/lib.rs +++ b/crates/codegraph-graph/src/lib.rs @@ -1,2 +1,103 @@ -//! Graph traversal: callers, callees, impact radius, path finding. -//! TODO: BFS/DFS over edges table, depth limits, kind filters. +//! 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) + } + + /// 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 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..5469a0ac --- /dev/null +++ b/crates/codegraph-graph/tests/traversal.rs @@ -0,0 +1,69 @@ +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 index 060af5da..c587016d 100644 --- a/crates/codegraph-installer/Cargo.toml +++ b/crates/codegraph-installer/Cargo.toml @@ -14,3 +14,6 @@ dirs = { 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 index fee3a0a6..1cbb57b5 100644 --- a/crates/codegraph-installer/src/lib.rs +++ b/crates/codegraph-installer/src/lib.rs @@ -1,9 +1,53 @@ //! Multi-agent installer. One target per file in `targets/`. -//! -//! Targets: claude, cursor, codex, opencode, hermes. -//! Idempotent install/uninstall, surgical edits preserving user formatting. pub mod targets; -// TODO: trait AgentTarget { fn name(); fn install(); fn uninstall(); fn detect(); } -// TODO: instructions-template (shared, agent-agnostic). +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, +} + +#[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), + ] +} + +/// 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.rs b/crates/codegraph-installer/src/targets.rs deleted file mode 100644 index 062c38aa..00000000 --- a/crates/codegraph-installer/src/targets.rs +++ /dev/null @@ -1 +0,0 @@ -// TODO: target registry + per-agent modules (claude/cursor/codex/opencode/hermes). diff --git a/crates/codegraph-installer/src/targets/claude.rs b/crates/codegraph-installer/src/targets/claude.rs new file mode 100644 index 00000000..cd924cdc --- /dev/null +++ b/crates/codegraph-installer/src/targets/claude.rs @@ -0,0 +1,107 @@ +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(p) = self.settings_path(opts) else { return DetectStatus::NotFound }; + if !p.exists() { return DetectStatus::NotFound; } + 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..b7e6a58b --- /dev/null +++ b/crates/codegraph-installer/src/targets/codex.rs @@ -0,0 +1,103 @@ +//! 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..4da0c8a1 --- /dev/null +++ b/crates/codegraph-installer/src/targets/cursor.rs @@ -0,0 +1,96 @@ +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(p) = self.mcp_path(opts) else { return DetectStatus::NotFound }; + if !p.exists() { return DetectStatus::NotFound; } + 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..e97c4acd --- /dev/null +++ b/crates/codegraph-installer/src/targets/hermes.rs @@ -0,0 +1,88 @@ +//! 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(p) = self.mcp_path(opts) else { return DetectStatus::NotFound }; + if !p.exists() { return DetectStatus::NotFound; } + 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..6fb31a3c --- /dev/null +++ b/crates/codegraph-installer/src/targets/mod.rs @@ -0,0 +1,6 @@ +pub mod claude; +pub mod codex; +pub mod cursor; +pub mod hermes; +pub mod opencode; +pub(crate) mod jsonutil; diff --git a/crates/codegraph-installer/src/targets/opencode.rs b/crates/codegraph-installer/src/targets/opencode.rs new file mode 100644 index 00000000..8a252f28 --- /dev/null +++ b/crates/codegraph-installer/src/targets/opencode.rs @@ -0,0 +1,155 @@ +//! 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())); } + // Strip comments to parse with serde_json. For round-trip preservation, + // a full surgical edit would use jsonc_parser::cst; for now we accept + // re-formatting on edit (parity will be added in a follow-up). + let stripped: String = strip_jsonc_comments(text); + Ok(serde_json::from_str(&stripped)?) + } +} + +fn strip_jsonc_comments(text: &str) -> String { + // Naive but safe enough: removes // line comments and /* */ block comments + // outside string literals. + 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 Some(p) = self.config_path(opts) 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(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..c1978184 --- /dev/null +++ b/crates/codegraph-installer/tests/install.rs @@ -0,0 +1,59 @@ +use camino::Utf8PathBuf; +use codegraph_installer::{project_registry as registry, DetectStatus, InstallOpts, InstallReport}; + +fn opts(root: &Utf8PathBuf) -> InstallOpts { + InstallOpts { + project_root: Some(root.clone()), + global: false, + binary_path: Utf8PathBuf::from("/usr/local/bin/codegraph"), + } +} + +#[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::NotFound); + 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 index 329aff2d..f072c6e5 100644 --- a/crates/codegraph-mcp/Cargo.toml +++ b/crates/codegraph-mcp/Cargo.toml @@ -15,3 +15,7 @@ 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 index 2b496864..a9fe274a 100644 --- a/crates/codegraph-mcp/src/lib.rs +++ b/crates/codegraph-mcp/src/lib.rs @@ -1,9 +1,98 @@ -//! MCP server (stdio JSON-RPC 2.0). Hand-rolled — no MCP SDK dep. -//! -//! Tools exposed: codegraph_search, codegraph_node, codegraph_callers, -//! codegraph_callees, codegraph_impact, codegraph_context, codegraph_explore, -//! codegraph_files, codegraph_status. -//! -//! TODO: transport (stdio framing), dispatch, tool handlers, server-instructions string. +//! MCP server (stdio JSON-RPC 2.0). Hand-rolled, no SDK. + +mod protocol; +mod tools; + +pub use protocol::{Response, ErrorObj, JsonRpcMessage}; +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..7344737e --- /dev/null +++ b/crates/codegraph-mcp/src/protocol.rs @@ -0,0 +1,38 @@ +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 index 2d76fb19..9465b6ad 100644 --- a/crates/codegraph-mcp/src/server-instructions.md +++ b/crates/codegraph-mcp/src/server-instructions.md @@ -1,3 +1,31 @@ # Codegraph — code intelligence over an indexed knowledge graph -(TODO: port from archive/src/mcp/server-instructions.ts) +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..b46f96e7 --- /dev/null +++ b/crates/codegraph-mcp/src/tools.rs @@ -0,0 +1,114 @@ +use codegraph_context::{build, ContextRequest, Format}; +use codegraph_db::Db; +use codegraph_graph::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_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_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 index bcd844b8..e8d74574 100644 --- a/crates/codegraph-resolve/Cargo.toml +++ b/crates/codegraph-resolve/Cargo.toml @@ -13,3 +13,6 @@ serde_json = { workspace = true } camino = { workspace = true } globset = { workspace = true } tracing = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/codegraph-resolve/src/lib.rs b/crates/codegraph-resolve/src/lib.rs index a1e36007..ae446734 100644 --- a/crates/codegraph-resolve/src/lib.rs +++ b/crates/codegraph-resolve/src/lib.rs @@ -1,10 +1,85 @@ -//! Reference resolution: imports, name-matching, framework patterns. +//! Reference resolution: name-match pending calls into actual `calls` edges. //! -//! TODO: import-resolver (tsconfig path aliases, cargo workspace members), -//! name-matcher, frameworks/{express,laravel,rails,fastapi,django,flask, -//! spring,gin,axum,aspnet,vapor,react-router,sveltekit,vue-nuxt,cargo,nestjs, -//! drupal}. Emits route nodes + references edges. +//! MVP: in-process resolver invoked by the orchestrator after a batch. +//! Strategy: for each PendingCall { from, target_name, line }, look up nodes +//! named `target_name` of kind function|method. If exactly one match in the +//! same file, link directly. If multiple, link to all (cheap recall over +//! precision). pub mod frameworks; pub mod imports; pub mod name_match; + +use codegraph_core::{EdgeKind, NodeId, Result}; +use codegraph_db::{Db, EdgeDraft}; +use std::collections::HashMap; + +/// 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); } + // Group by target_name to batch lookups. + 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; } + // Filter to callable kinds. + 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 { + // Prefer same-file match. If none, link all callable (recall over precision). + let same_file: Vec<_> = callable + .iter() + .filter(|n| { + self.db.file_by_path(n.file.as_str()) + .ok().flatten() + .and_then(|f| f.id) + .map(|id| id == site.file_id) + .unwrap_or(false) + }) + .collect(); + let targets: Vec<_> = if !same_file.is_empty() { + same_file.into_iter().collect() + } else { + callable.iter().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) + } +} diff --git a/crates/codegraph/Cargo.toml b/crates/codegraph/Cargo.toml index e951a802..6ca11d76 100644 --- a/crates/codegraph/Cargo.toml +++ b/crates/codegraph/Cargo.toml @@ -19,6 +19,7 @@ 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 } diff --git a/crates/codegraph/src/main.rs b/crates/codegraph/src/main.rs index bc19e80d..bba968cd 100644 --- a/crates/codegraph/src/main.rs +++ b/crates/codegraph/src/main.rs @@ -1,19 +1,30 @@ -use anyhow::Result; +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"; -/// codegraph — local-first code intelligence #[derive(Parser, Debug)] -#[command(name = "codegraph", version, about)] +#[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 { - /// Interactive multi-agent installer (default when run with no args). - Install, - /// Initialize .codegraph/ in the current directory and build the index. + /// Initialize .codegraph/ in the current directory. Init { #[arg(short, long)] index: bool, @@ -24,21 +35,24 @@ enum Cmd { Index, /// Incremental sync of changed files. Sync, - /// Show index health, backend, sizes. + /// Show index health. Status, - /// Search nodes by name / signature / docstring. - Query { query: String }, - /// List indexed files under a path. + /// 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 context for a symbol or topic. - Context { target: String }, - /// Show impact radius for a node. - Affected { node: String }, + /// 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, + #[arg(long)] mcp: bool, }, + /// Multi-agent installer (placeholder). + Install, } fn main() -> Result<()> { @@ -51,17 +65,158 @@ fn main() -> Result<()> { .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()))?, + }; + match cli.cmd.unwrap_or(Cmd::Install) { - Cmd::Install => todo!("installer"), - Cmd::Init { .. } => todo!("init"), - Cmd::Uninit => todo!("uninit"), - Cmd::Index => todo!("index"), - Cmd::Sync => todo!("sync"), - Cmd::Status => todo!("status"), - Cmd::Query { .. } => todo!("query"), - Cmd::Files { .. } => todo!("files"), - Cmd::Context { .. } => todo!("context"), - Cmd::Affected { .. } => todo!("affected"), - Cmd::Serve { .. } => todo!("mcp serve"), + Cmd::Init { index } => cmd_init(&root, 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_install(&root), + } +} + +fn cmd_install(root: &Utf8Path) -> Result<()> { + use codegraph_installer::{registry, InstallOpts, InstallReport}; + 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, + }; + for target in registry() { + let status = target.detect(&opts); + eprintln!("[{}] detected: {:?}", target.id(), status); + let report = target.install(&opts)?; + match report { + InstallReport::Installed(p) | InstallReport::Updated(p) => { + for f in p { eprintln!(" wrote {}", f); } + } + InstallReport::Unchanged => eprintln!(" unchanged"), + InstallReport::Skipped(r) => eprintln!(" skipped: {}", r), + } + } + Ok(()) +} + +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); + } + 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..19f9f510 --- /dev/null +++ b/crates/codegraph/src/watcher.rs @@ -0,0 +1,40 @@ +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/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..4e6c1e36 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# codegraph install script +# Usage: curl -fsSL https://raw.githubusercontent.com/cleboost/codegraph/main/scripts/install.sh | sh + +set -eu + +REPO="cleboost/codegraph" +BIN_NAME="codegraph" +INSTALL_DIR="${CODEGRAPH_INSTALL_DIR:-$HOME/.local/bin}" + +uname_s="$(uname -s | tr '[:upper:]' '[:lower:]')" +uname_m="$(uname -m)" + +case "$uname_s/$uname_m" in + linux/x86_64) target="x86_64-unknown-linux-musl" ;; + linux/aarch64) target="aarch64-unknown-linux-gnu" ;; + darwin/x86_64) target="x86_64-apple-darwin" ;; + darwin/arm64) target="aarch64-apple-darwin" ;; + *) echo "unsupported platform: $uname_s/$uname_m" >&2; exit 1 ;; +esac + +tag="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep -m1 tag_name | sed -E 's/.*"([^"]+)".*/\1/')" +[ -n "$tag" ] || { echo "could not detect latest tag" >&2; exit 1; } + +url="https://github.com/$REPO/releases/download/$tag/codegraph-$target.tar.gz" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +echo "Downloading $url" +curl -fsSL "$url" -o "$tmp/cg.tar.gz" +tar -xzf "$tmp/cg.tar.gz" -C "$tmp" +mkdir -p "$INSTALL_DIR" +install -m 0755 "$tmp/$BIN_NAME" "$INSTALL_DIR/$BIN_NAME" + +echo "Installed $BIN_NAME $tag to $INSTALL_DIR" +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) echo "Note: $INSTALL_DIR is not in PATH. Add it or move the binary." ;; +esac From faf3dda0457ee5e31990018918b4ba49094df203 Mon Sep 17 00:00:00 2001 From: Cleboost Date: Fri, 22 May 2026 20:27:39 +0200 Subject: [PATCH 04/33] feat(release): enhance GitHub Actions for AUR packaging and release management --- .github/workflows/release.yml | 90 +++++++++++++++++++++++++++- packaging/aur/README.md | 45 ++++++++++++++ packaging/aur/codegraph-bin/.SRCINFO | 18 ++++++ packaging/aur/codegraph-bin/PKGBUILD | 21 +++++++ 4 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 packaging/aur/README.md create mode 100644 packaging/aur/codegraph-bin/.SRCINFO create mode 100644 packaging/aur/codegraph-bin/PKGBUILD diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5e3506b..7a7f9d58 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,10 @@ on: push: tags: ["v*"] workflow_dispatch: + inputs: + tag: + description: "Tag (e.g. v0.1.0). Required for manual runs." + required: false permissions: contents: write @@ -78,16 +82,96 @@ jobs: name: GitHub Release needs: build runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') + 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@v4 - uses: actions/download-artifact@v4 with: path: artifacts merge-multiple: true + + - name: Resolve tag + id: tag + run: | + 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 + 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: Create release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - tag="${GITHUB_REF#refs/tags/}" - gh release create "$tag" --draft --title "$tag" --generate-notes artifacts/* + 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-bin) + needs: release + runs-on: ubuntu-latest + if: ${{ needs.release.outputs.tag != '' }} + steps: + - uses: actions/checkout@v4 + + - name: Download release archives + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.release.outputs.tag }} + run: | + 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 + .SRCINFO + env: + VERSION: ${{ needs.release.outputs.version }} + run: | + set -euo pipefail + x86_sha=$(cat dl/x86_64.sha256) + arm_sha=$(cat dl/aarch64.sha256) + cd packaging/aur/codegraph-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 + + docker run --rm -v "$PWD:/pkg" -w /pkg archlinux:base-devel bash -c ' + useradd -m builder && chown -R builder /pkg + su builder -c "makepkg --printsrcinfo > .SRCINFO" + ' + echo "--- PKGBUILD ---"; cat PKGBUILD + echo "--- .SRCINFO ---"; cat .SRCINFO + + - name: Publish to AUR + uses: KSXGitHub/github-actions-deploy-aur@v3.0.1 + with: + pkgname: codegraph-bin + pkgbuild: packaging/aur/codegraph-bin/PKGBUILD + assets: | + packaging/aur/codegraph-bin/.SRCINFO + 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/packaging/aur/README.md b/packaging/aur/README.md new file mode 100644 index 00000000..6c2af437 --- /dev/null +++ b/packaging/aur/README.md @@ -0,0 +1,45 @@ +# AUR packaging + +`codegraph-bin` — installs the prebuilt static binary from GitHub Releases. + +## One-time setup + +1. Create the package on AUR (manual, first time): + ```sh + ssh aur@aur.archlinux.org setup-repo codegraph-bin + git clone ssh://aur@aur.archlinux.org/codegraph-bin.git + cp packaging/aur/codegraph-bin/PKGBUILD packaging/aur/codegraph-bin/.SRCINFO codegraph-bin/ + cd codegraph-bin && git add . && git commit -m "init" && git push + ``` + +2. Add an AUR SSH key (ed25519) and register the **public** half at + . Add the **private** half + the + maintainer username/email as GitHub repo secrets: + + - `AUR_SSH_PRIVATE_KEY` + - `AUR_USERNAME` + - `AUR_EMAIL` + +## Release flow (automated) + +The `aur` job in `.github/workflows/release.yml` runs after the `release` +job succeeds: + +1. Downloads `codegraph-x86_64-unknown-linux-musl.tar.gz` and the aarch64 + variant from the release. +2. Computes SHA-256 sums. +3. Patches `PKGBUILD` (`pkgver`, `pkgrel=1`, both `sha256sums`). +4. Regenerates `.SRCINFO` via `makepkg --printsrcinfo` inside an + `archlinux:base-devel` container. +5. Pushes the updated package to AUR via SSH. + +Trigger manually with `gh workflow run release.yml -f tag=v0.1.0` (re-runs +the full release pipeline for that tag). + +## Local test + +```sh +cd packaging/aur/codegraph-bin +# edit pkgver to a real released version +makepkg -si +``` diff --git a/packaging/aur/codegraph-bin/.SRCINFO b/packaging/aur/codegraph-bin/.SRCINFO new file mode 100644 index 00000000..b8fb9038 --- /dev/null +++ b/packaging/aur/codegraph-bin/.SRCINFO @@ -0,0 +1,18 @@ +pkgbase = codegraph-bin + pkgdesc = Local-first code intelligence: tree-sitter knowledge graph + MCP server (prebuilt binary) + pkgver = 0.0.0 + pkgrel = 1 + url = https://github.com/cleboost/codegraph + arch = x86_64 + arch = aarch64 + license = MIT + provides = codegraph + conflicts = codegraph + options = !strip + options = !debug + source_x86_64 = codegraph-bin-0.0.0-x86_64.tar.gz::https://github.com/cleboost/codegraph/releases/download/v0.0.0/codegraph-x86_64-unknown-linux-musl.tar.gz + sha256sums_x86_64 = SKIP + source_aarch64 = codegraph-bin-0.0.0-aarch64.tar.gz::https://github.com/cleboost/codegraph/releases/download/v0.0.0/codegraph-aarch64-unknown-linux-gnu.tar.gz + sha256sums_aarch64 = SKIP + +pkgname = codegraph-bin diff --git a/packaging/aur/codegraph-bin/PKGBUILD b/packaging/aur/codegraph-bin/PKGBUILD new file mode 100644 index 00000000..35a9a4e5 --- /dev/null +++ b/packaging/aur/codegraph-bin/PKGBUILD @@ -0,0 +1,21 @@ +# Maintainer: Cleboost +pkgname=codegraph-bin +pkgver=0.0.0 +pkgrel=1 +pkgdesc="Local-first code intelligence: tree-sitter knowledge graph + MCP server (prebuilt binary)" +arch=('x86_64' 'aarch64') +url="https://github.com/cleboost/codegraph" +license=('MIT') +provides=('codegraph') +conflicts=('codegraph') +options=(!strip !debug) + +source_x86_64=("$pkgname-$pkgver-x86_64.tar.gz::$url/releases/download/v$pkgver/codegraph-x86_64-unknown-linux-musl.tar.gz") +source_aarch64=("$pkgname-$pkgver-aarch64.tar.gz::$url/releases/download/v$pkgver/codegraph-aarch64-unknown-linux-gnu.tar.gz") + +sha256sums_x86_64=('SKIP') +sha256sums_aarch64=('SKIP') + +package() { + install -Dm755 "$srcdir/codegraph" "$pkgdir/usr/bin/codegraph" +} From 6b9ae209cc70c021050b27b7473b57f05077e9d2 Mon Sep 17 00:00:00 2001 From: Cleboost Date: Fri, 22 May 2026 20:36:19 +0200 Subject: [PATCH 05/33] refactor(modules): reorganize language module imports and improve code formatting --- crates/codegraph-context/src/lib.rs | 49 ++++-- crates/codegraph-db/src/lib.rs | 36 ++-- crates/codegraph-db/src/queries.rs | 151 +++++++++++++---- crates/codegraph-db/tests/smoke.rs | 23 ++- crates/codegraph-extract/src/languages.rs | 32 ++-- crates/codegraph-extract/src/languages/c.rs | 11 +- .../codegraph-extract/src/languages/common.rs | 115 ++++++++++--- crates/codegraph-extract/src/languages/cpp.rs | 11 +- .../codegraph-extract/src/languages/csharp.rs | 10 +- crates/codegraph-extract/src/languages/go.rs | 146 +++++++++++++---- .../codegraph-extract/src/languages/java.rs | 6 +- .../src/languages/javascript.rs | 143 ++++++++++++---- crates/codegraph-extract/src/languages/lua.rs | 6 +- crates/codegraph-extract/src/languages/php.rs | 6 +- .../codegraph-extract/src/languages/python.rs | 145 ++++++++++++---- .../codegraph-extract/src/languages/ruby.rs | 6 +- .../codegraph-extract/src/languages/rust.rs | 121 ++++++++++---- .../codegraph-extract/src/languages/scala.rs | 6 +- .../codegraph-extract/src/languages/swift.rs | 6 +- .../src/languages/typescript.rs | 155 +++++++++++++----- crates/codegraph-extract/src/lib.rs | 12 +- crates/codegraph-extract/src/orchestrator.rs | 27 ++- crates/codegraph-extract/src/walker.rs | 25 ++- crates/codegraph-extract/tests/extract.rs | 48 ++++-- crates/codegraph-graph/src/lib.rs | 50 ++++-- crates/codegraph-graph/tests/traversal.rs | 71 ++++++-- crates/codegraph-installer/src/lib.rs | 11 +- .../codegraph-installer/src/targets/claude.rs | 63 +++++-- .../codegraph-installer/src/targets/codex.rs | 67 ++++++-- .../codegraph-installer/src/targets/cursor.rs | 76 +++++++-- .../codegraph-installer/src/targets/hermes.rs | 81 ++++++--- crates/codegraph-installer/src/targets/mod.rs | 2 +- .../src/targets/opencode.rs | 119 ++++++++++---- crates/codegraph-installer/tests/install.rs | 48 ++++-- crates/codegraph-mcp/src/lib.rs | 34 +++- crates/codegraph-mcp/src/protocol.rs | 16 +- crates/codegraph-mcp/src/tools.rs | 73 ++++++--- crates/codegraph-resolve/src/lib.rs | 41 +++-- crates/codegraph/src/main.rs | 61 +++++-- crates/codegraph/src/watcher.rs | 8 +- 40 files changed, 1603 insertions(+), 514 deletions(-) diff --git a/crates/codegraph-context/src/lib.rs b/crates/codegraph-context/src/lib.rs index 171aaa60..271439ec 100644 --- a/crates/codegraph-context/src/lib.rs +++ b/crates/codegraph-context/src/lib.rs @@ -6,11 +6,13 @@ use codegraph_graph::Traversal; use serde::{Deserialize, Serialize}; use std::fmt::Write; -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] -pub enum Format { Markdown, Json } - -impl Default for Format { fn default() -> Self { Format::Markdown } } +pub enum Format { + #[default] + Markdown, + Json, +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextRequest { @@ -23,7 +25,13 @@ pub struct ContextRequest { impl Default for ContextRequest { fn default() -> Self { - Self { query: String::new(), depth: 1, include_source: false, limit: 5, format: Format::Markdown } + Self { + query: String::new(), + depth: 1, + include_source: false, + limit: 5, + format: Format::Markdown, + } } } @@ -56,17 +64,35 @@ pub fn build_response(db: &Db, req: &ContextRequest) -> Result 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 { read_source_slice(&n) } else { None }; - hits.push(ContextHit { node: n, callers, callees, source }); + let source = if req.include_source { + read_source_slice(&n) + } else { + None + }; + hits.push(ContextHit { + node: n, + callers, + callees, + source, + }); } - Ok(ContextResponse { query: req.query.clone(), hits }) + Ok(ContextResponse { + query: req.query.clone(), + hits, + }) } fn read_source_slice(n: &Node) -> Option { let text = std::fs::read_to_string(n.file.as_std_path()).ok()?; let start = n.start_line.saturating_sub(1) as usize; let end = (n.end_line as usize).min(text.lines().count()); - Some(text.lines().skip(start).take(end - start).collect::>().join("\n")) + Some( + text.lines() + .skip(start) + .take(end - start) + .collect::>() + .join("\n"), + ) } fn render_markdown(resp: &ContextResponse) -> String { @@ -80,7 +106,10 @@ fn render_markdown(resp: &ContextResponse) -> String { let _ = writeln!( out, "\n## `{}` — {} — `{}:{}`", - h.node.name, h.node.kind.as_str(), h.node.file, h.node.start_line + 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); diff --git a/crates/codegraph-db/src/lib.rs b/crates/codegraph-db/src/lib.rs index fda2b029..cc25f5e7 100644 --- a/crates/codegraph-db/src/lib.rs +++ b/crates/codegraph-db/src/lib.rs @@ -6,7 +6,7 @@ mod migrations; mod model; mod queries; -pub use model::{DbStats, FileRow, NodeDraft, EdgeDraft}; +pub use model::{DbStats, EdgeDraft, FileRow, NodeDraft}; use camino::{Utf8Path, Utf8PathBuf}; use codegraph_core::{Edge, EdgeKind, Error, Node, NodeId, NodeKind, Result}; @@ -27,11 +27,17 @@ impl Db { 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)?; + 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() }) + Ok(Self { + conn: Mutex::new(conn), + path: path.to_path_buf(), + }) } pub fn open_read_only(path: &Utf8Path) -> Result { @@ -40,10 +46,15 @@ impl Db { 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() }) + Ok(Self { + conn: Mutex::new(conn), + path: path.to_path_buf(), + }) } - pub fn path(&self) -> &Utf8Path { &self.path } + pub fn path(&self) -> &Utf8Path { + &self.path + } pub fn schema_version(&self) -> Result { let c = self.conn.lock(); @@ -60,7 +71,8 @@ impl Db { 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)?; + c.execute("DELETE FROM files WHERE id = ?", [file_id]) + .map_err(db_err)?; Ok(()) } @@ -142,5 +154,9 @@ 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() } +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/queries.rs b/crates/codegraph-db/src/queries.rs index 1a1c1768..9f142573 100644 --- a/crates/codegraph-db/src/queries.rs +++ b/crates/codegraph-db/src/queries.rs @@ -5,7 +5,11 @@ 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)) + .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)) @@ -21,11 +25,22 @@ pub(crate) fn upsert_file(tx: &Transaction, f: &FileRow) -> Result { size=excluded.size, mtime=excluded.mtime, indexed_at=excluded.indexed_at", - params![f.path.as_str(), f.language, f.sha256, f.size as i64, f.mtime, f.indexed_at], + params![ + f.path.as_str(), + f.language, + f.sha256, + f.size as i64, + f.mtime, + f.indexed_at + ], ) .map_err(db_err)?; let id: i64 = tx - .query_row("SELECT id FROM files WHERE path=?1", [f.path.as_str()], |r| r.get(0)) + .query_row( + "SELECT id FROM files WHERE path=?1", + [f.path.as_str()], + |r| r.get(0), + ) .map_err(db_err)?; Ok(id) } @@ -50,7 +65,9 @@ pub(crate) fn files_under(c: &Connection, prefix: &str) -> Result> 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)?); } + for r in it { + out.push(r.map_err(db_err)?); + } Ok(out) } @@ -130,7 +147,9 @@ pub(crate) fn nodes_by_name(c: &Connection, name: &str) -> Result> { .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)?); } + for r in it { + out.push(r.map_err(db_err)?); + } Ok(out) } @@ -154,7 +173,9 @@ pub(crate) fn search_fts(c: &Connection, q: &str, limit: u32) -> Result Result Result Re } 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("?").take(kinds.len()).collect::>().join(","); + 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 @@ -199,20 +232,34 @@ fn edges_any(c: &Connection, id: NodeId, kinds: &[EdgeKind], from: bool) -> Resu 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))); } + 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)?); } + 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)?; + 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, @@ -238,8 +285,13 @@ fn row_to_file(r: &Row<'_>) -> rusqlite::Result { 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 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)?, @@ -257,8 +309,13 @@ fn row_to_node(r: &Row<'_>) -> rusqlite::Result { 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 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)?, @@ -272,19 +329,37 @@ fn row_to_edge(r: &Row<'_>) -> rusqlite::Result { #[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) } + 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, + "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, }) } @@ -292,10 +367,18 @@ fn parse_node_kind(s: &str) -> Option { 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, + "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, }) } diff --git a/crates/codegraph-db/tests/smoke.rs b/crates/codegraph-db/tests/smoke.rs index 444e6092..2f88e48b 100644 --- a/crates/codegraph-db/tests/smoke.rs +++ b/crates/codegraph-db/tests/smoke.rs @@ -54,7 +54,13 @@ 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)]) + .insert_nodes( + fid, + &[ + mk_node("foo", NodeKind::Function), + mk_node("bar", NodeKind::Function), + ], + ) .unwrap(); assert_eq!(ids.len(), 2); @@ -108,11 +114,18 @@ fn fts_search() { 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(); + 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(); + 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(); diff --git a/crates/codegraph-extract/src/languages.rs b/crates/codegraph-extract/src/languages.rs index 91b5a829..95b7d90f 100644 --- a/crates/codegraph-extract/src/languages.rs +++ b/crates/codegraph-extract/src/languages.rs @@ -2,31 +2,31 @@ pub mod common; -#[cfg(feature = "lang-typescript")] -pub mod typescript; -#[cfg(feature = "lang-javascript")] -pub mod javascript; -#[cfg(feature = "lang-python")] -pub mod python; -#[cfg(feature = "lang-rust")] -pub mod rust; -#[cfg(feature = "lang-go")] -pub mod go; -#[cfg(feature = "lang-java")] -pub mod java; #[cfg(feature = "lang-c")] pub mod c; #[cfg(feature = "lang-cpp")] pub mod cpp; #[cfg(feature = "lang-csharp")] pub mod csharp; -#[cfg(feature = "lang-ruby")] -pub mod ruby; +#[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-lua")] -pub mod lua; +#[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 index 3ec729b8..fcf2254c 100644 --- a/crates/codegraph-extract/src/languages/c.rs +++ b/crates/codegraph-extract/src/languages/c.rs @@ -1,15 +1,20 @@ -use crate::languages::common::LangSpec; 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 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()); + return ch.utf8_text(src).ok().map(|s| { + s.trim_matches(|c| c == '"' || c == '<' || c == '>') + .to_string() + }); } } None diff --git a/crates/codegraph-extract/src/languages/common.rs b/crates/codegraph-extract/src/languages/common.rs index 7620087c..c405b26f 100644 --- a/crates/codegraph-extract/src/languages/common.rs +++ b/crates/codegraph-extract/src/languages/common.rs @@ -4,7 +4,9 @@ //! common walker handles tree-sitter traversal, name extraction, signature //! capture, `contains` edges, and import/call emission. -use crate::{Extractor, ExtractResult, LocalEdge, PendingCall, RawImport}; +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}; @@ -25,15 +27,24 @@ pub struct LangSpec { /// 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 Option>, + 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 }; + 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) } @@ -60,28 +71,49 @@ fn walk(node: &Node, ctx: &mut 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.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); } + 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_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; } + 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 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()); + .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(), + 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) } @@ -90,16 +122,28 @@ 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; + 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 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 { @@ -109,7 +153,9 @@ fn emit_call(node: &Node, ctx: &mut Ctx) { 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, + from_idx: from, + target_name: n, + line: node.start_position().row as u32 + 1, }); } @@ -122,11 +168,15 @@ fn first_identifier_of_kinds(n: &Node, kinds: &[&str], src: &[u8]) -> Option = ch.children(&mut cc).collect(); - if !next.is_empty() { stack.push(next); } + if !next.is_empty() { + stack.push(next); + } } else { stack.pop(); } @@ -142,9 +192,13 @@ fn emit_import(node: &Node, ctx: &mut Ctx) { }; let Some(m) = module else { return }; let from = ctx.parent_idx.unwrap_or(usize::MAX); - if from == usize::MAX { return; } + if from == usize::MAX { + return; + } ctx.result.imports.push(RawImport { - from_idx: from, module: m, line: node.start_position().row as u32 + 1, + from_idx: from, + module: m, + line: node.start_position().row as u32 + 1, }); } @@ -152,14 +206,23 @@ fn emit_import(node: &Node, ctx: &mut Ctx) { #[macro_export] macro_rules! lang_extractor { ($struct:ident, $spec:expr) => { + #[derive(Default)] pub struct $struct; impl $struct { - pub fn new() -> Self { Self } + 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 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 index d72b7f2d..eb81f935 100644 --- a/crates/codegraph-extract/src/languages/cpp.rs +++ b/crates/codegraph-extract/src/languages/cpp.rs @@ -1,15 +1,20 @@ -use crate::languages::common::LangSpec; 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 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()); + return ch.utf8_text(src).ok().map(|s| { + s.trim_matches(|c| c == '"' || c == '<' || c == '>') + .to_string() + }); } } None diff --git a/crates/codegraph-extract/src/languages/csharp.rs b/crates/codegraph-extract/src/languages/csharp.rs index b1d98530..0a8e2aee 100644 --- a/crates/codegraph-extract/src/languages/csharp.rs +++ b/crates/codegraph-extract/src/languages/csharp.rs @@ -1,12 +1,16 @@ -use crate::languages::common::LangSpec; 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 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()) + n.child_by_field_name("name") + .and_then(|x| x.utf8_text(src).ok()) + .map(|s| s.to_string()) } pub static SPEC: LangSpec = LangSpec { diff --git a/crates/codegraph-extract/src/languages/go.rs b/crates/codegraph-extract/src/languages/go.rs index d96b6f05..c0ff3b37 100644 --- a/crates/codegraph-extract/src/languages/go.rs +++ b/crates/codegraph-extract/src/languages/go.rs @@ -1,35 +1,67 @@ -use crate::{parse_err, Extractor, ExtractResult, LocalEdge, PendingCall, RawImport}; +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 } +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() } } + 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 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 }; + 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 } +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); } + "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 @@ -42,21 +74,36 @@ fn walk(node: &Node, ctx: &mut Ctx) { .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); } + "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.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); } + for ch in node.children(&mut c) { + walk(&ch, ctx); + } ctx.parent_idx = prev; } @@ -65,41 +112,78 @@ fn push_named(ctx: &mut Ctx, node: &Node, kind: NodeKind) -> Option { 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; } + 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; } + 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 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()); + .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(), + 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 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()), + "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 }); + 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 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 }); + 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 index b5d069f8..600b2246 100644 --- a/crates/codegraph-extract/src/languages/java.rs +++ b/crates/codegraph-extract/src/languages/java.rs @@ -1,9 +1,11 @@ -use crate::languages::common::LangSpec; 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 ts_language() -> tree_sitter::Language { + tree_sitter_java::LANGUAGE.into() +} fn import_path(n: &Node, src: &[u8]) -> Option { let mut c = n.walk(); diff --git a/crates/codegraph-extract/src/languages/javascript.rs b/crates/codegraph-extract/src/languages/javascript.rs index d11076fd..fc2c52ed 100644 --- a/crates/codegraph-extract/src/languages/javascript.rs +++ b/crates/codegraph-extract/src/languages/javascript.rs @@ -1,29 +1,57 @@ -use crate::{parse_err, Extractor, ExtractResult, LocalEdge, PendingCall, RawImport}; +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 } +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() } } + 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 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 }; + 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 } +struct Ctx<'a> { + src: &'a [u8], + result: ExtractResult, + parent_idx: Option, +} fn walk(node: &Node, ctx: &mut Ctx) { let mut pushed: Option = None; @@ -31,22 +59,39 @@ fn walk(node: &Node, ctx: &mut Ctx) { "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); } + "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.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); } + for ch in node.children(&mut c) { + walk(&ch, ctx); + } ctx.parent_idx = prev; } @@ -55,41 +100,77 @@ fn push_named(ctx: &mut Ctx, node: &Node, kind: NodeKind) -> Option { 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; } + 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; } + 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 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()); + .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(), + 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 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()), + "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 }); + 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 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 }); + 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 index 567dec16..f08ce55b 100644 --- a/crates/codegraph-extract/src/languages/lua.rs +++ b/crates/codegraph-extract/src/languages/lua.rs @@ -1,8 +1,10 @@ -use crate::languages::common::LangSpec; use crate::lang_extractor; +use crate::languages::common::LangSpec; use codegraph_core::NodeKind; -fn ts_language() -> tree_sitter::Language { tree_sitter_lua::LANGUAGE.into() } +fn ts_language() -> tree_sitter::Language { + tree_sitter_lua::LANGUAGE.into() +} pub static SPEC: LangSpec = LangSpec { language_name: "lua", diff --git a/crates/codegraph-extract/src/languages/php.rs b/crates/codegraph-extract/src/languages/php.rs index 156b66f8..4a58093f 100644 --- a/crates/codegraph-extract/src/languages/php.rs +++ b/crates/codegraph-extract/src/languages/php.rs @@ -1,9 +1,11 @@ -use crate::languages::common::LangSpec; 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 ts_language() -> tree_sitter::Language { + tree_sitter_php::LANGUAGE_PHP.into() +} fn import_path(n: &Node, src: &[u8]) -> Option { let mut c = n.walk(); diff --git a/crates/codegraph-extract/src/languages/python.rs b/crates/codegraph-extract/src/languages/python.rs index d7ff0347..cf413066 100644 --- a/crates/codegraph-extract/src/languages/python.rs +++ b/crates/codegraph-extract/src/languages/python.rs @@ -1,67 +1,124 @@ -use crate::{parse_err, Extractor, ExtractResult, LocalEdge, PendingCall, RawImport}; +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 } +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() } } + 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 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 }; + 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 } +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); } + "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.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); } + 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; } + 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 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()); + .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)); + 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(), + kind, + name, + qualified_name: None, + start_line: start, + end_line: end, + signature: sig, + docstring, + language: "python".into(), }); Some(ctx.result.nodes.len() - 1) } @@ -69,30 +126,50 @@ fn push_named(ctx: &mut Ctx, node: &Node, kind: NodeKind) -> Option { 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 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 } + 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 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()), + "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 }); + 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()) + 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; @@ -106,6 +183,12 @@ fn emit_import(node: &Node, ctx: &mut Ctx) { }; 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 }); + 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 index fb327d10..55066cc5 100644 --- a/crates/codegraph-extract/src/languages/ruby.rs +++ b/crates/codegraph-extract/src/languages/ruby.rs @@ -1,8 +1,10 @@ -use crate::languages::common::LangSpec; use crate::lang_extractor; +use crate::languages::common::LangSpec; use codegraph_core::NodeKind; -fn ts_language() -> tree_sitter::Language { tree_sitter_ruby::LANGUAGE.into() } +fn ts_language() -> tree_sitter::Language { + tree_sitter_ruby::LANGUAGE.into() +} pub static SPEC: LangSpec = LangSpec { language_name: "ruby", diff --git a/crates/codegraph-extract/src/languages/rust.rs b/crates/codegraph-extract/src/languages/rust.rs index 0170a64c..6b6fb425 100644 --- a/crates/codegraph-extract/src/languages/rust.rs +++ b/crates/codegraph-extract/src/languages/rust.rs @@ -1,23 +1,47 @@ -use crate::{parse_err, Extractor, ExtractResult, LocalEdge, PendingCall, RawImport}; +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 } +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() } } + 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 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 }; + 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) } @@ -32,20 +56,40 @@ struct Ctx<'a> { 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" => { + "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); } + "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); + } _ => {} } @@ -53,15 +97,19 @@ fn walk(node: &Node, ctx: &mut Ctx) { 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, + 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); } + for ch in node.children(&mut c) { + walk(&ch, ctx); + } ctx.parent_idx = prev; } @@ -70,10 +118,15 @@ fn push_named(ctx: &mut Ctx, node: &Node, kind: NodeKind) -> Option { .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; } + 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 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()); @@ -92,11 +145,19 @@ fn push_named(ctx: &mut Ctx, node: &Node, kind: NodeKind) -> Option { } fn emit_call(node: &Node, ctx: &mut Ctx) { - let Some(callee) = node.child_by_field_name("function") else { return }; + 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()), + "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 }; @@ -114,7 +175,9 @@ fn emit_use(node: &Node, ctx: &mut Ctx) { 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; } + if from == usize::MAX { + return; + } ctx.result.imports.push(RawImport { from_idx: from, module, diff --git a/crates/codegraph-extract/src/languages/scala.rs b/crates/codegraph-extract/src/languages/scala.rs index 8c4b2eb5..d099557e 100644 --- a/crates/codegraph-extract/src/languages/scala.rs +++ b/crates/codegraph-extract/src/languages/scala.rs @@ -1,8 +1,10 @@ -use crate::languages::common::LangSpec; use crate::lang_extractor; +use crate::languages::common::LangSpec; use codegraph_core::NodeKind; -fn ts_language() -> tree_sitter::Language { tree_sitter_scala::LANGUAGE.into() } +fn ts_language() -> tree_sitter::Language { + tree_sitter_scala::LANGUAGE.into() +} pub static SPEC: LangSpec = LangSpec { language_name: "scala", diff --git a/crates/codegraph-extract/src/languages/swift.rs b/crates/codegraph-extract/src/languages/swift.rs index acd9761a..8b94a493 100644 --- a/crates/codegraph-extract/src/languages/swift.rs +++ b/crates/codegraph-extract/src/languages/swift.rs @@ -1,8 +1,10 @@ -use crate::languages::common::LangSpec; use crate::lang_extractor; +use crate::languages::common::LangSpec; use codegraph_core::NodeKind; -fn ts_language() -> tree_sitter::Language { tree_sitter_swift::LANGUAGE.into() } +fn ts_language() -> tree_sitter::Language { + tree_sitter_swift::LANGUAGE.into() +} pub static SPEC: LangSpec = LangSpec { language_name: "swift", diff --git a/crates/codegraph-extract/src/languages/typescript.rs b/crates/codegraph-extract/src/languages/typescript.rs index 4996544c..416126ec 100644 --- a/crates/codegraph-extract/src/languages/typescript.rs +++ b/crates/codegraph-extract/src/languages/typescript.rs @@ -1,36 +1,79 @@ -use crate::{parse_err, Extractor, ExtractResult, LocalEdge, PendingCall, RawImport}; +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 } +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() } } + 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() } } + 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") } + 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 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"))?; + 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, @@ -54,10 +97,14 @@ fn walk(node: &Node, ctx: &mut Ctx) { match kind { "function_declaration" | "function_expression" | "arrow_function" => { - if let Some(idx) = push_named(ctx, node, NodeKind::Function) { pushed = Some(idx); } + 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); } + 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) { @@ -66,16 +113,24 @@ fn walk(node: &Node, ctx: &mut Ctx) { } } "interface_declaration" => { - if let Some(idx) = push_named(ctx, node, NodeKind::Interface) { pushed = Some(idx); } + 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); } + 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); } + 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); } + if let Some(idx) = push_named(ctx, node, NodeKind::Variable) { + pushed = Some(idx); + } } "import_statement" => { emit_import(node, ctx); @@ -90,15 +145,19 @@ fn walk(node: &Node, ctx: &mut Ctx) { 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, + 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); } + for child in node.children(&mut c) { + walk(&child, ctx); + } ctx.parent_idx = prev; } @@ -107,11 +166,18 @@ 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 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_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()); @@ -133,7 +199,10 @@ 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") { + if matches!( + ch.kind(), + "identifier" | "type_identifier" | "property_identifier" + ) { found = Some(ch); break; } @@ -142,7 +211,9 @@ fn find_first_identifier<'a>(n: &Node<'a>) -> Option> { } fn emit_call(node: &Node, ctx: &mut Ctx) { - let Some(callee) = node.child_by_field_name("function") else { return }; + 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 @@ -161,11 +232,19 @@ fn emit_call(node: &Node, ctx: &mut Ctx) { } 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 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; } + if from == usize::MAX { + return; + } ctx.result.imports.push(RawImport { from_idx: from, module, @@ -173,12 +252,6 @@ fn emit_import(node: &Node, ctx: &mut Ctx) { }); } -fn emit_heritage(node: &Node, ctx: &mut Ctx, _class_idx: usize) { - let mut c = node.walk(); - for ch in node.children(&mut c) { - if ch.kind() == "class_heritage" { - // Could emit extends/implements pending references — left as TODO for resolver. - let _ = ch; - } - } +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 index 58264602..fa807fa5 100644 --- a/crates/codegraph-extract/src/lib.rs +++ b/crates/codegraph-extract/src/lib.rs @@ -7,7 +7,7 @@ mod walker; pub use orchestrator::{ExtractStats, Orchestrator}; use codegraph_core::{Error, NodeKind, Result}; -use codegraph_db::{EdgeDraft, NodeDraft}; +use codegraph_db::NodeDraft; use std::sync::Arc; /// Local edge using node-indices into the same ExtractResult.nodes vec. @@ -22,7 +22,7 @@ pub struct LocalEdge { /// 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 from_idx: usize, // index into ExtractResult.nodes pub target_name: String, pub line: u32, } @@ -86,7 +86,11 @@ pub fn registry() -> Vec> { v } -pub(crate) fn parse_err(s: impl Into) -> Error { Error::Parse(s.into()) } +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 } +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 index 65225d36..87fdd915 100644 --- a/crates/codegraph-extract/src/orchestrator.rs +++ b/crates/codegraph-extract/src/orchestrator.rs @@ -1,4 +1,4 @@ -use crate::{walker, Extractor, ExtractResult}; +use crate::{walker, ExtractResult, Extractor}; use camino::Utf8Path; use codegraph_core::Result; use codegraph_db::{Db, EdgeDraft, FileRow, NodeDraft}; @@ -22,9 +22,13 @@ pub struct Orchestrator { } impl Orchestrator { - pub fn new(extractors: Vec>) -> Self { Self { extractors } } + pub fn new(extractors: Vec>) -> Self { + Self { extractors } + } - pub fn with_registry() -> Self { Self::new(crate::registry()) } + pub fn with_registry() -> Self { + Self::new(crate::registry()) + } pub fn index_all(&self, root: &Utf8Path, db: &Db) -> Result { db.purge()?; @@ -40,14 +44,16 @@ impl Orchestrator { let mut stats = ExtractStats::default(); let mut all_pending: Vec = Vec::new(); - for Parsed { row, result, ext_idx: _ } in parsed { + 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)?; } + if let Some(eid) = existing.id { + db.delete_file_cascade(eid)?; + } } let fid = db.upsert_file(&row)?; @@ -60,8 +66,12 @@ impl Orchestrator { 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()), + from_id: f, + to_id: t, + kind: e.kind, + file_id: Some(fid), + line: e.line, + source: Some("extract".into()), }) }) .collect(); @@ -91,7 +101,6 @@ impl Orchestrator { struct Parsed { row: FileRow, result: ExtractResult, - ext_idx: usize, } fn parse_one(fm: &walker::FileMatch) -> Result> { @@ -127,5 +136,5 @@ fn parse_one(fm: &walker::FileMatch) -> Result> { .map(|d| d.as_secs() as i64) .unwrap_or(0), }; - Ok(Some(Parsed { row, result, ext_idx: 0 })) + Ok(Some(Parsed { row, result })) } diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index 04e6b947..7714c911 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -12,7 +12,9 @@ pub struct FileMatch { 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()); } + for e in ex.extensions() { + ext_map.insert(*e, ex.clone()); + } } let mut out = Vec::new(); @@ -26,11 +28,22 @@ pub fn walk(root: &Utf8Path, extractors: &[Arc]) -> Vec= 7, "expected at least 7 files, got {}", stats.files); + 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"); + 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"); + 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"); + assert!( + hits.iter().any(|n| n.language == "python"), + "expected python hit" + ); // Go let hits = db.search_nodes("ProcessUser", 10).unwrap(); @@ -39,11 +53,18 @@ fn index_fixtures_dir() { // JS let hits = db.search_nodes("processUser", 10).unwrap(); - assert!(hits.iter().any(|n| n.language == "javascript"), "expected js hit"); + 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); + 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(); @@ -51,7 +72,11 @@ fn index_fixtures_dir() { // 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()); + assert!( + hits.len() >= 2, + "expected UserService from both TS and Rust, got {}", + hits.len() + ); } #[test] @@ -59,8 +84,9 @@ 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(); + 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(); diff --git a/crates/codegraph-graph/src/lib.rs b/crates/codegraph-graph/src/lib.rs index d0758df3..dd3569d3 100644 --- a/crates/codegraph-graph/src/lib.rs +++ b/crates/codegraph-graph/src/lib.rs @@ -7,10 +7,14 @@ use std::collections::{HashMap, HashSet, VecDeque}; const HARD_LIMIT: usize = 5000; -pub struct Traversal<'a> { db: &'a Db } +pub struct Traversal<'a> { + db: &'a Db, +} impl<'a> Traversal<'a> { - pub fn new(db: &'a Db) -> Self { Self { db } } + 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) @@ -23,13 +27,17 @@ impl<'a> Traversal<'a> { /// 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, + 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 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; @@ -37,10 +45,18 @@ impl<'a> Traversal<'a> { 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()); } + if *d == 1 { + direct.push(n.clone()); + } else { + transitive.push(n.clone()); + } } Ok(ImpactReport { - root, direct, transitive, by_kind, truncated: hits.truncated, + root, + direct, + transitive, + by_kind, + truncated: hits.truncated, }) } @@ -61,8 +77,13 @@ impl<'a> Traversal<'a> { 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; } + 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 { @@ -81,7 +102,12 @@ impl<'a> Traversal<'a> { } } - Ok(TraverseHits { nodes, depths, edges, truncated }) + Ok(TraverseHits { + nodes, + depths, + edges, + truncated, + }) } } diff --git a/crates/codegraph-graph/tests/traversal.rs b/crates/codegraph-graph/tests/traversal.rs index 5469a0ac..e5188139 100644 --- a/crates/codegraph-graph/tests/traversal.rs +++ b/crates/codegraph-graph/tests/traversal.rs @@ -11,15 +11,27 @@ fn db() -> (tempfile::TempDir, Db) { 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() + 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(), + kind: NodeKind::Function, + name: name.into(), + qualified_name: None, + start_line: 1, + end_line: 1, + signature: None, + docstring: None, + language: "test".into(), } } @@ -28,12 +40,19 @@ 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 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, + 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(); + 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(); @@ -54,13 +73,37 @@ fn callers_callees_chain() { 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(); + 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(); + 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); diff --git a/crates/codegraph-installer/src/lib.rs b/crates/codegraph-installer/src/lib.rs index 1cbb57b5..acdcda4f 100644 --- a/crates/codegraph-installer/src/lib.rs +++ b/crates/codegraph-installer/src/lib.rs @@ -19,7 +19,11 @@ pub struct InstallOpts { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DetectStatus { NotFound, Found, AlreadyConfigured } +pub enum DetectStatus { + NotFound, + Found, + AlreadyConfigured, +} #[derive(Debug, Clone)] pub enum InstallReport { @@ -49,5 +53,8 @@ pub fn registry() -> Vec> { /// 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() + registry() + .into_iter() + .filter(|t| t.id() != "codex") + .collect() } diff --git a/crates/codegraph-installer/src/targets/claude.rs b/crates/codegraph-installer/src/targets/claude.rs index cd924cdc..95b718a1 100644 --- a/crates/codegraph-installer/src/targets/claude.rs +++ b/crates/codegraph-installer/src/targets/claude.rs @@ -1,4 +1,6 @@ -use crate::{targets::jsonutil, AgentTarget, DetectStatus, InstallOpts, InstallReport, INSTRUCTIONS_MD}; +use crate::{ + targets::jsonutil, AgentTarget, DetectStatus, InstallOpts, InstallReport, INSTRUCTIONS_MD, +}; use anyhow::Result; use camino::Utf8PathBuf; use serde_json::{json, Value}; @@ -26,13 +28,23 @@ impl ClaudeTarget { } impl AgentTarget for ClaudeTarget { - fn id(&self) -> &'static str { "claude" } - fn label(&self) -> &'static str { "Claude Code" } + fn id(&self) -> &'static str { + "claude" + } + fn label(&self) -> &'static str { + "Claude Code" + } fn detect(&self, opts: &InstallOpts) -> DetectStatus { - let Some(p) = self.settings_path(opts) else { return DetectStatus::NotFound }; - if !p.exists() { return DetectStatus::NotFound; } - let Ok(v) = jsonutil::read_or_default(&p) else { return DetectStatus::Found }; + let Some(p) = self.settings_path(opts) else { + return DetectStatus::NotFound; + }; + if !p.exists() { + return DetectStatus::NotFound; + } + let Ok(v) = jsonutil::read_or_default(&p) else { + return DetectStatus::Found; + }; if v.pointer("/mcpServers/codegraph").is_some() { DetectStatus::AlreadyConfigured } else { @@ -41,7 +53,9 @@ impl AgentTarget for ClaudeTarget { } fn install(&self, opts: &InstallOpts) -> Result { - let settings = self.settings_path(opts).ok_or_else(|| anyhow::anyhow!("no settings path"))?; + 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!({ @@ -50,9 +64,15 @@ impl AgentTarget for ClaudeTarget { }); 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"))?; + 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; @@ -69,13 +89,19 @@ impl AgentTarget for ClaudeTarget { 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())?; } + 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)) } + if written.is_empty() { + Ok(InstallReport::Unchanged) + } else { + Ok(InstallReport::Installed(written)) + } } fn uninstall(&self, opts: &InstallOpts) -> Result { @@ -84,8 +110,11 @@ impl AgentTarget for ClaudeTarget { 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 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)?; @@ -93,7 +122,11 @@ impl AgentTarget for ClaudeTarget { } } } - if removed.is_empty() { Ok(InstallReport::Unchanged) } else { Ok(InstallReport::Updated(removed)) } + if removed.is_empty() { + Ok(InstallReport::Unchanged) + } else { + Ok(InstallReport::Updated(removed)) + } } } diff --git a/crates/codegraph-installer/src/targets/codex.rs b/crates/codegraph-installer/src/targets/codex.rs index b7e6a58b..cdeae9b2 100644 --- a/crates/codegraph-installer/src/targets/codex.rs +++ b/crates/codegraph-installer/src/targets/codex.rs @@ -20,15 +20,32 @@ impl CodexTarget { } impl AgentTarget for CodexTarget { - fn id(&self) -> &'static str { "codex" } - fn label(&self) -> &'static str { "Codex CLI" } + 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() { + 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 @@ -36,9 +53,15 @@ impl AgentTarget for CodexTarget { } fn install(&self, opts: &InstallOpts) -> Result { - let config = self.config_path().ok_or_else(|| anyhow::anyhow!("no codex config path"))?; + 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 doc: DocumentMut = if text.is_empty() { + DocumentMut::new() + } else { + text.parse()? + }; let mut servers = match doc.remove("mcp_servers") { Some(Item::Table(t)) => t, @@ -69,29 +92,43 @@ impl AgentTarget for CodexTarget { let mut written = Vec::new(); if changed { - if let Some(parent) = config.parent() { std::fs::create_dir_all(parent.as_std_path())?; } + 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())?; } + 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)) } + 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 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 servers.remove("codegraph").is_some() { + changed = true; + } } if changed { std::fs::write(config.as_std_path(), doc.to_string())?; diff --git a/crates/codegraph-installer/src/targets/cursor.rs b/crates/codegraph-installer/src/targets/cursor.rs index 4da0c8a1..58485b7f 100644 --- a/crates/codegraph-installer/src/targets/cursor.rs +++ b/crates/codegraph-installer/src/targets/cursor.rs @@ -1,4 +1,6 @@ -use crate::{targets::jsonutil, AgentTarget, DetectStatus, InstallOpts, InstallReport, INSTRUCTIONS_MD}; +use crate::{ + targets::jsonutil, AgentTarget, DetectStatus, InstallOpts, InstallReport, INSTRUCTIONS_MD, +}; use anyhow::Result; use camino::Utf8PathBuf; use serde_json::{json, Value}; @@ -11,22 +13,36 @@ impl CursorTarget { 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")) + 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")) + 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 id(&self) -> &'static str { + "cursor" + } + fn label(&self) -> &'static str { + "Cursor" + } fn detect(&self, opts: &InstallOpts) -> DetectStatus { - let Some(p) = self.mcp_path(opts) else { return DetectStatus::NotFound }; - if !p.exists() { return DetectStatus::NotFound; } - let Ok(v) = jsonutil::read_or_default(&p) else { return DetectStatus::Found }; + let Some(p) = self.mcp_path(opts) else { + return DetectStatus::NotFound; + }; + if !p.exists() { + return DetectStatus::NotFound; + } + let Ok(v) = jsonutil::read_or_default(&p) else { + return DetectStatus::Found; + }; if v.pointer("/mcpServers/codegraph").is_some() { DetectStatus::AlreadyConfigured } else { @@ -35,7 +51,9 @@ impl AgentTarget for CursorTarget { } fn install(&self, opts: &InstallOpts) -> Result { - let mcp = self.mcp_path(opts).ok_or_else(|| anyhow::anyhow!("no mcp path"))?; + 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. @@ -50,9 +68,15 @@ impl AgentTarget for CursorTarget { 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"))?; + 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; @@ -65,15 +89,24 @@ impl AgentTarget for CursorTarget { 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 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())?; } + 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)) } + if written.is_empty() { + Ok(InstallReport::Unchanged) + } else { + Ok(InstallReport::Installed(written)) + } } fn uninstall(&self, opts: &InstallOpts) -> Result { @@ -82,8 +115,11 @@ impl AgentTarget for CursorTarget { 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 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)?; @@ -91,6 +127,10 @@ impl AgentTarget for CursorTarget { } } } - if removed.is_empty() { Ok(InstallReport::Unchanged) } else { Ok(InstallReport::Updated(removed)) } + 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 index e97c4acd..bc276518 100644 --- a/crates/codegraph-installer/src/targets/hermes.rs +++ b/crates/codegraph-installer/src/targets/hermes.rs @@ -1,7 +1,9 @@ //! 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 crate::{ + targets::jsonutil, AgentTarget, DetectStatus, InstallOpts, InstallReport, INSTRUCTIONS_MD, +}; use anyhow::Result; use camino::Utf8PathBuf; use serde_json::{json, Value}; @@ -14,7 +16,9 @@ impl HermesTarget { 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")) + opts.project_root + .as_ref() + .map(|r| r.join(".hermes").join("mcp.json")) } } fn instructions_path(&self, opts: &InstallOpts) -> Option { @@ -22,24 +26,42 @@ impl HermesTarget { 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")) + 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 id(&self) -> &'static str { + "hermes" + } + fn label(&self) -> &'static str { + "Hermes" + } fn detect(&self, opts: &InstallOpts) -> DetectStatus { - let Some(p) = self.mcp_path(opts) else { return DetectStatus::NotFound }; - if !p.exists() { return DetectStatus::NotFound; } - let Ok(v) = jsonutil::read_or_default(&p) else { return DetectStatus::Found }; - if v.pointer("/mcpServers/codegraph").is_some() { DetectStatus::AlreadyConfigured } else { DetectStatus::Found } + let Some(p) = self.mcp_path(opts) else { + return DetectStatus::NotFound; + }; + if !p.exists() { + return DetectStatus::NotFound; + } + 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 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 { @@ -49,34 +71,55 @@ impl AgentTarget for HermesTarget { 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"))?; + 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 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())?; } + 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)) } + 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 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 servers.remove("codegraph").is_some() { + changed = true; + } } if changed { jsonutil::write_pretty(&mcp, &v)?; diff --git a/crates/codegraph-installer/src/targets/mod.rs b/crates/codegraph-installer/src/targets/mod.rs index 6fb31a3c..c6993371 100644 --- a/crates/codegraph-installer/src/targets/mod.rs +++ b/crates/codegraph-installer/src/targets/mod.rs @@ -2,5 +2,5 @@ pub mod claude; pub mod codex; pub mod cursor; pub mod hermes; -pub mod opencode; 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 index 8a252f28..c80d6d2e 100644 --- a/crates/codegraph-installer/src/targets/opencode.rs +++ b/crates/codegraph-installer/src/targets/opencode.rs @@ -2,7 +2,9 @@ //! 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 crate::{ + targets::jsonutil, AgentTarget, DetectStatus, InstallOpts, InstallReport, INSTRUCTIONS_MD, +}; use anyhow::Result; use camino::Utf8PathBuf; use serde_json::{json, Value}; @@ -17,9 +19,13 @@ impl OpencodeTarget { } else { opts.project_root.as_ref().map(|r| { let jsonc = r.join("opencode.jsonc"); - if jsonc.exists() { return jsonc; } + if jsonc.exists() { + return jsonc; + } let json = r.join("opencode.json"); - if json.exists() { return json; } + if json.exists() { + return json; + } jsonc }) } @@ -35,7 +41,9 @@ impl OpencodeTarget { } fn parse_jsonc(&self, text: &str) -> Result { - if text.trim().is_empty() { return Ok(Value::Object(Default::default())); } + if text.trim().is_empty() { + return Ok(Value::Object(Default::default())); + } // Strip comments to parse with serde_json. For round-trip preservation, // a full surgical edit would use jsonc_parser::cst; for now we accept // re-formatting on edit (parity will be added in a follow-up). @@ -56,21 +64,34 @@ fn strip_jsonc_comments(text: &str) -> String { 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; } + 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'"' { 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; } + if bytes[i + 1] == b'/' { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } continue; } - if bytes[i+1] == b'*' { + if bytes[i + 1] == b'*' { i += 2; - while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i+1] == b'/') { i += 1; } + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } i = (i + 2).min(bytes.len()); continue; } @@ -82,23 +103,45 @@ fn strip_jsonc_comments(text: &str) -> String { } impl AgentTarget for OpencodeTarget { - fn id(&self) -> &'static str { "opencode" } - fn label(&self) -> &'static str { "opencode" } + fn id(&self) -> &'static str { + "opencode" + } + fn label(&self) -> &'static str { + "opencode" + } fn detect(&self, opts: &InstallOpts) -> DetectStatus { - let Some(p) = self.config_path(opts) 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(v) = self.parse_jsonc(&text) else { return DetectStatus::Found }; - if v.pointer("/mcp/codegraph").is_some() { DetectStatus::AlreadyConfigured } else { DetectStatus::Found } + let Some(p) = self.config_path(opts) 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(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 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())]; + 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())); @@ -111,9 +154,15 @@ impl AgentTarget for OpencodeTarget { 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"))?; + 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; @@ -128,22 +177,34 @@ impl AgentTarget for OpencodeTarget { 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())?; } + 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)) } + 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 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 mcp.remove("codegraph").is_some() { + changed = true; + } } if changed { jsonutil::write_pretty(&config, &v)?; diff --git a/crates/codegraph-installer/tests/install.rs b/crates/codegraph-installer/tests/install.rs index c1978184..fdc22318 100644 --- a/crates/codegraph-installer/tests/install.rs +++ b/crates/codegraph-installer/tests/install.rs @@ -17,10 +17,20 @@ fn install_idempotent() { for target in registry() { assert_eq!(target.detect(&o), DetectStatus::NotFound); let r1 = target.install(&o).unwrap(); - assert!(matches!(r1, InstallReport::Installed(_)), "{} first install: {:?}", target.id(), r1); + 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); + assert!( + matches!(r2, InstallReport::Unchanged), + "{} re-install: {:?}", + target.id(), + r2 + ); } } @@ -32,8 +42,18 @@ fn uninstall_removes_mcp_entry() { 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()); + assert!( + matches!(r, InstallReport::Updated(_)), + "{} uninstall: {:?}", + target.id(), + r + ); + assert_eq!( + target.detect(&o), + DetectStatus::Found, + "{} should remain installed-but-not-configured", + target.id() + ); } } @@ -46,14 +66,22 @@ fn sibling_keys_preserved() { std::fs::write( claude_settings.as_std_path(), r#"{"mcpServers":{"other":{"command":"foo"}},"theme":"dark"}"#, - ).unwrap(); + ) + .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"); + 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/src/lib.rs b/crates/codegraph-mcp/src/lib.rs index a9fe274a..67ac8a0b 100644 --- a/crates/codegraph-mcp/src/lib.rs +++ b/crates/codegraph-mcp/src/lib.rs @@ -3,7 +3,7 @@ mod protocol; mod tools; -pub use protocol::{Response, ErrorObj, JsonRpcMessage}; +pub use protocol::{ErrorObj, JsonRpcMessage, Response}; pub use tools::tool_definitions; use codegraph_db::Db; @@ -22,7 +22,9 @@ pub struct McpServer { } impl McpServer { - pub fn new(db: Arc) -> Self { Self { db } } + pub fn new(db: Arc) -> Self { + Self { db } + } pub async fn run_stdio(self) -> anyhow::Result<()> { let stdin = tokio::io::stdin(); @@ -33,14 +35,22 @@ impl McpServer { loop { line.clear(); let n = reader.read_line(&mut line).await?; - if n == 0 { break; } + if n == 0 { + break; + } let trimmed = line.trim(); - if trimmed.is_empty() { continue; } + 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?; + write_response( + &mut stdout, + Response::error(Value::Null, -32700, &format!("parse error: {e}")), + ) + .await?; continue; } }; @@ -69,7 +79,10 @@ impl McpServer { })), 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("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")), } @@ -86,7 +99,10 @@ impl McpServer { } } -async fn write_response(w: &mut W, r: Response) -> anyhow::Result<()> { +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?; @@ -95,4 +111,6 @@ async fn write_response(w: &mut W, r: Response } // Re-export for binary use without exposing Traversal lifetime annoyances. -pub fn traversal_for(db: &Db) -> Traversal<'_> { Traversal::new(db) } +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 index 7344737e..7a36a8b1 100644 --- a/crates/codegraph-mcp/src/protocol.rs +++ b/crates/codegraph-mcp/src/protocol.rs @@ -27,12 +27,22 @@ pub struct ErrorObj { impl Response { pub fn ok(id: Value, result: Value) -> Self { - Self { jsonrpc: "2.0", id, result: Some(result), error: None } + 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() }), + jsonrpc: "2.0", + id, + result: None, + error: Some(ErrorObj { + code, + message: message.into(), + }), } } } diff --git a/crates/codegraph-mcp/src/tools.rs b/crates/codegraph-mcp/src/tools.rs index b46f96e7..15730530 100644 --- a/crates/codegraph-mcp/src/tools.rs +++ b/crates/codegraph-mcp/src/tools.rs @@ -5,42 +5,66 @@ use serde_json::{json, Value}; pub fn tool_definitions() -> Vec { vec![ - tool("codegraph_search", "Search the knowledge graph by name / signature / docstring (FTS5).", + 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.", + }, "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.", + } }), + ), + 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.", + }, "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.", + }, "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).", + }, "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_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": {} })), + }, "required": ["query"] }), + ), + 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": {} }), + ), ] } @@ -89,7 +113,10 @@ pub fn dispatch(db: &Db, name: &str, args: Value) -> anyhow::Result { 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), + 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, }; @@ -99,16 +126,18 @@ pub fn dispatch(db: &Db, name: &str, args: Value) -> anyhow::Result { 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()?)?) - } + "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}")) + 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}")) + v.get(k) + .and_then(|x| x.as_i64()) + .ok_or_else(|| anyhow::anyhow!("missing int arg: {k}")) } diff --git a/crates/codegraph-resolve/src/lib.rs b/crates/codegraph-resolve/src/lib.rs index ae446734..0fe5f69c 100644 --- a/crates/codegraph-resolve/src/lib.rs +++ b/crates/codegraph-resolve/src/lib.rs @@ -23,39 +23,54 @@ pub struct PendingCallRow { pub line: u32, } -pub struct Resolver<'a> { db: &'a Db } +pub struct Resolver<'a> { + db: &'a Db, +} impl<'a> Resolver<'a> { - pub fn new(db: &'a Db) -> Self { Self { db } } + pub fn new(db: &'a Db) -> Self { + Self { db } + } pub fn resolve_calls(&self, pending: &[PendingCallRow]) -> Result { - if pending.is_empty() { return Ok(0); } + if pending.is_empty() { + return Ok(0); + } // Group by target_name to batch lookups. 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); } + 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; } + if candidates.is_empty() { + continue; + } // Filter to callable kinds. let callable: Vec<_> = candidates .into_iter() - .filter(|n| matches!( - n.kind, - codegraph_core::NodeKind::Function - | codegraph_core::NodeKind::Method - )) + .filter(|n| { + matches!( + n.kind, + codegraph_core::NodeKind::Function | codegraph_core::NodeKind::Method + ) + }) .collect(); - if callable.is_empty() { continue; } + if callable.is_empty() { + continue; + } for site in sites { // Prefer same-file match. If none, link all callable (recall over precision). let same_file: Vec<_> = callable .iter() .filter(|n| { - self.db.file_by_path(n.file.as_str()) - .ok().flatten() + self.db + .file_by_path(n.file.as_str()) + .ok() + .flatten() .and_then(|f| f.id) .map(|id| id == site.file_id) .unwrap_or(false) diff --git a/crates/codegraph/src/main.rs b/crates/codegraph/src/main.rs index bba968cd..e9b241d5 100644 --- a/crates/codegraph/src/main.rs +++ b/crates/codegraph/src/main.rs @@ -38,18 +38,25 @@ enum Cmd { /// Show index health. Status, /// Search nodes (FTS). - Query { query: String, #[arg(long, default_value_t = 20)] limit: u32 }, + 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, + #[arg(long, default_value_t = 1)] + depth: u32, + #[arg(long)] + source: bool, }, /// Run as MCP server over stdio. Serve { - #[arg(long)] mcp: bool, + #[arg(long)] + mcp: bool, }, /// Multi-agent installer (placeholder). Install, @@ -79,7 +86,11 @@ fn main() -> Result<()> { 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::Context { + target, + depth, + source, + } => cmd_context(&root, &target, depth, source), Cmd::Serve { mcp } => cmd_serve(&root, mcp), Cmd::Install => cmd_install(&root), } @@ -88,7 +99,8 @@ fn main() -> Result<()> { fn cmd_install(root: &Utf8Path) -> Result<()> { use codegraph_installer::{registry, InstallOpts, InstallReport}; let bin = std::env::current_exe()?; - let bin = Utf8PathBuf::from_path_buf(bin).map_err(|p| anyhow!("non-UTF8 bin path: {}", p.display()))?; + 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, @@ -100,7 +112,9 @@ fn cmd_install(root: &Utf8Path) -> Result<()> { let report = target.install(&opts)?; match report { InstallReport::Installed(p) | InstallReport::Updated(p) => { - for f in p { eprintln!(" wrote {}", f); } + for f in p { + eprintln!(" wrote {}", f); + } } InstallReport::Unchanged => eprintln!(" unchanged"), InstallReport::Skipped(r) => eprintln!(" skipped: {}", r), @@ -115,10 +129,7 @@ fn db_path(root: &Utf8Path) -> Utf8PathBuf { fn ensure_initialized(root: &Utf8Path) -> Result<()> { if !db_path(root).exists() { - return Err(anyhow!( - "not initialized: run `codegraph init` in {}", - root - )); + return Err(anyhow!("not initialized: run `codegraph init` in {}", root)); } Ok(()) } @@ -132,7 +143,10 @@ fn cmd_init(root: &Utf8Path, do_index: bool) -> Result<()> { 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!( + "indexed {} files, {} nodes, {} edges", + stats.files, stats.nodes, stats.edges + ); } Ok(()) } @@ -150,7 +164,10 @@ 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); + eprintln!( + "indexed {} files, {} nodes, {} edges (skipped {})", + stats.files, stats.nodes, stats.edges, stats.skipped + ); Ok(()) } @@ -158,7 +175,10 @@ 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); + eprintln!( + "synced {} files (skipped {}), nodes={} edges={}", + stats.files, stats.skipped, stats.nodes, stats.edges + ); Ok(()) } @@ -179,7 +199,14 @@ fn cmd_query(root: &Utf8Path, q: &str, limit: u32) -> Result<()> { 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); + println!( + "[{}] {} {} {}:{}", + h.id, + h.kind.as_str(), + h.name, + h.file, + h.start_line + ); } Ok(()) } @@ -213,7 +240,9 @@ fn cmd_serve(root: &Utf8Path, mcp: bool) -> Result<()> { } 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()?; + 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 diff --git a/crates/codegraph/src/watcher.rs b/crates/codegraph/src/watcher.rs index 19f9f510..90b524bf 100644 --- a/crates/codegraph/src/watcher.rs +++ b/crates/codegraph/src/watcher.rs @@ -23,7 +23,9 @@ fn run(root: Utf8PathBuf, db: Arc) -> Result<()> { Duration::from_millis(500), None, move |res: notify_debouncer_full::DebounceEventResult| { - if let Ok(events) = res { let _ = tx.send(events); } + if let Ok(events) = res { + let _ = tx.send(events); + } }, )?; debouncer.watch(root.as_std_path(), RecursiveMode::Recursive)?; @@ -31,7 +33,9 @@ fn run(root: Utf8PathBuf, db: Arc) -> Result<()> { 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(s) if s.files > 0 => { + tracing::info!("watch sync: {} files, {} edges", s.files, s.edges) + } Ok(_) => {} Err(e) => tracing::warn!("sync failed: {e}"), } From d37b47eef0cb13879a8235cf91fd9ab9192a5309 Mon Sep 17 00:00:00 2001 From: Cleboost Date: Fri, 22 May 2026 20:49:14 +0200 Subject: [PATCH 06/33] ci: update actions/checkout to v6 in CI configuration --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e68ce919..23355b0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: name: rustfmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt @@ -39,7 +39,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - 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 7a7f9d58..846ceccc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: - { os: macos-latest, target: aarch64-apple-darwin, ext: "" } - { os: windows-latest, target: x86_64-pc-windows-msvc, ext: ".exe" } steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} @@ -87,7 +87,7 @@ jobs: tag: ${{ steps.tag.outputs.tag }} version: ${{ steps.tag.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/download-artifact@v4 with: path: artifacts @@ -122,7 +122,7 @@ jobs: runs-on: ubuntu-latest if: ${{ needs.release.outputs.tag != '' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download release archives env: From 7b73cd5f1734da3c6afd582c3aa5a6b5390672fd Mon Sep 17 00:00:00 2001 From: Cleboost Date: Fri, 22 May 2026 21:22:52 +0200 Subject: [PATCH 07/33] ci(release): update macOS target to use latest version --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 846ceccc..d17e5708 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: - { 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-13, target: x86_64-apple-darwin, ext: "" } + - { 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: From 6a193b8c8517cc2e4bdca6fa209c1d7dc59e5be7 Mon Sep 17 00:00:00 2001 From: Cleboost Date: Fri, 22 May 2026 21:25:38 +0200 Subject: [PATCH 08/33] ci: update actions/upload-artifact to v6 in release workflow --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d17e5708..aec035f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,7 +73,7 @@ jobs: sha256sum "${name}".* > "${name}.sha256" fi - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: codegraph-${{ matrix.target }} path: dist/* From ce256c9335cd46e07c0a932f1e1130d6fd5c0e37 Mon Sep 17 00:00:00 2001 From: Cleboost Date: Fri, 22 May 2026 21:28:17 +0200 Subject: [PATCH 09/33] feat(release): add LICENSE file and update release packaging process --- .github/workflows/release.yml | 9 ++++++--- LICENSE | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 LICENSE diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aec035f8..98b76092 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,11 +60,14 @@ jobs: set -euo pipefail bin="target/${{ matrix.target }}/release/codegraph${{ matrix.ext }}" name="codegraph-${{ matrix.target }}" - mkdir -p dist + 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 - 7z a "dist/${name}.zip" "$bin" README.md LICENSE 2>/dev/null || zip -j "dist/${name}.zip" "$bin" + ( cd staging && 7z a "../dist/${name}.zip" . ) else - tar -czf "dist/${name}.tar.gz" -C "$(dirname "$bin")" "codegraph" + tar -czf "dist/${name}.tar.gz" -C staging . fi cd dist if command -v shasum >/dev/null; then diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..a31fde63 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +MIT License + +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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +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 OF THE SOFTWARE. From f867e8a98d9f41f042f136d0523a16bdac9b1a48 Mon Sep 17 00:00:00 2001 From: Cleboost Date: Fri, 22 May 2026 21:34:40 +0200 Subject: [PATCH 10/33] ci(release): update github-actions-deploy-aur to v4.1.3 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98b76092..f0965be8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -167,7 +167,7 @@ jobs: echo "--- .SRCINFO ---"; cat .SRCINFO - name: Publish to AUR - uses: KSXGitHub/github-actions-deploy-aur@v3.0.1 + uses: KSXGitHub/github-actions-deploy-aur@v4.1.3 with: pkgname: codegraph-bin pkgbuild: packaging/aur/codegraph-bin/PKGBUILD From e366a7da4a875e0a8a2e9cfb1bfc3d6d81e53b2f Mon Sep 17 00:00:00 2001 From: Cleboost Date: Fri, 22 May 2026 21:34:53 +0200 Subject: [PATCH 11/33] ci: update actions/download-artifact to v6 in release workflow --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0965be8..498ffe5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,7 +91,7 @@ jobs: version: ${{ steps.tag.outputs.version }} steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v6 with: path: artifacts merge-multiple: true From bc483ced360959ee82d8eaa5b93acc7146efebd1 Mon Sep 17 00:00:00 2001 From: Cleboost Date: Fri, 22 May 2026 21:41:47 +0200 Subject: [PATCH 12/33] refactor(release): rename package from codegraph-bin to codegraph-rs-bin --- .github/workflows/release.yml | 10 ++--- packaging/aur/README.md | 45 ------------------- packaging/aur/codegraph-bin/.SRCINFO | 18 -------- packaging/aur/codegraph-rs-bin/.SRCINFO | 19 ++++++++ .../PKGBUILD | 6 +-- 5 files changed, 27 insertions(+), 71 deletions(-) delete mode 100644 packaging/aur/README.md delete mode 100644 packaging/aur/codegraph-bin/.SRCINFO create mode 100644 packaging/aur/codegraph-rs-bin/.SRCINFO rename packaging/aur/{codegraph-bin => codegraph-rs-bin}/PKGBUILD (85%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 498ffe5a..ee2589a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -120,7 +120,7 @@ jobs: || gh release create "$tag" --draft --title "$tag" --generate-notes artifacts/* aur: - name: Publish AUR (codegraph-bin) + name: Publish AUR (codegraph-rs-bin) needs: release runs-on: ubuntu-latest if: ${{ needs.release.outputs.tag != '' }} @@ -150,7 +150,7 @@ jobs: set -euo pipefail x86_sha=$(cat dl/x86_64.sha256) arm_sha=$(cat dl/aarch64.sha256) - cd packaging/aur/codegraph-bin + cd packaging/aur/codegraph-rs-bin sed -i \ -e "s/^pkgver=.*/pkgver=$VERSION/" \ @@ -169,10 +169,10 @@ jobs: - name: Publish to AUR uses: KSXGitHub/github-actions-deploy-aur@v4.1.3 with: - pkgname: codegraph-bin - pkgbuild: packaging/aur/codegraph-bin/PKGBUILD + pkgname: codegraph-rs-bin + pkgbuild: packaging/aur/codegraph-rs-bin/PKGBUILD assets: | - packaging/aur/codegraph-bin/.SRCINFO + packaging/aur/codegraph-rs-bin/.SRCINFO commit_username: ${{ secrets.AUR_USERNAME }} commit_email: ${{ secrets.AUR_EMAIL }} ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} diff --git a/packaging/aur/README.md b/packaging/aur/README.md deleted file mode 100644 index 6c2af437..00000000 --- a/packaging/aur/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# AUR packaging - -`codegraph-bin` — installs the prebuilt static binary from GitHub Releases. - -## One-time setup - -1. Create the package on AUR (manual, first time): - ```sh - ssh aur@aur.archlinux.org setup-repo codegraph-bin - git clone ssh://aur@aur.archlinux.org/codegraph-bin.git - cp packaging/aur/codegraph-bin/PKGBUILD packaging/aur/codegraph-bin/.SRCINFO codegraph-bin/ - cd codegraph-bin && git add . && git commit -m "init" && git push - ``` - -2. Add an AUR SSH key (ed25519) and register the **public** half at - . Add the **private** half + the - maintainer username/email as GitHub repo secrets: - - - `AUR_SSH_PRIVATE_KEY` - - `AUR_USERNAME` - - `AUR_EMAIL` - -## Release flow (automated) - -The `aur` job in `.github/workflows/release.yml` runs after the `release` -job succeeds: - -1. Downloads `codegraph-x86_64-unknown-linux-musl.tar.gz` and the aarch64 - variant from the release. -2. Computes SHA-256 sums. -3. Patches `PKGBUILD` (`pkgver`, `pkgrel=1`, both `sha256sums`). -4. Regenerates `.SRCINFO` via `makepkg --printsrcinfo` inside an - `archlinux:base-devel` container. -5. Pushes the updated package to AUR via SSH. - -Trigger manually with `gh workflow run release.yml -f tag=v0.1.0` (re-runs -the full release pipeline for that tag). - -## Local test - -```sh -cd packaging/aur/codegraph-bin -# edit pkgver to a real released version -makepkg -si -``` diff --git a/packaging/aur/codegraph-bin/.SRCINFO b/packaging/aur/codegraph-bin/.SRCINFO deleted file mode 100644 index b8fb9038..00000000 --- a/packaging/aur/codegraph-bin/.SRCINFO +++ /dev/null @@ -1,18 +0,0 @@ -pkgbase = codegraph-bin - pkgdesc = Local-first code intelligence: tree-sitter knowledge graph + MCP server (prebuilt binary) - pkgver = 0.0.0 - pkgrel = 1 - url = https://github.com/cleboost/codegraph - arch = x86_64 - arch = aarch64 - license = MIT - provides = codegraph - conflicts = codegraph - options = !strip - options = !debug - source_x86_64 = codegraph-bin-0.0.0-x86_64.tar.gz::https://github.com/cleboost/codegraph/releases/download/v0.0.0/codegraph-x86_64-unknown-linux-musl.tar.gz - sha256sums_x86_64 = SKIP - source_aarch64 = codegraph-bin-0.0.0-aarch64.tar.gz::https://github.com/cleboost/codegraph/releases/download/v0.0.0/codegraph-aarch64-unknown-linux-gnu.tar.gz - sha256sums_aarch64 = SKIP - -pkgname = codegraph-bin diff --git a/packaging/aur/codegraph-rs-bin/.SRCINFO b/packaging/aur/codegraph-rs-bin/.SRCINFO new file mode 100644 index 00000000..e0de6467 --- /dev/null +++ b/packaging/aur/codegraph-rs-bin/.SRCINFO @@ -0,0 +1,19 @@ +pkgbase = codegraph-rs-bin + pkgdesc = Local-first code intelligence: tree-sitter knowledge graph + MCP server (prebuilt binary) + pkgver = 0.0.0 + pkgrel = 1 + url = https://github.com/Cleboost/codegraph-rs + arch = x86_64 + arch = aarch64 + license = MIT + provides = codegraph + conflicts = codegraph + conflicts = codegraph-bin + options = !strip + options = !debug + source_x86_64 = codegraph-rs-bin-0.0.0-x86_64.tar.gz::https://github.com/Cleboost/codegraph-rs/releases/download/v0.0.0/codegraph-x86_64-unknown-linux-musl.tar.gz + sha256sums_x86_64 = SKIP + source_aarch64 = codegraph-rs-bin-0.0.0-aarch64.tar.gz::https://github.com/Cleboost/codegraph-rs/releases/download/v0.0.0/codegraph-aarch64-unknown-linux-gnu.tar.gz + sha256sums_aarch64 = SKIP + +pkgname = codegraph-rs-bin diff --git a/packaging/aur/codegraph-bin/PKGBUILD b/packaging/aur/codegraph-rs-bin/PKGBUILD similarity index 85% rename from packaging/aur/codegraph-bin/PKGBUILD rename to packaging/aur/codegraph-rs-bin/PKGBUILD index 35a9a4e5..fb78b3d2 100644 --- a/packaging/aur/codegraph-bin/PKGBUILD +++ b/packaging/aur/codegraph-rs-bin/PKGBUILD @@ -1,13 +1,13 @@ # Maintainer: Cleboost -pkgname=codegraph-bin +pkgname=codegraph-rs-bin pkgver=0.0.0 pkgrel=1 pkgdesc="Local-first code intelligence: tree-sitter knowledge graph + MCP server (prebuilt binary)" arch=('x86_64' 'aarch64') -url="https://github.com/cleboost/codegraph" +url="https://github.com/Cleboost/codegraph-rs" license=('MIT') provides=('codegraph') -conflicts=('codegraph') +conflicts=('codegraph' 'codegraph-bin') options=(!strip !debug) source_x86_64=("$pkgname-$pkgver-x86_64.tar.gz::$url/releases/download/v$pkgver/codegraph-x86_64-unknown-linux-musl.tar.gz") From 1a3fc5076d3203d060245ecda836e8801a03b370 Mon Sep 17 00:00:00 2001 From: Cleboost Date: Sat, 23 May 2026 00:51:43 +0200 Subject: [PATCH 13/33] feat(agents): add support for antigravity CLI and enhance detection logic *close https://github.com/Cleboost/codegraph-rs/pull/3 --- Cargo.lock | 86 ++++++++++ Cargo.toml | 3 + crates/codegraph-installer/Cargo.toml | 1 + crates/codegraph-installer/src/lib.rs | 1 + .../src/targets/antigravity.rs | 156 ++++++++++++++++++ .../codegraph-installer/src/targets/claude.rs | 12 +- .../codegraph-installer/src/targets/cursor.rs | 12 +- .../codegraph-installer/src/targets/hermes.rs | 12 +- crates/codegraph-installer/src/targets/mod.rs | 1 + .../src/targets/opencode.rs | 13 +- crates/codegraph/Cargo.toml | 2 + crates/codegraph/src/main.rs | 83 +++++++++- 12 files changed, 366 insertions(+), 16 deletions(-) create mode 100644 crates/codegraph-installer/src/targets/antigravity.rs diff --git a/Cargo.lock b/Cargo.lock index fe4b0cf4..76b37004 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,6 +197,8 @@ dependencies = [ "codegraph-installer", "codegraph-mcp", "codegraph-resolve", + "console", + "dialoguer", "dirs", "notify", "notify-debouncer-full", @@ -297,6 +299,7 @@ dependencies = [ "tempfile", "toml_edit", "tracing", + "which", ] [[package]] @@ -336,6 +339,19 @@ 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" @@ -389,6 +405,19 @@ dependencies = [ "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" @@ -426,6 +455,18 @@ 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" @@ -1096,6 +1137,12 @@ 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" @@ -1481,6 +1528,12 @@ 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" @@ -1579,6 +1632,18 @@ dependencies = [ "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" @@ -1612,6 +1677,15 @@ 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" @@ -1825,6 +1899,12 @@ 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" @@ -1939,6 +2019,12 @@ dependencies = [ "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" diff --git a/Cargo.toml b/Cargo.toml index b66b0074..9eb17b40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,9 @@ 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 diff --git a/crates/codegraph-installer/Cargo.toml b/crates/codegraph-installer/Cargo.toml index c587016d..a5cb2d4d 100644 --- a/crates/codegraph-installer/Cargo.toml +++ b/crates/codegraph-installer/Cargo.toml @@ -11,6 +11,7 @@ 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 } diff --git a/crates/codegraph-installer/src/lib.rs b/crates/codegraph-installer/src/lib.rs index acdcda4f..52a24f6c 100644 --- a/crates/codegraph-installer/src/lib.rs +++ b/crates/codegraph-installer/src/lib.rs @@ -48,6 +48,7 @@ pub fn registry() -> Vec> { Arc::new(targets::codex::CodexTarget), Arc::new(targets::opencode::OpencodeTarget), Arc::new(targets::hermes::HermesTarget), + Arc::new(targets::antigravity::AntigravityTarget), ] } diff --git a/crates/codegraph-installer/src/targets/antigravity.rs b/crates/codegraph-installer/src/targets/antigravity.rs new file mode 100644 index 00000000..cb5d5705 --- /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 { + // Agent presence: ~/.gemini/antigravity-cli/ must exist. + let Some(home) = dirs::home_dir() else { + return DetectStatus::NotFound; + }; + if !home.join(".gemini").join("antigravity-cli").exists() { + return DetectStatus::NotFound; + } + // Check if codegraph is already configured in the target path. + 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 index 95b718a1..4c09c5a5 100644 --- a/crates/codegraph-installer/src/targets/claude.rs +++ b/crates/codegraph-installer/src/targets/claude.rs @@ -36,12 +36,20 @@ impl AgentTarget for ClaudeTarget { } fn detect(&self, opts: &InstallOpts) -> DetectStatus { - let Some(p) = self.settings_path(opts) else { + // Agent presence: ~/.claude/ must exist (created on install). + let Some(home) = dirs::home_dir() else { return DetectStatus::NotFound; }; - if !p.exists() { + if !home.join(".claude").exists() { return DetectStatus::NotFound; } + // Check if codegraph is already configured in the target path. + 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; }; diff --git a/crates/codegraph-installer/src/targets/cursor.rs b/crates/codegraph-installer/src/targets/cursor.rs index 58485b7f..494b1be4 100644 --- a/crates/codegraph-installer/src/targets/cursor.rs +++ b/crates/codegraph-installer/src/targets/cursor.rs @@ -34,12 +34,20 @@ impl AgentTarget for CursorTarget { } fn detect(&self, opts: &InstallOpts) -> DetectStatus { - let Some(p) = self.mcp_path(opts) else { + // Agent presence: ~/.cursor/ must exist (created by Cursor editor). + let Some(home) = dirs::home_dir() else { return DetectStatus::NotFound; }; - if !p.exists() { + if !home.join(".cursor").exists() { return DetectStatus::NotFound; } + // Check if codegraph is already configured in the target path. + 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; }; diff --git a/crates/codegraph-installer/src/targets/hermes.rs b/crates/codegraph-installer/src/targets/hermes.rs index bc276518..82d6a94e 100644 --- a/crates/codegraph-installer/src/targets/hermes.rs +++ b/crates/codegraph-installer/src/targets/hermes.rs @@ -42,12 +42,20 @@ impl AgentTarget for HermesTarget { } fn detect(&self, opts: &InstallOpts) -> DetectStatus { - let Some(p) = self.mcp_path(opts) else { + // Agent presence: ~/.hermes/ must exist. + let Some(home) = dirs::home_dir() else { return DetectStatus::NotFound; }; - if !p.exists() { + if !home.join(".hermes").exists() { return DetectStatus::NotFound; } + // Check if codegraph is already configured in the target path. + 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; }; diff --git a/crates/codegraph-installer/src/targets/mod.rs b/crates/codegraph-installer/src/targets/mod.rs index c6993371..6a4933e5 100644 --- a/crates/codegraph-installer/src/targets/mod.rs +++ b/crates/codegraph-installer/src/targets/mod.rs @@ -1,3 +1,4 @@ +pub mod antigravity; pub mod claude; pub mod codex; pub mod cursor; diff --git a/crates/codegraph-installer/src/targets/opencode.rs b/crates/codegraph-installer/src/targets/opencode.rs index c80d6d2e..40b6675d 100644 --- a/crates/codegraph-installer/src/targets/opencode.rs +++ b/crates/codegraph-installer/src/targets/opencode.rs @@ -111,11 +111,20 @@ impl AgentTarget for OpencodeTarget { } fn detect(&self, opts: &InstallOpts) -> DetectStatus { - let Some(p) = self.config_path(opts) else { + // Agent presence: binary in PATH or ~/.config/opencode/ exists. + let installed = which::which("opencode").is_ok() + || dirs::config_dir() + .map(|d| d.join("opencode").exists()) + .unwrap_or(false); + if !installed { return DetectStatus::NotFound; + } + // Check if codegraph is already configured in the target path. + let Some(p) = self.config_path(opts) else { + return DetectStatus::Found; }; if !p.exists() { - return DetectStatus::NotFound; + return DetectStatus::Found; } let Ok(text) = std::fs::read_to_string(p.as_std_path()) else { return DetectStatus::Found; diff --git a/crates/codegraph/Cargo.toml b/crates/codegraph/Cargo.toml index 6ca11d76..f5a91265 100644 --- a/crates/codegraph/Cargo.toml +++ b/crates/codegraph/Cargo.toml @@ -28,3 +28,5 @@ 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 index e9b241d5..aa425d8f 100644 --- a/crates/codegraph/src/main.rs +++ b/crates/codegraph/src/main.rs @@ -97,7 +97,10 @@ fn main() -> Result<()> { } fn cmd_install(root: &Utf8Path) -> Result<()> { - use codegraph_installer::{registry, InstallOpts, InstallReport}; + use codegraph_installer::{DetectStatus, registry, InstallOpts, InstallReport}; + use console::style; + use dialoguer::{MultiSelect, theme::ColorfulTheme}; + let bin = std::env::current_exe()?; let bin = Utf8PathBuf::from_path_buf(bin) .map_err(|p| anyhow!("non-UTF8 bin path: {}", p.display()))?; @@ -106,18 +109,82 @@ fn cmd_install(root: &Utf8Path) -> Result<()> { global: false, binary_path: bin, }; - for target in registry() { - let status = target.detect(&opts); - eprintln!("[{}] detected: {:?}", target.id(), status); + + let all_targets = 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() { + eprintln!("{}", style("No new agents to configure.").yellow()); + return Ok(()); + } + + let labels: Vec = found_indices + .iter() + .map(|&i| all_targets[i].label().to_string()) + .collect(); + + let defaults = vec![false; found_indices.len()]; + + let chosen = MultiSelect::with_theme(&ColorfulTheme::default()) + .with_prompt("Select agents to configure (space = toggle, enter = confirm)") + .items(&labels) + .defaults(&defaults) + .interact()?; + + if chosen.is_empty() { + eprintln!("nothing selected, aborted"); + 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 {}", f); + for f in &p { + eprintln!("[{}] wrote {}", target.id(), f); } } - InstallReport::Unchanged => eprintln!(" unchanged"), - InstallReport::Skipped(r) => eprintln!(" skipped: {}", r), + InstallReport::Unchanged => eprintln!("[{}] unchanged", target.id()), + InstallReport::Skipped(r) => eprintln!("[{}] skipped: {}", target.id(), r), } } Ok(()) From 910a1c2f6fb31617b2b9d212e08c07fafcffb95c Mon Sep 17 00:00:00 2001 From: Cleboost Date: Sat, 23 May 2026 01:00:48 +0200 Subject: [PATCH 14/33] feat(install): add Windows installation script for codegraph Co-authored-by: BaguetteUhq1 --- scripts/install.ps1 | 72 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 scripts/install.ps1 diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 00000000..586d5967 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,72 @@ +# codegraph install script for Windows +# Usage: irm https://raw.githubusercontent.com/Cleboost/codegraph-rs/main/scripts/install.ps1 | iex + +$ErrorActionPreference = 'Stop' + +$Repo = 'Cleboost/codegraph-rs' +$BinName = 'codegraph.exe' +$InstallDir = if ($env:CODEGRAPH_INSTALL_DIR) { $env:CODEGRAPH_INSTALL_DIR } ` + else { Join-Path $env:LOCALAPPDATA 'codegraph\bin' } + +# Detect architecture +$arch = (Get-CimInstance Win32_Processor).AddressWidth +if ($arch -ne 64) { + Write-Error "Only x86_64 is supported on Windows." + exit 1 +} +$Target = 'x86_64-pc-windows-msvc' + +# Fetch latest release tag +Write-Host "Fetching latest release..." +$release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest" +$Tag = $release.tag_name +if (-not $Tag) { + Write-Error "Could not detect latest release tag." + exit 1 +} + +$AssetName = "codegraph-$Target.zip" +$Url = "https://github.com/$Repo/releases/download/$Tag/$AssetName" +$Sha256Url = "https://github.com/$Repo/releases/download/$Tag/codegraph-$Target.sha256" + +# Download +$TmpDir = Join-Path $env:TEMP "codegraph-install-$(Get-Random)" +New-Item -ItemType Directory -Path $TmpDir | Out-Null +$ZipPath = Join-Path $TmpDir $AssetName + +Write-Host "Downloading $Url" +Invoke-WebRequest -Uri $Url -OutFile $ZipPath -UseBasicParsing + +# Verify checksum +Write-Host "Verifying checksum..." +$expectedHash = ([System.Text.Encoding]::UTF8.GetString((Invoke-WebRequest -Uri $Sha256Url -UseBasicParsing).Content)).Trim().Split(' ')[0].ToUpper() +$actualHash = (Get-FileHash -Path $ZipPath -Algorithm SHA256).Hash.ToUpper() +if ($expectedHash -ne $actualHash) { + Write-Error "Checksum mismatch!`n Expected: $expectedHash`n Got: $actualHash" + Remove-Item -Recurse -Force $TmpDir + exit 1 +} + +# Extract +Expand-Archive -Path $ZipPath -DestinationPath $TmpDir -Force + +# Install +if (-not (Test-Path $InstallDir)) { + New-Item -ItemType Directory -Path $InstallDir | Out-Null +} +$BinSrc = Join-Path $TmpDir $BinName +Copy-Item -Path $BinSrc -Destination (Join-Path $InstallDir $BinName) -Force + +# Cleanup +Remove-Item -Recurse -Force $TmpDir + +Write-Host "Installed codegraph $Tag to $InstallDir" + +# Add to user PATH if not already present +$UserPath = [Environment]::GetEnvironmentVariable('Path', 'User') +if ($UserPath -notlike "*$InstallDir*") { + [Environment]::SetEnvironmentVariable('Path', "$UserPath;$InstallDir", 'User') + Write-Host "Added $InstallDir to user PATH. Restart your terminal to apply." +} else { + Write-Host "$InstallDir is already in PATH." +} From 675a90072cc8f60e3c296453c6704c2d1569e3dd Mon Sep 17 00:00:00 2001 From: Cleboost Date: Sat, 23 May 2026 01:01:32 +0200 Subject: [PATCH 15/33] chore(archive): clean old folder --- archive/.cursor/rules/codegraph.mdc | 38 - archive/.github/workflows/release.yml | 121 - archive/.gitignore | 52 - archive/BUNDLING.md | 74 - archive/CHANGELOG.md | 576 --- archive/CLAUDE.md | 146 - archive/LICENSE | 21 - archive/README.md | 513 --- archive/__tests__/concurrent-locking.test.ts | 152 - archive/__tests__/context.test.ts | 374 -- archive/__tests__/drupal.test.ts | 518 --- archive/__tests__/evaluation/runner.ts | 123 - archive/__tests__/evaluation/scoring.ts | 82 - archive/__tests__/evaluation/test-cases.ts | 93 - archive/__tests__/evaluation/types.ts | 37 - .../__tests__/explore-output-budget.test.ts | 234 - archive/__tests__/extraction.test.ts | 3897 ----------------- archive/__tests__/foundation.test.ts | 305 -- .../__tests__/frameworks-integration.test.ts | 59 - archive/__tests__/frameworks.test.ts | 1069 ----- archive/__tests__/git-hooks.test.ts | 129 - archive/__tests__/glyphs.test.ts | 170 - archive/__tests__/graph.test.ts | 435 -- archive/__tests__/installer-targets.test.ts | 890 ---- archive/__tests__/installer.test.ts | 220 - archive/__tests__/is-test-file.test.ts | 53 - archive/__tests__/mcp-initialize.test.ts | 149 - archive/__tests__/mcp-roots.test.ts | 180 - archive/__tests__/node-sqlite-backend.test.ts | 71 - archive/__tests__/node-version-check.test.ts | 69 - archive/__tests__/npm-shim.test.ts | 208 - archive/__tests__/pr19-improvements.test.ts | 719 --- archive/__tests__/resolution.test.ts | 849 ---- archive/__tests__/search-query-parser.test.ts | 142 - archive/__tests__/security.test.ts | 572 --- archive/__tests__/sqlite-backend.test.ts | 44 - archive/__tests__/strip-comments.test.ts | 134 - archive/__tests__/symbol-lookup.test.ts | 194 - archive/__tests__/sync.test.ts | 306 -- archive/__tests__/wasm-runtime-flags.test.ts | 87 - archive/__tests__/watch-policy.test.ts | 82 - archive/__tests__/watcher.test.ts | 278 -- archive/docs/SEARCH_QUALITY_LOOP.md | 558 --- .../2026-04-24-framework-resolver-extract.md | 1117 ----- archive/install.ps1 | 59 - archive/install.sh | 83 - archive/package-lock.json | 1610 ------- archive/package.json | 56 - archive/scripts/add-lang/bench.sh | 60 - archive/scripts/add-lang/check-grammar.mjs | 75 - archive/scripts/add-lang/dump-ast.mjs | 103 - .../scripts/add-lang/verify-extraction.mjs | 70 - archive/scripts/agent-eval/audit.sh | 68 - archive/scripts/agent-eval/itrun.sh | 107 - archive/scripts/agent-eval/parse-run.mjs | 45 - archive/scripts/agent-eval/parse-session.mjs | 93 - archive/scripts/agent-eval/run-agent.sh | 34 - archive/scripts/agent-eval/run-all.sh | 67 - archive/scripts/build-bundle.sh | 118 - archive/scripts/extract-release-notes.mjs | 130 - archive/scripts/local-install.sh | 41 - archive/scripts/npm-shim.js | 246 -- archive/scripts/pack-npm.sh | 95 - archive/src/bin/codegraph.ts | 1449 ------ archive/src/bin/node-version-check.ts | 76 - archive/src/bin/uninstall.ts | 34 - archive/src/context/formatter.ts | 271 -- archive/src/context/index.ts | 1134 ----- archive/src/db/index.ts | 214 - archive/src/db/migrations.ts | 149 - archive/src/db/queries.ts | 1454 ------ archive/src/db/schema.sql | 151 - archive/src/db/sqlite-adapter.ts | 139 - archive/src/directory.ts | 260 -- archive/src/errors.ts | 240 - archive/src/extraction/dfm-extractor.ts | 159 - archive/src/extraction/grammars.ts | 333 -- archive/src/extraction/index.ts | 1447 ------ archive/src/extraction/languages/c-cpp.ts | 116 - archive/src/extraction/languages/csharp.ts | 67 - archive/src/extraction/languages/dart.ts | 195 - archive/src/extraction/languages/go.ts | 51 - archive/src/extraction/languages/index.ts | 50 - archive/src/extraction/languages/java.ts | 59 - .../src/extraction/languages/javascript.ts | 84 - archive/src/extraction/languages/kotlin.ts | 238 - archive/src/extraction/languages/lua.ts | 152 - archive/src/extraction/languages/luau.ts | 36 - archive/src/extraction/languages/pascal.ts | 62 - archive/src/extraction/languages/php.ts | 105 - archive/src/extraction/languages/python.ts | 53 - archive/src/extraction/languages/ruby.ts | 111 - archive/src/extraction/languages/rust.ts | 116 - archive/src/extraction/languages/scala.ts | 143 - archive/src/extraction/languages/swift.ts | 83 - .../src/extraction/languages/typescript.ts | 118 - archive/src/extraction/liquid-extractor.ts | 352 -- archive/src/extraction/parse-worker.ts | 101 - archive/src/extraction/svelte-extractor.ts | 323 -- archive/src/extraction/tree-sitter-helpers.ts | 80 - archive/src/extraction/tree-sitter-types.ts | 209 - archive/src/extraction/tree-sitter.ts | 2581 ----------- archive/src/extraction/vue-extractor.ts | 198 - archive/src/extraction/wasm-runtime-flags.ts | 96 - .../src/extraction/wasm/tree-sitter-lua.wasm | Bin 49488 -> 0 bytes .../src/extraction/wasm/tree-sitter-luau.wasm | Bin 94204 -> 0 bytes .../extraction/wasm/tree-sitter-pascal.wasm | Bin 716886 -> 0 bytes .../extraction/wasm/tree-sitter-scala.wasm | Bin 4958320 -> 0 bytes archive/src/graph/index.ts | 8 - archive/src/graph/queries.ts | 428 -- archive/src/graph/traversal.ts | 641 --- archive/src/index.ts | 937 ---- archive/src/installer/clack.d.ts | 50 - archive/src/installer/claude-md-template.ts | 19 - archive/src/installer/config-writer.ts | 79 - archive/src/installer/index.ts | 596 --- .../src/installer/instructions-template.ts | 63 - archive/src/installer/targets/claude.ts | 406 -- archive/src/installer/targets/codex.ts | 181 - archive/src/installer/targets/cursor.ts | 290 -- archive/src/installer/targets/hermes.ts | 299 -- archive/src/installer/targets/opencode.ts | 244 -- archive/src/installer/targets/registry.ts | 85 - archive/src/installer/targets/shared.ts | 206 - archive/src/installer/targets/toml.ts | 154 - archive/src/installer/targets/types.ts | 121 - archive/src/mcp/index.ts | 463 -- archive/src/mcp/server-instructions.ts | 67 - archive/src/mcp/tools.ts | 1863 -------- archive/src/mcp/transport.ts | 256 -- .../resolution/frameworks/cargo-workspace.ts | 244 -- archive/src/resolution/frameworks/csharp.ts | 246 -- archive/src/resolution/frameworks/drupal.ts | 373 -- archive/src/resolution/frameworks/express.ts | 261 -- archive/src/resolution/frameworks/go.ts | 181 - archive/src/resolution/frameworks/index.ts | 123 - archive/src/resolution/frameworks/java.ts | 206 - archive/src/resolution/frameworks/laravel.ts | 288 -- archive/src/resolution/frameworks/nestjs.ts | 438 -- archive/src/resolution/frameworks/python.ts | 297 -- archive/src/resolution/frameworks/react.ts | 309 -- archive/src/resolution/frameworks/ruby.ts | 236 - archive/src/resolution/frameworks/rust.ts | 239 - archive/src/resolution/frameworks/svelte.ts | 279 -- archive/src/resolution/frameworks/swift.ts | 429 -- archive/src/resolution/frameworks/vue.ts | 338 -- archive/src/resolution/import-resolver.ts | 731 ---- archive/src/resolution/index.ts | 768 ---- archive/src/resolution/name-matcher.ts | 463 -- archive/src/resolution/path-aliases.ts | 242 - archive/src/resolution/strip-comments.ts | 469 -- archive/src/resolution/types.ts | 182 - archive/src/search/query-parser.ts | 184 - archive/src/search/query-utils.ts | 342 -- archive/src/sync/git-hooks.ts | 208 - archive/src/sync/index.ts | 25 - archive/src/sync/watch-policy.ts | 104 - archive/src/sync/watcher.ts | 203 - archive/src/types.ts | 572 --- archive/src/ui/glyphs.ts | 91 - archive/src/ui/shimmer-progress.ts | 73 - archive/src/ui/shimmer-worker.ts | 123 - archive/src/ui/types.ts | 9 - archive/src/utils.ts | 566 --- archive/src/web-tree-sitter.d.ts | 182 - archive/tsconfig.json | 34 - archive/vitest.config.ts | 13 - 167 files changed, 50116 deletions(-) delete mode 100644 archive/.cursor/rules/codegraph.mdc delete mode 100644 archive/.github/workflows/release.yml delete mode 100644 archive/.gitignore delete mode 100644 archive/BUNDLING.md delete mode 100644 archive/CHANGELOG.md delete mode 100644 archive/CLAUDE.md delete mode 100644 archive/LICENSE delete mode 100644 archive/README.md delete mode 100644 archive/__tests__/concurrent-locking.test.ts delete mode 100644 archive/__tests__/context.test.ts delete mode 100644 archive/__tests__/drupal.test.ts delete mode 100644 archive/__tests__/evaluation/runner.ts delete mode 100644 archive/__tests__/evaluation/scoring.ts delete mode 100644 archive/__tests__/evaluation/test-cases.ts delete mode 100644 archive/__tests__/evaluation/types.ts delete mode 100644 archive/__tests__/explore-output-budget.test.ts delete mode 100644 archive/__tests__/extraction.test.ts delete mode 100644 archive/__tests__/foundation.test.ts delete mode 100644 archive/__tests__/frameworks-integration.test.ts delete mode 100644 archive/__tests__/frameworks.test.ts delete mode 100644 archive/__tests__/git-hooks.test.ts delete mode 100644 archive/__tests__/glyphs.test.ts delete mode 100644 archive/__tests__/graph.test.ts delete mode 100644 archive/__tests__/installer-targets.test.ts delete mode 100644 archive/__tests__/installer.test.ts delete mode 100644 archive/__tests__/is-test-file.test.ts delete mode 100644 archive/__tests__/mcp-initialize.test.ts delete mode 100644 archive/__tests__/mcp-roots.test.ts delete mode 100644 archive/__tests__/node-sqlite-backend.test.ts delete mode 100644 archive/__tests__/node-version-check.test.ts delete mode 100644 archive/__tests__/npm-shim.test.ts delete mode 100644 archive/__tests__/pr19-improvements.test.ts delete mode 100644 archive/__tests__/resolution.test.ts delete mode 100644 archive/__tests__/search-query-parser.test.ts delete mode 100644 archive/__tests__/security.test.ts delete mode 100644 archive/__tests__/sqlite-backend.test.ts delete mode 100644 archive/__tests__/strip-comments.test.ts delete mode 100644 archive/__tests__/symbol-lookup.test.ts delete mode 100644 archive/__tests__/sync.test.ts delete mode 100644 archive/__tests__/wasm-runtime-flags.test.ts delete mode 100644 archive/__tests__/watch-policy.test.ts delete mode 100644 archive/__tests__/watcher.test.ts delete mode 100644 archive/docs/SEARCH_QUALITY_LOOP.md delete mode 100644 archive/docs/plans/2026-04-24-framework-resolver-extract.md delete mode 100644 archive/install.ps1 delete mode 100755 archive/install.sh delete mode 100644 archive/package-lock.json delete mode 100644 archive/package.json delete mode 100755 archive/scripts/add-lang/bench.sh delete mode 100755 archive/scripts/add-lang/check-grammar.mjs delete mode 100755 archive/scripts/add-lang/dump-ast.mjs delete mode 100755 archive/scripts/add-lang/verify-extraction.mjs delete mode 100755 archive/scripts/agent-eval/audit.sh delete mode 100755 archive/scripts/agent-eval/itrun.sh delete mode 100644 archive/scripts/agent-eval/parse-run.mjs delete mode 100644 archive/scripts/agent-eval/parse-session.mjs delete mode 100755 archive/scripts/agent-eval/run-agent.sh delete mode 100755 archive/scripts/agent-eval/run-all.sh delete mode 100755 archive/scripts/build-bundle.sh delete mode 100755 archive/scripts/extract-release-notes.mjs delete mode 100755 archive/scripts/local-install.sh delete mode 100755 archive/scripts/npm-shim.js delete mode 100755 archive/scripts/pack-npm.sh delete mode 100644 archive/src/bin/codegraph.ts delete mode 100644 archive/src/bin/node-version-check.ts delete mode 100644 archive/src/bin/uninstall.ts delete mode 100644 archive/src/context/formatter.ts delete mode 100644 archive/src/context/index.ts delete mode 100644 archive/src/db/index.ts delete mode 100644 archive/src/db/migrations.ts delete mode 100644 archive/src/db/queries.ts delete mode 100644 archive/src/db/schema.sql delete mode 100644 archive/src/db/sqlite-adapter.ts delete mode 100644 archive/src/directory.ts delete mode 100644 archive/src/errors.ts delete mode 100644 archive/src/extraction/dfm-extractor.ts delete mode 100644 archive/src/extraction/grammars.ts delete mode 100644 archive/src/extraction/index.ts delete mode 100644 archive/src/extraction/languages/c-cpp.ts delete mode 100644 archive/src/extraction/languages/csharp.ts delete mode 100644 archive/src/extraction/languages/dart.ts delete mode 100644 archive/src/extraction/languages/go.ts delete mode 100644 archive/src/extraction/languages/index.ts delete mode 100644 archive/src/extraction/languages/java.ts delete mode 100644 archive/src/extraction/languages/javascript.ts delete mode 100644 archive/src/extraction/languages/kotlin.ts delete mode 100644 archive/src/extraction/languages/lua.ts delete mode 100644 archive/src/extraction/languages/luau.ts delete mode 100644 archive/src/extraction/languages/pascal.ts delete mode 100644 archive/src/extraction/languages/php.ts delete mode 100644 archive/src/extraction/languages/python.ts delete mode 100644 archive/src/extraction/languages/ruby.ts delete mode 100644 archive/src/extraction/languages/rust.ts delete mode 100644 archive/src/extraction/languages/scala.ts delete mode 100644 archive/src/extraction/languages/swift.ts delete mode 100644 archive/src/extraction/languages/typescript.ts delete mode 100644 archive/src/extraction/liquid-extractor.ts delete mode 100644 archive/src/extraction/parse-worker.ts delete mode 100644 archive/src/extraction/svelte-extractor.ts delete mode 100644 archive/src/extraction/tree-sitter-helpers.ts delete mode 100644 archive/src/extraction/tree-sitter-types.ts delete mode 100644 archive/src/extraction/tree-sitter.ts delete mode 100644 archive/src/extraction/vue-extractor.ts delete mode 100644 archive/src/extraction/wasm-runtime-flags.ts delete mode 100644 archive/src/extraction/wasm/tree-sitter-lua.wasm delete mode 100644 archive/src/extraction/wasm/tree-sitter-luau.wasm delete mode 100755 archive/src/extraction/wasm/tree-sitter-pascal.wasm delete mode 100644 archive/src/extraction/wasm/tree-sitter-scala.wasm delete mode 100644 archive/src/graph/index.ts delete mode 100644 archive/src/graph/queries.ts delete mode 100644 archive/src/graph/traversal.ts delete mode 100644 archive/src/index.ts delete mode 100644 archive/src/installer/clack.d.ts delete mode 100644 archive/src/installer/claude-md-template.ts delete mode 100644 archive/src/installer/config-writer.ts delete mode 100644 archive/src/installer/index.ts delete mode 100644 archive/src/installer/instructions-template.ts delete mode 100644 archive/src/installer/targets/claude.ts delete mode 100644 archive/src/installer/targets/codex.ts delete mode 100644 archive/src/installer/targets/cursor.ts delete mode 100644 archive/src/installer/targets/hermes.ts delete mode 100644 archive/src/installer/targets/opencode.ts delete mode 100644 archive/src/installer/targets/registry.ts delete mode 100644 archive/src/installer/targets/shared.ts delete mode 100644 archive/src/installer/targets/toml.ts delete mode 100644 archive/src/installer/targets/types.ts delete mode 100644 archive/src/mcp/index.ts delete mode 100644 archive/src/mcp/server-instructions.ts delete mode 100644 archive/src/mcp/tools.ts delete mode 100644 archive/src/mcp/transport.ts delete mode 100644 archive/src/resolution/frameworks/cargo-workspace.ts delete mode 100644 archive/src/resolution/frameworks/csharp.ts delete mode 100644 archive/src/resolution/frameworks/drupal.ts delete mode 100644 archive/src/resolution/frameworks/express.ts delete mode 100644 archive/src/resolution/frameworks/go.ts delete mode 100644 archive/src/resolution/frameworks/index.ts delete mode 100644 archive/src/resolution/frameworks/java.ts delete mode 100644 archive/src/resolution/frameworks/laravel.ts delete mode 100644 archive/src/resolution/frameworks/nestjs.ts delete mode 100644 archive/src/resolution/frameworks/python.ts delete mode 100644 archive/src/resolution/frameworks/react.ts delete mode 100644 archive/src/resolution/frameworks/ruby.ts delete mode 100644 archive/src/resolution/frameworks/rust.ts delete mode 100644 archive/src/resolution/frameworks/svelte.ts delete mode 100644 archive/src/resolution/frameworks/swift.ts delete mode 100644 archive/src/resolution/frameworks/vue.ts delete mode 100644 archive/src/resolution/import-resolver.ts delete mode 100644 archive/src/resolution/index.ts delete mode 100644 archive/src/resolution/name-matcher.ts delete mode 100644 archive/src/resolution/path-aliases.ts delete mode 100644 archive/src/resolution/strip-comments.ts delete mode 100644 archive/src/resolution/types.ts delete mode 100644 archive/src/search/query-parser.ts delete mode 100644 archive/src/search/query-utils.ts delete mode 100644 archive/src/sync/git-hooks.ts delete mode 100644 archive/src/sync/index.ts delete mode 100644 archive/src/sync/watch-policy.ts delete mode 100644 archive/src/sync/watcher.ts delete mode 100644 archive/src/types.ts delete mode 100644 archive/src/ui/glyphs.ts delete mode 100644 archive/src/ui/shimmer-progress.ts delete mode 100644 archive/src/ui/shimmer-worker.ts delete mode 100644 archive/src/ui/types.ts delete mode 100644 archive/src/utils.ts delete mode 100644 archive/src/web-tree-sitter.d.ts delete mode 100644 archive/tsconfig.json delete mode 100644 archive/vitest.config.ts diff --git a/archive/.cursor/rules/codegraph.mdc b/archive/.cursor/rules/codegraph.mdc deleted file mode 100644 index 3f23cf6b..00000000 --- a/archive/.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/archive/.github/workflows/release.yml b/archive/.github/workflows/release.yml deleted file mode 100644 index 51dea151..00000000 --- a/archive/.github/workflows/release.yml +++ /dev/null @@ -1,121 +0,0 @@ -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: {} - -permissions: - contents: write # create the GitHub Release + tag - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - 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 - - - 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: Resolve version - id: ver - run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" - - - name: Release notes from CHANGELOG.md - 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 - fi - echo "----- release notes -----"; cat notes.md - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ github.token }} - 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 - else - gh release create "$TAG" release/codegraph-* release/SHA256SUMS --title "$TAG" --notes-file notes.md - fi - - - name: Publish to npm - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_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 - 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 - 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 diff --git a/archive/.gitignore b/archive/.gitignore deleted file mode 100644 index 55b34a8f..00000000 --- a/archive/.gitignore +++ /dev/null @@ -1,52 +0,0 @@ -# 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) -.codegraph/ - -test_frameworks - -# Test language repos for manual testing -test-languages/ - -nul -release/ diff --git a/archive/BUNDLING.md b/archive/BUNDLING.md deleted file mode 100644 index dc21ab53..00000000 --- a/archive/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/archive/CHANGELOG.md b/archive/CHANGELOG.md deleted file mode 100644 index 535b0ce9..00000000 --- a/archive/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/archive/CLAUDE.md b/archive/CLAUDE.md deleted file mode 100644 index d5222f37..00000000 --- a/archive/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/archive/LICENSE b/archive/LICENSE deleted file mode 100644 index 31c84c9c..00000000 --- a/archive/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Colby Mchenry - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -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. diff --git a/archive/README.md b/archive/README.md deleted file mode 100644 index 511e2094..00000000 --- a/archive/README.md +++ /dev/null @@ -1,513 +0,0 @@ -
- -# CodeGraph - -### Supercharge Claude Code, Cursor, Codex, OpenCode, and Hermes Agent with Semantic Code Intelligence - -**~35% cheaper · ~70% fewer tool calls · 100% local** - -[![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/) - -[![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)](#) - -[![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)](#) - -
- -## Get Started - -**No Node.js required** — one command grabs the right build for your OS: - -```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 -``` - -Already have Node? Use npm instead (works on any version): - -```bash -npx @colbymchenry/codegraph # zero-install, or: -npm i -g @colbymchenry/codegraph -``` - -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. - -### Initialize Projects - -```bash -cd your-project -codegraph init -i -``` - -
- -![1_C_VYnhpys0UHrOuOgpgoyw](https://github.com/user-attachments/assets/f168182f-4d9a-44e0-94d7-08d018cc8a3a) - -
- -### Uninstall - -Changed your mind? One command removes CodeGraph from every agent it configured: - -```bash -codegraph uninstall -``` - -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. - -
- ---- - -## Key Features - -| | | -|---|---| -| **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 | - ---- - -## 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. - -| 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 -``` - -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 -``` - -| 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 - -```bash -cd your-project -codegraph init -i -``` - -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) - -**Install globally:** -```bash -npm install -g @colbymchenry/codegraph -``` - -**Add to `~/.claude.json`:** -```json -{ - "mcpServers": { - "codegraph": { - "type": "stdio", - "command": "codegraph", - "args": ["serve", "--mcp"] - } - } -} -``` - -**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" - ] - } -} -``` - -
- -
-Global Instructions Reference - -The installer automatically adds these instructions to `~/.claude/CLAUDE.md`: - -```markdown -## CodeGraph - -CodeGraph builds a semantic knowledge graph of codebases for faster, smarter code exploration. - -### If `.codegraph/` exists in the project - -**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?"). - -**When spawning Explore agents**, include this instruction in the prompt: - -> 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. - -**The main session may only use these lightweight tools directly** (for targeted lookups before making edits, not for exploration): - -| 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 | - -### If `.codegraph/` does NOT exist - -At the start of a session, ask the user if they'd like to initialize CodeGraph: - -"I notice this project doesn't have CodeGraph initialized. Would you like me to run `codegraph init -i` to build a code knowledge graph?" -``` - -
- ---- - -## How It Works - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 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 │ │ -│ └───────────────────────┘ │ -└───────────────────────────────────────────────────────────────────┘ -``` - -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. - -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` - -Traces import dependencies transitively to find which test files are affected by changed source files. - -```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 -``` - ---- - -## MCP Tools - -When running as an MCP server, CodeGraph exposes these tools to Claude Code: - -| 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 | - ---- - -## Library Usage - -```typescript -import CodeGraph from '@colbymchenry/codegraph'; - -const cg = await CodeGraph.init('/path/to/project'); -// Or: const cg = await CodeGraph.open('/path/to/project'); - -await cg.indexAll({ - onProgress: (p) => console.log(`${p.phase}: ${p.current}/${p.total}`) -}); - -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(); -``` - ---- - -## Configuration - -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 - - - -## License - -MIT - ---- - -
- -**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, and Hermes Agent** - -[Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues) - -
diff --git a/archive/__tests__/concurrent-locking.test.ts b/archive/__tests__/concurrent-locking.test.ts deleted file mode 100644 index 5c8ab518..00000000 --- a/archive/__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/archive/__tests__/context.test.ts b/archive/__tests__/context.test.ts deleted file mode 100644 index 52dae1fe..00000000 --- a/archive/__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/archive/__tests__/drupal.test.ts b/archive/__tests__/drupal.test.ts deleted file mode 100644 index fda5415b..00000000 --- a/archive/__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/archive/__tests__/evaluation/runner.ts b/archive/__tests__/evaluation/runner.ts deleted file mode 100644 index 7ff04359..00000000 --- a/archive/__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/archive/__tests__/evaluation/scoring.ts b/archive/__tests__/evaluation/scoring.ts deleted file mode 100644 index b20f604c..00000000 --- a/archive/__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/archive/__tests__/evaluation/test-cases.ts b/archive/__tests__/evaluation/test-cases.ts deleted file mode 100644 index b9db233f..00000000 --- a/archive/__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/archive/__tests__/evaluation/types.ts b/archive/__tests__/evaluation/types.ts deleted file mode 100644 index 64a24270..00000000 --- a/archive/__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/archive/__tests__/explore-output-budget.test.ts b/archive/__tests__/explore-output-budget.test.ts deleted file mode 100644 index 65ddc648..00000000 --- a/archive/__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/archive/__tests__/extraction.test.ts b/archive/__tests__/extraction.test.ts deleted file mode 100644 index 92717759..00000000 --- a/archive/__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/archive/__tests__/foundation.test.ts b/archive/__tests__/foundation.test.ts deleted file mode 100644 index 78ebfce4..00000000 --- a/archive/__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/archive/__tests__/frameworks-integration.test.ts b/archive/__tests__/frameworks-integration.test.ts deleted file mode 100644 index b64e8c66..00000000 --- a/archive/__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/archive/__tests__/frameworks.test.ts b/archive/__tests__/frameworks.test.ts deleted file mode 100644 index a5e5c56b..00000000 --- a/archive/__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/archive/__tests__/git-hooks.test.ts b/archive/__tests__/git-hooks.test.ts deleted file mode 100644 index 4dfd80eb..00000000 --- a/archive/__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/archive/__tests__/glyphs.test.ts b/archive/__tests__/glyphs.test.ts deleted file mode 100644 index db41a105..00000000 --- a/archive/__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/archive/__tests__/graph.test.ts b/archive/__tests__/graph.test.ts deleted file mode 100644 index 7c771af0..00000000 --- a/archive/__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/archive/__tests__/installer-targets.test.ts b/archive/__tests__/installer-targets.test.ts deleted file mode 100644 index 59e869e2..00000000 --- a/archive/__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/archive/__tests__/installer.test.ts b/archive/__tests__/installer.test.ts deleted file mode 100644 index 728ed7c3..00000000 --- a/archive/__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/archive/__tests__/is-test-file.test.ts b/archive/__tests__/is-test-file.test.ts deleted file mode 100644 index e3fc6d03..00000000 --- a/archive/__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/archive/__tests__/mcp-initialize.test.ts b/archive/__tests__/mcp-initialize.test.ts deleted file mode 100644 index 4a57ebae..00000000 --- a/archive/__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/archive/__tests__/mcp-roots.test.ts b/archive/__tests__/mcp-roots.test.ts deleted file mode 100644 index 8e1d4520..00000000 --- a/archive/__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/archive/__tests__/node-sqlite-backend.test.ts b/archive/__tests__/node-sqlite-backend.test.ts deleted file mode 100644 index d1e630f6..00000000 --- a/archive/__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/archive/__tests__/node-version-check.test.ts b/archive/__tests__/node-version-check.test.ts deleted file mode 100644 index fc455eb8..00000000 --- a/archive/__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/archive/__tests__/npm-shim.test.ts b/archive/__tests__/npm-shim.test.ts deleted file mode 100644 index 16e70506..00000000 --- a/archive/__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/archive/__tests__/pr19-improvements.test.ts b/archive/__tests__/pr19-improvements.test.ts deleted file mode 100644 index 6741e905..00000000 --- a/archive/__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/archive/__tests__/resolution.test.ts b/archive/__tests__/resolution.test.ts deleted file mode 100644 index 1ca3a3f8..00000000 --- a/archive/__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/archive/__tests__/search-query-parser.test.ts b/archive/__tests__/search-query-parser.test.ts deleted file mode 100644 index 8a7767da..00000000 --- a/archive/__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/archive/__tests__/security.test.ts b/archive/__tests__/security.test.ts deleted file mode 100644 index 782b99da..00000000 --- a/archive/__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/archive/__tests__/sqlite-backend.test.ts b/archive/__tests__/sqlite-backend.test.ts deleted file mode 100644 index 0815551d..00000000 --- a/archive/__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/archive/__tests__/strip-comments.test.ts b/archive/__tests__/strip-comments.test.ts deleted file mode 100644 index ef2ec057..00000000 --- a/archive/__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/archive/__tests__/symbol-lookup.test.ts b/archive/__tests__/symbol-lookup.test.ts deleted file mode 100644 index 86dda6cb..00000000 --- a/archive/__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/archive/__tests__/sync.test.ts b/archive/__tests__/sync.test.ts deleted file mode 100644 index 708a92a4..00000000 --- a/archive/__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/archive/__tests__/wasm-runtime-flags.test.ts b/archive/__tests__/wasm-runtime-flags.test.ts deleted file mode 100644 index a4dae8bb..00000000 --- a/archive/__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/archive/__tests__/watch-policy.test.ts b/archive/__tests__/watch-policy.test.ts deleted file mode 100644 index 5cb92ce7..00000000 --- a/archive/__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/archive/__tests__/watcher.test.ts b/archive/__tests__/watcher.test.ts deleted file mode 100644 index fde5f593..00000000 --- a/archive/__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/archive/docs/SEARCH_QUALITY_LOOP.md b/archive/docs/SEARCH_QUALITY_LOOP.md deleted file mode 100644 index 97d57ded..00000000 --- a/archive/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 ,