diff --git a/Cargo.lock b/Cargo.lock
index c3ebff9..f32c8a5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -782,7 +782,7 @@ dependencies = [
[[package]]
name = "codegraph-harness"
-version = "0.19.1"
+version = "0.20.0"
dependencies = [
"anyhow",
"clap",
@@ -890,7 +890,7 @@ dependencies = [
[[package]]
name = "codegraph-memory"
-version = "0.19.1"
+version = "0.20.0"
dependencies = [
"anyhow",
"bincode",
@@ -1073,7 +1073,7 @@ dependencies = [
[[package]]
name = "codegraph-server"
-version = "0.19.1"
+version = "0.20.0"
dependencies = [
"clap",
"codegraph",
diff --git a/Cargo.toml b/Cargo.toml
index 0830fdf..0f2b37f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -60,7 +60,7 @@ members = [
]
[workspace.package]
-version = "0.19.1"
+version = "0.20.0"
edition = "2021"
license = "Apache-2.0"
repository = "https://github.com/codegraph-ai/codegraph"
diff --git a/README.md b/README.md
index 04fe545..1c91c93 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
[](LICENSE)
-CodeGraph builds a semantic graph of your codebase — functions, classes, imports, call chains — and exposes it through **45 MCP tools**, a **VS Code extension**, and a **persistent memory layer**. Parses **37 languages** via tree-sitter. AI agents get structured code understanding instead of grepping through files.
+CodeGraph builds a semantic graph of your codebase — functions, classes, imports, call chains — and exposes it through **42 MCP tools**, a **VS Code extension**, a **JetBrains IDE plugin**, and a **persistent memory layer**. Parses **38 languages** via tree-sitter. AI agents get structured code understanding instead of grepping through files.
## Quick Start
@@ -30,10 +30,26 @@ The server indexes the current working directory automatically.
Install the VSIX:
```bash
-code --install-extension codegraph-0.14.0.vsix
+code --install-extension codegraph-0.20.0.vsix
```
-The extension starts the server automatically and registers all tools as Language Model Tools for Copilot.
+One VSIX serves every platform.
+The analysis engine is not bundled: on first activation the extension offers to download the engine built for your platform, verifies it against the published checksum, and installs it into `~/.codegraph/bin` - the same location the JetBrains plugin uses, so one download serves both.
+The download is offered rather than performed automatically, because it is a native binary that runs with your permissions.
+Decline it and run **CodeGraph: Download Analysis Engine** from the command palette whenever you are ready.
+
+Once an engine is present, the extension starts it automatically and registers all tools as Language Model Tools for Copilot.
+
+### JetBrains IDEs
+
+A plugin for IntelliJ IDEA, PyCharm, GoLand, Android Studio and the rest of the
+family drives the same engine over LSP: Code Vision, Symbols and Memories tool
+windows, a graph panel, and one-click MCP registration for the AI Assistant.
+It resolves or downloads the engine the same way the VS Code extension does,
+sharing `~/.codegraph/bin`.
+
+→ **[jetbrains/README.md](jetbrains/README.md)** for surfaces, engine
+resolution order, and building from source.
### Rules for AI agents
@@ -89,16 +105,23 @@ one tool and exits without the MCP stdio handshake — ideal for scripting.
Static (model2vec) embeddings replace the ONNX transformer with a token→vector
lookup table: indexing is **~100× faster** (this repo's 5,873 symbols embed in
~1 s vs ~3.4 min with BGE) and there's **no ONNX runtime or 1.5 GB RAM gate**.
-Retrieval stays **hybrid (BM25 + semantic)**, so end-to-end quality is **~90% of
-BGE**. The VS Code extension ships the model bundled, so `static` works there
-with no setup. For the CLI/MCP server it needs a local model directory
-(`config.json` + `tokenizer.json` + `model.safetensors`):
-
-- Point at it with `CODEGRAPH_STATIC_MODEL=/path/to/model` (or the VS Code
- `codegraph.staticModelPath` setting to override the bundled model). Default:
- `~/.codegraph/static_models/jina-code-static-256`.
-- Distill one from any sentence-transformer (Apache-2.0 Jina-Code by default) in
- ~30 s on CPU: `python scripts/distill_static_model.py`.
+Retrieval stays **hybrid (BM25 + semantic)**, so end-to-end quality is **~90% of BGE**.
+The model is not bundled with any client — it needs a local model directory
+(`config.json` + `tokenizer.json` + `model.safetensors`) at
+`~/.codegraph/static_models/jina-code-static-256`, or wherever
+`CODEGRAPH_STATIC_MODEL` points:
+
+- Installing `@astudioplus/codegraph-mcp` from npm downloads it into that
+ default location for you (best-effort; set `CODEGRAPH_SKIP_MODEL_FETCH=1` to
+ skip, and the install never fails over it).
+- Otherwise fetch the prebuilt one with `scripts/fetch-static-model.sh`, or
+ distill your own from any sentence-transformer (Apache-2.0 Jina-Code by
+ default) in ~30 s on CPU: `python scripts/distill_static_model.py`.
+- A model in the default location needs no IDE setting: both IDE clients leave
+ `CODEGRAPH_STATIC_MODEL` unset and let the engine resolve it. To use a model
+ kept somewhere else, set `codegraph.staticModelPath` in VS Code, or
+ *Settings → Tools → CodeGraph → Embeddings → Static model directory* in
+ JetBrains; each client then passes that path as `CODEGRAPH_STATIC_MODEL`.
#### `CODEGRAPH_SKIP_MEMORY_CHECK` — force the embedding model past the RAM gate
@@ -117,29 +140,22 @@ It works in both MCP and one-shot `--run-tool` modes.
#### `--profile` — narrow the MCP tool surface
-The full 32-tool surface is convenient but inflates the agent's prompt-context cost. A profile exposes only the slice you need (also settable via the `CODEGRAPH_TOOL_PROFILE` env var):
+The full 42-tool surface is convenient but inflates the agent's prompt-context cost. A profile exposes only the slice you need (also settable via the `CODEGRAPH_TOOL_PROFILE` env var):
| Profile | Tools | Use when |
|---------|-------|----------|
| `all` *(default)* | every tool (community + pro) | normal sessions |
| `core` | 8 — search + symbol info + AI context | chatty agent sessions where you only need lookups |
-| `graph` | 16 — callers/callees/deps/impact/traverse | refactoring + structural analysis |
-| `memory` | 7 — `codegraph_memory_*` only | note-taking / knowledge-base workflows |
+| `graph` | 17 — callers/callees/deps/impact/traverse/PR context | refactoring + structural analysis |
+| `memory` | 14 — `codegraph_memory_*` plus the docs tools | note-taking / knowledge-base workflows |
| `security` | pro security tools only (empty on community) | pro security audits |
### VS Code settings
-```jsonc
-{
- "codegraph.indexOnStartup": true,
- "codegraph.indexPaths": ["/path/to/project-a", "/path/to/project-b"],
- "codegraph.excludePatterns": ["**/cmake-build-debug/**", "**/generated/**"],
- "codegraph.embeddingModel": "bge-small", // or "static" for ~100× faster indexing
- "codegraph.staticModelPath": "", // model2vec model dir when embeddingModel is "static"
- "codegraph.maxFileSizeKB": 1024,
- "codegraph.debug": false
-}
-```
+The `codegraph.*` settings are documented once, next to the extension that
+reads them:
+
+→ **[vscode/README.md — Configuration](vscode/README.md#configuration)**
Full-body embeddings are enabled by default. Function body text is captured at parse time with zero I/O overhead.
@@ -151,9 +167,14 @@ Built-in exclusions (always skipped) cover ~47 directories across three categori
Plus glob patterns for binary archives, native libraries, OS metadata, and **secret file extensions** (`*.pem`, `*.key`, `*.p12`, `*.pfx`, `*.crt`, `*.gpg`, `*.kdbx`, SSH key conventions like `id_rsa`, etc.) — defense in depth against accidentally embedding credentials.
+Indexing produced zero files, or something else looks wrong? See
+**[docs/troubleshooting.md](docs/troubleshooting.md)**.
+
---
-## Tools (42 community + 27 pro, 17 security)
+## Tools
+
+42 community tools, plus 27 more (17 of them security analyzers) in CodeGraph Pro.
### Code Analysis (11)
@@ -339,7 +360,7 @@ Additional tools available in [CodeGraph Pro](https://codegraph.astudioplus.com/
| **Functional** | Haskell, OCaml, Julia, Erlang, Elm, Clojure |
| **Enterprise** | C#, COBOL, Fortran, Go |
| **Blockchain** | Solidity |
-| **Shell/Config** | Bash, HCL/Terraform, TOML, YAML |
+| **Shell/Config** | Bash, Dockerfile, HCL/Terraform, TOML, YAML |
| **Hardware** | Verilog/SystemVerilog, Tcl |
| **Data Science** | R, Julia |
@@ -356,11 +377,11 @@ HTTP handler detection: Python (FastAPI/Flask/Django), TypeScript (NestJS), Java
## Architecture
```
-MCP Client (Claude, Cursor, ...) VS Code Extension
- | |
- MCP (stdio) LSP Protocol
- | |
- └───────────┐ ┌───────────┘
+MCP Client (Claude, Cursor, ...) VS Code Extension JetBrains Plugin
+ | | |
+ MCP (stdio) LSP Protocol LSP Protocol
+ | | |
+ └───────────┐ ┌──────┴──────────────────┘
▼ ▼
┌─────────────────────────────┐
│ codegraph-server │
diff --git a/crates/codegraph-server/src/backend.rs b/crates/codegraph-server/src/backend.rs
index 14d4985..14a6acf 100644
--- a/crates/codegraph-server/src/backend.rs
+++ b/crates/codegraph-server/src/backend.rs
@@ -64,6 +64,47 @@ impl Default for CodeGraphConfig {
}
/// CodeGraph Language Server backend.
+/// Where the client says the workspace lives.
+///
+/// `workspaceFolders` is optional in LSP - a client may send only `rootUri`, or
+/// the deprecated `rootPath`, and several do. Reading only the first left the
+/// memory subsystem uninitialised for those clients, so every memory command
+/// failed for the whole session while indexing and search kept working: a
+/// half-broken server rather than an obvious failure.
+///
+/// An empty `workspaceFolders` list is treated as absent rather than as "no
+/// workspace", so a client that sends `[]` alongside a usable `rootUri` still
+/// works.
+fn workspace_paths_from(params: &InitializeParams) -> Vec {
+ let from_folders: Vec = params
+ .workspace_folders
+ .iter()
+ .flatten()
+ .filter_map(|folder| folder.uri.to_file_path().ok())
+ .collect();
+ if !from_folders.is_empty() {
+ return from_folders;
+ }
+
+ #[allow(deprecated)]
+ if let Some(path) = params
+ .root_uri
+ .as_ref()
+ .and_then(|uri| uri.to_file_path().ok())
+ {
+ tracing::info!("No workspaceFolders; falling back to rootUri");
+ return vec![path];
+ }
+
+ #[allow(deprecated)]
+ if let Some(path) = params.root_path.as_ref() {
+ tracing::info!("No workspaceFolders or rootUri; falling back to rootPath");
+ return vec![std::path::PathBuf::from(path)];
+ }
+
+ Vec::new()
+}
+
pub struct CodeGraphBackend {
/// LSP client for sending notifications.
pub client: Client,
@@ -87,7 +128,12 @@ pub struct CodeGraphBackend {
pub query_engine: Arc,
/// Memory manager for persistent AI context.
- pub memory_manager: Arc,
+ ///
+ /// Behind a lock because `initialize` replaces it: the embedding model and
+ /// the client's resource directory are only known once the client has sent
+ /// them, and both are baked in when the manager is constructed. Read it
+ /// through [`CodeGraphBackend::memory_manager`].
+ memory_manager: std::sync::RwLock>,
/// Workspace folders
pub workspace_folders: Arc>>,
@@ -142,7 +188,7 @@ impl CodeGraphBackend {
file_cache: Arc::new(DashMap::new()),
query_cache: Arc::new(QueryCache::new(1000)),
symbol_index: Arc::new(SymbolIndex::new()),
- memory_manager: Arc::new(MemoryManager::new(None)),
+ memory_manager: std::sync::RwLock::new(Arc::new(MemoryManager::new(None))),
workspace_folders: Arc::new(RwLock::new(Vec::new())),
file_watcher: Arc::new(Mutex::new(None)),
branch_watcher: Arc::new(Mutex::new(None)),
@@ -195,7 +241,7 @@ impl CodeGraphBackend {
file_cache: Arc::new(DashMap::new()),
query_cache: Arc::new(QueryCache::new(1000)),
symbol_index: Arc::new(SymbolIndex::new()),
- memory_manager: Arc::new(MemoryManager::new(None)),
+ memory_manager: std::sync::RwLock::new(Arc::new(MemoryManager::new(None))),
workspace_folders: Arc::new(RwLock::new(Vec::new())),
file_watcher: Arc::new(Mutex::new(None)),
branch_watcher: Arc::new(Mutex::new(None)),
@@ -208,6 +254,23 @@ impl CodeGraphBackend {
}
}
+ /// The memory manager currently in use.
+ ///
+ /// Hands back a clone of the `Arc` rather than a guard, so no caller can
+ /// hold the lock across an `.await`. A poisoned lock still yields the
+ /// manager: the value is only ever replaced wholesale, so a panic elsewhere
+ /// cannot have left it half-written, and refusing to serve memory commands
+ /// for the rest of the session would be the larger failure.
+ #[must_use]
+ pub fn memory_manager(&self) -> Arc {
+ Arc::clone(
+ &self
+ .memory_manager
+ .read()
+ .unwrap_or_else(|poisoned| poisoned.into_inner()),
+ )
+ }
+
/// Start the file watcher for the given workspace folders.
pub async fn start_file_watcher(&self, folders: &[PathBuf]) {
// Create the file watcher
@@ -215,7 +278,7 @@ impl CodeGraphBackend {
Arc::clone(&self.graph),
Arc::clone(&self.parsers),
self.client.clone(),
- Arc::clone(&self.memory_manager),
+ self.memory_manager(),
Arc::clone(&self.symbol_index),
Arc::clone(&self.query_engine),
self.embed_queue.clone(),
@@ -283,7 +346,7 @@ impl CodeGraphBackend {
Arc::clone(&self.query_engine),
Arc::clone(&self.query_cache),
self.client.clone(),
- Arc::clone(&self.memory_manager),
+ self.memory_manager(),
workspace_root.to_path_buf(),
) {
Ok(watcher) => {
@@ -332,7 +395,7 @@ impl CodeGraphBackend {
if !node_id_strings.is_empty() {
let reason = format!("Code changed: {}", path_str);
if let Err(e) = self
- .memory_manager
+ .memory_manager()
.invalidate_for_code_nodes(&node_id_strings, &reason)
.await
{
@@ -865,6 +928,9 @@ impl LanguageServer for CodeGraphBackend {
tracing::info!("Initializing CodeGraph LSP server");
// Extract extension path and config from initialization options
+ // Resolve the workspace location before `params` is partially moved.
+ let folder_paths = workspace_paths_from(¶ms);
+
let init_opts = params.initialization_options;
let extension_path = init_opts.as_ref().and_then(|opts| {
@@ -902,68 +968,78 @@ impl LanguageServer for CodeGraphBackend {
*self.config.write().await = config;
}
- if let Some(path) = extension_path {
- tracing::info!(
- "[LSP::initialize] Extension path received: {}",
- path.display()
- );
- // Update memory manager with extension path by replacing it
- // Read embedding model from init options
- let raw_model = init_opts
- .as_ref()
- .and_then(|opts| opts.get("embeddingModel"));
- tracing::info!(
- "[LSP::initialize] embeddingModel from init options: {:?}",
- raw_model
- );
+ // Embedding settings are read unconditionally. They used to sit inside
+ // `if let Some(extension_path)`, which made a VS Code-specific path the
+ // gate for two unrelated settings: any client that omitted it silently
+ // lost its embedding-model choice and fell back to signature-only
+ // embeddings, degrading duplicate detection, clustering and similarity
+ // search with nothing in the logs to say why.
+ let raw_model = init_opts
+ .as_ref()
+ .and_then(|opts| opts.get("embeddingModel"));
- let embedding_model = raw_model
- .and_then(|v| v.as_str())
- .map(|s| {
- tracing::info!("[LSP::initialize] Parsing embedding model string: {:?}", s);
- codegraph_memory::EmbeddingBackend::parse(s)
- })
- .unwrap_or_default();
+ let embedding_model = raw_model
+ .and_then(|v| v.as_str())
+ .map(codegraph_memory::EmbeddingBackend::parse)
+ .unwrap_or_default();
- tracing::info!(
- "[LSP::initialize] Selected embedding model: {}",
- embedding_model.display_name()
- );
+ tracing::info!(
+ "[LSP::initialize] Embedding model: {} (requested: {:?})",
+ embedding_model.display_name(),
+ raw_model
+ );
- // Safety: We're replacing the Arc contents during initialization before any use
- let new_manager = Arc::new(MemoryManager::with_model(
- Some(path.clone()),
- embedding_model,
- ));
- let self_mut = self as *const Self as *mut Self;
- unsafe {
- (*self_mut).memory_manager = new_manager;
- }
- tracing::info!("[LSP::initialize] MemoryManager updated with extension path and model");
-
- // Read full-body embedding setting
- let full_body = init_opts
- .as_ref()
- .and_then(|opts| opts.get("fullBodyEmbedding"))
- .and_then(|v| v.as_bool())
- .unwrap_or(false);
- self.query_engine.set_full_body_embedding(full_body);
- tracing::info!("[LSP::initialize] Full-body embedding: {}", full_body);
+ // `extensionPath` is really "a directory the client owns for its
+ // resources". It is optional; without it fastembed falls back to
+ // ~/.codegraph/fastembed_cache.
+ if let Some(path) = &extension_path {
+ tracing::info!("[LSP::initialize] Client resource path: {}", path.display());
} else {
- tracing::error!(
- "[LSP::initialize] CRITICAL: No extension path provided in initialization options!"
+ tracing::info!(
+ "[LSP::initialize] No client resource path given; fastembed will use ~/.codegraph/fastembed_cache/"
);
- tracing::warn!("[LSP::initialize] No extension path provided — fastembed will auto-download model to ~/.codegraph/fastembed_cache/");
}
- // Store workspace folders
- if let Some(folders) = params.workspace_folders {
+ // Swapped rather than mutated in place. This used to cast `&self` to
+ // `&mut Self`, which is undefined behaviour however carefully the
+ // timing is argued - and the timing argument no longer held once this
+ // stopped being gated on the client sending `extensionPath`.
+ *self
+ .memory_manager
+ .write()
+ .unwrap_or_else(|poisoned| poisoned.into_inner()) = Arc::new(
+ MemoryManager::with_model(extension_path.clone(), embedding_model),
+ );
+
+ let full_body = init_opts
+ .as_ref()
+ .and_then(|opts| opts.get("fullBodyEmbedding"))
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ self.query_engine.set_full_body_embedding(full_body);
+ tracing::info!("[LSP::initialize] Full-body embedding: {}", full_body);
+
+ // Store workspace folders.
+ //
+ // `workspaceFolders` is optional in LSP: a client may send only
+ // `rootUri` (or the deprecated `rootPath`), and several do. Treating it
+ // as the sole source left the whole memory subsystem uninitialised, so
+ // every memory command failed for the lifetime of the session while
+ // indexing and search worked normally - a confusing half-broken server
+ // rather than an obvious failure. Fall back through the other fields
+ // the client may have given us.
+ {
+ if folder_paths.is_empty() {
+ tracing::warn!(
+ "[LSP::initialize] No workspace location given (workspaceFolders, rootUri and \
+ rootPath are all absent); memory and indexing will be unavailable"
+ );
+ }
+
let mut workspace_folders = self.workspace_folders.write().await;
- for folder in folders {
- if let Ok(path) = folder.uri.to_file_path() {
- tracing::info!("Workspace folder: {}", path.display());
- workspace_folders.push(path);
- }
+ for path in folder_paths {
+ tracing::info!("Workspace folder: {}", path.display());
+ workspace_folders.push(path);
}
// Initialize index state with project slug from first workspace
@@ -1012,6 +1088,13 @@ impl LanguageServer for CodeGraphBackend {
format!("{p}.findRelatedTests"),
format!("{p}.getNodeLocation"),
format!("{p}.getWorkspaceSymbols"),
+ // Backs the inline CodeLens/Code Vision surface.
+ // VS Code reaches it through the custom-request form
+ // so it never noticed the omission, but a client that
+ // gates on ServerCapabilities - LSP4IJ's
+ // `supportsCommand` does - would see the whole
+ // surface as unsupported.
+ format!("{p}.getDocumentCodeLens"),
format!("{p}.analyzeComplexity"),
format!("{p}.symbolSearch"),
format!("{p}.findByImports"),
@@ -1238,7 +1321,7 @@ impl LanguageServer for CodeGraphBackend {
)
.await;
- match self.memory_manager.initialize(first_folder).await {
+ match self.memory_manager().initialize(first_folder).await {
Ok(_) => {
tracing::info!("Memory store initialization succeeded");
self.client
@@ -1246,7 +1329,7 @@ impl LanguageServer for CodeGraphBackend {
.await;
// Share vector engine with query engine for semantic symbol search
- if let Some(engine) = self.memory_manager.get_vector_engine().await {
+ if let Some(engine) = self.memory_manager().get_vector_engine().await {
self.query_engine.set_vector_engine(engine).await;
let slug = crate::memory::project_slug(first_folder);
@@ -1374,6 +1457,10 @@ impl LanguageServer for CodeGraphBackend {
async fn shutdown(&self) -> Result<()> {
tracing::info!("Shutting down CodeGraph LSP server");
+ // tower-lsp handles the `exit` notification that follows without waking
+ // its read loop, so the process would otherwise keep running until
+ // stdin closed. See `crate::lsp_exit`.
+ crate::lsp_exit::request_shutdown();
Ok(())
}
@@ -2009,6 +2096,18 @@ impl LanguageServer for CodeGraphBackend {
Ok(Some(serde_json::to_value(response).unwrap()))
}
+ "codegraph.getDocumentCodeLens" => {
+ let args = params.arguments.first().ok_or_else(|| {
+ tower_lsp::jsonrpc::Error::invalid_params("Missing arguments")
+ })?;
+ let params: crate::handlers::DocumentCodeLensParams =
+ serde_json::from_value(args.clone()).map_err(|e| {
+ tower_lsp::jsonrpc::Error::invalid_params(format!("Invalid params: {e}"))
+ })?;
+ let response = self.handle_get_document_code_lens(params).await?;
+ Ok(Some(serde_json::to_value(response).unwrap()))
+ }
+
"codegraph.analyzeComplexity" => {
let args = params.arguments.first().ok_or_else(|| {
tower_lsp::jsonrpc::Error::invalid_params("Missing arguments")
@@ -2419,7 +2518,7 @@ impl LanguageServer for CodeGraphBackend {
let ctx = crate::lsp_pro_hooks::ProCommandContext {
graph: Arc::clone(&self.graph),
query_engine: Arc::clone(&self.query_engine),
- memory_manager: Arc::clone(&self.memory_manager),
+ memory_manager: self.memory_manager(),
workspace_folders: self.workspace_folders.read().await.clone(),
};
if let Some(future) = self.pro_commands.handle_command(other, args, ctx) {
@@ -2588,12 +2687,16 @@ impl CodeGraphBackend {
tower_lsp::jsonrpc::Error::invalid_params(format!("Failed to build memory: {e}"))
})?;
- // Store the memory
- let id = self
- .memory_manager
- .put(memory)
- .await
- .map_err(|_| tower_lsp::jsonrpc::Error::internal_error())?;
+ // Store the memory. Report why it failed rather than discarding the
+ // error: a bare "Internal error" with nothing logged makes every store
+ // failure unactionable from a user report, and hid a kind-specific bug
+ // here for some time.
+ let id = self.memory_manager().put(memory).await.map_err(|e| {
+ tracing::error!("[memoryStore] failed to store memory: {e}");
+ let mut err = tower_lsp::jsonrpc::Error::internal_error();
+ err.message = format!("Failed to store memory: {e}").into();
+ err
+ })?;
Ok(crate::handlers::MemoryStoreResponse { id, success: true })
}
@@ -2635,7 +2738,7 @@ impl CodeGraphBackend {
// Perform search
let results = self
- .memory_manager
+ .memory_manager()
.search(¶ms.query, &config, ¶ms.code_context)
.await
.map_err(|_| tower_lsp::jsonrpc::Error::internal_error())?;
@@ -2679,7 +2782,7 @@ impl CodeGraphBackend {
params: crate::handlers::MemoryGetParams,
) -> Result
+
Requirements
+
+ The plugin runs a native analysis engine, which it offers to download
+ for your platform the first time you open a project. If you would rather
+ supply it yourself, install npm i -g @astudioplus/codegraph-mcp
+ or point the plugin at your own build in
+ Settings | Tools | CodeGraph. Your code is analysed locally by
+ that engine and is never uploaded.
+
+
Data collection
+
+ The plugin can report anonymous usage and error diagnostics: IDE product
+ and build, plugin version, operating system, an anonymous installation
+ id, and event outcomes such as whether the engine started, how long
+ indexing took, and how many files were indexed. It never sends source
+ code, file names, file paths, symbol names, or search queries.
+
+
+ Reporting is off by default and only ever happens if you turn it
+ on in Settings | Tools | CodeGraph, where you can also limit it to
+ error reports only.
+
+ ]]>
+
+ com.intellij.modules.platform
+
+ com.redhat.devtools.lsp4ij
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codegraph-server), resolved from an
+ existing install on PATH or from the path set in Settings.
+ ]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jetbrains/src/main/resources/messages/CodeGraphBundle.properties b/jetbrains/src/main/resources/messages/CodeGraphBundle.properties
new file mode 100644
index 0000000..d94e62f
--- /dev/null
+++ b/jetbrains/src/main/resources/messages/CodeGraphBundle.properties
@@ -0,0 +1,4 @@
+# Copyright 2026 Andrey Vasilevsky
+# SPDX-License-Identifier: Apache-2.0
+
+notification.group.codegraph=CodeGraph
diff --git a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/graph/GraphDataTest.kt b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/graph/GraphDataTest.kt
new file mode 100644
index 0000000..6cac7b8
--- /dev/null
+++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/graph/GraphDataTest.kt
@@ -0,0 +1,132 @@
+// Copyright 2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+package ai.codegraph.jetbrains.graph
+
+import com.google.gson.Gson
+import com.google.gson.JsonParser
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The two graph commands answer with different node shapes, and the panel
+ * normalises them into one. A normaliser that silently drops nodes produces an
+ * empty graph that looks exactly like "this file has no relationships".
+ */
+class GraphDataTest {
+
+ private val gson = Gson()
+
+ private fun parse(json: String) = GraphData.from(JsonParser.parseString(json), gson)
+
+ @Test
+ fun `dependency graph nodes keep label, type and language`() {
+ val graph = parse(
+ """
+ {
+ "nodes": [
+ {"id":"1","label":"service.py","type":"Module","language":"python","uri":"file:///a/service.py"},
+ {"id":"2","label":"repo.go","type":"Module","language":"go","uri":"file:///a/repo.go"}
+ ],
+ "edges": [{"from":"1","to":"2","type":"imports"}]
+ }
+ """.trimIndent(),
+ )
+
+ assertEquals(2, graph.nodes.size)
+ assertEquals("service.py", graph.nodes[0].label)
+ assertEquals("python", graph.nodes[0].language)
+ assertEquals("imports", graph.edges[0].type)
+ }
+
+ @Test
+ fun `call graph nodes label from name instead of label`() {
+ // The call graph reports FunctionNode, which has `name` and no `label`.
+ // Reading only `label` would leave every node showing its raw id.
+ val graph = parse(
+ """
+ {
+ "root": {"id":"1","name":"place_order"},
+ "nodes": [{"id":"1","name":"place_order"},{"id":"2","name":"save"}],
+ "edges": [{"from":"1","to":"2"}]
+ }
+ """.trimIndent(),
+ )
+
+ assertEquals(listOf("place_order", "save"), graph.nodes.map { it.label })
+ assertEquals("calls", graph.edges[0].type)
+ }
+
+ @Test
+ fun `a node without an id is dropped rather than rendered as a blank`() {
+ val graph = parse("""{"nodes":[{"label":"orphan"},{"id":"1","label":"real"}],"edges":[]}""")
+
+ assertEquals(1, graph.nodes.size)
+ assertEquals("real", graph.nodes[0].label)
+ }
+
+ @Test
+ fun `an edge missing an endpoint is dropped`() {
+ val graph = parse("""{"nodes":[{"id":"1","label":"a"}],"edges":[{"from":"1"},{"to":"1"}]}""")
+
+ assertTrue(graph.edges.isEmpty())
+ }
+
+ @Test
+ fun `a node with neither label nor name falls back to its id`() {
+ val graph = parse("""{"nodes":[{"id":"node-7"}],"edges":[]}""")
+
+ assertEquals("node-7", graph.nodes[0].label)
+ }
+
+ @Test
+ fun `an empty or malformed response is empty rather than an error`() {
+ assertEquals(0, GraphData.from(null, gson).nodes.size)
+ assertEquals(0, parse("{}").nodes.size)
+ assertEquals(0, parse("[]").nodes.size)
+ }
+
+ @Test
+ fun `html escapes labels so a symbol name cannot inject markup`() {
+ val graph = GraphData(
+ nodes = listOf(GraphNode("1", "", "Function", "python", "")),
+ edges = emptyList(),
+ )
+
+ val html = GraphHtml.render(graph, "Call Graph")
+
+ assertTrue("raw markup must not reach the page", !html.contains(" save"))
+ }
+}
diff --git a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/indexing/IndexingServiceTest.kt b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/indexing/IndexingServiceTest.kt
new file mode 100644
index 0000000..f92ff4e
--- /dev/null
+++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/indexing/IndexingServiceTest.kt
@@ -0,0 +1,62 @@
+// Copyright 2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+package ai.codegraph.jetbrains.indexing
+
+import com.google.gson.JsonParser
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * The engine answers `reindexWorkspace` with snake_case keys while its query
+ * responses are camelCase. Reading the wrong one does not fail loudly: it
+ * reports zero files and tells the user indexing found nothing, on a workspace
+ * that indexed perfectly.
+ */
+class IndexingServiceTest {
+
+ private fun filesIndexed(json: String) =
+ IndexingService.filesIndexed(JsonParser.parseString(json))
+
+ @Test
+ fun `reads the engine's snake_case file count`() {
+ val response = """
+ {
+ "status": "success",
+ "message": "Workspace reindexed: 1432 files",
+ "files_indexed": 1432,
+ "files_parsed": 1400,
+ "files_skipped": 32,
+ "duration_ms": 8123,
+ "by_language": {"rust": 900, "python": 532}
+ }
+ """.trimIndent()
+
+ assertEquals(1432, filesIndexed(response))
+ }
+
+ @Test
+ fun `a genuinely empty index reports zero`() {
+ assertEquals(0, filesIndexed("""{"status":"success","files_indexed":0}"""))
+ }
+
+ @Test
+ fun `a camelCase spelling is not silently accepted`() {
+ // If the engine ever renames the key, this must read as zero so the
+ // mismatch surfaces, rather than being papered over by guessing at
+ // alternative spellings.
+ assertEquals(0, filesIndexed("""{"filesIndexed":1432}"""))
+ }
+
+ @Test
+ fun `a non-numeric value does not throw`() {
+ assertEquals(0, filesIndexed("""{"files_indexed":"lots"}"""))
+ }
+
+ @Test
+ fun `a null or non-object response reports zero`() {
+ assertEquals(0, IndexingService.filesIndexed(null))
+ assertEquals(0, filesIndexed("[]"))
+ assertEquals(0, filesIndexed("\"done\""))
+ }
+}
diff --git a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/mcp/McpRegistrationTest.kt b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/mcp/McpRegistrationTest.kt
new file mode 100644
index 0000000..a6ca961
--- /dev/null
+++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/mcp/McpRegistrationTest.kt
@@ -0,0 +1,174 @@
+// Copyright 2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+package ai.codegraph.jetbrains.mcp
+
+import ai.codegraph.jetbrains.settings.CodeGraphSettings
+import com.google.gson.JsonParser
+import com.intellij.testFramework.fixtures.BasePlatformTestCase
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Registration writes into a file the user may already own.
+ *
+ * The failure that matters is not "the entry is missing" - that is visible
+ * immediately - but "the other entries are gone", which is silent, destroys
+ * configuration the plugin did not create, and is only noticed later when some
+ * unrelated AI tool stops working.
+ */
+class McpRegistrationTest : BasePlatformTestCase() {
+
+ private lateinit var projectDir: Path
+ private lateinit var engine: Path
+
+ override fun setUp() {
+ super.setUp()
+ projectDir = Files.createTempDirectory("codegraph-mcp-test")
+ // The test fixture's basePath is a temp path that is never materialised,
+ // so create it before anything tries to write a file there.
+ Files.createDirectories(Path.of(project.basePath!!))
+ // The light fixture reuses that directory across tests, so a config
+ // written by one test is still there for the next. Whether that matters
+ // depends on the order the methods happen to run in, which is exactly
+ // the kind of failure that shows up once and then hides.
+ clearConfig()
+ engine = projectDir.resolve("target/release/codegraph-server")
+ Files.createDirectories(engine.parent)
+ Files.createFile(engine)
+ engine.toFile().setExecutable(true)
+
+ // Point resolution at the fake engine explicitly; the resolver would
+ // otherwise find whatever this machine happens to have installed.
+ CodeGraphSettings.getInstance(project).state.serverPath = engine.toString()
+ }
+
+ override fun tearDown() {
+ try {
+ CodeGraphSettings.getInstance(project).state.serverPath = ""
+ clearConfig()
+ projectDir.toFile().deleteRecursively()
+ } finally {
+ super.tearDown()
+ }
+ }
+
+ private fun configFile(): Path = Path.of(project.basePath!!, McpRegistration.CONFIG_FILE)
+
+ private fun backupFile(): Path =
+ Path.of(project.basePath!!, McpRegistration.CONFIG_FILE + McpRegistration.BACKUP_SUFFIX)
+
+ private fun clearConfig() {
+ Files.deleteIfExists(configFile())
+ Files.deleteIfExists(backupFile())
+ }
+
+ private fun writeConfig(json: String) {
+ Files.writeString(configFile(), json)
+ }
+
+ private fun readServers() =
+ JsonParser.parseString(Files.readString(configFile())).asJsonObject.getAsJsonObject("mcpServers")
+
+ fun `test writes a codegraph entry into a fresh project`() {
+ val result = McpRegistration.register(project)
+
+ assertTrue("expected a written result, got $result", result is McpRegistration.Result.Written)
+ val servers = readServers()
+ assertTrue(servers.has(McpRegistration.SERVER_NAME))
+ val args = servers.getAsJsonObject(McpRegistration.SERVER_NAME).getAsJsonArray("args").map { it.asString }
+ assertTrue("--mcp must be passed or the engine starts in LSP mode", args.contains("--mcp"))
+ }
+
+ fun `test preserves MCP servers the project already had`() {
+ writeConfig(
+ """
+ {"mcpServers":{"stellarion":{"command":"/usr/local/bin/stellarion-server","args":["--mcp"]}}}
+ """.trimIndent(),
+ )
+
+ McpRegistration.register(project)
+
+ val servers = readServers()
+ assertTrue("the pre-existing server must survive", servers.has("stellarion"))
+ assertTrue(servers.has(McpRegistration.SERVER_NAME))
+ assertEquals(
+ "/usr/local/bin/stellarion-server",
+ servers.getAsJsonObject("stellarion").get("command").asString,
+ )
+ }
+
+ fun `test keeps unrelated top-level keys`() {
+ writeConfig("""{"someOtherTool":{"enabled":true},"mcpServers":{}}""")
+
+ McpRegistration.register(project)
+
+ val root = JsonParser.parseString(Files.readString(configFile())).asJsonObject
+ assertTrue("unrelated configuration must not be dropped", root.has("someOtherTool"))
+ }
+
+ fun `test re-registering updates in place rather than duplicating`() {
+ McpRegistration.register(project)
+ McpRegistration.register(project)
+
+ val servers = readServers()
+ assertEquals(1, servers.keySet().size)
+ assertTrue(McpRegistration.isRegistered(project))
+ }
+
+ fun `test malformed existing config does not block registration`() {
+ // Refusing to write because the file is broken would leave the user
+ // stuck with no way forward from inside the IDE.
+ writeConfig("{ this is not json")
+
+ val result = McpRegistration.register(project)
+
+ assertTrue(result is McpRegistration.Result.Written)
+ assertTrue(readServers().has(McpRegistration.SERVER_NAME))
+ }
+
+ fun `test a config we cannot parse is kept before it is replaced`() {
+ // A trailing comma is enough for the strict parser to reject a file,
+ // and everything else in it is servers the plugin did not create.
+ // Overwriting them with no copy and no warning is unrecoverable.
+ val original = """{"mcpServers":{"stellarion":{"command":"/usr/local/bin/stellarion-server"},}}"""
+ writeConfig(original)
+
+ val result = McpRegistration.register(project)
+
+ assertTrue("expected a written result, got $result", result is McpRegistration.Result.Written)
+ val backup = (result as McpRegistration.Result.Written).backup
+ assertNotNull("the unreadable config must be kept", backup)
+ assertEquals(original, Files.readString(backup!!))
+ assertTrue(readServers().has(McpRegistration.SERVER_NAME))
+ }
+
+ fun `test a config we could parse is not backed up`() {
+ writeConfig("""{"mcpServers":{}}""")
+
+ val result = McpRegistration.register(project)
+
+ assertNull((result as McpRegistration.Result.Written).backup)
+ assertFalse("nothing was lost, so nothing needs rescuing", Files.exists(backupFile()))
+ }
+
+ fun `test isRegistered is false before registering`() {
+ assertFalse(McpRegistration.isRegistered(project))
+ }
+
+ fun `test reports a missing engine instead of writing a broken config`() {
+ CodeGraphSettings.getInstance(project).state.serverPath = projectDir.resolve("nope").toString()
+ Files.deleteIfExists(engine)
+
+ val result = McpRegistration.register(project)
+
+ // Resolution may still find a real engine on a developer machine; the
+ // point is that it never writes an entry with no command.
+ if (result is McpRegistration.Result.Written) {
+ val command = readServers().getAsJsonObject(McpRegistration.SERVER_NAME).get("command").asString
+ assertTrue("a written entry must name a real engine", command.isNotBlank())
+ } else {
+ assertTrue(result is McpRegistration.Result.NoEngine)
+ }
+ }
+}
diff --git a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolverTest.kt b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolverTest.kt
new file mode 100644
index 0000000..07f1f28
--- /dev/null
+++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolverTest.kt
@@ -0,0 +1,224 @@
+// Copyright 2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+package ai.codegraph.jetbrains.server
+
+import com.intellij.testFramework.fixtures.BasePlatformTestCase
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Resolution-order tests.
+ *
+ * The resolver decides which engine a user actually runs, and its failure mode
+ * is silent: picking a stale cargo build over an installed release, or
+ * reporting "not found" while a perfectly good binary sits on PATH.
+ *
+ * Every case runs against a synthetic [ResolverEnvironment] rooted in a temp
+ * directory. Reading the real home directory and PATH would make these tests
+ * agree with whatever the developer happens to have installed.
+ */
+class CodeGraphServerResolverTest : BasePlatformTestCase() {
+
+ private lateinit var tempDir: Path
+ private lateinit var fakeHome: Path
+
+ override fun setUp() {
+ super.setUp()
+ tempDir = Files.createTempDirectory("codegraph-resolver-test")
+ fakeHome = Files.createDirectories(tempDir.resolve("home"))
+ }
+
+ override fun tearDown() {
+ try {
+ tempDir.toFile().deleteRecursively()
+ } finally {
+ super.tearDown()
+ }
+ }
+
+ /** A macOS/arm64 machine with an empty PATH and an empty home directory. */
+ private fun env(pathEntries: List = emptyList()) = ResolverEnvironment(
+ homeDir = fakeHome,
+ pathEntries = pathEntries,
+ osName = "Mac OS X",
+ osArch = "aarch64",
+ )
+
+ private fun executableAt(relative: String): Path {
+ val path = tempDir.resolve(relative)
+ Files.createDirectories(path.parent)
+ Files.createFile(path)
+ check(path.toFile().setExecutable(true)) { "could not mark $path executable" }
+ return path
+ }
+
+ private fun projectRoot() = tempDir.resolve("project").toString()
+
+ fun `test explicit override wins over every other candidate`() {
+ val override = executableAt("custom/codegraph-server")
+ executableAt("project/target/release/codegraph-server")
+
+ val resolved = CodeGraphServerResolver.resolve(projectRoot(), override.toString(), env())
+
+ assertNotNull(resolved)
+ assertEquals(override, resolved!!.path)
+ assertEquals(ResolvedServer.Origin.USER_OVERRIDE, resolved.origin)
+ }
+
+ fun `test unusable override falls through instead of failing`() {
+ val cargoBuild = executableAt("project/target/release/codegraph-server")
+
+ val resolved = CodeGraphServerResolver.resolve(
+ projectRoot(),
+ tempDir.resolve("does-not-exist").toString(),
+ env(),
+ )
+
+ assertNotNull(resolved)
+ assertEquals(cargoBuild, resolved!!.path)
+ assertEquals(ResolvedServer.Origin.CARGO_BUILD, resolved.origin)
+ }
+
+ fun `test release build is preferred over debug build`() {
+ executableAt("project/target/debug/codegraph-server")
+ val release = executableAt("project/target/release/codegraph-server")
+
+ val resolved = CodeGraphServerResolver.resolve(projectRoot(), null, env())
+
+ assertNotNull(resolved)
+ assertEquals(release, resolved!!.path)
+ }
+
+ fun `test PATH install is preferred over a managed download`() {
+ val binDir = tempDir.resolve("usr-bin")
+ Files.createDirectories(binDir)
+ val onPath = executableAt("usr-bin/codegraph-server")
+ executableAt("home/.codegraph/bin/codegraph-server-darwin-arm64")
+
+ val resolved = CodeGraphServerResolver.resolve(projectRoot(), null, env(listOf(binDir)))
+
+ assertNotNull(resolved)
+ assertEquals(onPath, resolved!!.path)
+ assertEquals(ResolvedServer.Origin.SYSTEM_PATH, resolved.origin)
+ }
+
+ fun `test managed download is preferred over a cargo build`() {
+ val managed = executableAt("home/.codegraph/bin/codegraph-server-darwin-arm64")
+ executableAt("project/target/release/codegraph-server")
+
+ val resolved = CodeGraphServerResolver.resolve(projectRoot(), null, env())
+
+ assertNotNull(resolved)
+ assertEquals(managed, resolved!!.path)
+ assertEquals(ResolvedServer.Origin.MANAGED_INSTALL, resolved.origin)
+ }
+
+ fun `test pro binary outranks a community install on PATH`() {
+ val binDir = tempDir.resolve("usr-bin")
+ Files.createDirectories(binDir)
+ executableAt("usr-bin/codegraph-server")
+ val pro = executableAt("home/.codegraph-pro/bin/codegraph-pro")
+
+ val resolved = CodeGraphServerResolver.resolve(projectRoot(), null, env(listOf(binDir)))
+
+ assertNotNull(resolved)
+ assertEquals(pro, resolved!!.path)
+ assertEquals(ServerEdition.PRO, resolved.edition)
+ assertEquals(ResolvedServer.Origin.PRO_INSTALL_DIR, resolved.origin)
+ }
+
+ fun `test nothing installed resolves to null rather than throwing`() {
+ assertNull(CodeGraphServerResolver.resolve(projectRoot(), null, env()))
+ }
+
+ fun `test no project open still resolves an installed engine`() {
+ val managed = executableAt("home/.codegraph/bin/codegraph-server-darwin-arm64")
+
+ val resolved = CodeGraphServerResolver.resolve(null, null, env())
+
+ assertNotNull(resolved)
+ assertEquals(managed, resolved!!.path)
+ }
+
+ fun `test platform binary name follows os and architecture`() {
+ fun nameFor(os: String, arch: String) = CodeGraphServerResolver.platformBinaryName(
+ ResolverEnvironment(fakeHome, emptyList(), os, arch),
+ )
+
+ assertEquals("codegraph-server-darwin-arm64", nameFor("Mac OS X", "aarch64"))
+ assertEquals("codegraph-server-darwin-x64", nameFor("Mac OS X", "x86_64"))
+ assertEquals("codegraph-server-linux-x64", nameFor("Linux", "amd64"))
+ assertEquals("codegraph-server-win32-x64.exe", nameFor("Windows 11", "amd64"))
+ }
+
+ fun `test unsupported platform is reported rather than guessed`() {
+ assertThrows(CodeGraphServerResolver.UnsupportedPlatformException::class.java) {
+ CodeGraphServerResolver.platformBinaryName(
+ ResolverEnvironment(fakeHome, emptyList(), "AIX", "ppc64"),
+ )
+ }
+ }
+
+ fun `test arm64 linux has no published engine but arm64 windows emulates x64`() {
+ // Handing the x64 asset to an arm64 Linux machine installs something
+ // that cannot execute, which shows up as an exec-format error rather
+ // than as the missing build it is. Windows on ARM is the exception: it
+ // runs x64 binaries under the OS's own emulation, so refusing there
+ // would leave those users with no engine for no reason.
+ fun nameFor(os: String, arch: String) = CodeGraphServerResolver.platformBinaryNameOrNull(
+ ResolverEnvironment(fakeHome, emptyList(), os, arch),
+ )
+
+ assertNull(nameFor("Linux", "aarch64"))
+ assertNull(nameFor("Linux", "arm64"))
+ assertEquals("codegraph-server-win32-x64.exe", nameFor("Windows 11", "aarch64"))
+ assertEquals("codegraph-server-win32-x64.exe", nameFor("Windows 11", "arm64"))
+ assertEquals("codegraph-server-linux-x64", nameFor("Linux", "x86_64"))
+ assertEquals("codegraph-server-darwin-arm64", nameFor("Mac OS X", "aarch64"))
+ }
+
+ fun `test only an older managed engine counts as stale`() {
+ // The managed directory is shared with the VS Code extension, which
+ // ships on its own schedule. Treating "different" as "stale" makes the
+ // two clients reinstall over each other on every launch.
+ assertTrue(CodeGraphServerResolver.isManagedEngineStale("0.19.1", "0.20.0"))
+ assertFalse(CodeGraphServerResolver.isManagedEngineStale("0.20.0", "0.20.0"))
+ assertFalse(CodeGraphServerResolver.isManagedEngineStale("0.21.0", "0.20.0"))
+ assertFalse(CodeGraphServerResolver.isManagedEngineStale("0.20", "0.20.0"))
+
+ // Nothing to compare against is the one case worth replacing: an
+ // unmarked install predates the marker, so its build is unknown.
+ assertTrue(CodeGraphServerResolver.isManagedEngineStale(null, "0.20.0"))
+ assertTrue(CodeGraphServerResolver.isManagedEngineStale("nightly", "0.20.0"))
+ }
+
+ fun `test resolution on an unpublished platform reports nothing rather than throwing`() {
+ // A null resolve sends the caller to the "offer a download" path; an
+ // exception here would escape project startup instead.
+ val armLinux = ResolverEnvironment(fakeHome, emptyList(), "Linux", "aarch64")
+
+ assertNull(CodeGraphServerResolver.resolve(projectRoot(), null, armLinux))
+ assertFalse(CodeGraphServerResolver.hasManagedInstall(armLinux))
+ }
+
+ fun `test the managed install records which release it came from`() {
+ assertNull("no marker means no known version", CodeGraphServerResolver.managedEngineVersion(env()))
+
+ val marker = fakeHome.resolve(".codegraph/bin/${CodeGraphServerResolver.VERSION_MARKER}")
+ Files.createDirectories(marker.parent)
+ Files.writeString(marker, "0.20.0\n")
+
+ assertEquals("0.20.0", CodeGraphServerResolver.managedEngineVersion(env()))
+ }
+
+ private fun assertThrows(expected: Class, block: () -> Unit) {
+ try {
+ block()
+ } catch (error: Throwable) {
+ assertTrue("expected ${expected.name} but got $error", expected.isInstance(error))
+ return
+ }
+ fail("expected ${expected.name} but nothing was thrown")
+ }
+}
diff --git a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbsTest.kt b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbsTest.kt
new file mode 100644
index 0000000..cbd2837
--- /dev/null
+++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CrashBreadcrumbsTest.kt
@@ -0,0 +1,204 @@
+// Copyright 2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+package ai.codegraph.jetbrains.server
+
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * The breadcrumb reader turns a crashed native process into a sentence a user
+ * can act on. Its two failure modes are both silent: reading a stale file and
+ * blaming the wrong thing, or leaving files behind so the next crash inherits
+ * this one's diagnosis.
+ */
+class CrashBreadcrumbsTest {
+
+ private lateinit var dir: Path
+ private var now: Long = 1_000_000L
+
+ /**
+ * Which pids the reader should see as still running. Stubbed rather than
+ * asked of the OS: real low pids (1 is init on every platform these tests
+ * run on) would make the outcome depend on the machine.
+ */
+ private val livePids = mutableSetOf()
+
+ @Before
+ fun setUp() {
+ dir = Files.createTempDirectory("codegraph-breadcrumbs-test")
+ }
+
+ @After
+ fun tearDown() {
+ dir.toFile().deleteRecursively()
+ }
+
+ private fun breadcrumbs() =
+ CrashBreadcrumbs(directory = dir, clock = { now }, isProcessAlive = { it in livePids })
+
+ private fun write(name: String, json: String, ageMillis: Long = 0) {
+ val file = dir.resolve(name)
+ Files.writeString(file, json)
+ Files.setLastModifiedTime(file, java.nio.file.attribute.FileTime.fromMillis(now - ageMillis))
+ }
+
+ @Test
+ fun `a panic breadcrumb yields its recorded class`() {
+ write("last-crash.4242.json", """{"kind":"panic","class":"oom"}""")
+
+ val diagnosis = breadcrumbs().readAndClear()
+
+ assertEquals("oom", diagnosis.cause)
+ assertTrue(diagnosis.describe().contains("out of memory"))
+ }
+
+ @Test
+ fun `a signal breadcrumb is distinguished from a panic`() {
+ write("last-crash.4242.json", """{"kind":"signal"}""")
+
+ assertEquals(CrashDiagnosis.SIGNAL, breadcrumbs().readAndClear().cause)
+ }
+
+ @Test
+ fun `no breadcrumb at all means the process died too hard to write one`() {
+ val diagnosis = breadcrumbs().readAndClear()
+
+ assertEquals(CrashDiagnosis.HARD_CRASH, diagnosis.cause)
+ assertNull(diagnosis.phase)
+ }
+
+ @Test
+ fun `a stale breadcrumb is ignored rather than blamed for this crash`() {
+ // Written during a previous session; reporting "oom" here would send the
+ // user chasing a memory problem that already happened days ago.
+ write("last-crash.4242.json", """{"kind":"panic","class":"oom"}""", ageMillis = 60_000)
+
+ assertEquals(CrashDiagnosis.HARD_CRASH, breadcrumbs().readAndClear().cause)
+ }
+
+ @Test
+ fun `the newest breadcrumb wins when several processes crashed`() {
+ write("last-crash.1.json", """{"kind":"panic","class":"rocksdb_lock"}""", ageMillis = 5_000)
+ write("last-crash.2.json", """{"kind":"panic","class":"utf8_parse"}""", ageMillis = 100)
+
+ assertEquals("utf8_parse", breadcrumbs().readAndClear().cause)
+ }
+
+ @Test
+ fun `the phase marker says where the engine was when it died`() {
+ write("last-crash.4242.json", """{"kind":"signal"}""")
+ write("last-phase.4242.json", """{"phase":"onnx_load"}""")
+
+ val diagnosis = breadcrumbs().readAndClear()
+
+ assertEquals("onnx_load", diagnosis.phase)
+ assertTrue(diagnosis.describe().contains("during onnx_load"))
+ }
+
+ @Test
+ fun `the breadcrumbs it consumed are deleted so the next crash starts clean`() {
+ write("last-crash.1.json", """{"kind":"panic","class":"oom"}""")
+ write("last-phase.1.json", """{"phase":"startup"}""")
+ write("unrelated.json", "{}")
+
+ breadcrumbs().readAndClear()
+
+ assertTrue(Files.notExists(dir.resolve("last-crash.1.json")))
+ assertTrue(Files.notExists(dir.resolve("last-phase.1.json")))
+ assertTrue("unrelated files must be left alone", Files.exists(dir.resolve("unrelated.json")))
+ }
+
+ @Test
+ fun `another dead engine's breadcrumbs are left for its own client to read`() {
+ // `~/.codegraph` is shared: a VS Code engine can die seconds before this
+ // one stops. Deleting its breadcrumb here means that crash is reported
+ // with no cause, because its extension has not activated to read it yet.
+ write("last-crash.2.json", """{"kind":"panic","class":"oom"}""", ageMillis = 200)
+ write("last-phase.2.json", """{"phase":"onnx_load"}""", ageMillis = 200)
+ write("last-crash.3.json", """{"kind":"signal"}""", ageMillis = 5_000)
+ write("last-phase.3.json", """{"phase":"indexing"}""", ageMillis = 5_000)
+
+ val diagnosis = breadcrumbs().readAndClear()
+
+ assertEquals("oom", diagnosis.cause)
+ assertTrue(Files.notExists(dir.resolve("last-crash.2.json")))
+ assertTrue(Files.notExists(dir.resolve("last-phase.2.json")))
+ assertTrue(Files.exists(dir.resolve("last-crash.3.json")))
+ assertTrue(Files.exists(dir.resolve("last-phase.3.json")))
+ }
+
+ @Test
+ fun `a breadcrumb too old to trust is left for the engine's own sweeper`() {
+ write("last-crash.4242.json", """{"kind":"panic","class":"oom"}""", ageMillis = 60_000)
+
+ breadcrumbs().readAndClear()
+
+ assertTrue(Files.exists(dir.resolve("last-crash.4242.json")))
+ }
+
+ @Test
+ fun `malformed json degrades to hard crash instead of throwing`() {
+ write("last-crash.4242.json", "{ this is not json")
+
+ assertEquals(CrashDiagnosis.HARD_CRASH, breadcrumbs().readAndClear().cause)
+ }
+
+ @Test
+ fun `a missing codegraph directory is a normal first run`() {
+ val missing = dir.resolve("does-not-exist")
+
+ val diagnosis = CrashBreadcrumbs(directory = missing, clock = { now }).readAndClear()
+
+ assertEquals(CrashDiagnosis.HARD_CRASH, diagnosis.cause)
+ }
+
+ @Test
+ fun `a running engine's breadcrumbs are neither read nor deleted`() {
+ // `~/.codegraph` is shared with every other engine on the machine. Its
+ // phase marker is live state, not a post-mortem, and deleting it throws
+ // away the attribution for a crash that has not happened yet.
+ livePids += 77L
+ write("last-crash.77.json", """{"kind":"panic","class":"oom"}""")
+ write("last-phase.77.json", """{"phase":"indexing"}""")
+
+ val diagnosis = breadcrumbs().readAndClear()
+
+ assertEquals(CrashDiagnosis.HARD_CRASH, diagnosis.cause)
+ assertNull(diagnosis.phase)
+ assertTrue(Files.exists(dir.resolve("last-crash.77.json")))
+ assertTrue(Files.exists(dir.resolve("last-phase.77.json")))
+ }
+
+ @Test
+ fun `the dead engine is diagnosed even when a newer marker belongs to a live one`() {
+ livePids += 9L
+ write("last-crash.2.json", """{"kind":"panic","class":"oom"}""", ageMillis = 200)
+ write("last-phase.2.json", """{"phase":"onnx_load"}""", ageMillis = 200)
+ write("last-phase.9.json", """{"phase":"indexing"}""", ageMillis = 0)
+
+ val diagnosis = breadcrumbs().readAndClear()
+
+ assertEquals("oom", diagnosis.cause)
+ assertEquals("onnx_load", diagnosis.phase)
+ assertTrue(Files.notExists(dir.resolve("last-crash.2.json")))
+ assertTrue(Files.exists(dir.resolve("last-phase.9.json")))
+ }
+
+ @Test
+ fun `a breadcrumb with no readable pid is still cleaned up`() {
+ write("last-crash.abc.json", """{"kind":"signal"}""")
+ write("last-phase.abc.json", """{"phase":"startup"}""")
+
+ breadcrumbs().readAndClear()
+
+ assertTrue(Files.notExists(dir.resolve("last-crash.abc.json")))
+ assertTrue(Files.notExists(dir.resolve("last-phase.abc.json")))
+ }
+}
diff --git a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/EngineDownloaderTest.kt b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/EngineDownloaderTest.kt
new file mode 100644
index 0000000..8d1bf49
--- /dev/null
+++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/EngineDownloaderTest.kt
@@ -0,0 +1,185 @@
+// Copyright 2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+package ai.codegraph.jetbrains.server
+
+import com.intellij.testFramework.fixtures.BasePlatformTestCase
+import com.sun.net.httpserver.HttpServer
+import java.net.InetSocketAddress
+import java.nio.file.Files
+import java.nio.file.Path
+import java.security.MessageDigest
+
+/**
+ * Serves a fake release over loopback and downloads from it.
+ *
+ * The interesting cases are the destructive ones: a corrupted transfer must not
+ * leave anything installed, and Windows must not end up with an engine and no
+ * `onnxruntime.dll` - a download that succeeds and then fails at startup is
+ * worse than one that visibly fails.
+ */
+class EngineDownloaderTest : BasePlatformTestCase() {
+
+ private lateinit var server: HttpServer
+ private lateinit var home: Path
+ private val assets = mutableMapOf()
+
+ override fun setUp() {
+ super.setUp()
+ home = Files.createTempDirectory("codegraph-download-test")
+ server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
+ server.createContext("/") { exchange ->
+ val body = assets[exchange.requestURI.path]
+ if (body == null) {
+ exchange.sendResponseHeaders(404, -1)
+ } else {
+ exchange.sendResponseHeaders(200, body.size.toLong())
+ exchange.responseBody.use { it.write(body) }
+ }
+ exchange.close()
+ }
+ server.start()
+ }
+
+ override fun tearDown() {
+ try {
+ server.stop(0)
+ home.toFile().deleteRecursively()
+ } finally {
+ super.tearDown()
+ }
+ }
+
+ private fun baseUrl() = "http://127.0.0.1:${server.address.port}/releases/download"
+
+ /** Publish an asset and its checksum, exactly as the release script lays them out. */
+ private fun publish(version: String, name: String, content: ByteArray, checksum: String? = null) {
+ assets["/releases/download/v$version/$name"] = content
+ val digest = checksum ?: MessageDigest.getInstance("SHA-256").digest(content)
+ .joinToString("") { "%02x".format(it) }
+ assets["/releases/download/v$version/$name.sha256"] = "$digest $name\n".toByteArray()
+ }
+
+ private fun env(os: String, arch: String = "aarch64") =
+ ResolverEnvironment(homeDir = home, pathEntries = emptyList(), osName = os, osArch = arch)
+
+ /**
+ * Windows and Linux are published for x64 only, so an arm64 environment
+ * there is an unsupported platform rather than a machine that downloads the
+ * x64 build.
+ */
+ private fun downloader(os: String, arch: String = "aarch64") =
+ EngineDownloader(env(os, arch), baseUrl())
+
+ fun `test downloads and installs the engine for this platform`() {
+ val content = "engine".toByteArray()
+ publish("0.19.1", "codegraph-server-darwin-arm64", content)
+
+ val path = downloader("Mac OS X").download("0.19.1")
+
+ assertEquals(String(content), Files.readString(path))
+ assertTrue("the engine must be executable", path.toFile().canExecute())
+ }
+
+ fun `test windows also installs the runtime library the engine loads`() {
+ publish("0.19.1", "codegraph-server-win32-x64.exe", "engine".toByteArray())
+ publish("0.19.1", EngineDownloader.WINDOWS_SIDECAR, "onnx".toByteArray())
+
+ val path = downloader("Windows 11", "amd64").download("0.19.1")
+
+ assertTrue(Files.exists(path))
+ assertTrue(
+ "without the sidecar the engine downloads fine and then fails to start",
+ Files.exists(path.parent.resolve(EngineDownloader.WINDOWS_SIDECAR)),
+ )
+ }
+
+ fun `test the installed release is recorded next to the engine`() {
+ // Resolution finds the engine by file name, which says nothing about
+ // which build it is. Without this marker an engine installed by an
+ // older plugin is reused for good.
+ publish("0.19.1", "codegraph-server-darwin-arm64", "engine".toByteArray())
+
+ val path = downloader("Mac OS X").download("0.19.1")
+
+ assertEquals(
+ "0.19.1",
+ Files.readString(path.parent.resolve(CodeGraphServerResolver.VERSION_MARKER)).trim(),
+ )
+ assertEquals("0.19.1", CodeGraphServerResolver.managedEngineVersion(env("Mac OS X")))
+ }
+
+ fun `test a failed download records no version`() {
+ publish("0.19.1", "codegraph-server-darwin-arm64", "engine".toByteArray(), checksum = "0".repeat(64))
+
+ runCatching { downloader("Mac OS X").download("0.19.1") }
+
+ assertNull(
+ "a marker written before the assets verify would claim an install that never happened",
+ CodeGraphServerResolver.managedEngineVersion(env("Mac OS X")),
+ )
+ }
+
+ fun `test a corrupted download installs nothing`() {
+ publish("0.19.1", "codegraph-server-darwin-arm64", "engine".toByteArray(), checksum = "0".repeat(64))
+
+ val failure = runCatching { downloader("Mac OS X").download("0.19.1") }.exceptionOrNull()
+
+ assertTrue(
+ "expected a checksum failure, got $failure",
+ failure is EngineDownloader.ChecksumMismatchException,
+ )
+ assertFalse(
+ "a mismatched engine must not be left on disk",
+ Files.exists(CodeGraphServerResolver.managedInstallDir(env("Mac OS X")).resolve("codegraph-server-darwin-arm64")),
+ )
+ }
+
+ fun `test a failed download leaves no partial files behind`() {
+ publish("0.19.1", "codegraph-server-darwin-arm64", "engine".toByteArray(), checksum = "0".repeat(64))
+
+ runCatching { downloader("Mac OS X").download("0.19.1") }
+
+ val leftovers = Files.list(CodeGraphServerResolver.managedInstallDir(env("Mac OS X"))).use { it.toList() }
+ assertTrue("staging files must be cleaned up, found $leftovers", leftovers.isEmpty())
+ }
+
+ fun `test a missing release surfaces rather than installing something wrong`() {
+ // Nothing published for this version at all.
+ val failure = runCatching { downloader("Mac OS X").download("9.9.9") }.exceptionOrNull()
+
+ assertNotNull("a missing release must fail loudly", failure)
+ }
+
+ fun `test windows failing on the sidecar does not leave a half install`() {
+ // Engine publishes fine, sidecar is corrupt: nothing may be installed.
+ // A new engine beside the previous sidecar is exactly the combination
+ // that downloads cleanly and then fails at startup.
+ publish("0.19.1", "codegraph-server-win32-x64.exe", "engine".toByteArray())
+ publish("0.19.1", EngineDownloader.WINDOWS_SIDECAR, "onnx".toByteArray(), checksum = "0".repeat(64))
+
+ val failure = runCatching { downloader("Windows 11", "amd64").download("0.19.1") }.exceptionOrNull()
+ val dir = CodeGraphServerResolver.managedInstallDir(env("Windows 11"))
+
+ assertTrue(failure is EngineDownloader.ChecksumMismatchException)
+ assertFalse(Files.exists(dir.resolve(EngineDownloader.WINDOWS_SIDECAR)))
+ assertFalse(Files.exists(dir.resolve("codegraph-server-win32-x64.exe")))
+ }
+
+ fun `test nothing is installed until the caller has had a chance to stop the engine`() {
+ // The engine holds its own binary open while it runs, so the installer
+ // stops it in this hook. That is only safe if nothing has been moved
+ // into place yet - otherwise a stop that fails leaves a half-updated
+ // install with a live process on the old binary.
+ publish("0.19.1", "codegraph-server-darwin-arm64", "engine".toByteArray())
+ val engine = CodeGraphServerResolver.managedInstallDir(env("Mac OS X"))
+ .resolve("codegraph-server-darwin-arm64")
+ // Starts true so a hook that never runs at all fails this test too.
+ var installedWhenCalled = true
+
+ downloader("Mac OS X").download("0.19.1") { installedWhenCalled = Files.exists(engine) }
+
+ assertFalse("nothing may be in place when the hook runs", installedWhenCalled)
+ assertTrue("the install still completes after the hook", Files.exists(engine))
+ }
+}
diff --git a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/RestartCircuitBreakerTest.kt b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/RestartCircuitBreakerTest.kt
new file mode 100644
index 0000000..13d8c9d
--- /dev/null
+++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/RestartCircuitBreakerTest.kt
@@ -0,0 +1,102 @@
+// Copyright 2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+package ai.codegraph.jetbrains.server
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The breaker is what stops a machine that cannot run the engine from
+ * restarting it forever, so its edge cases are the ones that matter: crashes
+ * spread over time must not trip it, and a tripped breaker must report the trip
+ * exactly once.
+ */
+class RestartCircuitBreakerTest {
+
+ private fun breaker() = RestartCircuitBreaker(maxCrashes = 3, windowMillis = 60_000)
+
+ @Test
+ fun `stays closed below the crash threshold`() {
+ val breaker = breaker()
+
+ assertFalse(breaker.recordCrash(0))
+ assertFalse(breaker.recordCrash(1_000))
+
+ assertFalse(breaker.isOpen)
+ }
+
+ @Test
+ fun `opens on the third crash inside the window`() {
+ val breaker = breaker()
+
+ breaker.recordCrash(0)
+ breaker.recordCrash(1_000)
+
+ assertTrue("third rapid crash should trip the breaker", breaker.recordCrash(2_000))
+ assertTrue(breaker.isOpen)
+ }
+
+ @Test
+ fun `crashes spread beyond the window never accumulate`() {
+ val breaker = breaker()
+
+ // One crash every ten minutes is a flaky engine, not a crash loop, and
+ // must not stop a user's session.
+ repeat(20) { index ->
+ assertFalse(
+ "crash ${index + 1} should not trip the breaker",
+ breaker.recordCrash(index * 600_000L),
+ )
+ }
+ assertFalse(breaker.isOpen)
+ }
+
+ @Test
+ fun `a crash at exactly the window edge does not count toward the trip`() {
+ val breaker = breaker()
+
+ breaker.recordCrash(0)
+ breaker.recordCrash(30_000)
+ // The first crash is now exactly 60s old, so it has aged out and only
+ // two crashes remain inside the window.
+ assertFalse(breaker.recordCrash(60_000))
+ assertFalse(breaker.isOpen)
+ }
+
+ @Test
+ fun `reports the trip only once so the user is warned once`() {
+ val breaker = breaker()
+
+ breaker.recordCrash(0)
+ breaker.recordCrash(1)
+ assertTrue(breaker.recordCrash(2))
+
+ assertFalse("already-open breaker should not re-report", breaker.recordCrash(3))
+ assertFalse(breaker.recordCrash(4))
+ }
+
+ @Test
+ fun `reset closes the breaker and forgets history`() {
+ val breaker = breaker()
+ breaker.recordCrash(0)
+ breaker.recordCrash(1)
+ breaker.recordCrash(2)
+ assertTrue(breaker.isOpen)
+
+ breaker.reset()
+
+ assertFalse(breaker.isOpen)
+ // History is gone, so it takes a fresh run of three to trip again.
+ assertFalse(breaker.recordCrash(3))
+ assertFalse(breaker.recordCrash(4))
+ assertTrue(breaker.recordCrash(5))
+ }
+
+ @Test
+ fun `trip condition reads as a sentence for the notification`() {
+ assertEquals("3 times in 60s", breaker().describeTripCondition())
+ }
+}
diff --git a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGateTest.kt b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGateTest.kt
new file mode 100644
index 0000000..28fee8c
--- /dev/null
+++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/telemetry/TelemetryGateTest.kt
@@ -0,0 +1,83 @@
+// Copyright 2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+package ai.codegraph.jetbrains.telemetry
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The gate decides whether data leaves someone's machine.
+ *
+ * A bug here is not a broken feature - it is measuring a user who declined,
+ * which nothing downstream can detect or undo. Every refusal path is asserted
+ * individually rather than trusting one happy-path test.
+ */
+class TelemetryGateTest {
+
+ private fun allows(
+ hasKey: Boolean = true,
+ pluginEnabled: Boolean = true,
+ errorReportsOnly: Boolean = false,
+ isErrorEvent: Boolean = false,
+ ) = TelemetryGate.allows(hasKey, pluginEnabled, errorReportsOnly, isErrorEvent)
+
+ @Test
+ fun `sends when every gate is open`() {
+ assertTrue(allows())
+ }
+
+ @Test
+ fun `a build with no compiled-in key never sends`() {
+ // Builds from source and forks must be silent without anyone having to
+ // remember a setting.
+ assertFalse(allows(hasKey = false))
+ assertFalse(allows(hasKey = false, isErrorEvent = true))
+ assertFalse(allows(hasKey = false, pluginEnabled = true))
+ }
+
+ @Test
+ fun `the plugin switch alone is enough to stop everything`() {
+ assertFalse(allows(pluginEnabled = false))
+ assertFalse(allows(pluginEnabled = false, isErrorEvent = true))
+ }
+
+ @Test
+ fun `error-reports-only drops ordinary events but keeps failures`() {
+ assertFalse(allows(errorReportsOnly = true, isErrorEvent = false))
+ assertTrue(allows(errorReportsOnly = true, isErrorEvent = true))
+ }
+
+ @Test
+ fun `error events still respect every other refusal`() {
+ // An error is not a licence to ignore consent.
+ assertFalse(allows(isErrorEvent = true, hasKey = false))
+ assertFalse(allows(isErrorEvent = true, pluginEnabled = false))
+ }
+
+ @Test
+ fun `unknown values are dropped rather than sent as placeholder strings`() {
+ val cleaned = TelemetryGate.clean(
+ mapOf(
+ "ide" to "jetbrains",
+ "serverEdition" to null,
+ "pluginVersion" to "",
+ "fileCount" to 0,
+ "ok" to false,
+ ),
+ )
+
+ // A literal "unknown" or an empty string looks like a real value in a
+ // dashboard and silently inflates whatever bucket it lands in.
+ assertEquals(setOf("ide", "fileCount", "ok"), cleaned.keys)
+ assertEquals(0, cleaned["fileCount"])
+ assertEquals(false, cleaned["ok"])
+ }
+
+ @Test
+ fun `cleaning an empty map is empty rather than null`() {
+ assertTrue(TelemetryGate.clean(emptyMap()).isEmpty())
+ }
+}
diff --git a/mcp-package/README.md b/mcp-package/README.md
index 960dcd2..50d2494 100644
--- a/mcp-package/README.md
+++ b/mcp-package/README.md
@@ -8,6 +8,29 @@ Cross-language code intelligence for AI agents — 42 tools, 38 languages, persi
npm install -g @astudioplus/codegraph-mcp
```
+The analysis engine is not bundled in the package.
+Install downloads the engine built for your platform from its GitHub release -
+tagged with the *engine's* version, which an npm-only patch release does not
+move - and verifies it against the published `.sha256` before installing it
+(on Windows the ONNX Runtime sidecar comes with it).
+
+A failed download never fails the install, because the CLI, the hooks and the
+docs all still work - it prints a warning instead. Retry it with:
+
+```bash
+npx codegraph-mcp-fetch-engine # --force re-downloads an engine that is already present
+```
+
+For air-gapped machines, or if you vendor the binary yourself, set
+`CODEGRAPH_SKIP_BINARY_FETCH=1` to skip the download and supply the engine one
+of two ways:
+
+- place it at `/bin/codegraph-server--` (`.exe` on
+ Windows) - the path `codegraph-mcp` launches by default; or
+- point `CODEGRAPH_SERVER_PATH` at the engine wherever it already lives. This
+ wins over the bundled path, and `codegraph-mcp` fails with a clear message
+ rather than falling back if nothing is there.
+
## Usage
### Claude Code
@@ -44,20 +67,36 @@ Pass flags after `--`:
}
```
+Leave the transport out of `args`: `codegraph-mcp` already puts the engine in
+MCP (stdio) mode, so `--mcp`, `--stdio` and `--connect` are dropped from
+whatever the client passes rather than forwarded twice.
+
| Flag | Default | Description |
|------|---------|-------------|
| `--workspace ` | current dir | Directories to index (repeatable) |
| `--exclude ` | — | Directories to skip (repeatable) |
-| `--embedding-model ` | `bge-small` | `bge-small`, `jina-code-v2`, `granite-97m` (32K, multilingual), or `static` (model2vec, ~100× faster indexing, ~90% of BGE quality; needs a local model dir via `CODEGRAPH_STATIC_MODEL`) |
+| `--embedding-model ` | `bge-small` | `bge-small`, `jina-code-v2`, `granite-97m` (32K, multilingual), or `static` (model2vec, ~100× faster indexing, ~90% of BGE quality; needs a local model dir - this install downloads one to `~/.codegraph/static_models/jina-code-static-256`, or point `CODEGRAPH_STATIC_MODEL` elsewhere) |
| `--max-files ` | 5000 | Maximum files to index |
-| `--profile ` | `all` | Scope tool surface: `core` (8), `graph` (16), `memory` (14), `security` (pro), `all` (42) |
+| `--profile ` | `all` | Scope tool surface: `core` (8), `graph` (17), `memory` (14), `security` (pro), `all` (42) |
| `--graph-only` | off | Skip embeddings — graph + structural tools only. No ONNX model load, 10-50× faster indexing. For CI / one-shot graph queries. |
| `--run-tool ` | — | One-shot: index, run a single tool, print result, exit. No MCP handshake. Pair with `--tool-args ''`. |
-Before loading the ONNX embedding model, the server checks available memory and runs graph-only if under ~1.5 GB.
-If embeddings are disabled even though the machine has plenty of free RAM, set `CODEGRAPH_SKIP_MEMORY_CHECK=1` (also accepts `true`/`yes`) to bypass the check.
-A reading of `0 MB available` is treated as a detection failure and the model loads anyway (common on macOS).
-Works in both MCP and one-shot `--run-tool` modes.
+### Troubleshooting: embeddings disabled / "Memory manager not initialized"
+
+Before loading the ONNX embedding model, the server checks available memory and
+runs graph-only if under ~1.5 GB, so an OOM-kill can't take down the process.
+If that check misfires, `index_markdown`, `search_docs`, `memory_*`, and
+semantic search are unavailable while graph-only tools keep working.
+
+- A reading of `0 MB available` is treated as a detection failure and the model
+ loads anyway.
+On macOS, reclaimable memory is parked in inactive/speculative/purgeable pages
+that some memory readers don't count as free, so a healthy Mac can report 0.
+- If embeddings stay disabled even though the machine has plenty of free RAM,
+ set `CODEGRAPH_SKIP_MEMORY_CHECK=1` (also accepts `true`/`yes`) to bypass the
+ check entirely.
+
+Both apply in MCP mode and one-shot `--run-tool` mode.
### Agent rules (recommended)
diff --git a/mcp-package/bin/codegraph-mcp.js b/mcp-package/bin/codegraph-mcp.js
index bedab5d..c63b53a 100755
--- a/mcp-package/bin/codegraph-mcp.js
+++ b/mcp-package/bin/codegraph-mcp.js
@@ -6,6 +6,9 @@ const path = require("path");
const os = require("os");
const fs = require("fs");
+/** Package version, reported on every event including pre-startup crashes. */
+const WRAPPER_VERSION = require("../package.json").version;
+
// ── PostHog telemetry (opt-out via CODEGRAPH_TELEMETRY=off) ──────────
const POSTHOG_KEY = "phc_pkWuLX7azFafdd7rqY4bfKhZ3aobCT9unTy9zSkXH3xB";
@@ -50,6 +53,10 @@ function sendTelemetry(eventData) {
...properties,
serverEdition: "community",
transport: "mcp",
+ // mcp.start carried the version and crashes did not - so a crash before
+ // startup, the case that most needs attributing to a release, was the
+ // one that could not be.
+ version: WRAPPER_VERSION,
os: os.platform(),
arch: os.arch(),
nodeVersion: process.version,
@@ -60,6 +67,61 @@ function sendTelemetry(eventData) {
}
}
+// ── Crash-loop protection ───────────────────────────────────────────
+//
+// The VS Code extension and the JetBrains plugin both stop restarting after
+// three crashes in a minute. This wrapper cannot do that in memory: an MCP
+// client respawns it as a brand new process each time, so the count has to
+// outlive the process. It lives in a small file keyed by the arguments, since
+// a loop is by definition the same invocation failing the same way.
+const LOOP_STATE = path.join(os.homedir(), ".codegraph", "mcp-failures.json");
+const LOOP_WINDOW_MS = 60_000;
+const LOOP_THRESHOLD = 3;
+
+function argsKey(argv) {
+ return require("crypto").createHash("sha256").update(argv.join("\u0000")).digest("hex").slice(0, 16);
+}
+
+/** Recent failures for this exact invocation, oldest first. */
+function readFailures(key) {
+ try {
+ const all = JSON.parse(fs.readFileSync(LOOP_STATE, "utf8"));
+ const now = Date.now();
+ return (all[key] || []).filter((t) => now - t < LOOP_WINDOW_MS);
+ } catch {
+ return [];
+ }
+}
+
+function recordFailure(key) {
+ try {
+ fs.mkdirSync(path.dirname(LOOP_STATE), { recursive: true });
+ let all = {};
+ try {
+ all = JSON.parse(fs.readFileSync(LOOP_STATE, "utf8"));
+ } catch {
+ // Missing or corrupt - start fresh rather than fail the exit path.
+ }
+ const now = Date.now();
+ const recent = (all[key] || []).filter((t) => now - t < LOOP_WINDOW_MS);
+ recent.push(now);
+ // Only ever track the current invocation: stale keys from configs the user
+ // has since fixed would otherwise accumulate forever.
+ fs.writeFileSync(LOOP_STATE, JSON.stringify({ [key]: recent }));
+ return recent.length;
+ } catch {
+ return 1;
+ }
+}
+
+function clearFailures() {
+ try {
+ fs.unlinkSync(LOOP_STATE);
+ } catch {
+ // Nothing recorded, or unwritable - neither is worth reporting.
+ }
+}
+
function flushAndExit(code) {
if (posthog) {
posthog
@@ -103,8 +165,18 @@ function getBinaryName() {
}
function findBinary() {
+ // An explicit engine wins. The postinstall already tells users to set this
+ // when a download fails or the machine is air-gapped, and until now it did
+ // nothing - the advice pointed at a variable this function never read.
+ const override = process.env.CODEGRAPH_SERVER_PATH;
+ if (override) {
+ if (fs.existsSync(override)) return override;
+ console.error(`CODEGRAPH_SERVER_PATH is set but no file exists there: ${override}`);
+ process.exit(1);
+ }
+
const binaryName = getBinaryName();
- const binDir = __dirname;
+ const binDir = process.env.CODEGRAPH_BIN_DIR || __dirname;
const binaryPath = path.join(binDir, binaryName);
if (fs.existsSync(binaryPath)) {
@@ -135,9 +207,26 @@ const USE_ENGINE =
["1", "true", "on", "yes"].includes(
(process.env.CODEGRAPH_ENGINE || "").toLowerCase()
) && os.platform() !== "win32";
+// Arguments the client passed through its MCP config, minus anything this
+// wrapper supplies itself.
+//
+// Every doc and example writes `--mcp`, so users naturally put it in their MCP
+// config too - and this wrapper already adds it. clap rejects the duplicate
+// with "the argument '--mcp' cannot be used multiple times" and exits 2 before
+// the engine emits any telemetry, so the client respawns with the same config
+// and fails identically, forever. That single collision produced 656k crash
+// events across ~134 machines in one month.
+//
+// Mode flags are dropped rather than passed through: this wrapper decides the
+// mode, so a client that names one is either agreeing with us (harmless) or
+// asking for a mode the wrapper cannot deliver (which would be a confusing
+// half-configured server).
+const WRAPPER_OWNED_FLAGS = new Set(["--mcp", "--connect", "--stdio"]);
+const clientArgs = process.argv.slice(2).filter((a) => !WRAPPER_OWNED_FLAGS.has(a));
+
const args = USE_ENGINE
- ? ["--connect", "--workspace", process.cwd(), ...process.argv.slice(2)]
- : ["--mcp", ...process.argv.slice(2)];
+ ? ["--connect", "--workspace", process.cwd(), ...clientArgs]
+ : ["--mcp", ...clientArgs];
// stdin/stdout are inherited (JSON-RPC channel — untouched).
// stderr is piped so we can intercept TEL: lines for PostHog.
@@ -158,6 +247,10 @@ child.stderr.on("data", (chunk) => {
if (line.startsWith("TEL: ")) {
try {
const data = JSON.parse(line.substring(5));
+ // The engine only reports mcp.start once it is past argument parsing
+ // and actually serving, so this is the signal that the configuration
+ // works and any recorded failures are history.
+ if (data && data.event === "mcp.start") clearFailures();
sendTelemetry(data);
} catch {
// Malformed TEL line — ignore
@@ -186,11 +279,44 @@ child.on("exit", (code, signal) => {
!intentionalShutdown &&
(signal != null || (typeof code === "number" && code !== 0));
if (abnormal) {
- sendTelemetry({
- event: "mcp.crash",
- exitCode: typeof code === "number" ? code : -1,
- exitSignal: signal || "none",
- });
+ const failures = recordFailure(argsKey(args));
+ const looping = failures >= LOOP_THRESHOLD;
+
+ // Exit 2 is clap refusing the command line. It is deterministic, so the
+ // client will respawn into the identical failure - say so plainly, with
+ // the arguments, because the user cannot see them anywhere else.
+ if (code === 2) {
+ process.stderr.write(
+ `\ncodegraph-mcp: the engine rejected its arguments and exited 2.\n` +
+ ` arguments: ${args.join(" ")}\n` +
+ ` This is a configuration problem, not a crash. Check the "args" in\n` +
+ ` your MCP client config - the wrapper already supplies --mcp.\n`
+ );
+ }
+ if (looping) {
+ process.stderr.write(
+ `codegraph-mcp: failed ${failures} times in under a minute with the same\n` +
+ ` arguments. Not reporting further failures for this configuration.\n`
+ );
+ }
+
+ // Report the first failures, then one summary, then nothing. Without this
+ // a single misconfigured machine sends a crash event every few seconds for
+ // as long as its client keeps respawning - one sent 504,256.
+ if (!looping) {
+ sendTelemetry({
+ event: "mcp.crash",
+ exitCode: typeof code === "number" ? code : -1,
+ exitSignal: signal || "none",
+ });
+ } else if (failures === LOOP_THRESHOLD) {
+ sendTelemetry({
+ event: "mcp.crashloop",
+ exitCode: typeof code === "number" ? code : -1,
+ exitSignal: signal || "none",
+ failures,
+ });
+ }
// Flush before exiting so the crash event isn't lost.
flushAndExit(typeof code === "number" ? code : 1);
} else if (signal) {
diff --git a/mcp-package/bin/fetch-engine-cli.js b/mcp-package/bin/fetch-engine-cli.js
new file mode 100755
index 0000000..aaffd56
--- /dev/null
+++ b/mcp-package/bin/fetch-engine-cli.js
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+// Copyright 2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+"use strict";
+
+/**
+ * Fetch the CodeGraph engine on demand.
+ *
+ * The postinstall does this automatically, but never fails the install if it
+ * cannot - a transient network problem should not roll back a package whose
+ * CLI, hooks and docs all work. This is the retry, and the way to force a
+ * re-download of an engine that was corrupted or replaced.
+ */
+
+const path = require("path");
+const { ensureEngine, platformBinaryName, ENGINE_VERSION } = require("./fetch-engine");
+
+const force = process.argv.includes("--force");
+// The engine release this package ships against, not the package's own version:
+// the release assets are tagged with the engine's version.
+const version = ENGINE_VERSION;
+const targetDir = __dirname;
+
+const binaryName = platformBinaryName();
+if (!binaryName) {
+ console.error(`No CodeGraph engine is published for ${process.platform}-${process.arch}.`);
+ process.exit(1);
+}
+
+console.log(`Fetching CodeGraph engine ${version} for ${process.platform}-${process.arch}`);
+
+ensureEngine(version, targetDir, {
+ force,
+ onProgress: (asset) => console.log(` ↓ ${asset}`),
+})
+ .then(({ binary, fetched }) => {
+ if (fetched.length === 0) {
+ console.log(`Already present at ${binary} (use --force to re-download)`);
+ } else {
+ console.log(`✓ Verified and installed: ${binary}`);
+ }
+ })
+ .catch((err) => {
+ console.error(`✗ ${err.message}`);
+ console.error("");
+ console.error("If this machine has no network access, supply the engine yourself:");
+ console.error(` - place it at ${path.join(targetDir, binaryName)}`);
+ console.error(" - or set CODEGRAPH_SERVER_PATH= for codegraph-mcp");
+ console.error(" - on Windows, put onnxruntime.dll beside the engine too");
+ process.exit(1);
+ });
diff --git a/mcp-package/bin/fetch-engine.js b/mcp-package/bin/fetch-engine.js
new file mode 100644
index 0000000..717af30
--- /dev/null
+++ b/mcp-package/bin/fetch-engine.js
@@ -0,0 +1,430 @@
+// Copyright 2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+"use strict";
+
+/**
+ * Fetches the CodeGraph engine for the current platform from its GitHub
+ * release, verifying it against the published checksum.
+ *
+ * Every distribution channel used to carry all four platform binaries: the npm
+ * package was 88 MB compressed and 498 MB unpacked, the VSIX 118 MB, and each
+ * user could run exactly one of the four. The binaries are now published once
+ * as release assets and each channel fetches only what it needs.
+ *
+ * This module is the single implementation of that contract for the JavaScript
+ * channels - the npm postinstall and the VS Code extension - so the URL layout,
+ * the checksum format and the Windows sidecar rule cannot drift between them.
+ * The JetBrains plugin implements the same contract in Kotlin
+ * (jetbrains/.../EngineDownloader.kt); the contract is documented in
+ * scripts/publish-release-assets.sh, which produces the assets.
+ */
+
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
+const crypto = require("crypto");
+const https = require("https");
+const http = require("http");
+
+/**
+ * Pick the transport from the URL scheme. Release assets are always https;
+ * this exists so the download path itself can be exercised against a local
+ * server, rather than being the one part nothing covers.
+ */
+function transportFor(url) {
+ return url.startsWith("http://") ? http : https;
+}
+
+/**
+ * Where a redirect actually points, refusing one that leaves https.
+ *
+ * The checksum is fetched over the same hops as the binary it verifies, so an
+ * https release URL redirected to plaintext would let whoever is on the path
+ * substitute both and have them agree. GitHub does not do this, but nothing in
+ * the transport stops it, and the http branch exists at all only so the local
+ * test server can exercise this code - an http origin is therefore allowed to
+ * stay http, and nothing else may become it.
+ *
+ * A relative `Location` is resolved against the URL that produced it, which is
+ * also what the transports cannot do for themselves.
+ */
+function redirectTarget(from, location) {
+ const target = new URL(location, from);
+ if (new URL(from).protocol === "https:" && target.protocol !== "https:") {
+ throw new Error(`refusing insecure redirect from ${from} to ${target.href}`);
+ }
+ return target.href;
+}
+
+const RELEASE_BASE = "https://github.com/codegraph-ai/CodeGraph/releases/download";
+
+/**
+ * The engine release every client fetches, and the version a managed install is
+ * expected to be.
+ *
+ * Deliberately not the client's own package version. Release assets are tagged
+ * with the *engine's* version (scripts/publish-release-assets.sh reads
+ * Cargo.toml), so a client-only patch - a VSIX with a UI fix, an npm release
+ * with a doc change - would ask for `v/…` and get a 404 on every
+ * fresh install, i.e. no engine at all now that nothing bundles one. Pinning it
+ * here lets the clients version independently, and lets all three compare the
+ * shared `~/.codegraph/bin` marker against the same number instead of against
+ * three separately drifting ones.
+ *
+ * Kept equal to the engine version by `scripts/publish-release-assets.sh`, which
+ * refuses to publish while any channel's pin disagrees with Cargo.toml.
+ */
+const ENGINE_VERSION = "0.20.0";
+
+/** Codes Windows and POSIX use for "something else has this file open". */
+const IN_USE_ERROR_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ETXTBSY"]);
+
+/**
+ * Raised when a verified download cannot be moved into place because the engine
+ * on disk is still running - on Windows a process holds its own executable
+ * open. Distinguished from an ordinary I/O failure because the remedy is
+ * different: stop the engine, then try again.
+ */
+class EngineInUseError extends Error {
+ constructor(asset, cause) {
+ super(
+ `${asset} could not be replaced because it is in use. ` +
+ `Stop the CodeGraph engine and try again.`
+ );
+ this.name = "EngineInUseError";
+ this.asset = asset;
+ this.cause = cause;
+ }
+}
+
+/** Windows loads this next to the executable; without it the engine cannot start. */
+const WINDOWS_SIDECAR = "onnxruntime.dll";
+
+const PLATFORM_MAP = { darwin: "darwin", linux: "linux", win32: "win32" };
+const ARCH_MAP = { arm64: "arm64", x64: "x64", x86_64: "x64" };
+
+/**
+ * Records which release the engines in a directory came from.
+ *
+ * Without it a managed install is identified by filename alone, so an engine
+ * left behind by an older client is indistinguishable from the one this client
+ * was built against and gets reused forever.
+ */
+const VERSION_MARKER = ".engine-version";
+
+/**
+ * Asset name for the running platform, matching the names
+ * publish-release-assets.sh uploads. Returns null when unsupported, so callers
+ * can degrade instead of throwing during an install.
+ */
+function platformBinaryName(platform = os.platform(), arch = os.arch()) {
+ const p = PLATFORM_MAP[platform];
+ const a = ARCH_MAP[arch];
+ if (!p || !a) return null;
+ // macOS is the only platform published for both architectures.
+ if (p === "darwin") return `codegraph-server-darwin-${a}`;
+ // Windows on ARM runs x64 executables under the OS's own emulation layer, so
+ // the x64 asset is the correct answer there and refusing it would leave those
+ // users with no engine at all.
+ if (p === "win32") return "codegraph-server-win32-x64.exe";
+ // Linux has no such layer. Handing the x64 build to an arm64 machine installs
+ // ~30 MB that cannot execute, which surfaces as an exec-format error at first
+ // use rather than as the unsupported platform it is.
+ return a === "x64" ? "codegraph-server-linux-x64" : null;
+}
+
+/** Numeric release components, or null when [version] is not one. */
+function versionParts(version) {
+ const core = String(version || "").trim().split("-")[0];
+ if (!core) return null;
+ const parts = core.split(".").map((part) => Number.parseInt(part, 10));
+ return parts.every((part) => Number.isInteger(part)) ? parts : null;
+}
+
+/**
+ * Release order of two versions: -1, 0 or 1, and null when either side is not a
+ * plain numeric version.
+ *
+ * Callers need "older" rather than "different". `~/.codegraph/bin` is shared by
+ * the CLI, the VS Code extension and the JetBrains plugin, which ship on
+ * independent schedules; treating any difference as staleness makes each client
+ * reinstall its own engine over the other's on every launch, forever.
+ */
+function compareVersions(a, b) {
+ const left = versionParts(a);
+ const right = versionParts(b);
+ if (!left || !right) return null;
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
+ const difference = (left[i] || 0) - (right[i] || 0);
+ if (difference !== 0) return difference < 0 ? -1 : 1;
+ }
+ return 0;
+}
+
+/** Everything this platform needs on disk, in the order it should be fetched. */
+function requiredAssets(platform = os.platform(), arch = os.arch()) {
+ const binary = platformBinaryName(platform, arch);
+ if (!binary) return [];
+ // The sidecar is not optional: fetching only the executable produces an
+ // install that succeeds and then fails at startup.
+ return PLATFORM_MAP[platform] === "win32" ? [binary, WINDOWS_SIDECAR] : [binary];
+}
+
+function download(url, destination, { redirects = 5 } = {}) {
+ return new Promise((resolve, reject) => {
+ if (redirects < 0) return reject(new Error(`too many redirects for ${url}`));
+ let file = null;
+ /**
+ * A transfer can die on the request (a socket reset), on the response (a
+ * message destroyed after the headers), or on the write stream (a full
+ * disk). All three must reject rather than throw uncaught - inside an npm
+ * postinstall that is the difference between a warning and a failed
+ * install - and all three must close the write stream, because a staged
+ * file with a live handle on it cannot be unlinked on Windows.
+ */
+ const fail = (error) => {
+ if (file) file.destroy();
+ reject(error);
+ };
+ transportFor(url)
+ .get(url, { headers: { "User-Agent": "codegraph-installer" } }, (res) => {
+ // GitHub release assets always redirect to object storage.
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ res.resume();
+ let next;
+ try {
+ next = redirectTarget(url, res.headers.location);
+ } catch (error) {
+ return fail(error);
+ }
+ return resolve(download(next, destination, { redirects: redirects - 1 }));
+ }
+ if (res.statusCode !== 200) {
+ res.resume();
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
+ }
+ file = fs.createWriteStream(destination);
+ res.on("error", fail);
+ file.on("error", fail);
+ file.on("finish", () => file.close(resolve));
+ res.pipe(file);
+ })
+ .on("error", fail);
+ });
+}
+
+function readText(url, options = {}) {
+ const redirects = options.redirects === undefined ? 5 : options.redirects;
+ return new Promise((resolve, reject) => {
+ if (redirects < 0) return reject(new Error(`too many redirects for ${url}`));
+ transportFor(url)
+ .get(url, { headers: { "User-Agent": "codegraph-installer" } }, (res) => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ res.resume();
+ let next;
+ try {
+ next = redirectTarget(url, res.headers.location);
+ } catch (error) {
+ return reject(error);
+ }
+ return resolve(readText(next, { redirects: redirects - 1 }));
+ }
+ if (res.statusCode !== 200) {
+ res.resume();
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
+ }
+ let body = "";
+ res.setEncoding("utf8");
+ res.on("data", (chunk) => (body += chunk));
+ res.on("end", () => resolve(body));
+ })
+ .on("error", reject);
+ });
+}
+
+function sha256(file) {
+ return new Promise((resolve, reject) => {
+ const hash = crypto.createHash("sha256");
+ fs.createReadStream(file)
+ .on("data", (chunk) => hash.update(chunk))
+ .on("end", () => resolve(hash.digest("hex")))
+ .on("error", reject);
+ });
+}
+
+/**
+ * Download one asset into targetDir and verify it, returning the staged file.
+ *
+ * Nothing is moved into place here, so an interrupted or corrupted download can
+ * never leave behind something that later looks like a valid install.
+ *
+ * The staged name is unique per call. This directory is shared, and the
+ * download is no longer one deliberate click: several VS Code windows can
+ * activate at once and each would otherwise truncate, hash and unlink the same
+ * `.partial` file, producing a spurious checksum failure or - worse - promoting
+ * a half-written engine into place.
+ */
+async function stageVerified(asset, version, targetDir, { baseUrl = RELEASE_BASE } = {}) {
+ const assetUrl = `${baseUrl}/v${version}/${asset}`;
+ const expected = (await readText(`${assetUrl}.sha256`)).trim().split(/\s+/)[0].toLowerCase();
+
+ const stamp = `${process.pid}.${crypto.randomBytes(6).toString("hex")}`;
+ const staged = path.join(targetDir, `.${asset}.${stamp}.partial`);
+ try {
+ await download(assetUrl, staged);
+ const actual = await sha256(staged);
+ if (actual.toLowerCase() !== expected) {
+ throw new Error(
+ `${asset} failed checksum verification (expected ${expected}, got ${actual})`
+ );
+ }
+ } catch (error) {
+ // A file the caller was never handed back is this function's to clean up.
+ if (fs.existsSync(staged)) fs.unlinkSync(staged);
+ throw error;
+ }
+ return staged;
+}
+
+/**
+ * Move a verified download into place, naming the one failure with a different
+ * remedy: a running engine holds its own binary open, and reporting that as a
+ * failed download sends the user to debug a network they have no problem with.
+ */
+function installStaged(staged, destination, asset) {
+ try {
+ fs.renameSync(staged, destination);
+ } catch (error) {
+ if (IN_USE_ERROR_CODES.has(error.code)) throw new EngineInUseError(asset, error);
+ throw error;
+ }
+}
+
+/** Fetch one asset into targetDir, verified, and install it. */
+async function fetchVerified(asset, version, targetDir, options = {}) {
+ const staged = await stageVerified(asset, version, targetDir, options);
+ try {
+ installStaged(staged, path.join(targetDir, asset), asset);
+ } finally {
+ if (fs.existsSync(staged)) fs.unlinkSync(staged);
+ }
+}
+
+/**
+ * Which release the engines in targetDir came from, or null when unknown -
+ * either nothing is installed, or it predates the marker.
+ */
+function installedVersion(targetDir) {
+ try {
+ return fs.readFileSync(path.join(targetDir, VERSION_MARKER), "utf8").trim() || null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * True when the engine installed in targetDir predates [version] and should be
+ * replaced. An unmarked install counts as stale: it predates the marker, so
+ * which build it is cannot be established.
+ *
+ * A *newer* engine is left alone. All three clients share this directory and
+ * ship independently, so one of them finding a newer engine is normal, and
+ * replacing it with its own older one only starts a downgrade war the other
+ * client undoes on its next launch.
+ */
+function isStale(targetDir, version) {
+ const installed = installedVersion(targetDir);
+ if (installed === null) return true;
+ const order = compareVersions(installed, version);
+ return order === null ? true : order < 0;
+}
+
+/**
+ * Ensure the engine for this platform is present in targetDir, at [version].
+ *
+ * A binary left by an older client is replaced rather than reused: clients ship
+ * in lockstep with the engine they were built against, so "a file with the
+ * right name exists" is not the same question as "the right engine is here".
+ *
+ * Every asset is staged and verified before any of them is moved into place.
+ * Installing as each download completes is how a Windows install ends up with a
+ * new engine beside the old `onnxruntime.dll` when the second transfer fails -
+ * a combination that installs cleanly and then fails at startup.
+ *
+ * `options.beforeInstall` runs after the last download is verified and before
+ * the first file is moved, so a caller can stop the running engine at the last
+ * possible moment: stopping it for the length of a transfer that may fail costs
+ * the user a working engine for nothing.
+ *
+ * @returns {Promise<{binary: string, fetched: string[]}>} path to the engine
+ * and which assets were downloaded (empty when everything was already there).
+ */
+async function ensureEngine(version, targetDir, options = {}) {
+ const assets = requiredAssets(options.platform, options.arch);
+ if (assets.length === 0) {
+ throw new Error(`no CodeGraph engine is published for ${os.platform()}-${os.arch()}`);
+ }
+
+ fs.mkdirSync(targetDir, { recursive: true });
+
+ const stale = isStale(targetDir, version);
+ const fetched = assets.filter(
+ (asset) => options.force || stale || !fs.existsSync(path.join(targetDir, asset))
+ );
+
+ const staged = new Map();
+ try {
+ for (const asset of fetched) {
+ if (options.onProgress) options.onProgress(asset);
+ staged.set(asset, await stageVerified(asset, version, targetDir, options));
+ }
+ if (staged.size > 0 && options.beforeInstall) await options.beforeInstall();
+ for (const [asset, file] of staged) {
+ installStaged(file, path.join(targetDir, asset), asset);
+ }
+ } finally {
+ // A successful move leaves nothing to remove; anything still here belongs
+ // to a download that failed or to an install that stopped part-way.
+ for (const file of staged.values()) {
+ if (fs.existsSync(file)) fs.unlinkSync(file);
+ }
+ }
+ // Written last, and only when this call put the engine there: a marker
+ // recorded before the assets are verified would claim an install that a later
+ // failure never completed, and one recorded over an untouched newer install
+ // would mislabel someone else's engine as ours. An unmarked directory is
+ // always stale, so it is already covered - nothing was fetched only when a
+ // marker was there to compare against.
+ if (fetched.length > 0) {
+ fs.writeFileSync(path.join(targetDir, VERSION_MARKER), `${version}\n`);
+ }
+
+ const binary = path.join(targetDir, assets[0]);
+ if (os.platform() !== "win32") {
+ try {
+ fs.chmodSync(binary, 0o755);
+ } catch {
+ // A read-only install location is the user's to fix; the download itself
+ // succeeded and reporting a chmod failure as a download failure misleads.
+ }
+ }
+ return { binary, fetched };
+}
+
+module.exports = {
+ RELEASE_BASE,
+ ENGINE_VERSION,
+ WINDOWS_SIDECAR,
+ VERSION_MARKER,
+ EngineInUseError,
+ platformBinaryName,
+ redirectTarget,
+ requiredAssets,
+ installedVersion,
+ compareVersions,
+ isStale,
+ ensureEngine,
+ fetchVerified,
+ sha256,
+};
diff --git a/mcp-package/bin/postinstall.js b/mcp-package/bin/postinstall.js
index 4d2d8d8..fbc31d7 100644
--- a/mcp-package/bin/postinstall.js
+++ b/mcp-package/bin/postinstall.js
@@ -6,94 +6,134 @@ const os = require("os");
const fs = require("fs");
const { execFileSync } = require("child_process");
-const PLATFORM_MAP = {
- darwin: "darwin",
- linux: "linux",
- win32: "win32",
-};
+const {
+ ensureEngine,
+ platformBinaryName,
+ requiredAssets,
+ ENGINE_VERSION,
+} = require("./fetch-engine");
-const ARCH_MAP = {
- arm64: "arm64",
- x64: "x64",
- x86_64: "x64",
-};
+const platform = os.platform();
+const arch = os.arch();
-const platform = PLATFORM_MAP[os.platform()];
-const arch = ARCH_MAP[os.arch()];
+// One place decides which platforms have a published engine: fetch-engine.js,
+// which also names the asset. A second copy of that rule here is how a platform
+// with no build ends up downloading someone else's binary.
+const binaryName = platformBinaryName();
-if (!platform || !arch) {
- console.warn(
- `⚠ codegraph-mcp: unsupported platform ${os.platform()}-${os.arch()}`
- );
+if (!binaryName) {
+ console.warn(`⚠ codegraph-mcp: unsupported platform ${platform}-${arch}`);
process.exit(0);
}
-const ext = platform === "win32" ? ".exe" : "";
-const binaryName = `codegraph-server-${platform}-${arch}${ext}`;
const binaryPath = path.join(__dirname, binaryName);
+// The engine release, not this package's version. Release assets are tagged
+// with the engine's version, so asking for this package's would 404 on any
+// npm-only patch release - and leave the install with no engine at all.
+const version = ENGINE_VERSION;
-if (!fs.existsSync(binaryPath)) {
- console.warn(`⚠ codegraph-mcp: binary not found for ${platform}-${arch}`);
- console.warn(` Expected: ${binaryPath}`);
- process.exit(0);
-}
+// The engine is fetched rather than bundled. Shipping all four platform
+// binaries made this package 88 MB compressed and 498 MB unpacked so that every
+// user could run exactly one of them. Fetching keeps the path identical -
+// `/bin/codegraph-server--` - which matters because
+// consumers resolve it directly, the PR-review workflow among them.
+//
+// CODEGRAPH_SKIP_BINARY_FETCH exists for air-gapped installs and for anyone
+// vendoring the binary themselves; every asset already being present skips the
+// fetch anyway.
+//
+// The guard asks about all required assets, not just the executable: on Windows
+// the engine also needs its ONNX Runtime sidecar, and an install that moved the
+// exe and then failed on the sidecar would otherwise never be retried - the
+// binary is there, so the fetch is skipped, and the engine can never start.
+// ensureEngine's own per-asset filter decides what actually gets downloaded.
+const missingAssets = () =>
+ requiredAssets().some((asset) => !fs.existsSync(path.join(__dirname, asset)));
-if (platform !== "win32") {
- try {
- fs.chmodSync(binaryPath, 0o755);
- } catch {
- // Ignore permission errors
+(async () => {
+ if (missingAssets() && !process.env.CODEGRAPH_SKIP_BINARY_FETCH) {
+ try {
+ console.log(`codegraph-mcp: fetching engine ${version} for ${platform}-${arch}...`);
+ const { fetched } = await ensureEngine(version, __dirname, {
+ onProgress: (asset) => console.log(` ↓ ${asset}`),
+ });
+ if (fetched.length > 0) console.log(`✓ codegraph-mcp: engine downloaded and verified`);
+ } catch (err) {
+ // Never fail the install over this: npm would roll back a package whose
+ // CLI, hooks and docs are all perfectly usable, and the engine can still
+ // be supplied by hand.
+ console.warn(`⚠ codegraph-mcp: could not download the engine — ${err.message}`);
+ console.warn(` Retry with: npx codegraph-mcp-fetch-engine`);
+ // Both ways of supplying an engine by hand, because an air-gapped or
+ // read-only install may not be able to use the first one.
+ console.warn(` Or supply an engine you already have:`);
+ console.warn(` - place it at ${binaryPath}`);
+ console.warn(` - or set CODEGRAPH_SERVER_PATH= for codegraph-mcp`);
+ }
}
-}
-try {
- const output = execFileSync(binaryPath, ["--info"], {
- timeout: 10000,
- encoding: "utf8",
- });
- console.log(`✓ codegraph-mcp installed: ${output.trim().split("\n")[0]}`);
-} catch (err) {
- console.warn(`⚠ codegraph-mcp: binary exists but --info check failed`);
- console.warn(` ${err.message}`);
-}
+ if (!fs.existsSync(binaryPath)) {
+ console.warn(`⚠ codegraph-mcp: no engine at ${binaryPath}`);
+ return;
+ }
-// Fetch the distilled static embedding model (best-effort) from the
-// release-independent `model` GitHub release. Only needed for
-// `--embedding-model static`; skipped if already present or if
-// CODEGRAPH_SKIP_MODEL_FETCH is set. Never fails the install.
-if (!process.env.CODEGRAPH_SKIP_MODEL_FETCH) {
- const MODEL = "jina-code-static-256";
- const modelDir = path.join(os.homedir(), ".codegraph", "static_models", MODEL);
- if (!fs.existsSync(path.join(modelDir, "model.safetensors"))) {
+ if (platform !== "win32") {
try {
- fs.mkdirSync(modelDir, { recursive: true });
- const url = `https://github.com/codegraph-ai/CodeGraph/releases/download/model/${MODEL}.tar.gz`;
- const tgz = path.join(modelDir, "_model.tar.gz");
- execFileSync("curl", ["-fsSL", url, "-o", tgz], { timeout: 180000 });
- execFileSync("tar", ["xzf", tgz, "-C", modelDir], { timeout: 60000 });
- fs.unlinkSync(tgz);
- console.log(`✓ codegraph-mcp: static embedding model ready (${modelDir})`);
+ fs.chmodSync(binaryPath, 0o755);
} catch {
- console.warn(
- `ℹ codegraph-mcp: static model not fetched (optional — only for --embedding-model static)`
- );
+ // Ignore permission errors
}
}
-}
-// Hint about the optional Claude Code hook. Installation is opt-in to avoid
-// silently modifying the user's ~/.claude/settings.json. Both Unix
-// (bash) and Windows (PowerShell) variants are shipped — the installer
-// picks the right one for the current OS.
-{
- const scriptName =
- platform === "win32" ? "codegraph-pre-edit.ps1" : "codegraph-pre-edit.sh";
- const hookScriptPath = path.join(__dirname, "..", "hooks", scriptName);
- if (fs.existsSync(hookScriptPath)) {
- console.log("");
- console.log("ℹ Optional: enable automatic context injection in Claude Code:");
- console.log(" npx codegraph-mcp-install-hooks");
- console.log(" Adds a PreToolUse hook that nudges agents to fetch graph context");
- console.log(" before Edit/Write on source files. Idempotent, opt-out via --uninstall.");
+ try {
+ const output = execFileSync(binaryPath, ["--info"], {
+ timeout: 10000,
+ encoding: "utf8",
+ });
+ console.log(`✓ codegraph-mcp installed: ${output.trim().split("\n")[0]}`);
+ } catch (err) {
+ console.warn(`⚠ codegraph-mcp: binary exists but --info check failed`);
+ console.warn(` ${err.message}`);
}
-}
+
+ // Fetch the distilled static embedding model (best-effort) from the
+ // release-independent `model` GitHub release. Only needed for
+ // `--embedding-model static`; skipped if already present or if
+ // CODEGRAPH_SKIP_MODEL_FETCH is set. Never fails the install.
+ if (!process.env.CODEGRAPH_SKIP_MODEL_FETCH) {
+ const MODEL = "jina-code-static-256";
+ const modelDir = path.join(os.homedir(), ".codegraph", "static_models", MODEL);
+ if (!fs.existsSync(path.join(modelDir, "model.safetensors"))) {
+ try {
+ fs.mkdirSync(modelDir, { recursive: true });
+ const url = `https://github.com/codegraph-ai/CodeGraph/releases/download/model/${MODEL}.tar.gz`;
+ const tgz = path.join(modelDir, "_model.tar.gz");
+ execFileSync("curl", ["-fsSL", url, "-o", tgz], { timeout: 180000 });
+ execFileSync("tar", ["xzf", tgz, "-C", modelDir], { timeout: 60000 });
+ fs.unlinkSync(tgz);
+ console.log(`✓ codegraph-mcp: static embedding model ready (${modelDir})`);
+ } catch {
+ console.warn(
+ `ℹ codegraph-mcp: static model not fetched (optional — only for --embedding-model static)`
+ );
+ }
+ }
+ }
+
+ // Hint about the optional Claude Code hook. Installation is opt-in to avoid
+ // silently modifying the user's ~/.claude/settings.json. Both Unix
+ // (bash) and Windows (PowerShell) variants are shipped — the installer
+ // picks the right one for the current OS.
+ {
+ const scriptName =
+ platform === "win32" ? "codegraph-pre-edit.ps1" : "codegraph-pre-edit.sh";
+ const hookScriptPath = path.join(__dirname, "..", "hooks", scriptName);
+ if (fs.existsSync(hookScriptPath)) {
+ console.log("");
+ console.log("ℹ Optional: enable automatic context injection in Claude Code:");
+ console.log(" npx codegraph-mcp-install-hooks");
+ console.log(" Adds a PreToolUse hook that nudges agents to fetch graph context");
+ console.log(" before Edit/Write on source files. Idempotent, opt-out via --uninstall.");
+ }
+ }
+})();
diff --git a/mcp-package/package.json b/mcp-package/package.json
index b61d40a..5651e8a 100644
--- a/mcp-package/package.json
+++ b/mcp-package/package.json
@@ -1,8 +1,8 @@
{
"name": "@astudioplus/codegraph-mcp",
- "version": "0.19.1",
+ "version": "0.20.0",
"mcpName": "io.github.codegraph-ai/codegraph",
- "description": "CodeGraph MCP server — cross-language code intelligence with 42 tools, 38 languages",
+ "description": "CodeGraph MCP server \u2014 cross-language code intelligence with 42 tools, 38 languages",
"author": "Andrey Vasilevsky ",
"license": "Apache-2.0",
"repository": {
@@ -22,7 +22,8 @@
"bin": {
"codegraph-mcp": "./bin/codegraph-mcp.js",
"codegraph-daemon": "./bin/codegraph-daemon.js",
- "codegraph-mcp-install-hooks": "./bin/install-hooks.js"
+ "codegraph-mcp-install-hooks": "./bin/install-hooks.js",
+ "codegraph-mcp-fetch-engine": "./bin/fetch-engine-cli.js"
},
"files": [
"bin/",
@@ -46,6 +47,7 @@
"posthog-node": "^4.18.0"
},
"scripts": {
- "postinstall": "node bin/postinstall.js"
+ "postinstall": "node bin/postinstall.js",
+ "test": "node test/fetch-engine.test.js && node test/wrapper-args.test.js"
}
}
diff --git a/mcp-package/server.json b/mcp-package/server.json
index 6568818..9fd21f9 100644
--- a/mcp-package/server.json
+++ b/mcp-package/server.json
@@ -6,12 +6,12 @@
"url": "https://github.com/codegraph-ai/CodeGraph",
"source": "github"
},
- "version": "0.19.1",
+ "version": "0.20.0",
"packages": [
{
"registryType": "npm",
"identifier": "@astudioplus/codegraph-mcp",
- "version": "0.19.1",
+ "version": "0.20.0",
"transport": {
"type": "stdio"
},
diff --git a/mcp-package/test/fetch-engine.test.js b/mcp-package/test/fetch-engine.test.js
new file mode 100644
index 0000000..7f84257
--- /dev/null
+++ b/mcp-package/test/fetch-engine.test.js
@@ -0,0 +1,440 @@
+#!/usr/bin/env node
+// Copyright 2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+"use strict";
+
+/**
+ * Serves a fake release over loopback and downloads from it.
+ *
+ * Run with `node test/fetch-engine.test.js`. No test framework: this package
+ * has no dev dependencies and adding one to check a download would be a poor
+ * trade.
+ *
+ * The cases worth covering are the destructive ones. A corrupted transfer must
+ * install nothing and leave nothing behind, and Windows must never end up with
+ * an engine and no `onnxruntime.dll` - that combination downloads cleanly and
+ * then fails at startup, which is harder to diagnose than an obvious failure.
+ */
+
+const crypto = require("crypto");
+const fs = require("fs");
+const http = require("http");
+const os = require("os");
+const path = require("path");
+
+const {
+ ensureEngine,
+ requiredAssets,
+ platformBinaryName,
+ redirectTarget,
+ installedVersion,
+ compareVersions,
+ ENGINE_VERSION,
+ EngineInUseError,
+ WINDOWS_SIDECAR,
+ VERSION_MARKER,
+} = require("../bin/fetch-engine");
+
+const VERSION = "0.20.0";
+let failures = 0;
+
+function check(ok, message) {
+ console.log((ok ? "PASS " : "FAIL ") + message);
+ if (!ok) failures++;
+}
+
+/**
+ * A release server whose assets and checksums the test controls.
+ *
+ * With `redirect`, every asset is served one 302 away at a *relative*
+ * `Location`, which is how real object storage answers and what the transports
+ * cannot resolve on their own.
+ */
+function startRelease(assets, { redirect = false } = {}) {
+ const routes = {};
+ for (const [name, body] of Object.entries(assets)) {
+ const content = Buffer.from(body.content);
+ routes[`/v${VERSION}/${name}`] = content;
+ const digest =
+ body.checksum ?? crypto.createHash("sha256").update(content).digest("hex");
+ routes[`/v${VERSION}/${name}.sha256`] = Buffer.from(`${digest} ${name}\n`);
+ }
+ const server = http.createServer((req, res) => {
+ const target = redirect ? req.url.replace(/^\/objects/, "") : req.url;
+ const body = routes[target];
+ if (!body) {
+ res.writeHead(404);
+ res.end();
+ return;
+ }
+ if (redirect && target === req.url) {
+ res.writeHead(302, { Location: `/objects${req.url}` });
+ res.end();
+ return;
+ }
+ res.writeHead(200, { "Content-Length": body.length });
+ res.end(body);
+ });
+ return new Promise((resolve) =>
+ server.listen(0, "127.0.0.1", () =>
+ resolve({ server, baseUrl: `http://127.0.0.1:${server.address().port}` })
+ )
+ );
+}
+
+function scratch() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), "codegraph-fetch-"));
+}
+
+async function run() {
+ // --- a verified download installs the engine -------------------------
+ {
+ const dir = scratch();
+ const name = platformBinaryName();
+ const assets = { [name]: { content: "engine" } };
+ for (const asset of requiredAssets().slice(1)) assets[asset] = { content: "sidecar" };
+
+ const release = await startRelease(assets);
+ try {
+ const { binary, fetched } = await ensureEngine(VERSION, dir, { baseUrl: release.baseUrl });
+ check(fs.readFileSync(binary, "utf8") === "engine", "engine content is what the release served");
+ check(fetched.length === requiredAssets().length, "every required asset was fetched");
+ for (const asset of requiredAssets()) {
+ check(fs.existsSync(path.join(dir, asset)), `${asset} is installed`);
+ }
+ } finally {
+ release.server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ }
+
+ // --- a corrupted download installs nothing ---------------------------
+ {
+ const dir = scratch();
+ const name = platformBinaryName();
+ const assets = { [name]: { content: "engine", checksum: "0".repeat(64) } };
+ for (const a of requiredAssets().slice(1)) assets[a] = { content: "sidecar" };
+ const { server, baseUrl } = await startRelease(assets);
+ try {
+ let threw = null;
+ await ensureEngine(VERSION, dir, { baseUrl }).catch((e) => (threw = e));
+ check(threw !== null && /checksum/i.test(threw.message), "a checksum mismatch is reported");
+ check(!fs.existsSync(path.join(dir, name)), "a mismatched engine is not installed");
+ const leftovers = fs.readdirSync(dir);
+ check(leftovers.length === 0, `nothing is left behind (found ${JSON.stringify(leftovers)})`);
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ }
+
+ // --- an already-present engine of the same version is not re-downloaded --
+ {
+ const dir = scratch();
+ const name = platformBinaryName();
+ for (const asset of requiredAssets()) fs.writeFileSync(path.join(dir, asset), "existing");
+ fs.writeFileSync(path.join(dir, VERSION_MARKER), `${VERSION}\n`);
+ // Serve nothing: any fetch attempt would 404 and fail the call.
+ const { server, baseUrl } = await startRelease({});
+ try {
+ const { fetched } = await ensureEngine(VERSION, dir, { baseUrl });
+ check(fetched.length === 0, "an existing install of the same version is left alone");
+ check(fs.readFileSync(path.join(dir, name), "utf8") === "existing", "it is not overwritten");
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ }
+
+ // --- an engine from an older client is replaced ----------------------
+ // Resolving by filename alone is what let a client keep talking to the
+ // engine a previous release installed, forever.
+ {
+ const dir = scratch();
+ const name = platformBinaryName();
+ for (const asset of requiredAssets()) fs.writeFileSync(path.join(dir, asset), "stale");
+ fs.writeFileSync(path.join(dir, VERSION_MARKER), "0.19.1\n");
+
+ const assets = { [name]: { content: "engine" } };
+ for (const asset of requiredAssets().slice(1)) assets[asset] = { content: "sidecar" };
+ const { server, baseUrl } = await startRelease(assets);
+ try {
+ const { binary, fetched } = await ensureEngine(VERSION, dir, { baseUrl });
+ check(fetched.length === requiredAssets().length, "a version mismatch re-fetches every asset");
+ check(fs.readFileSync(binary, "utf8") === "engine", "the stale engine is replaced");
+ check(installedVersion(dir) === VERSION, "the installed version is recorded");
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ }
+
+ // --- an engine from a newer client is left alone ---------------------
+ // This directory is shared by the CLI, the VS Code extension and the
+ // JetBrains plugin, which ship independently. Replacing a newer engine with
+ // this client's older one is a downgrade the other client undoes on its next
+ // launch, and the two then take turns forever.
+ {
+ const dir = scratch();
+ const name = platformBinaryName();
+ for (const asset of requiredAssets()) fs.writeFileSync(path.join(dir, asset), "newer");
+ fs.writeFileSync(path.join(dir, VERSION_MARKER), "0.21.0\n");
+ // Serve nothing: any fetch attempt would 404 and fail the call.
+ const { server, baseUrl } = await startRelease({});
+ try {
+ const { fetched } = await ensureEngine(VERSION, dir, { baseUrl });
+ check(fetched.length === 0, "a newer install is not downgraded");
+ check(fs.readFileSync(path.join(dir, name), "utf8") === "newer", "its engine is untouched");
+ check(installedVersion(dir) === "0.21.0", "and its version marker is left as it was");
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ }
+
+ // --- an unmarked install is treated as unknown, not as current -------
+ {
+ const dir = scratch();
+ for (const asset of requiredAssets()) fs.writeFileSync(path.join(dir, asset), "unmarked");
+ check(installedVersion(dir) === null, "an install with no marker reports no version");
+ }
+
+ // --- release ordering -------------------------------------------------
+ check(compareVersions("0.19.1", "0.20.0") === -1, "0.19.1 precedes 0.20.0");
+ check(compareVersions("0.21.0", "0.20.0") === 1, "0.21.0 follows 0.20.0");
+ check(compareVersions("0.20.0", "0.20.0") === 0, "a version equals itself");
+ check(compareVersions("0.20", "0.20.0") === 0, "missing components read as zero");
+ check(compareVersions("0.20.0-beta.1", "0.20.0") === 0, "a prerelease compares by its core");
+ check(compareVersions("nightly", "0.20.0") === null, "an unparseable version has no order");
+
+ // --- a missing release fails loudly ----------------------------------
+ {
+ const dir = scratch();
+ const { server, baseUrl } = await startRelease({});
+ try {
+ let threw = null;
+ await ensureEngine(VERSION, dir, { baseUrl }).catch((e) => (threw = e));
+ check(threw !== null, "a missing asset fails rather than reporting success");
+ check(fs.readdirSync(dir).length === 0, "nothing is left behind after a failure");
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ }
+
+ // --- a transfer that dies after the headers ---------------------------
+ // A half-finished download must reject, install nothing, and leave no
+ // staged file behind. Silently keeping the truncated bytes would produce an
+ // install that looks complete and fails at first use.
+ {
+ const dir = scratch();
+ const name = platformBinaryName();
+ const digest = crypto.createHash("sha256").update("engine").digest("hex");
+ const server = http.createServer((req, res) => {
+ if (req.url.endsWith(".sha256")) {
+ res.writeHead(200);
+ res.end(`${digest} ${name}\n`);
+ return;
+ }
+ // Promise far more than we send, then cut the connection.
+ res.writeHead(200, { "Content-Length": 4096 });
+ res.write("partial");
+ res.socket.destroy();
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const baseUrl = `http://127.0.0.1:${server.address().port}`;
+ try {
+ let threw = null;
+ await ensureEngine(VERSION, dir, { baseUrl }).catch((e) => (threw = e));
+ check(threw !== null, "an aborted transfer rejects rather than throwing uncaught");
+ check(!fs.existsSync(path.join(dir, name)), "an aborted transfer installs nothing");
+ check(fs.readdirSync(dir).length === 0, "an aborted transfer leaves nothing behind");
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ }
+
+ // --- nothing is installed until everything is verified ----------------
+ // Installing each asset as its download finishes is how a Windows install
+ // ends up with a new engine beside the old onnxruntime.dll: that combination
+ // downloads cleanly and then fails at startup.
+ {
+ const dir = scratch();
+ const name = platformBinaryName();
+ const assets = { [name]: { content: "engine" } };
+ // Serve the engine but not the sidecar, so the second fetch 404s. On
+ // platforms with no sidecar, corrupt the engine instead.
+ const sidecars = requiredAssets().slice(1);
+ if (sidecars.length === 0) assets[name].checksum = "0".repeat(64);
+
+ const { server, baseUrl } = await startRelease(assets);
+ try {
+ let threw = null;
+ await ensureEngine(VERSION, dir, { baseUrl }).catch((e) => (threw = e));
+ check(threw !== null, "a partial release fails rather than half-installing");
+ check(!fs.existsSync(path.join(dir, name)), "the verified engine is not installed alone");
+ check(fs.readdirSync(dir).length === 0, "and nothing is staged behind");
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ }
+
+ // --- the engine is stopped before it is replaced ----------------------
+ // A running engine holds its own binary open; on Windows the move fails
+ // outright, and elsewhere the old process keeps serving while the version
+ // marker records a build nobody runs. The hook fires once every asset is
+ // verified, and only when there is something to install.
+ {
+ const dir = scratch();
+ const name = platformBinaryName();
+ for (const asset of requiredAssets()) fs.writeFileSync(path.join(dir, asset), "stale");
+ fs.writeFileSync(path.join(dir, VERSION_MARKER), "0.19.1\n");
+
+ const assets = { [name]: { content: "engine" } };
+ for (const asset of requiredAssets().slice(1)) assets[asset] = { content: "sidecar" };
+ const { server, baseUrl } = await startRelease(assets);
+ try {
+ const seen = [];
+ await ensureEngine(VERSION, dir, {
+ baseUrl,
+ beforeInstall: async () => seen.push(fs.readFileSync(path.join(dir, name), "utf8")),
+ });
+ check(seen.length === 1, "the install hook runs exactly once");
+ check(seen[0] === "stale", "it runs before the old engine is replaced");
+ check(
+ fs.readFileSync(path.join(dir, name), "utf8") === "engine",
+ "and the new engine is in place afterwards"
+ );
+
+ const skipped = [];
+ await ensureEngine(VERSION, dir, { baseUrl, beforeInstall: async () => skipped.push(1) });
+ check(skipped.length === 0, "an up-to-date install does not stop the engine for nothing");
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ }
+
+ // --- a locked binary is reported as such ------------------------------
+ // Reporting "in use" as a download failure sends the user to debug a network
+ // they have no problem with, and the update they cannot complete is then
+ // re-offered on every activation.
+ {
+ const dir = scratch();
+ const name = platformBinaryName();
+ const assets = { [name]: { content: "engine" } };
+ for (const asset of requiredAssets().slice(1)) assets[asset] = { content: "sidecar" };
+ const { server, baseUrl } = await startRelease(assets);
+ const realRename = fs.renameSync;
+ try {
+ fs.renameSync = (from, to) => {
+ if (path.basename(to) === name) {
+ const error = new Error("EBUSY: resource busy or locked");
+ error.code = "EBUSY";
+ throw error;
+ }
+ return realRename(from, to);
+ };
+ let threw = null;
+ await ensureEngine(VERSION, dir, { baseUrl }).catch((e) => (threw = e));
+ check(threw instanceof EngineInUseError, "a locked binary is reported as in use");
+ check(/in use/i.test(threw.message), "and says so in the message");
+ } finally {
+ fs.renameSync = realRename;
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ }
+
+ // --- the pinned engine release ---------------------------------------
+ // The clients fetch this version, not their own package version: release
+ // assets are tagged with the engine's version, so a client-only patch would
+ // otherwise ask for a tag that was never published and get no engine at all.
+ check(/^\d+\.\d+\.\d+/.test(ENGINE_VERSION), `a concrete engine version is pinned (${ENGINE_VERSION})`);
+
+ // --- redirects are followed, but never off https ---------------------
+ {
+ const dir = scratch();
+ const name = platformBinaryName();
+ const assets = { [name]: { content: "engine" } };
+ for (const a of requiredAssets().slice(1)) assets[a] = { content: "sidecar" };
+ const { server, baseUrl } = await startRelease(assets, { redirect: true });
+ try {
+ const { binary } = await ensureEngine(VERSION, dir, { baseUrl });
+ check(
+ fs.readFileSync(binary, "utf8") === "engine",
+ "an asset served behind a relative redirect still arrives"
+ );
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ }
+
+ // The binary and the checksum that verifies it travel the same hops, so a
+ // redirect off https would let one party serve both and have them agree.
+ check(
+ redirectTarget("https://example.com/v1/engine", "https://cdn.example.net/engine") ===
+ "https://cdn.example.net/engine",
+ "an https redirect to https is followed"
+ );
+ let downgrade = null;
+ try {
+ redirectTarget("https://example.com/v1/engine", "http://cdn.example.net/engine");
+ } catch (error) {
+ downgrade = error;
+ }
+ check(downgrade !== null, "a redirect off https is refused rather than followed");
+ check(
+ redirectTarget("http://127.0.0.1:9/v1/engine", "http://127.0.0.1:9/objects/engine") ===
+ "http://127.0.0.1:9/objects/engine",
+ "an http origin - the test server - may stay http"
+ );
+ check(
+ redirectTarget("https://example.com/v1/engine", "/objects/engine") ===
+ "https://example.com/objects/engine",
+ "a relative Location resolves against the URL that produced it"
+ );
+
+ // --- the windows sidecar rule ----------------------------------------
+ check(
+ requiredAssets("win32", "x64").includes(WINDOWS_SIDECAR),
+ "windows requires the runtime library the engine loads"
+ );
+ check(
+ !requiredAssets("linux", "x64").includes(WINDOWS_SIDECAR),
+ "other platforms do not"
+ );
+
+ // --- only published platform/arch pairs resolve to an asset ----------
+ // An x64 asset handed to an arm64 Linux machine downloads and chmods cleanly
+ // and then fails to exec, which is far harder to read than "not published".
+ // Windows on ARM is the exception: it emulates x64, so the x64 build runs.
+ check(platformBinaryName("linux", "arm64") === null, "linux-arm64 has no published engine");
+ check(
+ platformBinaryName("win32", "arm64") === "codegraph-server-win32-x64.exe",
+ "win32-arm64 uses the x64 engine, which Windows emulates"
+ );
+ check(
+ requiredAssets("win32", "arm64").includes(WINDOWS_SIDECAR),
+ "and still needs the runtime library beside it"
+ );
+ check(
+ platformBinaryName("darwin", "arm64") === "codegraph-server-darwin-arm64",
+ "darwin-arm64 does"
+ );
+ check(platformBinaryName("linux", "x64") === "codegraph-server-linux-x64", "linux-x64 does");
+ check(requiredAssets("linux", "arm64").length === 0, "an unpublished pair needs no assets");
+
+ console.log("");
+ console.log(`${failures} failure(s)`);
+ process.exit(failures ? 1 : 0);
+}
+
+run().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/mcp-package/test/wrapper-args.test.js b/mcp-package/test/wrapper-args.test.js
new file mode 100644
index 0000000..267a114
--- /dev/null
+++ b/mcp-package/test/wrapper-args.test.js
@@ -0,0 +1,166 @@
+#!/usr/bin/env node
+// Copyright 2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+"use strict";
+
+/**
+ * Guards the argument handling in bin/codegraph-mcp.js.
+ *
+ * A duplicated `--mcp` made clap exit 2 before the engine emitted any
+ * telemetry; the MCP client respawned into the identical failure and 134
+ * machines produced 656,283 crash events in a month. The wrapper always
+ * supplies `--mcp`, and every doc and example shows `--mcp`, so a user copying
+ * one into their MCP config triggered it deterministically.
+ *
+ * These run the real wrapper against a stub "engine" so the argument contract
+ * is checked without a 116 MB binary or a network.
+ *
+ * Run with `node test/wrapper-args.test.js`.
+ */
+
+const { execFileSync, spawnSync } = require("child_process");
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
+
+const WRAPPER = path.join(__dirname, "..", "bin", "codegraph-mcp.js");
+
+// The crash-loop counter lives under the home directory, and these tests both
+// read and delete it. They get a home of their own: a real one holds a real
+// user's state, and may not even be writable, which would fail the breaker
+// assertion for a reason that has nothing to do with the breaker.
+const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "cg-home-"));
+const LOOP_STATE = path.join(HOME, ".codegraph", "mcp-failures.json");
+
+function cleanup() {
+ fs.rmSync(HOME, { recursive: true, force: true });
+}
+
+let failures = 0;
+function check(ok, message) {
+ console.log((ok ? "PASS " : "FAIL ") + message);
+ if (!ok) failures++;
+}
+
+/**
+ * A stand-in engine that echoes its argv and exits how the test asks.
+ * The wrapper locates the binary by platform name, so the stub takes that name.
+ */
+function stubEngine(dir, exitCode) {
+ const name =
+ os.platform() === "win32"
+ ? "codegraph-server-win32-x64.exe"
+ : `codegraph-server-${os.platform()}-${os.arch() === "arm64" ? "arm64" : "x64"}`;
+ const file = path.join(dir, name);
+ fs.writeFileSync(
+ file,
+ `#!/usr/bin/env node\n` +
+ `process.stderr.write("ARGV:" + JSON.stringify(process.argv.slice(2)) + "\\n");\n` +
+ `process.exit(${exitCode});\n`
+ );
+ fs.chmodSync(file, 0o755);
+ return file;
+}
+
+function runWrapper(clientArgs, exitCode) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cg-wrapper-"));
+ stubEngine(dir, exitCode);
+ try {
+ const result = spawnSync(process.execPath, [WRAPPER, ...clientArgs], {
+ // CODEGRAPH_BIN_DIR is how the wrapper is pointed at a specific engine
+ // directory; falling back to the bundled path would find the real one.
+ // Telemetry is off because these runs fabricate crashes on purpose, and
+ // reporting them would be indistinguishable from the field crash loop
+ // this test exists to prevent.
+ env: {
+ ...process.env,
+ HOME,
+ USERPROFILE: HOME,
+ CODEGRAPH_BIN_DIR: dir,
+ CODEGRAPH_SKIP_MODEL_FETCH: "1",
+ CODEGRAPH_TELEMETRY: "off",
+ },
+ encoding: "utf8",
+ timeout: 20000,
+ input: "",
+ });
+ const stderr = result.stderr || "";
+ const argvLine = stderr.split("\n").find((l) => l.startsWith("ARGV:"));
+ return {
+ argv: argvLine ? JSON.parse(argvLine.slice(5)) : null,
+ stderr,
+ status: result.status,
+ };
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+// The wrapper resolves its engine through findBinary(); if that cannot be
+// redirected by env, these tests cannot isolate and should say so rather than
+// silently exercise the real binary.
+const probe = runWrapper([], 0);
+if (probe.argv === null) {
+ console.log(
+ "SKIP wrapper argument tests - the stub engine was not used " +
+ "(findBinary() ignored CODEGRAPH_BIN_DIR)."
+ );
+ cleanup();
+ process.exit(0);
+}
+
+// --- the bug: a client that also passes --mcp ------------------------
+{
+ const { argv } = runWrapper(["--mcp"], 0);
+ const mcpCount = argv.filter((a) => a === "--mcp").length;
+ check(mcpCount === 1, `client --mcp is not duplicated (saw ${mcpCount})`);
+}
+
+// --- mode flags the wrapper owns are dropped, others survive ---------
+{
+ const { argv } = runWrapper(["--mcp", "--workspace", "/tmp/x"], 0);
+ check(argv.includes("--workspace"), "genuine client args are forwarded");
+ check(argv.includes("/tmp/x"), "their values are forwarded");
+ check(argv.filter((a) => a === "--mcp").length === 1, "only one --mcp reaches the engine");
+}
+
+// --- the wrapper still supplies the mode when the client does not ----
+{
+ const { argv } = runWrapper([], 0);
+ check(argv[0] === "--mcp", "wrapper supplies --mcp when the client omits it");
+}
+
+// --- exit 2 is explained rather than reported as a crash -------------
+{
+ try {
+ fs.unlinkSync(LOOP_STATE);
+ } catch {
+ /* nothing recorded */
+ }
+ const { stderr } = runWrapper(["--whatever"], 2);
+ check(/rejected its arguments/.test(stderr), "exit 2 produces a plain explanation");
+ check(/configuration problem/.test(stderr), "it is named as configuration, not a crash");
+ check(/--mcp --whatever/.test(stderr), "the actual arguments are shown");
+}
+
+// --- repeated identical failures stop being reported -----------------
+{
+ try {
+ fs.unlinkSync(LOOP_STATE);
+ } catch {
+ /* nothing recorded */
+ }
+ let sawBreaker = false;
+ for (let i = 0; i < 3; i++) {
+ const { stderr } = runWrapper(["--whatever"], 2);
+ if (/Not reporting further failures/.test(stderr)) sawBreaker = true;
+ }
+ check(sawBreaker, "the breaker engages within three identical failures");
+}
+
+cleanup();
+
+console.log("");
+console.log(`${failures} failure(s)`);
+process.exit(failures ? 1 : 0);
diff --git a/scripts/fetch-static-model.sh b/scripts/fetch-static-model.sh
index 2f886fd..60d01fa 100755
--- a/scripts/fetch-static-model.sh
+++ b/scripts/fetch-static-model.sh
@@ -3,12 +3,16 @@
# `model` GitHub release (decoupled from versioned releases — bumping the app
# version never requires re-uploading the model).
#
-# Used two ways:
-# • Package time — place the model into the VS Code extension bundle:
-# scripts/fetch-static-model.sh vscode/bin/jina-code-static-256
-# • Manually — populate the server's default resolve path:
+# Populates the server's default resolve path so `--embedding-model static`
+# works without any further configuration:
# scripts/fetch-static-model.sh
#
+# The model is no longer staged into the VS Code extension bundle: the VSIX
+# excludes bin/** (see vscode/.vscodeignore), so a copy placed there would be
+# dropped at package time. The IDE clients leave CODEGRAPH_STATIC_MODEL unset
+# unless the user names a directory, so the default path this script writes is
+# what they pick up - no per-client setting needed.
+#
# Usage: scripts/fetch-static-model.sh [DEST_DIR] [MODEL_NAME]
# DEST_DIR default ~/.codegraph/static_models/
# MODEL_NAME default jina-code-static-256
diff --git a/scripts/package-npm.sh b/scripts/package-npm.sh
index 686e6df..f96d189 100755
--- a/scripts/package-npm.sh
+++ b/scripts/package-npm.sh
@@ -6,7 +6,13 @@
# Run from the repo root after all platform binaries are built.
#
# Usage:
-# ./scripts/package-npm.sh # copy from vscode/bin/
+# The engine is not bundled: it is fetched from the GitHub release at install
+# time by bin/postinstall.js. Publish the release assets first with
+# ./scripts/publish-release-assets.sh, or installs of this version will fail to
+# find an engine.
+#
+# Usage:
+# ./scripts/package-npm.sh # pack only
# ./scripts/package-npm.sh --publish # also publish to npmjs.com
set -euo pipefail
@@ -14,66 +20,38 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PKG_DIR="$REPO_ROOT/mcp-package"
BIN_DIR="$PKG_DIR/bin"
-VSCODE_BIN="$REPO_ROOT/vscode/bin"
-
-BINARIES=(
- "codegraph-server-darwin-arm64"
- "codegraph-server-darwin-x64"
- "codegraph-server-linux-x64"
- "codegraph-server-win32-x64.exe"
-)
+# The package no longer bundles platform binaries. Shipping all four made it
+# 88 MB compressed and 498 MB unpacked so that every user could run exactly one
+# of them; the engine is now published once as release assets and fetched by
+# bin/postinstall.js for the platform doing the installing.
+#
+# Any binary left over in mcp-package/bin/ from an older build is removed here,
+# so a stale one cannot be published by accident.
echo "=== CodeGraph npm package builder ==="
echo ""
-# Step 1: Check that source binaries exist
-MISSING=0
-for bin in "${BINARIES[@]}"; do
- if [ ! -f "$VSCODE_BIN/$bin" ]; then
- echo " ✗ Missing: vscode/bin/$bin"
- MISSING=1
- else
- SIZE=$(du -h "$VSCODE_BIN/$bin" | cut -f1)
- echo " ✓ Found: vscode/bin/$bin ($SIZE)"
+echo "Removing any bundled binaries (the engine is fetched at install time)..."
+for stale in "$BIN_DIR"/codegraph-server-* "$BIN_DIR/onnxruntime.dll"; do
+ if [ -e "$stale" ]; then
+ rm -f "$stale"
+ echo " - removed $(basename "$stale")"
fi
done
-if [ "$MISSING" -eq 1 ]; then
- echo ""
- echo "ERROR: Not all platform binaries are present in vscode/bin/"
- echo "Build missing platforms first. See: scripts/build-all.sh or ~/.claude/cross-platform-builds.md"
- exit 1
-fi
-
-# Step 2: Copy binaries to mcp-package/bin/
+# The fetch path is what every install now depends on, and the wrapper's
+# argument contract is what the crash loop came down to, so both are checked
+# here rather than discovered by the first user to install the package. The
+# package's own `npm test` is the single list of what must pass, so a test added
+# there is not silently skipped by this gate.
echo ""
-echo "Copying binaries to mcp-package/bin/..."
-mkdir -p "$BIN_DIR"
-
-for bin in "${BINARIES[@]}"; do
- cp "$VSCODE_BIN/$bin" "$BIN_DIR/$bin"
- # Set executable on Unix binaries
- if [[ "$bin" != *.exe ]]; then
- chmod +x "$BIN_DIR/$bin"
- fi
-done
-
-# Copy Windows ONNX runtime DLL (required for Windows binary)
-if [ -f "$VSCODE_BIN/onnxruntime.dll" ]; then
- cp "$VSCODE_BIN/onnxruntime.dll" "$BIN_DIR/"
- echo " ✓ Copied onnxruntime.dll"
-elif [ -f "$BIN_DIR/codegraph-server-win32-x64.exe" ]; then
- echo " ⚠ WARNING: Windows binary present but onnxruntime.dll missing!"
- echo " Windows users will fail at runtime without this DLL."
- echo " Copy from Windows build host: C:\\Users\\Administrator\\projects\\codegraph\\target\\release\\onnxruntime.dll"
+echo "Checking the engine fetch and the wrapper arguments..."
+if ! test_log="$( cd "$PKG_DIR" && npm test 2>&1 )"; then
+ printf '%s\n' "$test_log" >&2
+ echo " ✗ package tests FAILED - not packaging" >&2
+ exit 1
fi
-
-# Ensure launcher scripts are executable
-chmod +x "$BIN_DIR/codegraph-mcp.js"
-
-echo ""
-echo "Package contents:"
-ls -lh "$BIN_DIR/"
+echo " ✓ package tests pass"
# Step 3: Verify version consistency
PKG_VERSION=$(node -e "console.log(require('$PKG_DIR/package.json').version)")
diff --git a/scripts/package-vsix.sh b/scripts/package-vsix.sh
index 88e83d7..2566590 100755
--- a/scripts/package-vsix.sh
+++ b/scripts/package-vsix.sh
@@ -2,26 +2,19 @@
# Copyright 2025-2026 Andrey Vasilevsky
# SPDX-License-Identifier: Apache-2.0
#
-# Package VS Code extension with platform-specific binaries.
-# Run from the repo root after all platform binaries are built.
+# Package the VS Code extension.
+#
+# One VSIX serves every platform: the engine is fetched for the machine the
+# extension lands on rather than bundled, so there is nothing platform-specific
+# to package and no per-platform argument to pass.
#
# Usage:
-# ./scripts/package-vsix.sh # all platforms (universal)
-# ./scripts/package-vsix.sh darwin-arm64 # single platform
+# ./scripts/package-vsix.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VSCODE_DIR="$REPO_ROOT/vscode"
-BIN_DIR="$VSCODE_DIR/bin"
-TARGET="${1:-all}"
-
-PLATFORMS=(
- "darwin-arm64:codegraph-server-darwin-arm64"
- "darwin-x64:codegraph-server-darwin-x64"
- "linux-x64:codegraph-server-linux-x64"
- "win32-x64:codegraph-server-win32-x64.exe"
-)
echo "=== CodeGraph VSIX builder ==="
echo ""
@@ -46,30 +39,24 @@ echo "Building extension..."
npm run esbuild-base -- --production
echo ""
-if [ "$TARGET" = "all" ]; then
- # Build platform-specific VSIX for each available binary
- for entry in "${PLATFORMS[@]}"; do
- PLAT="${entry%%:*}"
- BIN="${entry##*:}"
- if [ -f "$BIN_DIR/$BIN" ]; then
- echo "Packaging for $PLAT..."
- npx @vscode/vsce package --target "$PLAT" 2>&1 | grep -E "DONE|ERROR"
- else
- echo " ⚠ Skipping $PLAT (binary not found: bin/$BIN)"
- fi
- done
-
- # Combined VSIX: no --target, includes all 4 platform binaries + the
- # Windows onnxruntime.dll. Useful for manual sideload + as a fallback
- # for marketplace listings that don't yet have platform-targeted
- # distribution wired up.
- echo "Packaging combined (no --target)..."
- npx @vscode/vsce package 2>&1 | grep -E "DONE|ERROR"
-else
- # Single platform
- echo "Packaging for $TARGET..."
- npx @vscode/vsce package --target "$TARGET" 2>&1 | grep -E "DONE|ERROR"
+# One VSIX for every platform. The extension fetches the engine for the
+# machine it lands on (src/engineDownload.ts), so there is nothing
+# platform-specific left to package. Building four targeted VSIXs plus a
+# combined one previously produced a 118 MB artifact in which any given user
+# could run a quarter of the payload.
+#
+# Publish the release assets first with ./scripts/publish-release-assets.sh, or
+# installs of this version will have no engine to fetch.
+echo "Packaging (platform-independent; the engine is fetched at first use)..."
+# Only the DONE/ERROR lines are interesting on success, but a failure has to
+# show everything: filtering vsce through grep alone both hid the reason and,
+# under `set -o pipefail`, turned "no line matched" into a bare exit 1.
+if ! vsce_log="$(npx @vscode/vsce package 2>&1)"; then
+ printf '%s\n' "$vsce_log" >&2
+ echo "ERROR: vsce package failed." >&2
+ exit 1
fi
+printf '%s\n' "$vsce_log" | grep -E "DONE|ERROR" || true
echo ""
echo "VSIX packages:"
diff --git a/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh
new file mode 100755
index 0000000..7590ad2
--- /dev/null
+++ b/scripts/publish-release-assets.sh
@@ -0,0 +1,222 @@
+#!/bin/bash
+# Copyright 2026 Andrey Vasilevsky
+# SPDX-License-Identifier: Apache-2.0
+#
+# Publish the per-platform engine binaries as GitHub release assets.
+#
+# No channel bundles engines any more. Shipping all four platform binaries meant
+# a 118 MB VSIX and a 498 MB npm package for the ~30 MB a given user can
+# actually run, and the JetBrains Marketplace cannot ship per-platform artifacts
+# at all. The binaries are published here once, and each client fetches only
+# what it needs - which makes this release the single source of engines, so a
+# missing or mistagged one leaves every channel with no engine at all.
+#
+# This does not build anything. It uploads what ./scripts/package-*.sh already
+# expect to find in vscode/bin/, so it slots in after the existing
+# cross-platform build (see cross-platform-builds.md).
+#
+# Usage:
+# ./scripts/publish-release-assets.sh # stage + verify only
+# ./scripts/publish-release-assets.sh --publish # upload to GitHub
+#
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+# Overridable so CI (and the refusal path's own test) can point at a different
+# staging location without editing this script.
+VSCODE_BIN="${CODEGRAPH_BIN_DIR:-$REPO_ROOT/vscode/bin}"
+STAGE_DIR="$REPO_ROOT/target/release-assets"
+REPO="codegraph-ai/CodeGraph"
+
+# The engine's own version is the one that matters here - these are engine
+# binaries, and every client asks for them by the engine version it pins.
+VERSION="$(grep -m1 '^version' "$REPO_ROOT/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/' || true)"
+if [ -z "$VERSION" ]; then
+ echo "ERROR: no 'version = \"...\"' line in $REPO_ROOT/Cargo.toml." >&2
+ echo "Every asset and every client pin is keyed on it, so nothing can be" >&2
+ echo "published without it." >&2
+ exit 1
+fi
+TAG="v${VERSION}"
+
+BINARIES=(
+ "codegraph-server-darwin-arm64"
+ "codegraph-server-darwin-x64"
+ "codegraph-server-linux-x64"
+ "codegraph-server-win32-x64.exe"
+)
+
+# The Windows engine loads this at runtime. Shipping the exe without it gives
+# users a download that succeeds and then fails at startup, which is a worse
+# outcome than no download at all - so it is treated as required, not optional.
+WINDOWS_SIDECAR="onnxruntime.dll"
+
+echo "CodeGraph release assets"
+echo " version: $VERSION"
+echo " tag: $TAG"
+echo " repo: $REPO"
+echo
+
+# ---------------------------------------------------------------- pins
+# Each client hard-codes the engine release it fetches, so that a client-only
+# patch release does not start asking for a tag that was never published. That
+# only works while the pins agree with the engine being published here: a pin
+# ahead of this tag 404s on every fresh install, and one behind it silently
+# installs the previous engine. Checked before anything is uploaded, because
+# after the fact the only symptom is "CodeGraph has no engine".
+PIN_SOURCES=(
+ "mcp-package/bin/fetch-engine.js|npm + VS Code"
+ "jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt|JetBrains"
+)
+
+pin_mismatch=0
+for entry in "${PIN_SOURCES[@]}"; do
+ file="${entry%%|*}"
+ label="${entry##*|}"
+ pin="$(grep -m1 'ENGINE_VERSION = "' "$REPO_ROOT/$file" | sed 's/.*"\(.*\)".*/\1/' || true)"
+ if [ "$pin" = "$VERSION" ]; then
+ printf ' ✓ %-14s pins engine %s\n' "$label" "$pin"
+ else
+ printf ' ✗ %-14s pins engine %s, not %s (%s)\n' \
+ "$label" "${pin:-}" "$VERSION" "$file"
+ pin_mismatch=1
+ fi
+done
+
+if [ "$pin_mismatch" -ne 0 ]; then
+ cat >&2 </dev/null | awk '{print $1}' | tr '\n' ' ' || true)"
+ printf ' ✗ %-36s %s NOT STAMPED %s\n' "$bin" "$size" "${recorded:+(manifest says: $recorded)}"
+ stale=1
+ fi
+done
+
+if [ "$missing" -ne 0 ]; then
+ cat >&2 <&2 <
+
+Publishing an unverified binary would produce a release whose checksums are
+perfectly valid for the wrong build - the hardest kind of mistake to notice.
+EOF
+ exit 1
+fi
+
+# ---------------------------------------------------------------- checksums
+# An engine binary runs on the user's machine with their permissions, so the
+# client verifies what it downloaded. TLS alone does not cover a mirror, a
+# proxy, or a truncated transfer.
+echo
+echo "Staging with checksums in ${STAGE_DIR#"$REPO_ROOT"/} ..."
+rm -rf "$STAGE_DIR"
+mkdir -p "$STAGE_DIR"
+
+for bin in "${BINARIES[@]}" "$WINDOWS_SIDECAR"; do
+ cp "$VSCODE_BIN/$bin" "$STAGE_DIR/$bin"
+ # `shasum -a 256` and `sha256sum` produce the same "" format;
+ # the clients read the first field.
+ ( cd "$STAGE_DIR" && shasum -a 256 "$bin" > "$bin.sha256" )
+ printf ' %s %s\n' "$(cut -c1-16 < "$STAGE_DIR/$bin.sha256")" "$bin"
+done
+
+if [ "${1:-}" != "--publish" ]; then
+ echo
+ echo "Staged only. Re-run with --publish to upload to $REPO."
+ exit 0
+fi
+
+# ---------------------------------------------------------------- publish
+if ! command -v gh >/dev/null 2>&1; then
+ echo "ERROR: the GitHub CLI (gh) is required to publish." >&2
+ exit 1
+fi
+
+echo
+if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
+ echo "Release $TAG exists; uploading assets (--clobber replaces same-named files)."
+else
+ echo "Creating release $TAG."
+ gh release create "$TAG" \
+ --repo "$REPO" \
+ --title "CodeGraph $VERSION" \
+ --notes "Engine binaries for CodeGraph $VERSION.
+
+Each binary has a matching \`.sha256\`. Clients that download an engine are
+expected to verify it before running.
+
+Windows additionally requires \`onnxruntime.dll\` alongside the executable."
+fi
+
+gh release upload "$TAG" --repo "$REPO" --clobber "$STAGE_DIR"/*
+
+echo
+echo "Published $TAG:"
+gh release view "$TAG" --repo "$REPO" --json assets \
+ --jq '.assets[] | " \(.name) \(.size) bytes"'
diff --git a/scripts/stamp-binary.sh b/scripts/stamp-binary.sh
new file mode 100755
index 0000000..e51a89e
--- /dev/null
+++ b/scripts/stamp-binary.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+# Copyright 2026 Andrey Vasilevsky
+# SPDX-License-Identifier: Apache-2.0
+#
+# Record which version a staged engine binary was built from.
+#
+# vscode/bin/ is a staging directory that is not cleaned between releases, so a
+# binary left over from an earlier version is indistinguishable from a fresh
+# one by inspection. Scraping the version out of the image does not work
+# reliably across targets - the engine does not store it as a standalone string
+# everywhere, and good binaries get reported as stale.
+#
+# So provenance is recorded at the point where it is actually known: whoever
+# stages a binary states what produced it. publish-release-assets.sh refuses to
+# publish anything not stamped for the version being released.
+#
+# Usage:
+# ./scripts/stamp-binary.sh codegraph-server-linux-x64 0.20.0
+#
+set -euo pipefail
+
+if [ $# -ne 2 ]; then
+ echo "usage: $(basename "$0") " >&2
+ exit 2
+fi
+
+BINARY="$1"
+VERSION="$2"
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+BIN_DIR="${CODEGRAPH_BIN_DIR:-$REPO_ROOT/vscode/bin}"
+MANIFEST="$BIN_DIR/BUILD-MANIFEST"
+
+if [ ! -f "$BIN_DIR/$BINARY" ]; then
+ echo "ERROR: $BIN_DIR/$BINARY does not exist - stage the binary first." >&2
+ exit 1
+fi
+
+touch "$MANIFEST"
+# One line per binary: re-stamping replaces the previous entry rather than
+# appending, so the manifest can never claim two versions for one file.
+tmp="$(mktemp)"
+grep -vF " $BINARY" "$MANIFEST" > "$tmp" 2>/dev/null || true
+printf '%s %s\n' "$VERSION" "$BINARY" >> "$tmp"
+sort -k2 "$tmp" > "$MANIFEST"
+rm -f "$tmp"
+
+echo "Stamped $BINARY as $VERSION"
+cat "$MANIFEST"
diff --git a/vscode/.vscodeignore b/vscode/.vscodeignore
index c246f73..3b37bfa 100644
--- a/vscode/.vscodeignore
+++ b/vscode/.vscodeignore
@@ -1,9 +1,12 @@
# Include only what the extension needs:
# package.json, README.md, CHANGELOG.md, LICENSE
# out/extension.js (compiled TS)
-# bin/* (platform binaries)
# images/* (icons)
+# The engine is fetched at first use, not bundled - see src/engineDownload.ts.
+# Any binary left in bin/ from a local build must not reach the marketplace.
+bin/**
+
# Exclude everything else
**/.git/**
**/.github/**
diff --git a/vscode/README.md b/vscode/README.md
index a2a53bf..bf6f1d3 100644
--- a/vscode/README.md
+++ b/vscode/README.md
@@ -4,7 +4,7 @@
[](LICENSE)
-CodeGraph builds a semantic graph of your codebase — functions, classes, imports, call chains — and exposes it through **45 MCP tools**, a **VS Code extension**, and a **persistent memory layer**. Parses **37 languages** via tree-sitter. AI agents get structured code understanding instead of grepping through files.
+CodeGraph builds a semantic graph of your codebase — functions, classes, imports, call chains — and exposes it through **42 MCP tools**, a **VS Code extension**, and a **persistent memory layer**. Parses **38 languages** via tree-sitter. AI agents get structured code understanding instead of grepping through files.
## Quick Start
@@ -27,13 +27,18 @@ The server indexes the current working directory automatically.
### VS Code Extension
-Install the VSIX:
+Install from the marketplace, or sideload the VSIX:
```bash
-code --install-extension codegraph-0.14.0.vsix
+code --install-extension codegraph-0.20.0.vsix
```
-The extension starts the server automatically and registers all tools as Language Model Tools for Copilot.
+One VSIX serves every platform.
+The analysis engine is not bundled: on first activation the extension offers to download the engine built for your platform, verifies it against the published checksum, and installs it into `~/.codegraph/bin`.
+The download is offered rather than performed automatically, because it is a native binary that runs with your permissions - decline it and run **CodeGraph: Download Analysis Engine** from the command palette whenever you are ready.
+
+Once an engine is present, the extension starts it automatically and registers all tools as Language Model Tools for Copilot.
+CodeGraph's Symbols and Memories views live in the CodeGraph activity-bar container, and inline CodeLens above each function reports callers, related tests and complexity (`codegraph.codeLens.enabled` / `codegraph.hover.enabled` turn those off).
---
@@ -45,7 +50,7 @@ The extension starts the server automatically and registers all tools as Languag
|------|---------|-------------|
| `--workspace ` | current dir | Directories to index (repeatable for multi-project) |
| `--exclude ` | — | Directories to skip (repeatable) |
-| `--embedding-model ` | `bge-small` | `bge-small` (384d, fast), `jina-code-v2` (768d, 6x slower), or `static` (model2vec, 256d — ~100× faster indexing, no ONNX, ~90% of BGE quality in hybrid search; the model ships bundled in the extension, so no setup is needed) |
+| `--embedding-model ` | `bge-small` | `bge-small` (384d, fast), `jina-code-v2` (768d, 6x slower), or `static` (model2vec, 256d — ~100× faster indexing, no ONNX, ~90% of BGE quality in hybrid search; needs a local model directory, see `codegraph.staticModelPath` below) |
| `--full-body-embedding` | `true` | Embed full function body (~50 lines) for better semantic search and duplicate detection |
| `--max-files ` | 5000 | Maximum files to index |
@@ -56,128 +61,54 @@ The extension starts the server automatically and registers all tools as Languag
"codegraph.indexOnStartup": true,
"codegraph.indexPaths": ["/path/to/project-a", "/path/to/project-b"],
"codegraph.excludePatterns": ["**/cmake-build-debug/**", "**/generated/**"],
- "codegraph.embeddingModel": "bge-small", // or "static" for ~100× faster indexing (model bundled, no path needed)
- "codegraph.staticModelPath": "", // optional: override the bundled model2vec dir
+ "codegraph.embeddingModel": "bge-small", // or "static" for ~100× faster indexing
+ "codegraph.staticModelPath": "", // only to override the default model2vec model dir
"codegraph.maxFileSizeKB": 1024,
+ "codegraph.codeLens.enabled": true, // caller / test / complexity counts above functions
+ "codegraph.hover.enabled": true, // the same stats on hover
"codegraph.debug": false
}
```
+The static (model2vec) model is not bundled with the extension.
+The engine looks for it in `~/.codegraph/static_models/jina-code-static-256`, which is where the `@astudioplus/codegraph-mcp` npm install puts it, so a model in that location needs no setting at all.
+Set `codegraph.staticModelPath` only to point at a model somewhere else - for instance one you distilled yourself with [`scripts/distill_static_model.py`](https://github.com/codegraph-ai/codegraph/blob/main/scripts/distill_static_model.py).
+
Full-body embeddings are enabled by default. Function body text is captured at parse time with zero I/O overhead.
Built-in exclusions (always skipped): `node_modules`, `target`, `dist`, `build`, `out`, `.git`, `__pycache__`, `vendor`, `DerivedData`, `tmp`, `coverage`, `logs`.
---
-## Tools (34 community + 27 pro, 17 security)
-
-### Code Analysis (11)
-
-| Tool | What it does |
-|------|-------------|
-| `get_ai_context` | **Primary context tool.** Intent-aware (explain/modify/debug/test) with token budgeting. Returns source, related symbols, imports, siblings, debug hints. |
-| `get_edit_context` | Everything needed before editing: source + callers + tests + memories + git history |
-| `get_curated_context` | Cross-codebase context for a natural language query ("how does auth work?") |
-| `analyze_impact` | Blast radius prediction — what breaks if you modify, delete, or rename |
-| `analyze_complexity` | Cyclomatic complexity with breakdown (branches, loops, nesting, exceptions, early returns) |
-| `find_circular_deps` | Detect circular import/dependency chains across files |
-| `find_hot_paths` | Most-called functions ranked by transitive caller count |
-| `find_dead_imports` | Find unused imports — modules imported but never referenced |
-| `get_module_summary` | High-level summary of a directory: file count, functions, language breakdown, top complex functions |
-| `search_by_pattern` | Regex search across function bodies, signatures, names, and docstrings |
-| `search_by_error` | Find functions that throw, catch, or handle specific error types |
-
-### Code Navigation (13)
-
-| Tool | What it does |
-|------|-------------|
-| `symbol_search` | Find symbols by name or natural language (hybrid BM25 + semantic search) |
-| `get_callers` / `get_callees` | Who calls this? What does it call? (with transitive depth) |
-| `get_detailed_symbol` | Full symbol info: source, callers, callees, complexity |
-| `get_symbol_info` | Quick metadata: signature, visibility, kind |
-| `get_dependency_graph` | File/module import relationships with depth control |
-| `get_call_graph` | Function call chains (callers and callees) |
-| `find_by_imports` | Find files importing a module |
-| `find_by_signature` | Search by param count, return type, modifiers |
-| `find_entry_points` | Main functions, HTTP handlers, CLI commands, event handlers |
-| `find_implementors` | Find all functions registered as ops struct callbacks |
-| `find_related_tests` | Tests that exercise a given function |
-| `traverse_graph` | Custom graph traversal with edge/node type filters |
-
-### Indexing (3)
-
-| Tool | What it does |
-|------|-------------|
-| `reindex_workspace` | Full or incremental workspace reindex |
-| `index_files` | Add/update specific files without full reindex |
-| `index_directory` | Add directory to graph alongside existing data |
-
-### Memory (7)
-
-Persistent AI context across sessions — debugging insights, architectural decisions, known issues.
-
-| Tool | What it does |
-|------|-------------|
-| `memory_store` / `memory_get` / `memory_search` | Store, retrieve, search memories (BM25 + semantic) |
-| `memory_context` | Get memories relevant to a file/function |
-| `memory_list` / `memory_invalidate` / `memory_stats` | Browse, retire, monitor |
-
-All tool names are prefixed with `codegraph_` (e.g. `codegraph_get_ai_context`). Tools that target a specific symbol accept `uri` + `line` or `nodeId` from `symbol_search` results.
-
-### CodeGraph Pro
-
-Additional tools available in [CodeGraph Pro](https://codegraph.astudioplus.com/pro):
-
-| Tool | What it does |
-|------|-------------|
-| `scan_security` | Security vulnerability scan: 40+ dangerous function patterns, source-to-sink taint tracing, auth coverage for HTTP endpoints (7 languages/frameworks), architectural layer violations, weak crypto, hardcoded secrets |
-| `analyze_coupling` | Module coupling metrics and instability scores |
-| `find_unused_code` | Dead code detection with confidence scoring |
-| `find_duplicates` | Detect duplicate/near-duplicate functions |
-| `find_similar` / `cluster_symbols` / `compare_symbols` | Embedding-based code similarity |
-| `cross_project_search` | Search across all indexed projects |
-| `mine_git_history` / `mine_git_history_for_file` / `search_git_history` | Git history mining and semantic search |
-| `security_control_flow` | Map every execution path through a function — "can this return without hitting the auth check?" |
-| `security_trace_data_flow` | Follow a variable from birth to death — "does user input reach this SQL query?" |
-| `security_generate_sbom` | CycloneDX SBOM from 8 lockfile formats |
-| `security_audit_deps` | OSV vulnerability check on dependencies |
-| `security_check_unchecked_returns` / `_resource_leaks` / `_misconfig` / `_input_validation` / `_error_exposure` | 5 heuristic analyzers covering ~80% of CWE Top 25 |
-| `security_scan_iac` | Docker / Kubernetes / Terraform misconfiguration scan |
-| `security_check_licenses` | Lockfile license policy enforcement (copyleft detection) |
-| `security_check_secrets_entropy` | Shannon-entropy hardcoded-secret detection |
-| `security_detect_injection` | Focused SQL/XSS/cmd/path/deser/template injection detection (20 patterns) |
-| `security_check_search_path` | Untrusted search-path / DLL-hijacking detection (CWE-426/CWE-427) |
-| `security_check_crypto` | Cryptographic misuse: weak ciphers/hashes/PRNG/keys, static IVs, timing-leak comparisons (CWE-208/326-330/338/916, 35 patterns) |
-| `security_export_sarif` | Aggregate findings as SARIF 2.1.0 (GitHub Code Scanning, GitLab SAST) |
-
-**Cross-cutting features (all `security_check_*` tools):**
-- `include_tests` / `treat_as_production` — first-class skip for tests/samples/vendored
-- `check_compile_gates` — C/C++ findings inside `#ifdef X` are marked DEFENSIVE_GATED_OFF when X isn't defined by CMake/Cargo/Makefile
-- 25-marker suppression honoring (`# nosec`, `// NOLINT`, etc.) at line and function level
-- Telemetry blocks: `path_filter` + `compile_gate` for transparent triage
+## Tools
+
+**42 community tools**, plus 27 more (17 of them security analyzers) in [CodeGraph Pro](https://codegraph.astudioplus.com/pro).
+All names are prefixed with `codegraph_` (e.g. `codegraph_get_ai_context`); tools that target a symbol accept `uri` + `line`, or a `nodeId` from `symbol_search` results.
+
+| Category | Count | What's in it |
+|---|---|---|
+| Code analysis | 11 | AI/edit/curated context, impact, complexity, circular deps, hot paths, module summary |
+| Search | 8 | Symbol search (BM25 + semantic), by imports/signature/pattern/error, entry points, traversal |
+| Navigation | 3 | Callers, callees, detailed symbol |
+| Memory | 7 | Store, get, search, context, list, invalidate, stats |
+| Documentation | 7 | Markdown indexing, doc search + sources, design verification, architecture docs |
+| Indexing | 3 | Reindex workspace, index files, index directory |
+| PR analysis | 1 | One-call review context for a change |
+| Dead imports / ops structs | 2 | Unused imports, ops-struct callback implementors |
+
+→ **[Full tool reference](https://github.com/codegraph-ai/codegraph#tools)** — every tool with its description, and the pro/security surface.
---
## Languages
-31 languages parsed via tree-sitter — functions, classes, imports, call graph, complexity metrics, dependency graphs, symbol search, and impact analysis:
-
-| Category | Languages |
-|---|---|
-| **Systems** | C, C++, Rust, Zig, Objective-C |
-| **JVM** | Java, Kotlin, Scala, Groovy, Clojure |
-| **Web/Scripting** | TypeScript/JS, Python, Ruby, PHP, Perl, Lua, Elixir, Elm |
-| **Web/Style** | CSS |
-| **Mobile** | Swift, Dart |
-| **Functional** | Haskell, OCaml, Julia, Erlang, Elm, Clojure |
-| **Enterprise** | C#, COBOL, Fortran, Go |
-| **Blockchain** | Solidity |
-| **Shell/Config** | Bash, HCL/Terraform, TOML, YAML |
-| **Hardware** | Verilog/SystemVerilog, Tcl |
-| **Data Science** | R, Julia |
+**38 languages** parsed via tree-sitter — functions, classes, imports, call graph, complexity metrics, dependency graphs, symbol search, and impact analysis.
+Systems (C, C++, Rust, Zig, Objective-C), JVM (Java, Kotlin, Scala, Groovy, Clojure), web/scripting (TypeScript/JS, Python, Ruby, PHP, Perl, Lua, Elixir, Elm, CSS), mobile (Swift, Dart), functional (Haskell, OCaml, Julia, Erlang), enterprise (C#, COBOL, Fortran, Go), Solidity, shell/config (Bash, Dockerfile, HCL/Terraform, TOML, YAML), hardware (Verilog/SystemVerilog, Tcl) and R.
HTTP handler detection: Python (FastAPI/Flask/Django), TypeScript (NestJS), Java (Spring/JAX-RS), Go (stdlib/Gin/Echo/Fiber), C# (ASP.NET), Ruby (Rails), PHP (Laravel/Symfony).
+→ **[Full language table](https://github.com/codegraph-ai/codegraph#languages)**, including which languages need the `extra-languages` build.
+
---
## Architecture
@@ -192,7 +123,7 @@ MCP Client (Claude, Cursor, ...) VS Code Extension
┌─────────────────────────────┐
│ codegraph-server │
├─────────────────────────────┤
- │ 37 tree-sitter parsers │
+ │ 38 tree-sitter parsers │
│ Semantic graph engine │
│ AI query engine (BM25) │
│ Memory layer (RocksDB) │
diff --git a/vscode/media/codegraph-activitybar.svg b/vscode/media/codegraph-activitybar.svg
new file mode 100644
index 0000000..58de97d
--- /dev/null
+++ b/vscode/media/codegraph-activitybar.svg
@@ -0,0 +1,9 @@
+
diff --git a/vscode/media/walkthrough/ai.md b/vscode/media/walkthrough/ai.md
new file mode 100644
index 0000000..a45a7ac
--- /dev/null
+++ b/vscode/media/walkthrough/ai.md
@@ -0,0 +1,15 @@
+# Give your AI assistant the graph
+
+CodeGraph registers a set of **language-model tools**, so an AI assistant in
+your editor can query the graph directly - callers, dependencies, impact,
+related tests, and curated context - instead of guessing from a few open files.
+
+Ask your assistant things like:
+
+- "What breaks if I change the signature of `parseConfig`?"
+- "Show me the tests related to this module."
+- "What are the entry points into this service?"
+
+It answers from your actual code graph, grounded in the index you just built.
+
+No setup needed - the tools are available as soon as your workspace is indexed.
diff --git a/vscode/media/walkthrough/callgraph.md b/vscode/media/walkthrough/callgraph.md
new file mode 100644
index 0000000..8ae10f2
--- /dev/null
+++ b/vscode/media/walkthrough/callgraph.md
@@ -0,0 +1,15 @@
+# Visualize the call graph
+
+Put your cursor on any function and run **Show Call Graph** to see an
+interactive diagram of what it calls and what calls it, several levels deep.
+
+Use it to:
+
+- Trace how a request flows through the system
+- Find every path that reaches a function before you change it
+- Spot tightly-coupled hotspots worth refactoring
+
+You can also run **Show Dependency Graph** for a module-level view, or
+**Analyze Impact** to preview the blast radius of an edit.
+
+Put your cursor in a function and click **Show Call Graph** to try it.
diff --git a/vscode/media/walkthrough/explore.md b/vscode/media/walkthrough/explore.md
new file mode 100644
index 0000000..59c2abb
--- /dev/null
+++ b/vscode/media/walkthrough/explore.md
@@ -0,0 +1,16 @@
+# Explore your code as a graph
+
+Open the **CodeGraph Symbols** view in the Explorer sidebar to browse every
+function, class, and module the index found. Click any symbol to jump straight
+to its definition.
+
+From a symbol you can pivot through the graph:
+
+- **Callers** - everything that calls this function
+- **Callees** - everything this function calls
+- **Dependencies** - modules this file imports, and who imports it
+
+This is the fastest way to understand unfamiliar code: start at one symbol and
+follow the edges instead of grepping.
+
+Click **Explore Symbols** to open the view.
diff --git a/vscode/media/walkthrough/index.md b/vscode/media/walkthrough/index.md
new file mode 100644
index 0000000..6e59141
--- /dev/null
+++ b/vscode/media/walkthrough/index.md
@@ -0,0 +1,15 @@
+# Index your workspace
+
+CodeGraph parses your code into a **local graph** of symbols, calls, imports,
+and dependencies. Everything runs on your machine - no code leaves it.
+
+Indexing takes a few seconds on a small repo and scales to large monorepos.
+
+**What you get once it's indexed:**
+
+- Jump to any symbol and see its callers, callees, and dependencies
+- One-click impact analysis before you change a function
+- Complexity and dead-code signals inline
+- The same graph powers your AI assistant's answers about the codebase
+
+Click **Index Workspace** below to build the graph.
diff --git a/vscode/package.json b/vscode/package.json
index 128d2bc..309b9e4 100644
--- a/vscode/package.json
+++ b/vscode/package.json
@@ -2,7 +2,7 @@
"name": "codegraph",
"displayName": "CodeGraph",
"description": "Cross-language code intelligence powered by graph analysis",
- "version": "0.19.1",
+ "version": "0.20.0",
"publisher": "aStudioPlus",
"author": "Andrey Vasilevsky ",
"license": "Apache-2.0",
@@ -75,6 +75,11 @@
"title": "Reindex Workspace",
"category": "CodeGraph"
},
+ {
+ "command": "codegraph.openWalkthrough",
+ "title": "Open Getting Started Walkthrough",
+ "category": "CodeGraph"
+ },
{
"command": "codegraph.debugTools",
"title": "Debug Language Model Tools",
@@ -136,6 +141,11 @@
"command": "codegraph.indexDirectory",
"title": "Index Directory",
"category": "CodeGraph"
+ },
+ {
+ "command": "codegraph.downloadEngine",
+ "title": "Download Analysis Engine",
+ "category": "CodeGraph"
}
],
"configuration": {
@@ -201,6 +211,18 @@
"scope": "resource",
"description": "Index workspace on startup. When false, use 'Index Directory' command to index specific folders on demand."
},
+ "codegraph.codeLens.enabled": {
+ "type": "boolean",
+ "default": true,
+ "scope": "resource",
+ "description": "Show inline CodeLens above functions with caller count, related test count, and cyclomatic complexity. Click to open the call graph."
+ },
+ "codegraph.hover.enabled": {
+ "type": "boolean",
+ "default": true,
+ "scope": "resource",
+ "description": "Show CodeGraph stats (callers, tests, complexity) when hovering a function declaration."
+ },
"codegraph.maxFileSizeKB": {
"type": "number",
"default": 1024,
@@ -282,7 +304,7 @@
"enumDescriptions": [
"BGE-Small-EN-v1.5 (384d) — fast, good quality with full-body embeddings. ~127MB download.",
"Jina Code V2 (768d) — 6x slower indexing, no quality advantage with full-body embeddings. ~642MB download.",
- "Static (model2vec, 256d) — ~100x faster indexing, no ONNX runtime or 1.5GB RAM gate, ~90% of BGE quality in hybrid search. Requires a local model dir (codegraph.staticModelPath or CODEGRAPH_STATIC_MODEL env)."
+ "Static (model2vec, 256d) — ~100x faster indexing, no ONNX runtime or 1.5GB RAM gate, ~90% of BGE quality in hybrid search. Needs a local model dir: ~/.codegraph/static_models/jina-code-static-256 by default, or codegraph.staticModelPath."
],
"description": "Embedding model for semantic search and code similarity"
},
@@ -290,7 +312,7 @@
"type": "string",
"default": "",
"scope": "resource",
- "description": "Directory of the model2vec static model (config.json + tokenizer.json + model.safetensors) used when embeddingModel is 'static'. Empty defaults to ~/.codegraph/static_models/jina-code-static-256. Distill one with scripts/distill_static_model.py."
+ "description": "Directory of the model2vec static model (config.json + tokenizer.json + model.safetensors). Only set this to use a model outside the default location: left empty, the engine resolves ~/.codegraph/static_models/jina-code-static-256, which is where installing @astudioplus/codegraph-mcp from npm puts it. The model is not bundled with the extension - distill one with scripts/distill_static_model.py if you have neither."
},
"codegraph.fullBodyEmbedding": {
"type": "boolean",
@@ -1672,25 +1694,94 @@
}
]
},
+ "viewsContainers": {
+ "activitybar": [
+ {
+ "id": "codegraph",
+ "title": "CodeGraph",
+ "icon": "media/codegraph-activitybar.svg"
+ }
+ ]
+ },
"views": {
- "explorer": [
+ "codegraph": [
{
"id": "codegraphSymbols",
- "name": "CodeGraph Symbols",
+ "name": "Symbols",
"when": "codegraph.enabled"
},
{
"id": "codegraphMemories",
- "name": "CodeGraph Memories",
+ "name": "Memories",
"when": "codegraph.enabled"
}
]
},
"viewsWelcome": [
+ {
+ "view": "codegraphSymbols",
+ "when": "!codegraph.indexed",
+ "contents": "CodeGraph turns your codebase into a searchable graph - callers, dependencies, impact, and complexity.\n[Index Workspace](command:codegraph.reindex)\n[Open Walkthrough](command:codegraph.openWalkthrough)\nIndexing runs locally; nothing leaves your machine."
+ },
+ {
+ "view": "codegraphSymbols",
+ "when": "codegraph.indexed",
+ "contents": "No symbols to show here yet. Open a source file to browse its symbols, or [reindex the workspace](command:codegraph.reindex)."
+ },
{
"view": "codegraphMemories",
"contents": "No memories stored yet.\n[Store Memory](command:codegraph.storeMemory)\n[Mine Git History](command:codegraph.mineGitHistory)"
}
+ ],
+ "walkthroughs": [
+ {
+ "id": "codegraph.gettingStarted",
+ "title": "Get Started with CodeGraph",
+ "description": "Turn your codebase into a queryable graph - then explore it and hand it to your AI assistant.",
+ "steps": [
+ {
+ "id": "index",
+ "title": "Index your workspace",
+ "description": "Build a local graph of your code.\n[Index Workspace](command:codegraph.reindex)",
+ "media": {
+ "markdown": "media/walkthrough/index.md"
+ },
+ "completionEvents": [
+ "onContext:codegraph.indexed"
+ ]
+ },
+ {
+ "id": "explore",
+ "title": "Explore symbols",
+ "description": "Browse and pivot through your code as a graph.\n[Explore Symbols](command:codegraphSymbols.focus)",
+ "media": {
+ "markdown": "media/walkthrough/explore.md"
+ },
+ "completionEvents": [
+ "onCommand:codegraphSymbols.focus"
+ ]
+ },
+ {
+ "id": "callgraph",
+ "title": "Visualize the call graph",
+ "description": "See what calls what, several levels deep.\n[Show Call Graph](command:codegraph.showCallGraph)",
+ "media": {
+ "markdown": "media/walkthrough/callgraph.md"
+ },
+ "completionEvents": [
+ "onCommand:codegraph.showCallGraph"
+ ]
+ },
+ {
+ "id": "ai",
+ "title": "Power your AI assistant",
+ "description": "Let your AI assistant query the graph directly.",
+ "media": {
+ "markdown": "media/walkthrough/ai.md"
+ }
+ }
+ ]
+ }
]
},
"scripts": {
diff --git a/vscode/src/ai/toolManager.ts b/vscode/src/ai/toolManager.ts
index 1f180a4..1dd72b7 100644
--- a/vscode/src/ai/toolManager.ts
+++ b/vscode/src/ai/toolManager.ts
@@ -29,6 +29,7 @@ import {
MemoryStatsResponse,
} from '../types';
import { describeArgShape, type Reporter } from '../telemetry/reporter';
+import { handleIndexOutcome, filesIndexed, reportIndexTelemetry } from '../funnel';
/**
* Map an LSP command name to the corresponding language-model tool name.
@@ -68,7 +69,11 @@ export class CodeGraphToolManager {
return this._lastToolName;
}
- constructor(private client: LanguageClient, private reporter?: Reporter) {}
+ constructor(
+ private client: LanguageClient,
+ private reporter?: Reporter,
+ private context?: vscode.ExtensionContext,
+ ) {}
/**
* Check if workspace is indexed. Prompt to index on first tool use.
@@ -108,8 +113,28 @@ export class CodeGraphToolManager {
{ command: 'codegraph.reindexWorkspace', arguments: [{}] },
);
this.isIndexed = true;
- this.reportIndexCompleted(startedAt, result);
- vscode.window.showInformationMessage(`Indexed ${result?.files_indexed ?? 0} files`);
+ reportIndexTelemetry(this.reporter, startedAt, result);
+ const fileCount = filesIndexed(result);
+ if (this.context) {
+ // Agent-driven index: sync the codegraph.indexed
+ // context key and (for a zero-file result) show
+ // recovery, but don't steer to a surface mid-task.
+ // handleIndexOutcome shows its prompts fire-and-
+ // forget, so this await never blocks on user input.
+ const action = await handleIndexOutcome(
+ this.context,
+ this.reporter,
+ fileCount,
+ { offerSurfaceCta: false },
+ );
+ if (action === 'none' && fileCount > 0) {
+ vscode.window.showInformationMessage(
+ `CodeGraph: Indexed ${fileCount.toLocaleString()} file${fileCount === 1 ? '' : 's'}`,
+ );
+ }
+ } else {
+ vscode.window.showInformationMessage(`Indexed ${fileCount} files`);
+ }
} catch (err) {
this.reporter?.indexCompleted({
outcome: 'error',
@@ -124,23 +149,6 @@ export class CodeGraphToolManager {
}
}
- /** Map a reindex RPC response → `index.completed` + `index.languageBreakdown`. */
- private reportIndexCompleted(localStartedAt: number, result: any): void {
- const fileCount = typeof result?.files_indexed === 'number' ? result.files_indexed : 0;
- const durationMs =
- typeof result?.duration_ms === 'number'
- ? Number(result.duration_ms)
- : Date.now() - localStartedAt;
- this.reporter?.indexCompleted({ outcome: 'ok', durationMs, fileCount });
- const byLanguage = result?.by_language;
- if (byLanguage && typeof byLanguage === 'object') {
- const map = new Map();
- for (const [lang, count] of Object.entries(byLanguage)) {
- if (typeof count === 'number') map.set(lang as any, count);
- }
- if (map.size > 0) this.reporter?.indexLanguageBreakdown(map as any);
- }
- }
/**
* Execute an LSP command with a small retry/backoff to smooth over transient timeouts.
diff --git a/vscode/src/commands/index.ts b/vscode/src/commands/index.ts
index e6662cc..40e9a94 100644
--- a/vscode/src/commands/index.ts
+++ b/vscode/src/commands/index.ts
@@ -16,6 +16,7 @@ import {
} from '../types';
import { GraphVisualizationPanel } from '../views/graphPanel';
import type { Reporter } from '../telemetry/reporter';
+import { handleIndexOutcome, filesIndexed, reportIndexTelemetry } from '../funnel';
// Define custom request types (used for LSP type inference)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -253,26 +254,54 @@ export function registerCommands(
// Reindex Workspace
safeRegisterCommand('codegraph.reindex', async () => {
+ const startedAt = Date.now();
try {
- await vscode.window.withProgress(
+ const result = await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'CodeGraph: Reindexing workspace...',
cancellable: false,
},
async () => {
- await client.sendRequest('workspace/executeCommand', {
+ return await client.sendRequest('workspace/executeCommand', {
command: 'codegraph.reindexWorkspace',
arguments: []
});
}
);
- vscode.window.showInformationMessage('CodeGraph: Workspace reindexed successfully');
+ reportIndexTelemetry(reporter, startedAt, result);
+ const fileCount = filesIndexed(result);
+ // handleIndexOutcome syncs the codegraph.indexed context key
+ // and shows zero-file recovery or the one-time first-index
+ // steer. The user explicitly triggered this reindex, so confirm
+ // the result when the funnel handler didn't show its own prompt.
+ const action = await handleIndexOutcome(context, reporter, fileCount);
+ if (action === 'none') {
+ vscode.window.showInformationMessage(
+ `CodeGraph: Reindexed ${fileCount.toLocaleString()} file${fileCount === 1 ? '' : 's'}`,
+ );
+ }
} catch (error) {
+ reporter?.indexCompleted({
+ outcome: 'error',
+ durationMs: Date.now() - startedAt,
+ fileCount: 0,
+ errorCategory: 'other',
+ });
vscode.window.showErrorMessage(`CodeGraph: Failed to reindex workspace: ${error}`);
}
});
+ // Open the first-run getting-started walkthrough on demand (also linked
+ // from the Symbols view empty state).
+ safeRegisterCommand('codegraph.openWalkthrough', async () => {
+ await vscode.commands.executeCommand(
+ 'workbench.action.openWalkthrough',
+ 'aStudioPlus.codegraph#codegraph.gettingStarted',
+ false,
+ );
+ });
+
// Index Directory - pick folders to index on demand
safeRegisterCommand('codegraph.indexDirectory', async () => {
const uris = await vscode.window.showOpenDialog({
@@ -303,6 +332,9 @@ export function registerCommands(
});
}
);
+ // Indexing specific directories means the graph now has
+ // content - clear the "not indexed" empty state.
+ void vscode.commands.executeCommand('setContext', 'codegraph.indexed', true);
vscode.window.showInformationMessage(
`CodeGraph: Indexed ${paths.length} director${paths.length === 1 ? 'y' : 'ies'} successfully`
);
diff --git a/vscode/src/engineDownload.ts b/vscode/src/engineDownload.ts
new file mode 100644
index 0000000..b1cc1f2
--- /dev/null
+++ b/vscode/src/engineDownload.ts
@@ -0,0 +1,240 @@
+// Copyright 2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+//! Fetches the engine for this platform when the VSIX does not carry one.
+//!
+//! The VSIX used to bundle all four platform binaries (118 MB, of which a user
+//! can run one). The binaries are now published once as GitHub release assets
+//! and each channel fetches only what it needs - the npm package does this in
+//! its postinstall, the JetBrains plugin in Kotlin, and this is the VS Code
+//! half. A VSIX has no install hook, so the fetch happens on first activation.
+//!
+//! The download contract - URL layout, checksum file format, and the Windows
+//! sidecar rule - is shared with `mcp-package/bin/fetch-engine.js`, which this
+//! module re-exports rather than reimplements, so the three clients cannot
+//! disagree about where the engine lives or how it is verified.
+
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import * as vscode from 'vscode';
+
+// The canonical implementation lives with the npm package; esbuild follows the
+// path and inlines it into out/extension.js, so both JavaScript channels ship
+// the same code rather than two implementations that drift.
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const fetchEngine = require('../../mcp-package/bin/fetch-engine.js');
+
+/** Where downloaded engines live, shared with the CLI and the JetBrains plugin. */
+export function managedInstallDir(): string {
+ return path.join(os.homedir(), '.codegraph', 'bin');
+}
+
+/**
+ * The engine release this extension fetches.
+ *
+ * Not the extension's own version. Release assets are tagged with the engine's
+ * version, so a VSIX-only patch - a UI fix, a doc change - would ask for
+ * `v/…` and get a 404 on every fresh install, which now
+ * means no engine at all. It is also the number the shared `~/.codegraph/bin`
+ * marker is compared against, so all three clients judge staleness by the same
+ * yardstick instead of by three independently drifting ones.
+ */
+export function engineVersion(): string {
+ return fetchEngine.ENGINE_VERSION as string;
+}
+
+/**
+ * How the running engine is stopped and started around an in-place update.
+ *
+ * The engine holds its own binary open, so replacing it while it runs fails
+ * outright on Windows and elsewhere leaves the old process serving requests
+ * while the version marker records a build nobody is running.
+ */
+export interface EngineLifecycle {
+ isRunning(): boolean;
+ stop(): Promise;
+ start(): Promise;
+}
+
+/** globalState key holding the engine version whose update offer was declined. */
+const UPDATE_DECLINED_KEY = 'codegraph.engineUpdateDeclined';
+
+/** The engine asset for this platform, or null when none is published. */
+export function platformBinaryName(): string | null {
+ return fetchEngine.platformBinaryName();
+}
+
+/** Path the engine would occupy once downloaded, or null on an unsupported platform. */
+export function managedEnginePath(): string | null {
+ const name = platformBinaryName();
+ return name ? path.join(managedInstallDir(), name) : null;
+}
+
+/**
+ * Download the engine for [version], reporting progress in the notification
+ * area.
+ *
+ * Offered rather than automatic: this pulls a native binary that runs with the
+ * user's permissions, and doing that unasked on first activation is not the
+ * extension's decision to make.
+ */
+export async function downloadEngine(
+ version: string,
+ options: { beforeInstall?: () => Promise } = {},
+): Promise {
+ return vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: `Downloading the CodeGraph engine ${version}`,
+ cancellable: false,
+ },
+ async (progress) => {
+ const { binary } = await fetchEngine.ensureEngine(version, managedInstallDir(), {
+ onProgress: (asset: string) => progress.report({ message: asset }),
+ beforeInstall: options.beforeInstall,
+ });
+ return binary as string;
+ },
+ );
+}
+
+/**
+ * Offer to replace a managed engine that predates this extension release.
+ *
+ * The managed engine is resolved by filename, so without this an engine left
+ * behind by a previous release is found and reused indefinitely and a client
+ * built against a newer engine keeps talking to the old one.
+ *
+ * Offered rather than done, for the same reason `downloadEngine` is: this is
+ * ~30 MB of native binary that will run with the user's permissions. It is also
+ * deliberately not awaited by the caller - the engine on disk still works, so
+ * blocking activation behind a transfer would cost every surface the extension
+ * provides for an update that is not urgent.
+ *
+ * Only an *older* engine counts. `~/.codegraph/bin` is shared with the CLI and
+ * the JetBrains plugin, which ship on their own schedules; treating a newer
+ * engine as a mismatch would have the two clients reinstall over each other on
+ * every launch.
+ *
+ * A decline is remembered. Activation happens once per window, so without that
+ * a user with three windows open gets three toasts for the same drift, on every
+ * launch until they give in; recording the version they declined lets the offer
+ * come back on the next engine release and not before.
+ */
+export async function offerEngineUpdateIfStale(
+ version: string,
+ state: vscode.Memento,
+ lifecycle?: EngineLifecycle,
+): Promise {
+ const engine = managedEnginePath();
+ if (!engine || !fs.existsSync(engine)) {
+ return;
+ }
+ if (!fetchEngine.isStale(managedInstallDir(), version)) {
+ return;
+ }
+ if (state.get(UPDATE_DECLINED_KEY) === version) {
+ return;
+ }
+
+ const installed = fetchEngine.installedVersion(managedInstallDir()) ?? 'an unknown version';
+ const choice = await vscode.window.showInformationMessage(
+ `The installed CodeGraph engine (${installed}) predates the one this extension ships ` +
+ `against (${version}). They ship together, so features this build expects may be missing.`,
+ 'Update',
+ 'Not Now',
+ );
+ if (choice !== 'Update') {
+ await state.update(UPDATE_DECLINED_KEY, version);
+ return;
+ }
+
+ // The engine holds its own binary open, so it is stopped once every asset
+ // is downloaded and verified - the last possible moment, since stopping it
+ // for the length of a transfer that may fail costs the user a working
+ // engine for nothing - and started again whichever way the install ends.
+ let stopped = false;
+ const beforeInstall = async () => {
+ if (lifecycle?.isRunning()) {
+ stopped = true;
+ await lifecycle.stop();
+ }
+ };
+
+ try {
+ await downloadEngine(version, { beforeInstall });
+ if (stopped && lifecycle) {
+ await lifecycle.start();
+ }
+ vscode.window.showInformationMessage(
+ stopped
+ ? `CodeGraph engine ${version} installed and restarted.`
+ : `CodeGraph engine ${version} installed. It takes effect the next time the engine starts.`,
+ );
+ } catch (error) {
+ if (stopped && lifecycle) {
+ // A failed update must not leave the user without an engine: the
+ // binary on disk is still the one that was running.
+ await lifecycle.start().catch(() => { });
+ }
+ const message = error instanceof Error ? error.message : String(error);
+ // Not fatal: the engine already on disk still runs, and losing a working
+ // install over one version of drift is worse than the drift. "In use"
+ // gets its own wording because it is the one failure the user can act
+ // on, and reading it as a network problem sends them somewhere useless.
+ vscode.window.showWarningMessage(
+ isEngineInUse(error)
+ ? `The CodeGraph engine could not be replaced because it is still running. ` +
+ `Close other windows or editors using it, then reload this window to try again.`
+ : `Could not update the CodeGraph engine to ${version}: ${message}`,
+ );
+ }
+}
+
+/** True for the "another process holds the binary" failure `fetch-engine.js` names. */
+function isEngineInUse(error: unknown): boolean {
+ return error instanceof Error && error.name === 'EngineInUseError';
+}
+
+/**
+ * Ask whether to download, then do it.
+ *
+ * Returns the engine path, or null if the user declined or it failed - callers
+ * treat that as "no engine", which is the same state they already handle.
+ */
+export async function offerEngineDownload(version: string): Promise {
+ if (!platformBinaryName()) {
+ vscode.window.showErrorMessage(
+ `CodeGraph does not publish an engine for ${os.platform()}-${os.arch()}. ` +
+ 'Point the extension at your own build with the codegraph.serverPath setting.',
+ );
+ return null;
+ }
+
+ const choice = await vscode.window.showInformationMessage(
+ 'CodeGraph needs its analysis engine, which is downloaded separately for your platform.',
+ 'Download',
+ 'Not Now',
+ );
+ if (choice !== 'Download') {
+ return null;
+ }
+
+ try {
+ const binary = await downloadEngine(version);
+ vscode.window.showInformationMessage('CodeGraph engine installed.');
+ return binary;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ // A checksum failure is not a network failure, and saying so matters:
+ // one is worth retrying, the other means something served the wrong
+ // bytes.
+ vscode.window.showErrorMessage(
+ /checksum/i.test(message)
+ ? `The downloaded engine failed verification and was discarded: ${message}`
+ : `Could not download the CodeGraph engine: ${message}`,
+ );
+ return null;
+ }
+}
diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts
index 8caf67b..7364700 100644
--- a/vscode/src/extension.ts
+++ b/vscode/src/extension.ts
@@ -13,11 +13,14 @@ import {
} from 'vscode-languageclient/node';
import { registerCommands } from './commands';
import { registerTreeDataProviders } from './views/treeProviders';
+import { registerCodeLens } from './views/codeLensProvider';
import { CodeGraphAIProvider } from './ai/contextProvider';
import { CodeGraphToolManager } from './ai/toolManager';
-import { getServerPath } from './server';
+import { getServerPath, engineSpawnEnv } from './server';
+import { engineVersion, managedEnginePath, offerEngineDownload, offerEngineUpdateIfStale } from './engineDownload';
import { createReporter, setServerEdition, type Reporter } from './telemetry/reporter';
import { detectMachineProfile } from './telemetry/machineProfile';
+import { handleIndexOutcome, filesIndexed, reportIndexTelemetry } from './funnel';
let client: LanguageClient;
let aiProvider: CodeGraphAIProvider;
@@ -265,9 +268,79 @@ export async function activate(context: vscode.ExtensionContext): Promise
return;
}
+ // The one command registered before the engine is resolved, because
+ // declining the download below ends activation and nothing after it -
+ // commands, tree views, lenses - is ever contributed. Without this, a user
+ // who says "Not Now" and later changes their mind has no way back short of
+ // reloading the window and guessing that the prompt returns; the npm
+ // channel ships `codegraph-mcp-fetch-engine` for exactly that case.
+ //
+ // Reloading is what puts a late download to use, so the command offers it -
+ // but only when activation did stop early, since in a session that already
+ // has an engine running a reload prompt is noise.
+ context.subscriptions.push(
+ vscode.commands.registerCommand('codegraph.downloadEngine', async () => {
+ const activationStopped = !client;
+ if (!(await offerEngineDownload(engineVersion()))) {
+ return;
+ }
+ if (!activationStopped) {
+ return;
+ }
+ const choice = await vscode.window.showInformationMessage(
+ 'Reload the window to start the CodeGraph engine.',
+ 'Reload Window',
+ );
+ if (choice === 'Reload Window') {
+ void vscode.commands.executeCommand('workbench.action.reloadWindow');
+ }
+ }),
+ );
+
// Determine server binary path — may upgrade the edition label from
// 'community' to 'pro' if the user has the pro binary on PATH.
- const serverInfo = getServerPath(context);
+ //
+ // The published VSIX no longer bundles engines: shipping all four platform
+ // binaries meant a 118 MB download for the one a user can actually run.
+ // When none is found we offer to fetch this platform's engine, which is
+ // also where an npm- or JetBrains-installed engine gets picked up, since
+ // all three channels share ~/.codegraph/bin.
+ let serverInfo: ReturnType;
+ try {
+ serverInfo = getServerPath(context);
+ } catch {
+ const downloaded = await offerEngineDownload(engineVersion());
+ if (!downloaded) {
+ reporter.activationServerStartResult({
+ outcome: 'spawn_fail',
+ durationMs: 0,
+ serverBinaryFound: false,
+ errorHint: 'engine_not_installed',
+ });
+ return;
+ }
+ serverInfo = getServerPath(context);
+ }
+
+ // The managed engine is found by filename alone, so one installed by an
+ // earlier release would otherwise be reused forever. The extension and the
+ // engine ship in lockstep, so offer to bring it up to the engine release
+ // this build expects - only when it is the binary we actually resolved,
+ // since a pro, bundled or locally built engine is the user's to manage.
+ //
+ // Not awaited: the engine on disk still runs, and holding activation - and
+ // with it the language client, the tree views and the lenses - behind a
+ // 30 MB transfer on a slow network is a far worse trade than one release of
+ // drift. The lifecycle callbacks read `client` lazily for the same reason:
+ // by the time the user answers the prompt it has been created and started.
+ if (serverInfo.path === managedEnginePath()) {
+ void offerEngineUpdateIfStale(engineVersion(), context.globalState, {
+ isRunning: () => client?.isRunning() ?? false,
+ stop: () => client.stop(),
+ start: () => client.start(),
+ });
+ }
+
setServerEdition(serverInfo.edition === 'pro' ? 'pro' : 'community');
// Log server path for debugging
@@ -292,19 +365,9 @@ export async function activate(context: vscode.ExtensionContext): Promise
// space the way `shell:true` + cmd.exe did. stdio defaults to pipes, which
// vscode-languageclient uses for the LSP transport (stderr → outputChannel).
const serverOptions: ServerOptions = () => {
- // When the static (model2vec) embedding model is selected, point the
- // server at the model dir via CODEGRAPH_STATIC_MODEL — the server
- // resolves the static path from this env, falling back to
- // ~/.codegraph/static_models/jina-code-static-256.
const wsFolder = vscode.workspace.workspaceFolders?.[0]?.uri;
const cfg = vscode.workspace.getConfiguration('codegraph', wsFolder);
- const spawnEnv = { ...process.env };
- if (cfg.get('embeddingModel') === 'static') {
- // staticModelPath override, else the model bundled next to the binary.
- const staticModelPath = cfg.get('staticModelPath')
- || path.join(context.extensionPath, 'bin', 'jina-code-static-256');
- spawnEnv.CODEGRAPH_STATIC_MODEL = staticModelPath;
- }
+ const spawnEnv = engineSpawnEnv(cfg, process.env);
const child = cp.spawn(serverModule, [], { cwd: context.extensionPath, env: spawnEnv });
child.once('exit', (code, signal) => {
lastExitCode = code;
@@ -494,7 +557,7 @@ export async function activate(context: vscode.ExtensionContext): Promise
// Register Language Model Tools for autonomous AI agent access
try {
- toolManager = new CodeGraphToolManager(client, reporter);
+ toolManager = new CodeGraphToolManager(client, reporter, context);
toolManager.registerTools();
const lmAvailable = !!(vscode as any).lm;
reporter.activationToolRegistration({
@@ -537,7 +600,11 @@ export async function activate(context: vscode.ExtensionContext): Promise
command: 'codegraph.symbolSearch',
arguments: [{ query: '*', limit: 1 }],
});
- if (!check?.results?.length) {
+ const alreadyIndexed = !!check?.results?.length;
+ // Drives the codegraphSymbols empty-state welcome (index CTA vs.
+ // "open a file") and any `codegraph.indexed`-gated UI.
+ void vscode.commands.executeCommand('setContext', 'codegraph.indexed', alreadyIndexed);
+ if (!alreadyIndexed) {
const choice = await vscode.window.showInformationMessage(
'CodeGraph: Workspace not indexed. Index now for full code intelligence?',
'Index Workspace',
@@ -554,8 +621,18 @@ export async function activate(context: vscode.ExtensionContext): Promise
command: 'codegraph.reindexWorkspace',
arguments: [{}],
});
- reportIndexCompleted(reporter, startedAt, result);
- vscode.window.showInformationMessage(`Indexed ${result?.files_indexed ?? 0} files`);
+ reportIndexTelemetry(reporter, startedAt, result);
+ const fileCount = filesIndexed(result);
+ // handleIndexOutcome syncs the codegraph.indexed
+ // context key and shows zero-file recovery or the
+ // one-time first-index steer. Confirm success here
+ // only when it didn't show its own prompt.
+ const action = await handleIndexOutcome(context, reporter, fileCount);
+ if (action === 'none' && fileCount > 0) {
+ vscode.window.showInformationMessage(
+ `CodeGraph: Indexed ${fileCount.toLocaleString()} file${fileCount === 1 ? '' : 's'}`,
+ );
+ }
} catch (err) {
reporter.indexCompleted({
outcome: 'error',
@@ -602,6 +679,7 @@ export async function activate(context: vscode.ExtensionContext): Promise
// Register commands, tree providers, etc.
registerCommands(context, client, aiProvider, reporter);
registerTreeDataProviders(context, client, reporter);
+ registerCodeLens(context, client, reporter);
// Add debug command to verify tool registration
context.subscriptions.push(
@@ -672,30 +750,3 @@ export async function deactivate(): Promise {
}
}
-/**
- * Map the reindex-RPC response (which now ships `by_language` /
- * `parser_errors_by_language` / `duration_ms` from the server) into
- * the appropriate telemetry events. Two events fire per index:
- * - `index.completed` with the aggregate numbers
- * - `index.languageBreakdown` with the per-language file counts
- * The wall-clock duration is computed locally for cancel/error paths
- * but the server-side `duration_ms` is used when present (it excludes
- * network RTT and is more accurate for product-decision purposes).
- */
-function reportIndexCompleted(r: Reporter, localStartedAt: number, result: any): void {
- const fileCount = typeof result?.files_indexed === 'number' ? result.files_indexed : 0;
- const durationMs =
- typeof result?.duration_ms === 'number'
- ? Number(result.duration_ms)
- : Date.now() - localStartedAt;
- r.indexCompleted({ outcome: 'ok', durationMs, fileCount });
-
- const byLanguage = result?.by_language;
- if (byLanguage && typeof byLanguage === 'object') {
- const map = new Map();
- for (const [lang, count] of Object.entries(byLanguage)) {
- if (typeof count === 'number') map.set(lang as any, count);
- }
- if (map.size > 0) r.indexLanguageBreakdown(map as any);
- }
-}
diff --git a/vscode/src/funnel.test.ts b/vscode/src/funnel.test.ts
new file mode 100644
index 0000000..8808632
--- /dev/null
+++ b/vscode/src/funnel.test.ts
@@ -0,0 +1,167 @@
+// Copyright 2025-2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Minimal, controllable `vscode` mock - the real module only exists in the
+// VS Code runtime. Test state is driven through `mockState`.
+//
+// findFiles is dispatched by argument shape to mirror the three distinct
+// scans diagnoseZeroFile performs:
+// - RelativePattern include -> an indexPaths-scoped scan
+// - string include, no exclude -> the whole-workspace scan
+// - string include, with exclude glob -> the excludes scan
+const mockState = {
+ folders: undefined as { uri: unknown }[] | undefined,
+ config: {} as Record,
+ indexPathScoped: [] as unknown[],
+ supportedNoExclude: [] as unknown[],
+ supportedWithExclude: [] as unknown[],
+};
+
+vi.mock('vscode', () => {
+ // Defined inside the (hoisted) factory: vitest only lets `mock`-prefixed
+ // outer variables be referenced here, so the class must live in-closure.
+ class RelativePattern {
+ constructor(
+ public base: unknown,
+ public pattern: string,
+ ) {}
+ }
+ return {
+ workspace: {
+ get workspaceFolders() {
+ return mockState.folders;
+ },
+ getConfiguration: () => ({
+ get: (key: string) => mockState.config[key],
+ }),
+ findFiles: vi.fn(async (include: unknown, exclude: unknown) => {
+ if (include instanceof RelativePattern) return mockState.indexPathScoped;
+ return exclude === undefined
+ ? mockState.supportedNoExclude
+ : mockState.supportedWithExclude;
+ }),
+ },
+ RelativePattern,
+ // codeLensRefresh.ts constructs an EventEmitter at module load.
+ EventEmitter: class {
+ fire() {}
+ get event() {
+ return () => ({ dispose() {} });
+ }
+ dispose() {}
+ },
+ Uri: { parse: (s: string) => ({ toString: () => s }) },
+ commands: { executeCommand: vi.fn() },
+ window: { showWarningMessage: vi.fn(), showInformationMessage: vi.fn() },
+ env: { openExternal: vi.fn() },
+ };
+});
+
+import {
+ diagnoseZeroFile,
+ supportedFilesGlob,
+ toExcludeGlob,
+ filesIndexed,
+ SUPPORTED_EXTENSIONS,
+} from './funnel';
+
+beforeEach(() => {
+ mockState.folders = [{ uri: {} }];
+ mockState.config = {};
+ mockState.indexPathScoped = [];
+ mockState.supportedNoExclude = [];
+ mockState.supportedWithExclude = [];
+});
+
+describe('supportedFilesGlob', () => {
+ it('covers the common languages seen in telemetry', () => {
+ for (const ext of ['ts', 'py', 'rs', 'c', 'cpp', 'java', 'cs', 'go', 'kt']) {
+ expect(SUPPORTED_EXTENSIONS).toContain(ext);
+ }
+ });
+
+ it('produces a single brace-expansion glob', () => {
+ const glob = supportedFilesGlob();
+ expect(glob.startsWith('**/*.{')).toBe(true);
+ expect(glob.endsWith('}')).toBe(true);
+ expect(glob).toContain('ts,');
+ });
+});
+
+describe('toExcludeGlob', () => {
+ it('passes a lone pattern through without wrapping braces', () => {
+ // Wrapping one pattern that itself contains a nested {a,b} group in an
+ // outer single-element brace is what some glob engines mis-parse.
+ expect(toExcludeGlob(['**/{test,spec}/**'])).toBe('**/{test,spec}/**');
+ });
+
+ it('brace-joins multiple patterns', () => {
+ expect(toExcludeGlob(['**/node_modules/**', '**/dist/**'])).toBe(
+ '{**/node_modules/**,**/dist/**}',
+ );
+ });
+});
+
+describe('filesIndexed', () => {
+ it('reads a numeric files_indexed and defaults everything else to 0', () => {
+ expect(filesIndexed({ files_indexed: 42 })).toBe(42);
+ expect(filesIndexed({ files_indexed: '42' })).toBe(0);
+ expect(filesIndexed({})).toBe(0);
+ expect(filesIndexed(null)).toBe(0);
+ expect(filesIndexed(undefined)).toBe(0);
+ });
+});
+
+describe('diagnoseZeroFile', () => {
+ it('reports no_workspace when no folder is open', async () => {
+ mockState.folders = undefined;
+ const d = await diagnoseZeroFile();
+ expect(d).toEqual({ reason: 'no_workspace', hadWorkspace: false });
+ });
+
+ it('reports no_supported_files when the folder has nothing we parse', async () => {
+ mockState.supportedNoExclude = []; // no supported source found
+ const d = await diagnoseZeroFile();
+ expect(d).toEqual({ reason: 'no_supported_files', hadWorkspace: true });
+ });
+
+ it('reports index_paths_empty when indexPaths yields nothing IN SCOPE, even if source exists elsewhere', async () => {
+ mockState.config['indexPaths'] = ['does/not/exist'];
+ mockState.indexPathScoped = []; // configured paths hold no source
+ mockState.supportedNoExclude = [{ path: 'src/a.ts' }]; // ...but the workspace does
+ const d = await diagnoseZeroFile();
+ // The whole-workspace source must NOT mask the misconfigured indexPaths.
+ expect(d).toEqual({ reason: 'index_paths_empty', hadWorkspace: true });
+ });
+
+ it('does not report index_paths_empty when the configured paths do contain source', async () => {
+ mockState.config['indexPaths'] = ['src'];
+ mockState.indexPathScoped = [{ path: 'src/a.ts' }];
+ const d = await diagnoseZeroFile();
+ expect(d.reason).toBe('unknown'); // source in scope, no excludes -> server-side gap
+ });
+
+ it('reports all_excluded when excludes filter out every source file', async () => {
+ mockState.config['excludePatterns'] = ['**/*'];
+ mockState.supportedNoExclude = [{ path: 'a.ts' }]; // source exists
+ mockState.supportedWithExclude = []; // ...but all excluded
+ const d = await diagnoseZeroFile();
+ expect(d).toEqual({ reason: 'all_excluded', hadWorkspace: true });
+ });
+
+ it('reports unknown when source is present and not excluded (server-side gap)', async () => {
+ mockState.supportedNoExclude = [{ path: 'a.ts' }];
+ mockState.supportedWithExclude = [{ path: 'a.ts' }];
+ const d = await diagnoseZeroFile();
+ expect(d).toEqual({ reason: 'unknown', hadWorkspace: true });
+ });
+
+ it('treats an empty indexPaths array as "scan whole workspace"', async () => {
+ mockState.config['indexPaths'] = [];
+ mockState.supportedNoExclude = [];
+ const d = await diagnoseZeroFile();
+ expect(d.reason).toBe('no_supported_files');
+ });
+});
diff --git a/vscode/src/funnel.ts b/vscode/src/funnel.ts
new file mode 100644
index 0000000..715a353
--- /dev/null
+++ b/vscode/src/funnel.ts
@@ -0,0 +1,317 @@
+// Copyright 2025-2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * Onboarding-funnel repair.
+ *
+ * Telemetry (30-day window, ~2,540 active machines) showed two large leaks:
+ * 1. ~445 machines produced a zero-file index and 84% of them never came
+ * back - the old "Indexed 0 files" toast was a dead end with no next step.
+ * 2. Of ~2,394 machines that activate cleanly, only ~20% ever open a visible
+ * surface (tree views: 483, call graph: 116) and only ~9% invoke an agent
+ * tool - most activate and see nothing.
+ *
+ * This module owns the post-index UX that addresses both: it diagnoses why an
+ * index came back empty and offers a concrete recovery, and - on the first
+ * successful index - steers the user to the surfaces that already convert.
+ *
+ * The diagnosis is pure/observable so it can be unit-tested without a live
+ * server; the notification wiring is a thin shell around it.
+ */
+
+import * as vscode from 'vscode';
+import type { Reporter } from './telemetry/reporter';
+import type { Language } from './telemetry/allowlists';
+import type { ZeroFileReason } from './telemetry/allowlists';
+import { refreshCodeLenses } from './views/codeLensRefresh';
+
+/** globalState key: set once the first-index CTA has been shown. */
+export const FIRST_INDEX_CTA_SHOWN_KEY = 'codegraph.funnel.firstIndexCtaShown';
+
+/** Context key that gates the codegraphSymbols empty-state welcome. */
+export const INDEXED_CONTEXT_KEY = 'codegraph.indexed';
+
+const DOCS_ZERO_FILE_URL =
+ 'https://github.com/codegraph-ai/CodeGraph/blob/main/docs/troubleshooting.md#no-files-indexed';
+
+/**
+ * File extensions the community parsers understand, one flat set so a single
+ * `findFiles` glob can answer "does this workspace contain anything we could
+ * have parsed?". Kept deliberately broad so we never misdiagnose a real
+ * workspace as `no_supported_files`.
+ *
+ * AUTHORITATIVE SOURCE: `crates/codegraph-server/src/parser_registry.rs`
+ * (`supported_extensions()`, aggregated from each `codegraph-` parser's
+ * `file_extensions()`). This list is a client-side mirror and must be kept in
+ * sync when a parser is added or its extensions change. It is only used to
+ * distinguish the `no_supported_files` vs `unknown` zero-file message, both of
+ * which link to the same troubleshooting doc, so drift degrades the wording of
+ * a recovery hint rather than breaking a feature. Follow-up: expose
+ * `supported_extensions()` over LSP and consume it, with this list as the
+ * offline fallback.
+ */
+export const SUPPORTED_EXTENSIONS: readonly string[] = [
+ // scripting / dynamic
+ 'py', 'pyi', 'rb', 'php', 'pl', 'pm', 'lua', 'r', 'tcl', 'sh', 'bash',
+ // systems
+ 'c', 'h', 'cpp', 'cc', 'cxx', 'hpp', 'hh', 'hxx', 'rs', 'go', 'zig',
+ 'swift', 'm', 'mm', 'v', 'sv', 'svh',
+ // jvm
+ 'java', 'kt', 'kts', 'scala', 'sc', 'groovy', 'gradle', 'clj', 'cljs', 'cljc',
+ // ml / functional
+ 'hs', 'ml', 'mli', 'ex', 'exs', 'erl', 'hrl', 'elm', 'jl',
+ // web / .net
+ 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'cs', 'css', 'scss', 'sass', 'less', 'dart',
+ // data / infra / legacy
+ 'toml', 'yaml', 'yml', 'tf', 'hcl', 'sol', 'cob', 'cbl', 'cpy',
+ 'f', 'f90', 'f95', 'f03', 'for',
+];
+
+/** The `findFiles` include-glob for any supported source file. */
+export function supportedFilesGlob(): string {
+ return `**/*.{${SUPPORTED_EXTENSIONS.join(',')}}`;
+}
+
+/**
+ * Combine exclude patterns into a single `findFiles` exclude glob. A lone
+ * pattern is passed through untouched: wrapping one pattern in `{...}` yields a
+ * single-element brace whose nested `{a,b}` groups some glob engines mis-parse.
+ * Multiple patterns are joined at the top level, where the separating commas
+ * are unambiguous because each pattern's own braces balance.
+ */
+export function toExcludeGlob(patterns: string[]): string {
+ return patterns.length === 1 ? patterns[0] : `{${patterns.join(',')}}`;
+}
+
+/** Read `result.files_indexed` from a reindex RPC response, defaulting to 0. */
+export function filesIndexed(result: unknown): number {
+ const n = (result as { files_indexed?: unknown } | null | undefined)?.files_indexed;
+ return typeof n === 'number' ? n : 0;
+}
+
+export interface ZeroFileDiagnosis {
+ reason: ZeroFileReason;
+ hadWorkspace: boolean;
+}
+
+/**
+ * Work out *why* an index produced no files, so the recovery prompt can offer
+ * the one action that will actually help. Cheap and bounded: every scan is
+ * capped and short-circuits on the first match.
+ */
+export async function diagnoseZeroFile(): Promise {
+ const folders = vscode.workspace.workspaceFolders;
+ if (!folders || folders.length === 0) {
+ return { reason: 'no_workspace', hadWorkspace: false };
+ }
+
+ const config = vscode.workspace.getConfiguration('codegraph');
+ const indexPaths = config.get('indexPaths') ?? [];
+ const excludePatterns = config.get('excludePatterns') ?? [];
+
+ // When indexPaths is set it defines the *effective* scope of indexing, so
+ // "is there anything to index?" must be asked within that scope. A
+ // whole-workspace scan would miss the common misconfiguration where the
+ // configured paths are missing/empty but source lives elsewhere.
+ if (indexPaths.length > 0) {
+ const inScope = await indexPathsContainSource(folders[0], indexPaths);
+ if (!inScope) {
+ return { reason: 'index_paths_empty', hadWorkspace: true };
+ }
+ } else {
+ const anySupported = await vscode.workspace.findFiles(supportedFilesGlob(), undefined, 1);
+ if (anySupported.length === 0) {
+ return { reason: 'no_supported_files', hadWorkspace: true };
+ }
+ }
+
+ // Supported files exist in scope. If excludes filter every one of them out,
+ // the excludes are the cause; otherwise it's an unexplained server-side gap
+ // (files present, still zero indexed).
+ if (excludePatterns.length > 0) {
+ const anyIncluded = await vscode.workspace.findFiles(
+ supportedFilesGlob(),
+ toExcludeGlob(excludePatterns),
+ 1,
+ );
+ if (anyIncluded.length === 0) {
+ return { reason: 'all_excluded', hadWorkspace: true };
+ }
+ }
+
+ return { reason: 'unknown', hadWorkspace: true };
+}
+
+/** True if any configured index path contains at least one supported source file. */
+async function indexPathsContainSource(
+ folder: vscode.WorkspaceFolder,
+ indexPaths: string[],
+): Promise {
+ const suffix = `/**/*.{${SUPPORTED_EXTENSIONS.join(',')}}`;
+ for (const raw of indexPaths) {
+ const rel = raw.replace(/^\.\//, '').replace(/\/+$/, '');
+ const pattern = new vscode.RelativePattern(folder, `${rel}${suffix}`);
+ const hits = await vscode.workspace.findFiles(pattern, undefined, 1);
+ if (hits.length > 0) return true;
+ }
+ return false;
+}
+
+/** What {@link handleIndexOutcome} did, so callers can decide any follow-up. */
+export type IndexOutcomeAction = 'zero_file' | 'first_index_cta' | 'none';
+
+/**
+ * Route the outcome of an index run to the right onboarding UX, and keep the
+ * `codegraph.indexed` context key (which gates the Symbols empty state and the
+ * walkthrough's index step) in sync with the result.
+ *
+ * - `fileCount === 0` -> diagnose and offer a targeted recovery.
+ * - first `fileCount > 0` on this install -> one-time steer to a converting
+ * surface, then never again (globalState-gated).
+ * - subsequent successful indexes -> nothing (avoid nagging).
+ *
+ * Notifications are shown fire-and-forget: this function performs its
+ * synchronous decisions (context key, globalState flag) and returns the chosen
+ * action WITHOUT blocking on the user's button click, so it is safe to await
+ * from an agent tool invocation. Callers use the returned action to decide
+ * whether to add their own confirmation toast.
+ */
+export async function handleIndexOutcome(
+ context: vscode.ExtensionContext,
+ reporter: Reporter | undefined,
+ fileCount: number,
+ opts: { offerSurfaceCta: boolean } = { offerSurfaceCta: true },
+): Promise {
+ // Centralized so every index-completion caller keeps the empty-state and
+ // walkthrough in sync without duplicating the setContext call.
+ void vscode.commands.executeCommand('setContext', INDEXED_CONTEXT_KEY, fileCount > 0);
+
+ // Counts behind CodeLens/hover just changed - drop the per-document cache
+ // so the editor re-fetches fresh caller/test/complexity stats.
+ refreshCodeLenses();
+
+ if (fileCount === 0) {
+ // Detached: an agent-triggered index must not hang awaiting a dialog
+ // the agent can't answer. The recovery prompt still shows to the human.
+ void showZeroFileRecovery(reporter);
+ return 'zero_file';
+ }
+
+ // The surface-steer prompt is only appropriate when a human just indexed
+ // (activation / command flow). On the agent-driven reindex path we suppress
+ // it - popping "Explore Symbols" mid-agent-task is disruptive, not helpful.
+ if (!opts.offerSurfaceCta) return 'none';
+
+ if (!context.globalState.get(FIRST_INDEX_CTA_SHOWN_KEY)) {
+ // Persist the flag before showing (awaited) so a reload mid-prompt
+ // can't replay it; the prompt itself is detached.
+ await context.globalState.update(FIRST_INDEX_CTA_SHOWN_KEY, true);
+ void showFirstIndexCta(reporter, fileCount);
+ return 'first_index_cta';
+ }
+
+ return 'none';
+}
+
+async function showZeroFileRecovery(reporter: Reporter | undefined): Promise {
+ const diag = await diagnoseZeroFile();
+ reporter?.funnelZeroFileIndex({ reason: diag.reason, hadWorkspace: diag.hadWorkspace });
+
+ // Message + actions tailored to the diagnosis. Each action maps to a
+ // bounded ZeroFileCta so we can measure which recovery users take.
+ let message: string;
+ const actions: { label: string; cta: 'open_folder' | 'configure_paths' | 'learn_more' }[] = [];
+
+ switch (diag.reason) {
+ case 'no_workspace':
+ message = 'CodeGraph: no folder is open, so there was nothing to index. Open a folder to get code intelligence.';
+ actions.push({ label: 'Open Folder', cta: 'open_folder' });
+ break;
+ case 'index_paths_empty':
+ message = 'CodeGraph indexed 0 files: your codegraph.indexPaths setting points at locations with no source files. Update it or clear it to index the whole workspace.';
+ actions.push({ label: 'Edit Settings', cta: 'configure_paths' });
+ actions.push({ label: 'Learn More', cta: 'learn_more' });
+ break;
+ case 'all_excluded':
+ message = 'CodeGraph indexed 0 files: every source file is matched by codegraph.excludePatterns. Loosen the excludes to index your code.';
+ actions.push({ label: 'Edit Settings', cta: 'configure_paths' });
+ actions.push({ label: 'Learn More', cta: 'learn_more' });
+ break;
+ case 'no_supported_files':
+ message = 'CodeGraph indexed 0 files: no files in a supported language were found in this workspace.';
+ actions.push({ label: 'Learn More', cta: 'learn_more' });
+ break;
+ default:
+ message = 'CodeGraph indexed 0 files even though supported source files are present. This may be a bug - see troubleshooting.';
+ actions.push({ label: 'Learn More', cta: 'learn_more' });
+ break;
+ }
+
+ const choice = await vscode.window.showWarningMessage(message, ...actions.map((a) => a.label));
+ const picked = actions.find((a) => a.label === choice);
+ reporter?.funnelZeroFileCta({ reason: diag.reason, action: picked?.cta ?? 'dismissed' });
+
+ switch (picked?.cta) {
+ case 'open_folder':
+ await vscode.commands.executeCommand('workbench.action.files.openFolder');
+ break;
+ case 'configure_paths':
+ await vscode.commands.executeCommand(
+ 'workbench.action.openSettings',
+ 'codegraph.indexPaths',
+ );
+ break;
+ case 'learn_more':
+ await vscode.env.openExternal(vscode.Uri.parse(DOCS_ZERO_FILE_URL));
+ break;
+ default:
+ break;
+ }
+}
+
+async function showFirstIndexCta(reporter: Reporter | undefined, fileCount: number): Promise {
+ const EXPLORE = 'Explore Symbols';
+ const CALL_GRAPH = 'Show Call Graph';
+ const message = `CodeGraph indexed ${fileCount.toLocaleString()} file${fileCount === 1 ? '' : 's'}. Explore your code as a graph:`;
+
+ const choice = await vscode.window.showInformationMessage(message, EXPLORE, CALL_GRAPH);
+
+ if (choice === EXPLORE) {
+ reporter?.funnelFirstIndexCta({ action: 'explore_symbols', fileCount });
+ // Reveal the Symbols tree in the CodeGraph activity-bar container.
+ await vscode.commands.executeCommand('codegraphSymbols.focus');
+ } else if (choice === CALL_GRAPH) {
+ reporter?.funnelFirstIndexCta({ action: 'show_call_graph', fileCount });
+ await vscode.commands.executeCommand('codegraph.showCallGraph');
+ } else {
+ reporter?.funnelFirstIndexCta({ action: 'dismissed', fileCount });
+ }
+}
+
+/**
+ * Map a reindex-RPC response to the `index.completed` + `index.languageBreakdown`
+ * telemetry events. Shared by every index-completion site (activation, the
+ * reindex command, and the agent tool path) so the response-shape coupling
+ * lives in exactly one place. The server-side `duration_ms` is preferred when
+ * present (it excludes network RTT); otherwise the local wall-clock is used.
+ */
+export function reportIndexTelemetry(
+ reporter: Reporter | undefined,
+ localStartedAt: number,
+ result: unknown,
+): void {
+ if (!reporter) return;
+ const r = result as { duration_ms?: unknown; by_language?: unknown } | null | undefined;
+ const durationMs =
+ typeof r?.duration_ms === 'number' ? Number(r.duration_ms) : Date.now() - localStartedAt;
+ reporter.indexCompleted({ outcome: 'ok', durationMs, fileCount: filesIndexed(result) });
+
+ const byLanguage = r?.by_language;
+ if (byLanguage && typeof byLanguage === 'object') {
+ const map = new Map();
+ for (const [lang, count] of Object.entries(byLanguage)) {
+ if (typeof count === 'number') map.set(lang as Language, count);
+ }
+ if (map.size > 0) reporter.indexLanguageBreakdown(map);
+ }
+}
diff --git a/vscode/src/server.spawnEnv.test.ts b/vscode/src/server.spawnEnv.test.ts
new file mode 100644
index 0000000..553af06
--- /dev/null
+++ b/vscode/src/server.spawnEnv.test.ts
@@ -0,0 +1,61 @@
+// Copyright 2025-2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, it, expect, vi } from 'vitest';
+
+// Minimal `vscode` mock - the real module only exists in the VS Code runtime.
+// Nothing in this module's import chain touches the API at load time, and the
+// function under test takes its configuration as an argument, so an empty
+// namespace is enough.
+vi.mock('vscode', () => ({}));
+
+import { engineSpawnEnv } from './server';
+
+/** Stands in for a `WorkspaceConfiguration` over the given settings. */
+function config(settings: Record) {
+ return { get: (key: string) => settings[key] as T | undefined };
+}
+
+describe('engineSpawnEnv', () => {
+ it('passes the base environment through untouched', () => {
+ const env = engineSpawnEnv(config({}), { PATH: '/usr/bin' });
+
+ expect(env.PATH).toBe('/usr/bin');
+ });
+
+ it('does not point CODEGRAPH_STATIC_MODEL at a bundled path the VSIX no longer ships', () => {
+ // The regression: with `bin/**` excluded from the VSIX, defaulting to
+ // /bin/jina-code-static-256 left the engine pointed at
+ // a directory that no longer existed and its vector engine failed to
+ // start. Unset, the engine finds the shared ~/.codegraph copy itself.
+ const env = engineSpawnEnv(config({ embeddingModel: 'static' }), {});
+
+ expect(env.CODEGRAPH_STATIC_MODEL).toBeUndefined();
+ });
+
+ it('honours a static model directory the user named', () => {
+ const env = engineSpawnEnv(
+ config({ embeddingModel: 'static', staticModelPath: '/models/jina' }),
+ {},
+ );
+
+ expect(env.CODEGRAPH_STATIC_MODEL).toBe('/models/jina');
+ });
+
+ it('ignores a static model directory when the static embedder is not selected', () => {
+ const env = engineSpawnEnv(
+ config({ embeddingModel: 'onnx', staticModelPath: '/models/jina' }),
+ {},
+ );
+
+ expect(env.CODEGRAPH_STATIC_MODEL).toBeUndefined();
+ });
+
+ it('leaves an inherited CODEGRAPH_STATIC_MODEL alone rather than clearing it', () => {
+ const env = engineSpawnEnv(config({ embeddingModel: 'static' }), {
+ CODEGRAPH_STATIC_MODEL: '/from/the/users/shell',
+ });
+
+ expect(env.CODEGRAPH_STATIC_MODEL).toBe('/from/the/users/shell');
+ });
+});
diff --git a/vscode/src/server.ts b/vscode/src/server.ts
index ad19f20..1697869 100644
--- a/vscode/src/server.ts
+++ b/vscode/src/server.ts
@@ -6,6 +6,7 @@ import * as path from 'path';
import * as fs from 'fs';
import * as vscode from 'vscode';
import { execSync } from 'child_process';
+import { managedEnginePath, platformBinaryName } from './engineDownload';
export interface ServerInfo {
path: string;
@@ -19,6 +20,7 @@ export interface ServerInfo {
* 1. CodeGraph Pro binary (if installed)
* 2. Community binary (packaged with extension)
* 3. Development builds (cargo target dir)
+ * 4. The engine downloaded into ~/.codegraph/bin, shared with the other clients
*/
export function getServerPath(context: vscode.ExtensionContext): ServerInfo {
// Try pro binary first — check PATH and common locations
@@ -32,6 +34,35 @@ export function getServerPath(context: vscode.ExtensionContext): ServerInfo {
return { path: communityBinary, edition: 'community' };
}
+/**
+ * Environment for the spawned engine process.
+ *
+ * CODEGRAPH_STATIC_MODEL is set only when the user names a directory.
+ *
+ * It used to default to /bin/jina-code-static-256, which
+ * stopped existing when `bin/**` was excluded from the VSIX to drop the
+ * bundled engines - the model lived in that directory too. Overriding with a
+ * path that no longer ships is strictly worse than not overriding: unset, the
+ * engine resolves ~/.codegraph/static_models/jina-code-static-256, which is
+ * exactly where the npm postinstall puts it and is shared across every client.
+ *
+ * Lives here, next to the rest of "where the engine comes from", rather than
+ * in the `ServerOptions` closure it is called from: activate() cannot be
+ * driven from a unit test, so the rule would otherwise have no regression
+ * guard.
+ */
+export function engineSpawnEnv(
+ cfg: Pick,
+ baseEnv: NodeJS.ProcessEnv,
+): NodeJS.ProcessEnv {
+ const spawnEnv = { ...baseEnv };
+ const staticModelPath = cfg.get('staticModelPath');
+ if (cfg.get('embeddingModel') === 'static' && staticModelPath) {
+ spawnEnv.CODEGRAPH_STATIC_MODEL = staticModelPath;
+ }
+ return spawnEnv;
+}
+
function findProBinary(): string | null {
const platform = os.platform();
const binaryName = platform === 'win32' ? 'codegraph-pro.exe' : 'codegraph-pro';
@@ -67,28 +98,20 @@ function findProBinary(): string | null {
function findCommunityBinary(context: vscode.ExtensionContext): string {
const platform = os.platform();
- const arch = os.arch();
-
- let binaryName: string;
- switch (platform) {
- case 'linux':
- binaryName = 'codegraph-server-linux-x64';
- break;
- case 'darwin':
- binaryName = arch === 'arm64'
- ? 'codegraph-server-darwin-arm64'
- : 'codegraph-server-darwin-x64';
- break;
- case 'win32':
- binaryName = 'codegraph-server-win32-x64.exe';
- break;
- default:
- throw new Error(`Unsupported platform: ${platform}`);
- }
- // Packaged binary (production)
- const packagedPath = context.asAbsolutePath(path.join('bin', binaryName));
- if (fs.existsSync(packagedPath)) {
+ // One place decides which platform gets which asset - engineDownload.ts,
+ // which re-exports the rule the npm postinstall and this client share. A
+ // second copy here is how a platform ends up resolving a name nothing ever
+ // downloads, and it would silently defeat the update path, which compares a
+ // resolved path against `managedEnginePath()`.
+ const binaryName = platformBinaryName();
+
+ // Packaged binary — only present in a VSIX built with binaries bundled.
+ // The published VSIX no longer carries one; see engineDownload.ts.
+ const packagedPath = binaryName
+ ? context.asAbsolutePath(path.join('bin', binaryName))
+ : null;
+ if (packagedPath && fs.existsSync(packagedPath)) {
return packagedPath;
}
@@ -126,8 +149,28 @@ function findCommunityBinary(context: vscode.ExtensionContext): string {
}
}
+ // Engine downloaded on demand, shared with the CLI and the JetBrains
+ // plugin so a user who installed via any channel is found by all of them.
+ //
+ // Last, and after the cargo paths on purpose. Those only exist in a source
+ // checkout, so ordinary installs never reach past this point anyway, while
+ // a contributor who has also downloaded an engine - through npm, the
+ // JetBrains plugin, or an earlier prompt - would otherwise silently run
+ // that one instead of the build they just made.
+ const managedPath = managedEnginePath();
+ if (managedPath && fs.existsSync(managedPath)) {
+ return managedPath;
+ }
+
+ // A platform with no published engine reaches here too, once the cargo
+ // paths have been tried: a contributor on such a machine builds their own,
+ // and saying "not found" while pointing at the build command serves both
+ // cases better than refusing outright.
throw new Error(
- `CodeGraph server binary not found. Expected at: ${packagedPath}\n` +
- `For development, build with: cargo build --release -p codegraph-server`
+ binaryName
+ ? `CodeGraph server binary not found. Expected at: ${packagedPath}\n` +
+ `For development, build with: cargo build --release -p codegraph-server`
+ : `CodeGraph does not publish an engine for ${platform}-${os.arch()}.\n` +
+ `Build one with: cargo build --release -p codegraph-server`
);
}
diff --git a/vscode/src/telemetry/allowlists.ts b/vscode/src/telemetry/allowlists.ts
index c007690..ec21438 100644
--- a/vscode/src/telemetry/allowlists.ts
+++ b/vscode/src/telemetry/allowlists.ts
@@ -229,6 +229,43 @@ export type TreeView = (typeof TREE_VIEWS)[number];
export const GRAPH_PANELS = ['dependency', 'call', 'impact'] as const;
export type GraphPanel = (typeof GRAPH_PANELS)[number];
+/**
+ * Why an index run produced zero files - the funnel dead-end that ~445 of
+ * ~2,540 active machines hit, of which only ~16% ever recovered. Bounded so
+ * we can measure which recovery hint to invest in without logging paths.
+ */
+export const ZERO_FILE_REASONS = [
+ 'no_workspace', // no folder open at all
+ 'no_supported_files', // folder open, but no files in a language we parse
+ 'index_paths_empty', // codegraph.indexPaths points only at missing/empty dirs
+ 'all_excluded', // matches exist but excludePatterns filtered every one
+ 'unknown', // files present, count still 0 (server-side gap)
+] as const;
+export type ZeroFileReason = (typeof ZERO_FILE_REASONS)[number];
+const ZERO_FILE_REASON_SET = new Set(ZERO_FILE_REASONS);
+export function normalizeZeroFileReason(s: string | undefined): ZeroFileReason {
+ if (!s) return 'unknown';
+ return (ZERO_FILE_REASON_SET.has(s) ? s : 'unknown') as ZeroFileReason;
+}
+
+/**
+ * Actions offered on the one-time post-first-index prompt that steers users
+ * toward the surfaces telemetry shows already convert best (tree views: 483
+ * machines; call graph: 116 - vs 238 that ever invoke an agent tool).
+ * `dismissed` covers closing the toast without choosing.
+ */
+export const FIRST_INDEX_CTAS = [
+ 'explore_symbols',
+ 'show_call_graph',
+ 'open_walkthrough',
+ 'dismissed',
+] as const;
+export type FirstIndexCta = (typeof FIRST_INDEX_CTAS)[number];
+
+/** Actions offered on the zero-file recovery prompt. */
+export const ZERO_FILE_CTAS = ['open_folder', 'configure_paths', 'learn_more', 'dismissed'] as const;
+export type ZeroFileCta = (typeof ZERO_FILE_CTAS)[number];
+
/** Server-health reasons. */
export const SERVER_RESTART_REASONS = ['crash', 'manual', 'setting_change'] as const;
export type ServerRestartReason = (typeof SERVER_RESTART_REASONS)[number];
@@ -376,6 +413,8 @@ export const SETTINGS_SNAPSHOT_KEYS = {
'memory.enabled',
'memory.autoInvalidate',
'memory.gitMining.enabled',
+ 'codeLens.enabled',
+ 'hover.enabled',
] as const,
enum: ['embeddingModel', 'ai.contextStrategy'] as const,
bucketedNumber: [
diff --git a/vscode/src/telemetry/reporter.ts b/vscode/src/telemetry/reporter.ts
index 78af29e..5fffe9e 100644
--- a/vscode/src/telemetry/reporter.ts
+++ b/vscode/src/telemetry/reporter.ts
@@ -32,12 +32,16 @@ import {
type CommandId,
categorizeError,
type ErrorCategory,
+ type FirstIndexCta,
type GraphPanel,
type IndexOutcome,
type IndexTrigger,
isCommandId,
isToolName,
type Language,
+ normalizeZeroFileReason,
+ type ZeroFileCta,
+ type ZeroFileReason,
normalizeCrashCause,
normalizeCrashPhase,
normalizeExitSignal,
@@ -107,6 +111,13 @@ export interface Reporter {
}): void;
indexLanguageBreakdown(languageFileCounts: Map): void;
+ /** An index run produced zero files — records the diagnosed reason. */
+ funnelZeroFileIndex(props: { reason: ZeroFileReason; hadWorkspace: boolean }): void;
+ /** User's choice on the zero-file recovery prompt (or that it was dismissed). */
+ funnelZeroFileCta(props: { reason: ZeroFileReason; action: ZeroFileCta }): void;
+ /** User's choice on the one-time post-first-index prompt (or that it was dismissed). */
+ funnelFirstIndexCta(props: { action: FirstIndexCta; fileCount: number }): void;
+
toolInvoke(toolName: string, argShape: string): void;
toolResult(props: {
toolName: string;
@@ -130,6 +141,8 @@ export interface Reporter {
engagementTreeViewOpened(view: TreeView): void;
engagementGraphPanelOpened(panel: GraphPanel): void;
+ /** User clicked an inline CodeGraph CodeLens (callers/tests/complexity). */
+ engagementCodeLensClicked(): void;
engagementSettingsSnapshot(): void;
/** One-time machine fingerprint (bucketed/enum only) to triage the graph_load crash cohort. */
engagementMachineProfile(profile: { dataDirKind: string; machineKind: string; totalRamGb: number; antivirusKind: string }): void;
@@ -314,6 +327,33 @@ export function createReporter(ctx: vscode.ExtensionContext): Reporter {
send('index.languageBreakdown', breakdown, false);
},
+ funnelZeroFileIndex(props) {
+ // 100% capture (isError=true): this is the primary funnel leak we
+ // are trying to close, so we never want it sampled away.
+ send(
+ 'funnel.zeroFileIndex',
+ {
+ reason: normalizeZeroFileReason(props.reason),
+ hadWorkspace: props.hadWorkspace,
+ },
+ true,
+ );
+ },
+ funnelZeroFileCta(props) {
+ send(
+ 'funnel.zeroFileCta',
+ { reason: normalizeZeroFileReason(props.reason), action: props.action },
+ false,
+ );
+ },
+ funnelFirstIndexCta(props) {
+ send(
+ 'funnel.firstIndexCta',
+ { action: props.action, fileCountBucket: fileCountBucket(props.fileCount) },
+ false,
+ );
+ },
+
toolInvoke(toolName, argShape) {
if (!sample()) return;
send(
@@ -383,6 +423,9 @@ export function createReporter(ctx: vscode.ExtensionContext): Reporter {
engagementGraphPanelOpened(panel) {
send('engagement.graphPanelOpened', { panelType: panel }, false);
},
+ engagementCodeLensClicked() {
+ send('engagement.codeLensClicked', {}, false);
+ },
engagementSettingsSnapshot() {
const cfg = vscode.workspace.getConfiguration('codegraph');
const props: EventProps = {};
diff --git a/vscode/src/views/codeLensProvider.test.ts b/vscode/src/views/codeLensProvider.test.ts
new file mode 100644
index 0000000..6a1c414
--- /dev/null
+++ b/vscode/src/views/codeLensProvider.test.ts
@@ -0,0 +1,212 @@
+// Copyright 2025-2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Minimal, controllable `vscode` mock - the real module only exists in the VS
+// Code runtime. The registered providers are captured out of the mock so the
+// test drives the same objects the editor would.
+const mockState = {
+ config: {} as Record,
+ symbols: [] as unknown[],
+ codeLensProvider: undefined as
+ | { provideCodeLenses(doc: unknown, token: unknown): Promise }
+ | undefined,
+ hoverProvider: undefined as
+ | { provideHover(doc: unknown, pos: unknown): Promise }
+ | undefined,
+};
+
+vi.mock('vscode', () => {
+ class CodeLens {
+ constructor(
+ public range: unknown,
+ public command: { title: string; command: string; arguments: unknown[] },
+ ) {}
+ }
+ class MarkdownString {
+ value = '';
+ constructor(
+ _value?: string,
+ public supportThemeIcons?: boolean,
+ ) {}
+ appendMarkdown(text: string) {
+ this.value += text;
+ return this;
+ }
+ }
+ return {
+ workspace: {
+ getConfiguration: () => ({
+ get: (key: string, fallback: unknown) =>
+ key in mockState.config ? mockState.config[key] : fallback,
+ }),
+ onDidCloseTextDocument: () => ({ dispose() {} }),
+ onDidChangeConfiguration: () => ({ dispose() {} }),
+ },
+ languages: {
+ registerCodeLensProvider: (_selector: unknown, provider: never) => {
+ mockState.codeLensProvider = provider;
+ return { dispose() {} };
+ },
+ registerHoverProvider: (_selector: unknown, provider: never) => {
+ mockState.hoverProvider = provider;
+ return { dispose() {} };
+ },
+ },
+ commands: { registerCommand: () => ({ dispose() {} }), executeCommand: vi.fn() },
+ window: { showTextDocument: vi.fn() },
+ // codeLensRefresh.ts constructs an EventEmitter at module load.
+ EventEmitter: class {
+ fire() {}
+ get event() {
+ return () => ({ dispose() {} });
+ }
+ dispose() {}
+ },
+ CodeLens,
+ MarkdownString,
+ Hover: class {
+ constructor(
+ public contents: unknown,
+ public range: unknown,
+ ) {}
+ },
+ Position: class {
+ constructor(
+ public line: number,
+ public character: number,
+ ) {}
+ },
+ Selection: class {},
+ Range: class {},
+ };
+});
+
+import { registerCodeLens } from './codeLensProvider';
+
+/** A document whose every line is its own range, as far as the provider cares. */
+function documentWith(lineCount: number) {
+ return {
+ uri: { toString: () => 'file:///repo/src/lib.rs' },
+ version: 1,
+ lineCount,
+ lineAt: (line: number) => ({ range: { line } }),
+ };
+}
+
+function lensTitles(lenses: unknown[]): string[] {
+ return (lenses as { command: { title: string } }[]).map((l) => l.command.title);
+}
+
+beforeEach(() => {
+ mockState.config = {};
+ mockState.symbols = [];
+ mockState.codeLensProvider = undefined;
+ mockState.hoverProvider = undefined;
+
+ const client = {
+ sendRequest: vi.fn(async () => ({ symbols: mockState.symbols })),
+ };
+ registerCodeLens({ subscriptions: [] } as never, client as never, undefined);
+});
+
+const token = { isCancellationRequested: false };
+
+describe('CodeLens titles', () => {
+ it('reports the counts a symbol actually has', async () => {
+ mockState.symbols = [
+ { name: 'parse_config', line: 5, callerCount: 1, testCount: 1, complexity: 1 },
+ ];
+ const lenses = await mockState.codeLensProvider!.provideCodeLenses(
+ documentWith(30),
+ token,
+ );
+ expect(lensTitles(lenses)).toEqual(['$(references) 1 caller · $(beaker) 1 test']);
+ });
+
+ it('pluralises counts above one', async () => {
+ mockState.symbols = [
+ { name: 'load', line: 2, callerCount: 3, testCount: 2, complexity: 0 },
+ ];
+ const lenses = await mockState.codeLensProvider!.provideCodeLenses(
+ documentWith(30),
+ token,
+ );
+ expect(lensTitles(lenses)).toEqual(['$(references) 3 callers · $(beaker) 2 tests']);
+ });
+
+ it('omits a zero count rather than rendering it', async () => {
+ mockState.symbols = [
+ { name: 'helper', line: 4, callerCount: 2, testCount: 0, complexity: 0 },
+ ];
+ const lenses = await mockState.codeLensProvider!.provideCodeLenses(
+ documentWith(30),
+ token,
+ );
+ expect(lensTitles(lenses)).toEqual(['$(references) 2 callers']);
+ });
+
+ it('renders no lens at all for a symbol with nothing to report', async () => {
+ mockState.symbols = [
+ // The uncalled, untested, trivial function that used to get
+ // "0 callers · 0 tests · complexity 1" above it.
+ { name: 'load_settings', line: 11, callerCount: 0, testCount: 0, complexity: 1 },
+ ];
+ const lenses = await mockState.codeLensProvider!.provideCodeLenses(
+ documentWith(30),
+ token,
+ );
+ expect(lenses).toEqual([]);
+ });
+
+ it('shows complexity only once it is worth the chrome', async () => {
+ mockState.symbols = [
+ { name: 'below', line: 1, callerCount: 0, testCount: 0, complexity: 4 },
+ { name: 'atFloor', line: 2, callerCount: 0, testCount: 0, complexity: 5 },
+ ];
+ const lenses = await mockState.codeLensProvider!.provideCodeLenses(
+ documentWith(30),
+ token,
+ );
+ expect(lensTitles(lenses)).toEqual(['$(pulse) complexity 5']);
+ });
+
+ it('drops symbols the document no longer has a line for', async () => {
+ mockState.symbols = [
+ { name: 'stale', line: 99, callerCount: 1, testCount: 1, complexity: 1 },
+ ];
+ const lenses = await mockState.codeLensProvider!.provideCodeLenses(
+ documentWith(30),
+ token,
+ );
+ expect(lenses).toEqual([]);
+ });
+
+ it('renders nothing when the surface is switched off', async () => {
+ mockState.config['codeLens.enabled'] = false;
+ mockState.symbols = [
+ { name: 'parse_config', line: 5, callerCount: 1, testCount: 1, complexity: 1 },
+ ];
+ const lenses = await mockState.codeLensProvider!.provideCodeLenses(
+ documentWith(30),
+ token,
+ );
+ expect(lenses).toEqual([]);
+ });
+});
+
+describe('hover', () => {
+ it('still shows every stat, including the zeroes the lens drops', async () => {
+ mockState.symbols = [
+ { name: 'load_settings', line: 11, callerCount: 0, testCount: 0, complexity: 1 },
+ ];
+ const hover = (await mockState.hoverProvider!.provideHover(documentWith(30), {
+ line: 11,
+ })) as { contents: { value: string } };
+ expect(hover.contents.value).toContain('**load_settings**');
+ expect(hover.contents.value).toContain('0 callers');
+ expect(hover.contents.value).toContain('0 tests');
+ expect(hover.contents.value).toContain('complexity 1');
+ });
+});
diff --git a/vscode/src/views/codeLensProvider.ts b/vscode/src/views/codeLensProvider.ts
new file mode 100644
index 0000000..10b8fe1
--- /dev/null
+++ b/vscode/src/views/codeLensProvider.ts
@@ -0,0 +1,242 @@
+// Copyright 2025-2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+//! Inline CodeLens (and hover) surfacing graph intelligence directly in the
+//! editor: callers, related tests, and cyclomatic complexity above every
+//! function. Telemetry showed humans engage the visible surfaces (tree views,
+//! call graph) far more than the agent tools, so this puts the graph where
+//! people already read code. One batched `codegraph/getDocumentCodeLens`
+//! request per document backs both the lenses and the hovers.
+
+import * as vscode from 'vscode';
+import { LanguageClient } from 'vscode-languageclient/node';
+import type { Reporter } from '../telemetry/reporter';
+import { onDidRefreshCodeLenses, refreshCodeLenses } from './codeLensRefresh';
+
+/** Per-symbol stats returned by the server for one document. */
+interface CodeLensSymbol {
+ name: string;
+ /** 0-based start line. */
+ line: number;
+ callerCount: number;
+ testCount: number;
+ complexity: number;
+}
+
+interface DocumentCodeLensResponse {
+ symbols: CodeLensSymbol[];
+}
+
+// Register for all on-disk files rather than an enumerated language list (which
+// would be a fourth place to update per new parser, and would drift silently).
+// The server returns no symbols for a file it didn't index, so an unsupported
+// file simply yields no lenses/hover - no list to maintain, no feature gap.
+const SELECTOR: vscode.DocumentSelector = { scheme: 'file' };
+
+/** True when the CodeLens surface is enabled in settings (default on). */
+function codeLensEnabled(): boolean {
+ return vscode.workspace.getConfiguration('codegraph').get('codeLens.enabled', true);
+}
+
+/** True when the hover surface is enabled in settings (default on). */
+function hoverEnabled(): boolean {
+ return vscode.workspace.getConfiguration('codegraph').get('hover.enabled', true);
+}
+
+/**
+ * Fetch per-document symbol stats, cached by document URI + version so
+ * scrolling or re-render doesn't re-hit the server; a new edit (version bump)
+ * or an explicit {@link refreshCodeLenses} invalidates the entry.
+ */
+class DocumentStatsCache {
+ /**
+ * The entry is the *promise*, not the resolved symbols, so concurrent
+ * misses share one request. The lens provider and the hover provider hold
+ * the same cache and both fire on open and on every version bump; caching
+ * only on resolution had each of them walk the whole file's symbols and
+ * their incoming edges under the server's graph read lock.
+ */
+ private entries = new Map }>();
+
+ constructor(private client: LanguageClient) {}
+
+ invalidate(): void {
+ this.entries.clear();
+ }
+
+ /** Drop one document's entry (call when its editor closes) to bound memory. */
+ evict(uri: vscode.Uri): void {
+ this.entries.delete(uri.toString());
+ }
+
+ get(document: vscode.TextDocument): Promise {
+ const key = document.uri.toString();
+ const cached = this.entries.get(key);
+ if (cached && cached.version === document.version) {
+ return cached.symbols;
+ }
+ const version = document.version;
+ const symbols = this.fetch(document).catch(() => {
+ // Server not ready / not indexed / unsupported file - no lenses.
+ // The failed entry is dropped rather than remembered: the usual
+ // cause is an engine still starting, and caching the emptiness
+ // would hide the stats until the next edit.
+ if (this.entries.get(key)?.version === version) {
+ this.entries.delete(key);
+ }
+ return [] as CodeLensSymbol[];
+ });
+ this.entries.set(key, { version, symbols });
+ return symbols;
+ }
+
+ private async fetch(document: vscode.TextDocument): Promise {
+ // Dispatched via workspace/executeCommand (the server's live custom
+ // command path); the `codegraph/*` LSP request namespace is not
+ // registered on the service.
+ const response = await this.client.sendRequest(
+ 'workspace/executeCommand',
+ {
+ command: 'codegraph.getDocumentCodeLens',
+ arguments: [{ uri: document.uri.toString() }],
+ },
+ );
+ return response?.symbols ?? [];
+ }
+}
+
+/**
+ * Below this, cyclomatic complexity is not worth a line of editor chrome.
+ * Shared value with the JetBrains code vision (COMPLEXITY_FLOOR there) so the
+ * two clients render the same amount of chrome for the same graph.
+ */
+const COMPLEXITY_FLOOR = 5;
+
+/**
+ * The lens text, or null when the symbol has nothing to report. Zero counts are
+ * omitted rather than rendered: a lens reading `0 callers · 0 tests` above every
+ * uncalled function in a library crate is noise, and a lens that renders and
+ * says nothing is worse than no lens. The hover still shows every stat,
+ * including the zeroes, because it is asked for explicitly.
+ */
+function formatLensTitle(s: CodeLensSymbol): string | null {
+ const parts: string[] = [];
+ if (s.callerCount > 0) {
+ parts.push(`$(references) ${s.callerCount} caller${s.callerCount === 1 ? '' : 's'}`);
+ }
+ if (s.testCount > 0) {
+ parts.push(`$(beaker) ${s.testCount} test${s.testCount === 1 ? '' : 's'}`);
+ }
+ if (s.complexity >= COMPLEXITY_FLOOR) {
+ parts.push(`$(pulse) complexity ${s.complexity}`);
+ }
+ return parts.length > 0 ? parts.join(' · ') : null;
+}
+
+class CodeGraphCodeLensProvider implements vscode.CodeLensProvider {
+ readonly onDidChangeCodeLenses = onDidRefreshCodeLenses;
+
+ constructor(private cache: DocumentStatsCache) {}
+
+ async provideCodeLenses(
+ document: vscode.TextDocument,
+ token: vscode.CancellationToken,
+ ): Promise {
+ if (!codeLensEnabled()) return [];
+ const symbols = await this.cache.get(document);
+ if (token.isCancellationRequested) return [];
+
+ const lenses: vscode.CodeLens[] = [];
+ for (const s of symbols) {
+ if (s.line < 0 || s.line >= document.lineCount) continue;
+ const title = formatLensTitle(s);
+ if (title === null) continue;
+ const range = document.lineAt(s.line).range;
+ lenses.push(
+ new vscode.CodeLens(range, {
+ title,
+ command: 'codegraph.revealCallGraphAt',
+ arguments: [document.uri, s.line],
+ }),
+ );
+ }
+ return lenses;
+ }
+}
+
+class CodeGraphHoverProvider implements vscode.HoverProvider {
+ constructor(private cache: DocumentStatsCache) {}
+
+ async provideHover(
+ document: vscode.TextDocument,
+ position: vscode.Position,
+ ): Promise {
+ if (!hoverEnabled()) return undefined;
+ const symbols = await this.cache.get(document);
+ // Match the symbol whose declaration line the hover is on.
+ const s = symbols.find((sym) => sym.line === position.line);
+ if (!s) return undefined;
+
+ const md = new vscode.MarkdownString(undefined, true);
+ md.appendMarkdown(`**${s.name}** · CodeGraph\n\n`);
+ md.appendMarkdown(
+ `$(references) ${s.callerCount} caller${s.callerCount === 1 ? '' : 's'} · ` +
+ `$(beaker) ${s.testCount} test${s.testCount === 1 ? '' : 's'}` +
+ (s.complexity > 0 ? ` · $(pulse) complexity ${s.complexity}` : ''),
+ );
+ return new vscode.Hover(md, document.lineAt(s.line).range);
+ }
+}
+
+/**
+ * Register the CodeLens provider, the matching hover, and the click command
+ * that opens the call graph at a symbol. Returns disposables via `context`.
+ */
+export function registerCodeLens(
+ context: vscode.ExtensionContext,
+ client: LanguageClient,
+ reporter?: Reporter,
+): void {
+ const cache = new DocumentStatsCache(client);
+
+ // Clear the cache whenever a refresh is requested (post-reindex) so the
+ // next provideCodeLenses fetches fresh counts.
+ context.subscriptions.push(onDidRefreshCodeLenses(() => cache.invalidate()));
+
+ // Bound memory: drop a document's cached stats when its editor closes.
+ context.subscriptions.push(
+ vscode.workspace.onDidCloseTextDocument((doc) => cache.evict(doc.uri)),
+ );
+
+ context.subscriptions.push(
+ vscode.languages.registerCodeLensProvider(SELECTOR, new CodeGraphCodeLensProvider(cache)),
+ vscode.languages.registerHoverProvider(SELECTOR, new CodeGraphHoverProvider(cache)),
+ );
+
+ // Re-render lenses when the toggles change.
+ context.subscriptions.push(
+ vscode.workspace.onDidChangeConfiguration((e) => {
+ if (
+ e.affectsConfiguration('codegraph.codeLens.enabled') ||
+ e.affectsConfiguration('codegraph.hover.enabled')
+ ) {
+ refreshCodeLenses();
+ }
+ }),
+ );
+
+ // CodeLens click: reveal the symbol's line, then open its call graph.
+ context.subscriptions.push(
+ vscode.commands.registerCommand(
+ 'codegraph.revealCallGraphAt',
+ async (uri: vscode.Uri, line: number) => {
+ reporter?.engagementCodeLensClicked();
+ const editor = await vscode.window.showTextDocument(uri);
+ const pos = new vscode.Position(line, 0);
+ editor.selection = new vscode.Selection(pos, pos);
+ editor.revealRange(new vscode.Range(pos, pos));
+ await vscode.commands.executeCommand('codegraph.showCallGraph');
+ },
+ ),
+ );
+}
diff --git a/vscode/src/views/codeLensRefresh.ts b/vscode/src/views/codeLensRefresh.ts
new file mode 100644
index 0000000..c6c87f7
--- /dev/null
+++ b/vscode/src/views/codeLensRefresh.ts
@@ -0,0 +1,19 @@
+// Copyright 2025-2026 Andrey Vasilevsky
+// SPDX-License-Identifier: Apache-2.0
+
+//! Standalone refresh signal for the CodeLens/hover surfaces. Kept in its own
+//! module (depending only on `vscode`, not `vscode-languageclient`) so that
+//! index-completion code - which is unit-tested with a mocked `vscode` - can
+//! fire a refresh without pulling the language-client runtime into the test.
+
+import * as vscode from 'vscode';
+
+const refreshEmitter = new vscode.EventEmitter();
+
+/** Fires when CodeLens/hover data should be re-fetched (e.g. after a reindex). */
+export const onDidRefreshCodeLenses = refreshEmitter.event;
+
+/** Invalidate all CodeLens/hover data so the editor re-requests fresh stats. */
+export function refreshCodeLenses(): void {
+ refreshEmitter.fire();
+}
diff --git a/vscode/src/views/treeProviders.ts b/vscode/src/views/treeProviders.ts
index 27bce3d..fd032aa 100644
--- a/vscode/src/views/treeProviders.ts
+++ b/vscode/src/views/treeProviders.ts
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import * as vscode from 'vscode';
-import { LanguageClient, RequestType } from 'vscode-languageclient/node';
+import { LanguageClient } from 'vscode-languageclient/node';
import { registerMemoryTreeView } from './memoryProvider';
import type { Reporter } from '../telemetry/reporter';
@@ -23,11 +23,6 @@ interface WorkspaceSymbolsResponse {
symbols: SymbolInfo[];
}
-namespace GetWorkspaceSymbolsRequest {
- export const type = new RequestType<{ query?: string }, WorkspaceSymbolsResponse, void>(
- 'codegraph/getWorkspaceSymbols'
- );
-}
/**
* Tree item for CodeGraph symbols view.
@@ -118,11 +113,16 @@ export class SymbolTreeProvider implements vscode.TreeDataProvider(
+ 'workspace/executeCommand',
+ {
+ command: 'codegraph.getWorkspaceSymbols',
+ arguments: [{ query: this.filter || undefined }],
+ }
);
this.symbols = response.symbols;